diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 000000000..85ed5cd3b --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,18 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +language: en-US +reviews: + profile: chill + request_changes_workflow: false + high_level_summary: true + poem: false + review_status: true + auto_review: + enabled: true + drafts: false + path_filters: + - "!**/*.tsx" + - "!**/*.ts" + - "!**/*.js" + - "!**/*.svg" +chat: + auto_reply: true diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 80809e667..9b6a0edfd 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25-bookworm +FROM golang:1.26.7-bookworm RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ && apt-get -y install --no-install-recommends\ @@ -6,7 +6,6 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ iptables=1.8.9-2 \ libgl1-mesa-dev=22.3.6-1+deb12u1 \ xorg-dev=1:7.7+23 \ - libayatana-appindicator3-dev=0.5.92-1 \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* \ && go install -v golang.org/x/tools/gopls@latest diff --git a/.git-branches.toml b/.git-branches.toml index d1818090f..4c34d7928 100644 --- a/.git-branches.toml +++ b/.git-branches.toml @@ -3,7 +3,7 @@ [branches] main = "main" perennials = [] -perennial-regex = "" +perennial-regex = "^release-" [create] new-branch-type = "feature" diff --git a/.github/DISCUSSION_TEMPLATE/ideas-feature-requests.yml b/.github/DISCUSSION_TEMPLATE/ideas-feature-requests.yml new file mode 100644 index 000000000..f57a62107 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/ideas-feature-requests.yml @@ -0,0 +1,130 @@ +body: + - type: markdown + attributes: + value: | + ## Ideas & Feature Requests + + Use this category for feature requests, enhancements, integrations, and product ideas. + + NetBird uses community traction in discussions — upvotes, replies, affected users, and use-case detail — as an input when deciding what should become a maintainer-curated issue or roadmap item. A clear problem statement is more useful than a solution-only request. + + Please search first and add your use case to an existing discussion when one already exists. + + - type: checkboxes + id: preflight + attributes: + label: Before posting + options: + - label: I searched existing discussions and issues for similar requests. + required: true + - label: I checked the documentation to confirm this is not already supported. + required: true + - label: This is a product idea or enhancement request, not a support question. + required: true + - label: I removed or anonymized sensitive details from examples and screenshots. + required: true + + - type: dropdown + id: area + attributes: + label: Product area + description: Select every area this request touches. + multiple: true + options: + - Client / Agent + - CLI + - Desktop UI + - Mobile app + - Dashboard / Admin UI + - Management service / API + - Signal service + - Relay + - DNS + - Routes / Exit nodes + - NetBird SSH + - Access control policies + - Posture checks + - Identity provider / SSO + - Self-hosting / Deployment + - Kubernetes / Operator + - Terraform / Automation + - Documentation + - Other / not sure + validations: + required: true + + - type: textarea + id: problem + attributes: + label: Problem or use case + description: What are you trying to accomplish, and what is difficult or impossible today? + placeholder: | + As a ... + I want to ... + Because ... + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: Describe the behavior, workflow, API, UI, or integration you would like to see. + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives or workarounds considered + description: What have you tried today? Why is the current workaround not enough? + + - type: textarea + id: impact + attributes: + label: Community impact and priority + description: Help us understand who benefits and how urgent this is. + placeholder: | + - Number of users/teams/peers affected: + - Deployment type: Cloud / self-hosted / both + - Frequency: daily / weekly / occasional + - Blocking production adoption? yes/no + - Related comments, discussions, or customer requests: + validations: + required: true + + - type: textarea + id: examples + attributes: + label: Examples from other tools or products + description: If another tool solves this well, link or describe the behavior. + + - type: textarea + id: security + attributes: + label: Security, privacy, and compatibility considerations + description: Note any access-control, audit, data retention, network, platform, or backward-compatibility concerns. + + - type: textarea + id: implementation + attributes: + label: Implementation ideas + description: Optional. If you are familiar with the codebase or API, share possible implementation notes. + + - type: dropdown + id: contribution + attributes: + label: Are you willing to help? + options: + - Yes, I can submit a PR if the approach is accepted. + - Yes, I can test or validate a proposed implementation. + - Yes, I can provide more use-case details. + - Not at this time. + validations: + required: true + + - type: textarea + id: additional-context + attributes: + label: Additional context + description: Add screenshots, diagrams, links, or anything else that helps explain the request. diff --git a/.github/DISCUSSION_TEMPLATE/issue-triage.yml b/.github/DISCUSSION_TEMPLATE/issue-triage.yml new file mode 100644 index 000000000..b13ec066e --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/issue-triage.yml @@ -0,0 +1,237 @@ +body: + - type: markdown + attributes: + value: | + ## Issue Triage + + Use this category for reproducible bugs and regressions in NetBird. + + The more context you include, the faster we can validate and act on your report. If you're not sure whether something is a bug, **Q&A / Support** is a good starting point — we can always move the conversation here once we've confirmed it's a product issue. + + Intermittent issues are useful too. Include the trigger, frequency, timing, and any logs or debug evidence you have, and we'll work from there. + + Please don't include secrets, tokens, private keys, internal hostnames, or public IPs. Security vulnerabilities should be reported through the repository security policy rather than a public discussion. + + - type: checkboxes + id: preflight + attributes: + label: Before posting + options: + - label: I searched existing discussions and issues, including closed ones, and checked the relevant docs. + required: true + - label: I believe this is a product bug rather than a configuration or setup question. + required: true + - label: I can reproduce this issue, or for intermittent issues I've included trigger, frequency, and timing details below. + required: true + - label: I removed or anonymized sensitive data from logs, screenshots, and configuration. + required: true + + - type: dropdown + id: area + attributes: + label: Affected area + description: Select every area this report touches. + multiple: true + options: + - Client / Agent + - Reverse Proxy + - CLI + - Desktop UI + - Mobile app + - Peer connectivity + - DNS + - Routes / Exit nodes + - NetBird SSH + - Relay / Signal / NAT traversal + - Login / Authentication / IdP + - Dashboard / Admin UI + - Management service / API + - Access control policies / Posture checks + - Self-hosting / Deployment + - Kubernetes / Operator + - Documentation + - Other / not sure + validations: + required: true + + - type: dropdown + id: deployment + attributes: + label: Deployment type + options: + - NetBird Cloud + - Self-hosted - quickstart script + - Self-hosted - advanced/custom deployment + - Local development build + - Not sure / environment I do not fully control + validations: + required: true + + - type: dropdown + id: platform + attributes: + label: Operating system or environment + description: Select every environment involved in the reproduction. + multiple: true + options: + - Linux + - macOS + - Windows + - Android + - iOS + - FreeBSD + - OpenWRT + - Docker + - Kubernetes + - Synology + - Browser + - Other / not sure + validations: + required: true + + - type: textarea + id: version + attributes: + label: NetBird version and upgrade status + description: Run `netbird version` where applicable. For self-hosted deployments, include management, signal, relay, and dashboard versions if available. If you cannot test on a current/supported version, explain why. + placeholder: | + Example: + - Client: 0.30.2 + - Management: 0.30.2 + - Signal: 0.30.2 + - Relay: 0.30.2 + - Dashboard: 0.30.2 + - Upgrade status: reproduced on current version / cannot upgrade because ... + validations: + required: true + + - type: dropdown + id: regression + attributes: + label: Did this work before? + options: + - Yes, this worked before + - No, this never worked + - Not sure + validations: + required: true + + - type: textarea + id: regression-details + attributes: + label: Regression details + description: If this worked before, include the last known working version, first known broken version, and any recent upgrade, configuration, network, or IdP changes. + placeholder: | + - Last known working version: + - First known broken version: + - Recent changes: + + - type: textarea + id: summary + attributes: + label: Summary + description: Briefly describe the reproducible bug. + placeholder: What is broken? + validations: + required: true + + - type: textarea + id: current-behavior + attributes: + label: Current behavior + description: What happens now? Include exact errors, timeouts, UI messages, or failed commands when possible. + validations: + required: true + + - type: textarea + id: expected-behavior + attributes: + label: Expected behavior + description: What did you expect to happen instead? + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Steps to reproduce + description: Provide the smallest set of steps that reliably reproduces the bug. If the issue is intermittent, include the trigger, frequency, timing, and relevant timestamps. + placeholder: | + 1. Configure ... + 2. Run ... + 3. Observe ... + + For intermittent issues: + - Trigger: + - Frequency: + - Timing/timestamps: + validations: + required: true + + - type: textarea + id: environment + attributes: + label: Environment and topology + description: Include the relevant topology and software involved in the reproduction. For UI/docs-only reports, write `N/A` if this does not apply. Use `None`, `Unknown`, or `N/A` where appropriate. + placeholder: | + - Peer A: + - Peer B: + - Same LAN or different networks: + - NAT/CGNAT/corporate firewall/mobile network: + - Other VPN software: + - Firewall, DNS, or endpoint security software: + - Routes, DNS, policies, posture checks, or SSH rules involved: + - IdP, reverse proxy, or browser involved: + validations: + required: true + + - type: textarea + id: self-hosted-details + attributes: + label: Self-hosted details, if available + description: Optional. If you use self-hosting and have access to these details, include them. If you do not administer the environment, provide what you know and say what you cannot access. + placeholder: | + - Deployment method: quickstart / Docker Compose / Helm / operator / custom + - Management/signal/relay/dashboard versions: + - Reverse proxy: + - IdP/provider: + - STUN/TURN/coturn/relay details: + - Relevant component logs: + + - type: textarea + id: logs + attributes: + label: Logs, status output, or debug evidence + description: | + For client, connectivity, DNS, route, relay/signal, or self-hosted reports, logs are essential — please include anonymized output from `netbird status -dA`, or a debug bundle via `netbird debug for 1m -AS -U`. Debug bundles are automatically deleted after 30 days. + + For UI, dashboard, or documentation reports, leave the pre-filled `N/A`. + value: "N/A" + render: shell + validations: + required: true + + - type: textarea + id: related-reports + attributes: + label: Related issues or discussions + description: Optional. Link similar reports you found while searching, if any. + placeholder: | + - Related issue/discussion: + - Why this may be the same or different: + + - type: textarea + id: impact + attributes: + label: Impact + description: Optional. Help us understand priority. How many users, peers, environments, or workflows are affected? Is there a workaround? + placeholder: | + - Affected users/peers: + - Business or production impact: + - Workaround available: + + - type: textarea + id: additional-context + attributes: + label: Additional context + description: Add links to related discussions, issues, docs, screenshots, recordings, or anything else that may help validation. diff --git a/.github/DISCUSSION_TEMPLATE/q-a-support.yml b/.github/DISCUSSION_TEMPLATE/q-a-support.yml new file mode 100644 index 000000000..725f8737c --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/q-a-support.yml @@ -0,0 +1,146 @@ +body: + - type: markdown + attributes: + value: | + ## Q&A / Support + + Use this category for questions about configuration, setup, self-hosted deployments, troubleshooting, and general NetBird usage. + + This is community support and does not provide an SLA. For NetBird Cloud support, use the official support channel linked from the issue creation page. Please do not post secrets, tokens, private keys, internal hostnames, or public IPs unless you intentionally want them public. + + If your question turns into a reproducible product defect, DevRel or a maintainer may ask you to open or move the conversation to Issue Triage. + + - type: checkboxes + id: preflight + attributes: + label: Before posting + options: + - label: I searched existing discussions and issues for similar questions. + required: true + - label: I reviewed the relevant NetBird documentation or troubleshooting guide. + required: true + - label: I removed or anonymized sensitive data from logs, screenshots, and configuration. + required: true + + - type: dropdown + id: topic + attributes: + label: Topic + multiple: true + options: + - Getting started + - Self-hosting + - Client / Agent + - CLI + - Desktop UI + - Mobile app + - Dashboard / Admin UI + - DNS + - Routes / Exit nodes + - NetBird SSH + - Relay + - Access control policies + - Posture checks + - Identity provider / SSO + - API + - Kubernetes / Operator + - Terraform / Automation + - Documentation + - Other / not sure + validations: + required: true + + - type: dropdown + id: deployment + attributes: + label: Deployment type + options: + - NetBird Cloud + - Self-hosted - quickstart script + - Self-hosted - advanced/custom deployment + - Local development build + - Not sure + validations: + required: true + + - type: dropdown + id: platform + attributes: + label: Operating system or environment + multiple: true + options: + - Linux + - macOS + - Windows + - Android + - iOS + - FreeBSD + - OpenWRT + - Docker + - Kubernetes + - Synology + - Browser + - Other / not sure + validations: + required: true + + - type: input + id: version + attributes: + label: NetBird version + description: Run `netbird version` where applicable. For self-hosted deployments, include component versions if relevant. + placeholder: "Example: client 0.30.2, management 0.30.2" + + - type: textarea + id: question + attributes: + label: Question + description: What are you trying to understand or accomplish? + placeholder: Describe your question clearly. + validations: + required: true + + - type: textarea + id: goal + attributes: + label: Desired outcome + description: What would a successful answer help you do? + placeholder: | + I want to configure ... + I expected ... + I need help deciding ... + + - type: textarea + id: attempted + attributes: + label: What have you tried? + description: Include commands, documentation links, configuration attempts, or troubleshooting steps already tried. + placeholder: | + - Read ... + - Ran ... + - Changed ... + - Observed ... + + - type: textarea + id: environment + attributes: + label: Relevant environment details + description: Include redacted topology, IdP/provider, reverse proxy, firewall, DNS, route, policy, or self-hosted setup details that may affect the answer. + placeholder: | + - Deployment: + - Components involved: + - Network/topology: + - Related config: + + - type: textarea + id: logs + attributes: + label: Logs or output + description: Optional. Include anonymized logs, command output, screenshots, or `netbird status -dA` if relevant. + render: shell + + - type: textarea + id: additional-context + attributes: + label: Additional context + description: Add links, diagrams, screenshots, or other details that may help the community answer. diff --git a/.github/ISSUE_TEMPLATE/bug-issue-report.md b/.github/ISSUE_TEMPLATE/bug-issue-report.md deleted file mode 100644 index df670db06..000000000 --- a/.github/ISSUE_TEMPLATE/bug-issue-report.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -name: Bug/Issue report -about: Create a report to help us improve -title: '' -labels: ['triage-needed'] -assignees: '' - ---- - -**Describe the problem** - -A clear and concise description of what the problem is. - -**To Reproduce** - -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior** - -A clear and concise description of what you expected to happen. - -**Are you using NetBird Cloud?** - -Please specify whether you use NetBird Cloud or self-host NetBird's control plane. - -**NetBird version** - -`netbird version` - -**Is any other VPN software installed?** - -If yes, which one? - -**Debug output** - -To help us resolve the problem, please attach the following anonymized status output - - netbird status -dA - -Create and upload a debug bundle, and share the returned file key: - - netbird debug for 1m -AS -U - -*Uploaded files are automatically deleted after 30 days.* - - -Alternatively, create the file only and attach it here manually: - - netbird debug for 1m -AS - - -**Screenshots** - -If applicable, add screenshots to help explain your problem. - -**Additional context** - -Add any other context about the problem here. - -**Have you tried these troubleshooting steps?** -- [ ] Reviewed [client troubleshooting](https://docs.netbird.io/how-to/troubleshooting-client) (if applicable) -- [ ] Checked for newer NetBird versions -- [ ] Searched for similar issues on GitHub (including closed ones) -- [ ] Restarted the NetBird client -- [ ] Disabled other VPN software -- [ ] Checked firewall settings - diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index e9ffaf8a3..ee3e84df6 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,14 +1,26 @@ -blank_issues_enabled: true +blank_issues_enabled: false contact_links: - - name: Community Support + - name: Start an Issue Triage discussion + url: https://github.com/netbirdio/netbird/discussions/new?category=issue-triage + about: Report a bug, regression, or unexpected behavior so DevRel can validate it before it becomes an issue. + - name: Propose an idea or feature request + url: https://github.com/netbirdio/netbird/discussions/new?category=ideas-feature-requests + about: Share feature requests, enhancements, and integration ideas for community feedback and prioritization. + - name: Ask a Q&A / Support question + url: https://github.com/netbirdio/netbird/discussions/new?category=q-a-support + about: Get help with setup, configuration, self-hosting, troubleshooting, and general usage. + - name: Security vulnerability disclosure + url: https://github.com/netbirdio/netbird/security/policy + about: Please do not report security vulnerabilities in public issues or discussions. + - name: Community Support Forum url: https://forum.netbird.io/ - about: Community support forum + 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 + about: Contact NetBird for Cloud support. + - name: Client / Connection Troubleshooting url: https://docs.netbird.io/help/troubleshooting-client - about: See our client troubleshooting guide for help addressing common issues + about: See the client troubleshooting guide for common connectivity issues. - name: Self-host Troubleshooting url: https://docs.netbird.io/selfhosted/troubleshooting - about: See our self-host troubleshooting guide for help addressing common issues + about: See the self-host troubleshooting guide for common deployment issues. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 4a3e5782c..000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: ['feature-request'] -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/validated_issue.yml b/.github/ISSUE_TEMPLATE/validated_issue.yml new file mode 100644 index 000000000..2a21b73b2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/validated_issue.yml @@ -0,0 +1,128 @@ +name: Validated issue +description: Maintainer/DevRel only. Create an issue after a discussion has been validated or for internally validated work. +title: "[Validated]: " +body: + - type: markdown + attributes: + value: | + ## Discussion-first issue policy + + Issues are maintainer-curated work items. Community reports and feature requests should start in [Discussions](https://github.com/netbirdio/netbird/discussions) so DevRel can validate, reproduce, and route them before engineering time is committed. + + Use this form when: + - A discussion has been validated and should become actionable work. + - A maintainer is opening internally validated work that can bypass the discussion-first flow. + + Issues opened without a relevant validated discussion or maintainer context may be closed and redirected to Discussions. + + - type: checkboxes + id: validation-checks + attributes: + label: Validation checklist + options: + - label: This issue is linked to a validated discussion, or it is being opened directly by a maintainer. + required: true + - label: The report has enough context for engineering to act on it without re-triaging from scratch. + required: true + - label: Sensitive data, secrets, private keys, internal hostnames, and public IPs have been removed or intentionally disclosed. + required: true + + - type: dropdown + id: issue-type + attributes: + label: Issue type + options: + - Bug / Regression + - Feature / Enhancement + - Documentation + - Maintenance / Refactor + - Cross-repository coordination + - Other + validations: + required: true + + - type: input + id: source-discussion + attributes: + label: Source discussion + description: Link the GitHub Discussion that was validated. Maintainers bypassing the flow can write "Maintainer-created" and explain why below. + placeholder: https://github.com/netbirdio/netbird/discussions/1234 + validations: + required: true + + - type: input + id: validation-owner + attributes: + label: Validation owner + description: GitHub handle of the DevRel team member or maintainer who validated this work. + placeholder: "@username" + validations: + required: true + + - type: dropdown + id: target-repository + attributes: + label: Target repository + description: Where should the implementation work happen? + options: + - netbirdio/netbird + - netbirdio/dashboard + - netbirdio/kubernetes-operator + - netbirdio/docs + - Multiple repositories + - Unknown / needs routing + validations: + required: true + + - type: textarea + id: summary + attributes: + label: Summary + description: Concise description of the validated work. + placeholder: What needs to be fixed, changed, documented, or built? + validations: + required: true + + - type: textarea + id: evidence + attributes: + label: Validation evidence + description: For bugs, include reproduction status, affected versions, logs, and environment. For features, include community traction, affected users, and alignment notes. + placeholder: | + - Reproduced by: + - Affected versions / platforms: + - Community signal: + - Related logs or screenshots: + validations: + required: true + + - type: textarea + id: scope + attributes: + label: Proposed scope + description: Describe what is in scope and, if helpful, what is explicitly out of scope. + placeholder: | + In scope: + - ... + + Out of scope: + - ... + validations: + required: true + + - type: textarea + id: acceptance-criteria + attributes: + label: Acceptance criteria + description: What must be true for this issue to be closed? + placeholder: | + - [ ] ... + - [ ] ... + validations: + required: true + + - type: textarea + id: additional-context + attributes: + label: Additional context + description: Links to related PRs, docs, issues in other repositories, roadmap items, or implementation notes. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..647e04936 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,48 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 3 + groups: + actions: + patterns: + - "*" + ignore: + # git-town/action v1.3.x crashes on cyclic PR graphs (self-loop main->main + # fork PRs) via its topological-sort visualization. Pinned to v1.2.1 in + # git-town.yml; block v1.3.x until upstream tolerates cyclic edges. + - dependency-name: "git-town/action" + update-types: + - "version-update:semver-minor" + - "version-update:semver-major" + + - package-ecosystem: "gomod" + directories: + - "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 15 + groups: + golang-x-packages: + patterns: + - "golang.org/x/*" + aws-sdk: + patterns: + - "github.com/aws/aws-sdk-go-v2/*" + pion: + patterns: + - "github.com/pion/*" + gorm: + patterns: + - "gorm.io/*" + otel: + patterns: + - "go.opentelemetry.io/*" + testcontainers: + patterns: + - "github.com/testcontainers/testcontainers-go/*" + wireguard: + patterns: + - "golang.zx2c4.com/wireguard*" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 9d6bc96eb..9b796f262 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,6 +2,12 @@ ## Issue ticket number and link + + ## Stack @@ -12,6 +18,9 @@ - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) +- [ ] I ran and tested this change locally — I did not rely on CI to find out whether it works +- [ ] This PR has a single purpose (not a fix + refactor + feature in one) +- [ ] This change is a trivial fix, **OR** it links an issue the NetBird team agreed on beforehand. Changes to the public API, gRPC protocols, functionality behavior, CLI / service flags, or new features always need that agreement first. See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). diff --git a/.github/workflows/agent-network-e2e.yml b/.github/workflows/agent-network-e2e.yml new file mode 100644 index 000000000..9501c5fba --- /dev/null +++ b/.github/workflows/agent-network-e2e.yml @@ -0,0 +1,91 @@ +name: Agent Network E2E + +on: + # Nightly at 03:00 UTC, plus on demand from the Actions tab. + schedule: + - cron: "0 3 * * *" + workflow_dispatch: + inputs: + bedrock_model: + description: >- + Bedrock inference-profile id to drive the matrix with, exactly as + AWS issues it. Leave empty for the Sonnet 4.6 default. + required: false + default: "" + test_pattern: + description: >- + Package pattern to run. Defaults to the whole suite; narrow it to one + package (e.g. ./e2e/agentnetwork/...) when a run only needs that + package's answer and not the sixteen minutes the container suite costs. + required: false + default: "./e2e/..." + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e: + name: Agent Network E2E + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Install Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: "go.mod" + + # Container-driver builder so the harness can build the combined/proxy/ + # client images from source with a local layer cache. + - name: Set up Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + # Persist the Docker layer cache across runs. This caches the base, apt, + # and go-mod-download layers; the Go compile still re-runs, as BuildKit + # mount caches cannot be exported to the GitHub cache. + - name: Cache Docker layers + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-anet-e2e-buildx-${{ hashFiles('go.sum', 'combined/Dockerfile.multistage', 'proxy/Dockerfile.multistage', 'e2e/harness/Dockerfile.client') }} + restore-keys: | + ${{ runner.os }}-anet-e2e-buildx- + + - name: Run agent-network e2e + env: + # Build the images from source (this branch's code) with the shared + # local layer cache. + NB_E2E_BUILDX_CACHE: /tmp/.buildx-cache + # Provider credentials. Each provider scenario skips if its + # token (and URL, for gateways) is unset, so partial coverage is fine. + OPENAI_TOKEN: ${{ secrets.E2E_OPENAI_TOKEN }} + ANTHROPIC_TOKEN: ${{ secrets.E2E_ANTHROPIC_TOKEN }} + # Moonshot AI platform key (platform.kimi.ai); drives both Kimi wire + # shapes (OpenAI /v1 and Anthropic /anthropic) through kimi_api. + KIMI_TOKEN: ${{ secrets.E2E_KIMI_TOKEN }} + VERCEL_URL: ${{ secrets.E2E_VERCEL_URL }} + VERCEL_TOKEN: ${{ secrets.E2E_VERCEL_TOKEN }} + OPENROUTER_URL: ${{ secrets.E2E_OPENROUTER_URL }} + OPENROUTER_TOKEN: ${{ secrets.E2E_OPENROUTER_TOKEN }} + CLOUDFLARE_URL: ${{ secrets.E2E_CLOUDFLARE_URL }} + CLOUDFLARE_TOKEN: ${{ secrets.E2E_CLOUDFLARE_TOKEN }} + AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.E2E_AWS_BEARER_TOKEN_BEDROCK }} + AWS_REGION: ${{ secrets.E2E_AWS_REGION }} + # Bedrock model override: dispatch input wins, then the repo variable, else the test default. + AWS_BEDROCK_MODEL: ${{ inputs.bedrock_model || vars.E2E_AWS_BEDROCK_MODEL }} + # Vertex (Anthropic-on-Vertex): SA + project required; region defaults + # to "global", model to a pinned claude snapshot. + GOOGLE_VERTEX_SA_BASE64: ${{ secrets.E2E_GOOGLE_VERTEX_SA_BASE64 }} + GOOGLE_VERTEX_PROJECT: ${{ secrets.E2E_GOOGLE_VERTEX_PROJECT }} + GOOGLE_VERTEX_REGION: ${{ secrets.E2E_GOOGLE_VERTEX_REGION }} + GOOGLE_VERTEX_MODEL: ${{ secrets.E2E_GOOGLE_VERTEX_MODEL }} + # Read through an env var rather than interpolated into the run + # script: a dispatch input reaching a shell command directly is a + # script-injection seam, however trusted the dispatcher. + TEST_PATTERN: ${{ inputs.test_pattern || './e2e/...' }} + run: go test -tags e2e -timeout 40m -v "$TEST_PATTERN" diff --git a/.github/workflows/buf.yml b/.github/workflows/buf.yml new file mode 100644 index 000000000..cc7629534 --- /dev/null +++ b/.github/workflows/buf.yml @@ -0,0 +1,48 @@ +name: protobuf checks +on: + push: + branches: + - main + - "release-*" + pull_request: + paths: + - ".github/workflows/buf.yml" + - "**/buf.yaml" + - "**/buf.lock" + - "**/buf.gen.yaml" + - "**.proto" +permissions: + contents: read + pull-requests: read +jobs: + buf: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: bufbuild/buf-action@8c6a16e16f12ba20b6470afa9c2ba9b5ba8c97c3 # v1.5.0 + with: + push: false + archive: false + pr_comment: false + lint: false + format: false + # A push that creates a branch carries no `before` commit, so the + # action's default baseline is the all-zero SHA and `buf breaking` + # dies cloning it. Skipping costs nothing: every commit on a freshly + # cut release branch should have already passed this check on main. + breaking: ${{ !github.event.created }} + # The alternative is to compare against the default branch instead of + # skipping. Not used: buf clones the baseline when the job runs, so a + # main that has moved on since the branch was cut reads as protos + # deleted on the release branch. Resolving to an empty string on every + # other event is what keeps the action's own default in place, which + # stacked pull requests need. + # breaking_against: >- + # ${{ github.event.created + # && format('{0}#format=git,branch={1}', + # github.event.repository.clone_url, + # github.event.repository.default_branch) + # || '' }} diff --git a/.github/workflows/check-license-dependencies.yml b/.github/workflows/check-license-dependencies.yml index a721cb516..81d293e4f 100644 --- a/.github/workflows/check-license-dependencies.yml +++ b/.github/workflows/check-license-dependencies.yml @@ -2,16 +2,16 @@ name: Check License Dependencies on: push: - branches: [ main ] + branches: [main, "release-*"] paths: - - 'go.mod' - - 'go.sum' - - '.github/workflows/check-license-dependencies.yml' + - "go.mod" + - "go.sum" + - ".github/workflows/check-license-dependencies.yml" pull_request: paths: - - 'go.mod' - - 'go.sum' - - '.github/workflows/check-license-dependencies.yml' + - "go.mod" + - "go.sum" + - ".github/workflows/check-license-dependencies.yml" jobs: check-internal-dependencies: @@ -19,7 +19,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Check for problematic license dependencies run: | @@ -56,55 +59,57 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: 'go.mod' - cache: true + - name: Set up Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: "go.mod" + cache: true - - name: Install go-licenses - run: go install github.com/google/go-licenses@v1.6.0 + - name: Install go-licenses + run: go install github.com/google/go-licenses@v1.6.0 - - name: Check for GPL/AGPL licensed dependencies - run: | - echo "Checking for GPL/AGPL/LGPL licensed dependencies..." - echo "" - - # Check all Go packages for copyleft licenses, excluding internal netbird packages - COPYLEFT_DEPS=$(go-licenses report ./... 2>/dev/null | grep -E 'GPL|AGPL|LGPL' | grep -v 'github.com/netbirdio/netbird/' || true) - - if [ -n "$COPYLEFT_DEPS" ]; then - echo "Found copyleft licensed dependencies:" - echo "$COPYLEFT_DEPS" + - name: Check for GPL/AGPL licensed dependencies + run: | + echo "Checking for GPL/AGPL/LGPL licensed dependencies..." echo "" - # Filter out dependencies that are only pulled in by internal AGPL packages - INCOMPATIBLE="" - while IFS=',' read -r package url license; do - if echo "$license" | grep -qE 'GPL-[0-9]|AGPL-[0-9]|LGPL-[0-9]'; then - # Find ALL packages that import this GPL package using go list - IMPORTERS=$(go list -json -deps ./... 2>/dev/null | jq -r "select(.Imports[]? == \"$package\") | .ImportPath") + # Check all Go packages for copyleft licenses, excluding internal netbird packages + COPYLEFT_DEPS=$(go-licenses report ./... 2>/dev/null | grep -E 'GPL|AGPL|LGPL' | grep -v 'github.com/netbirdio/netbird/' || true) - # Check if any importer is NOT in management/signal/relay - BSD_IMPORTER=$(echo "$IMPORTERS" | grep -v "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\|combined\|tools/idp-migrate\)" | head -1) - - if [ -n "$BSD_IMPORTER" ]; then - echo "❌ $package ($license) is imported by BSD-licensed code: $BSD_IMPORTER" - INCOMPATIBLE="${INCOMPATIBLE}${package},${url},${license}\n" - else - echo "✓ $package ($license) is only used by internal AGPL packages - OK" - fi - fi - done <<< "$COPYLEFT_DEPS" - - if [ -n "$INCOMPATIBLE" ]; then + if [ -n "$COPYLEFT_DEPS" ]; then + echo "Found copyleft licensed dependencies:" + echo "$COPYLEFT_DEPS" echo "" - echo "❌ INCOMPATIBLE licenses found that are used by BSD-licensed code:" - echo -e "$INCOMPATIBLE" - exit 1 - fi - fi - echo "✅ All external license dependencies are compatible with BSD-3-Clause" + # Filter out dependencies that are only pulled in by internal AGPL packages + INCOMPATIBLE="" + while IFS=',' read -r package url license; do + if echo "$license" | grep -qE 'GPL-[0-9]|AGPL-[0-9]|LGPL-[0-9]'; then + # Find ALL packages that import this GPL package using go list + IMPORTERS=$(go list -json -deps ./... 2>/dev/null | jq -r "select(.Imports[]? == \"$package\") | .ImportPath") + + # Check if any importer is NOT in management/signal/relay + BSD_IMPORTER=$(echo "$IMPORTERS" | grep -v "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\|combined\|tools/idp-migrate\)" | head -1) + + if [ -n "$BSD_IMPORTER" ]; then + echo "❌ $package ($license) is imported by BSD-licensed code: $BSD_IMPORTER" + INCOMPATIBLE="${INCOMPATIBLE}${package},${url},${license}\n" + else + echo "✓ $package ($license) is only used by internal AGPL packages - OK" + fi + fi + done <<< "$COPYLEFT_DEPS" + + if [ -n "$INCOMPATIBLE" ]; then + echo "" + echo "❌ INCOMPATIBLE licenses found that are used by BSD-licensed code:" + echo -e "$INCOMPATIBLE" + exit 1 + fi + fi + + echo "✅ All external license dependencies are compatible with BSD-3-Clause" diff --git a/.github/workflows/docs-ack.yml b/.github/workflows/docs-ack.yml index f11142a36..7e34e2f8a 100644 --- a/.github/workflows/docs-ack.yml +++ b/.github/workflows/docs-ack.yml @@ -83,7 +83,7 @@ jobs: - name: Verify docs PR exists (and is open or merged) if: steps.validate.outputs.mode == 'added' - uses: actions/github-script@v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 id: verify with: pr_number: ${{ steps.extract.outputs.pr_number }} diff --git a/.github/workflows/forum.yml b/.github/workflows/forum.yml index a26a72586..75543ef8b 100644 --- a/.github/workflows/forum.yml +++ b/.github/workflows/forum.yml @@ -8,11 +8,10 @@ jobs: post: runs-on: ubuntu-latest steps: - - uses: roots/discourse-topic-github-release-action@main + - uses: roots/discourse-topic-github-release-action@557d74ea05b6cc0c47f555c1d5d28a89d904005b # v1.1.0 with: discourse-api-key: ${{ secrets.DISCOURSE_RELEASES_API_KEY }} discourse-base-url: https://forum.netbird.io discourse-author-username: NetBird discourse-category: 17 - discourse-tags: - releases + discourse-tags: releases diff --git a/.github/workflows/frontend-ui.yml b/.github/workflows/frontend-ui.yml new file mode 100644 index 000000000..014c5c2ae --- /dev/null +++ b/.github/workflows/frontend-ui.yml @@ -0,0 +1,99 @@ +name: UI Frontend + +on: + pull_request: + paths: + - "client/ui/frontend/**" + - "client/ui/i18n/**" + - "client/ui/**/*.go" + - ".github/workflows/frontend-ui.yml" + push: + branches: + - main + - "release-*" + paths: + - "client/ui/frontend/**" + - "client/ui/i18n/**" + - "client/ui/**/*.go" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }} + cancel-in-progress: true + +jobs: + lint-and-build: + name: Lint & Build + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: client/ui/frontend + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Set up pnpm + uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0 + with: + version: 11 + + # Bindings are generated by wails3 from the Go service definitions and + # are not checked in (see client/ui/frontend/bindings/). Without them, + # typecheck/build fail on missing module imports. + - name: Set up Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: "go.mod" + cache: false + + # wails3 CLI links against GTK4 / WebKitGTK 6.0 via its internal/operatingsystem + # package, so the dev libraries must be present before `go install`. + - name: Install Wails Linux system dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + pkg-config \ + libgtk-4-dev \ + libwebkitgtk-6.0-dev + + - name: Install wails3 CLI + # Version derived from go.mod so the binding generator always matches + # the wails runtime the daemon links against. + working-directory: ${{ github.workspace }} + run: | + WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3) + go install github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION + + - name: Get pnpm store directory + id: pnpm-store + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - name: Cache pnpm store + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-pnpm-${{ hashFiles('client/ui/frontend/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm- + + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Generate Wails bindings + run: pnpm run bindings + + - name: Lint, typecheck, format + run: pnpm check + + - name: Build + run: pnpm build diff --git a/.github/workflows/git-town.yml b/.github/workflows/git-town.yml index 699ed7d93..160c2ea38 100644 --- a/.github/workflows/git-town.yml +++ b/.github/workflows/git-town.yml @@ -3,7 +3,7 @@ name: Git Town on: pull_request: branches: - - '**' + - "**" jobs: git-town: @@ -15,7 +15,9 @@ jobs: pull-requests: write steps: - - uses: actions/checkout@v4 - - uses: git-town/action@v1.2.1 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: git-town/action@3d8b878379abb1ee393fb49865a28b4a6c2cd3b0 # v1.2.1 with: skip-single-stacks: true diff --git a/.github/workflows/golang-test-darwin.yml b/.github/workflows/golang-test-darwin.yml index 0528ed086..c17d8e775 100644 --- a/.github/workflows/golang-test-darwin.yml +++ b/.github/workflows/golang-test-darwin.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - "release-*" pull_request: concurrency: @@ -16,16 +17,18 @@ jobs: runs-on: macos-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Install Go - uses: actions/setup-go@v5 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false - name: Cache Go modules - uses: actions/cache@v4 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: ~/go/pkg/mod key: macos-gotest-${{ hashFiles('**/go.sum') }} @@ -43,5 +46,19 @@ 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 -e /management -e /signal -e /relay -e /proxy -e /combined) + # Exclude client/ui: its main.go uses //go:embed all:frontend/dist, + # which fails to compile until the frontend has been built. The Wails UI + # has no Go-side unit tests, and its release pipeline runs `pnpm build` + # before goreleaser. + # `go list -e` lets the listing succeed even though the embed fails to + # resolve; the grep then drops the broken package by path. Without -e, + # go list aborts with empty stdout and `go test` falls back to the repo + # root, which has no Go files. + run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags 'devcert privileged' -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /client/testutil/privileged) + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: netbirdio/netbird + flags: unit,client diff --git a/.github/workflows/golang-test-freebsd.yml b/.github/workflows/golang-test-freebsd.yml index 2c029b117..65c39147a 100644 --- a/.github/workflows/golang-test-freebsd.yml +++ b/.github/workflows/golang-test-freebsd.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - "release-*" pull_request: concurrency: @@ -15,20 +16,31 @@ jobs: name: "Client / Unit" runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v4 + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Read Go version from go.mod + id: goversion + run: echo "version=$(awk '/^go / {print $2}' go.mod)" >> "$GITHUB_OUTPUT" + - name: Test in FreeBSD id: test - uses: vmactions/freebsd-vm@v1 + env: + GO_VERSION: ${{ steps.goversion.outputs.version }} + uses: vmactions/freebsd-vm@b84ab5559b5a1bb4b8ee2737d2506a16e1737636 # v1.4.8 with: usesh: true copyback: false - release: "14.2" + release: "15.0" + envs: "GO_VERSION" prepare: | pkg install -y curl pkgconf xorg - GO_TARBALL="go1.25.3.freebsd-amd64.tar.gz" + GO_TARBALL="go${GO_VERSION}.freebsd-amd64.tar.gz" GO_URL="https://go.dev/dl/$GO_TARBALL" curl -vLO "$GO_URL" - tar -C /usr/local -vxzf "$GO_TARBALL" + tar -C /usr/local -vxzf "$GO_TARBALL" # -x - to print all executed commands # -e - to faile on first error @@ -37,14 +49,14 @@ jobs: export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin time go build -o netbird client/main.go # check all component except management, since we do not support management server on freebsd - time go test -timeout 1m -failfast ./base62/... + time go test -tags privileged -timeout 1m -failfast ./base62/... # NOTE: without -p1 `client/internal/dns` will fail because of `listen udp4 :33100: bind: address already in use` - time go test -timeout 8m -failfast -v -p 1 ./client/... - time go test -timeout 1m -failfast ./dns/... - time go test -timeout 1m -failfast ./encryption/... - time go test -timeout 1m -failfast ./formatter/... - 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 ./util/... - time go test -timeout 1m -failfast ./version/... + time go test -tags privileged -timeout 8m -failfast -v -p 1 ./client/... + time go test -tags privileged -timeout 1m -failfast ./dns/... + time go test -tags privileged -timeout 1m -failfast ./encryption/... + time go test -tags privileged -timeout 1m -failfast ./formatter/... + time go test -tags privileged -timeout 1m -failfast ./client/iface/... + time go test -tags privileged -timeout 1m -failfast ./route/... + time go test -tags privileged -timeout 1m -failfast ./sharedsock/... + time go test -tags privileged -timeout 1m -failfast ./util/... + time go test -tags privileged -timeout 1m -failfast ./version/... diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml index 450c44aea..f24dfbe9d 100644 --- a/.github/workflows/golang-test-linux.yml +++ b/.github/workflows/golang-test-linux.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - "release-*" pull_request: concurrency: @@ -18,9 +19,11 @@ jobs: management: ${{ steps.filter.outputs.management }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - - uses: dorny/paths-filter@v3 + - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 id: filter with: filters: | @@ -28,7 +31,7 @@ jobs: - 'management/**' - name: Install Go - uses: actions/setup-go@v5 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false @@ -36,10 +39,10 @@ jobs: - name: Get Go environment run: | echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV - echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV + echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV - name: Cache Go modules - uses: actions/cache@v4 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 id: cache with: path: | @@ -51,7 +54,7 @@ jobs: - name: Install dependencies if: steps.cache.outputs.cache-hit != 'true' - run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev + run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev - name: Install 32-bit libpcap if: steps.cache.outputs.cache-hit != 'true' @@ -113,14 +116,16 @@ jobs: strategy: fail-fast: false matrix: - arch: [ '386','amd64' ] + arch: ["386", "amd64"] runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Install Go - uses: actions/setup-go@v5 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false @@ -128,10 +133,10 @@ jobs: - name: Get Go environment run: | echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV - echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV + echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV - name: Cache Go modules - uses: actions/cache/restore@v4 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ${{ env.cache }} @@ -141,7 +146,7 @@ jobs: ${{ runner.os }}-gotest-cache- - name: Install dependencies - run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev + run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev - name: Install 32-bit libpcap if: matrix.arch == '386' @@ -154,18 +159,36 @@ 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 -e /proxy -e /combined) + # Exclude client/ui: its main.go uses //go:embed all:frontend/dist, + # which fails to compile until the frontend has been built. The Wails UI + # has no Go-side unit tests, and its release pipeline runs `pnpm build` + # before goreleaser. + # `go list -e` lets the listing succeed even though the embed fails to + # resolve; the grep then drops the broken package by path. Without -e, + # go list aborts with empty stdout and `go test` falls back to the repo + # root, which has no Go files. + run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -coverprofile=coverage.txt -tags 'devcert privileged' -exec 'sudo --preserve-env=CI,CGO_ENABLED' -timeout 10m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /client/testutil/privileged) + + - name: Upload coverage reports to Codecov + if: matrix.arch == 'amd64' + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: netbirdio/netbird + flags: unit,client test_client_on_docker: name: "Client (Docker) / Unit" - needs: [ build-cache ] + needs: [build-cache] runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Install Go - uses: actions/setup-go@v5 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false @@ -177,7 +200,7 @@ jobs: echo "modcache_dir=$(go env GOMODCACHE)" >> $GITHUB_OUTPUT - name: Cache Go modules - uses: actions/cache/restore@v4 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 id: cache-restore with: path: | @@ -210,11 +233,11 @@ jobs: -e GOCACHE=${CONTAINER_GOCACHE} \ -e GOMODCACHE=${CONTAINER_GOMODCACHE} \ -e CONTAINER=${CONTAINER} \ - golang:1.25-alpine \ + golang:1.26.7-alpine \ sh -c ' \ apk update; apk add --no-cache \ ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \ - 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) + go test -buildvcs=false -tags "devcert privileged" -v -timeout 10m -p 1 $(go list -e -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server -e /client/testutil/privileged) ' test_relay: @@ -231,10 +254,12 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Install Go - uses: actions/setup-go@v5 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false @@ -246,10 +271,10 @@ jobs: - name: Get Go environment run: | echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV - echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV + echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV - name: Cache Go modules - uses: actions/cache/restore@v4 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ${{ env.cache }} @@ -268,23 +293,33 @@ jobs: run: | CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \ go test ${{ matrix.raceFlag }} \ - -exec 'sudo' \ + -exec 'sudo' -coverprofile=coverage.txt \ -timeout 10m -p 1 ./relay/... ./shared/relay/... + - name: Upload coverage reports to Codecov + if: matrix.arch == 'amd64' + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: netbirdio/netbird + flags: unit,relay + test_proxy: name: "Proxy / Unit" needs: [build-cache] strategy: fail-fast: false matrix: - arch: [ '386','amd64' ] + arch: ["386", "amd64"] runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Install Go - uses: actions/setup-go@v5 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false @@ -298,7 +333,7 @@ jobs: echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV - name: Cache Go modules - uses: actions/cache/restore@v4 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ${{ env.cache }} @@ -316,7 +351,15 @@ jobs: - name: Test run: | CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \ - go test -timeout 10m -p 1 ./proxy/... + go test -timeout 10m -p 1 -coverprofile=coverage.txt ./proxy/... + + - name: Upload coverage reports to Codecov + if: matrix.arch == 'amd64' + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: netbirdio/netbird + flags: unit,proxy test_signal: name: "Signal / Unit" @@ -324,14 +367,16 @@ jobs: strategy: fail-fast: false matrix: - arch: [ '386','amd64' ] + arch: ["386", "amd64"] runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Install Go - uses: actions/setup-go@v5 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false @@ -343,10 +388,10 @@ jobs: - name: Get Go environment run: | echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV - echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV + echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV - name: Cache Go modules - uses: actions/cache/restore@v4 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ${{ env.cache }} @@ -365,24 +410,34 @@ jobs: run: | CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \ go test \ - -exec 'sudo' \ + -exec 'sudo' -coverprofile=coverage.txt \ -timeout 10m ./signal/... ./shared/signal/... + - name: Upload coverage reports to Codecov + if: matrix.arch == 'amd64' + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: netbirdio/netbird + flags: unit,signal + test_management: name: "Management / Unit" - needs: [ build-cache ] + needs: [build-cache] strategy: fail-fast: false matrix: - arch: [ 'amd64' ] - store: [ 'sqlite', 'postgres', 'mysql' ] + arch: ["amd64"] + store: ["sqlite", "postgres", "mysql"] runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Install Go - uses: actions/setup-go@v5 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false @@ -390,10 +445,10 @@ jobs: - name: Get Go environment run: | echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV - echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV + echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV - name: Cache Go modules - uses: actions/cache/restore@v4 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ${{ env.cache }} @@ -410,7 +465,7 @@ jobs: - name: Login to Docker hub 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 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_TOKEN }} @@ -427,23 +482,31 @@ jobs: run: docker pull mlsmaycon/warmed-mysql:8 - name: Test - run: | + run: | CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \ NETBIRD_STORE_ENGINE=${{ matrix.store }} \ CI=true \ - go test -tags=devcert \ + go test -tags=devcert -coverprofile=coverage.txt \ -exec "sudo --preserve-env=CI,NETBIRD_STORE_ENGINE" \ -timeout 20m ./management/... ./shared/management/... + - name: Upload coverage reports to Codecov + if: matrix.arch == 'amd64' + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: netbirdio/netbird + flags: unit,management + benchmark: name: "Management / Benchmark" - needs: [ build-cache ] + needs: [build-cache] if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }} strategy: fail-fast: false matrix: - arch: [ 'amd64' ] - store: [ 'sqlite', 'postgres' ] + arch: ["amd64"] + store: ["sqlite", "postgres"] runs-on: ubuntu-22.04 steps: - name: Create Docker network @@ -474,10 +537,12 @@ jobs: prom/prometheus - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Install Go - uses: actions/setup-go@v5 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false @@ -485,10 +550,10 @@ jobs: - name: Get Go environment run: | echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV - echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV + echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV - name: Cache Go modules - uses: actions/cache/restore@v4 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ${{ env.cache }} @@ -505,7 +570,7 @@ jobs: - name: Login to Docker hub 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 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_TOKEN }} @@ -522,20 +587,21 @@ jobs: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \ NETBIRD_STORE_ENGINE=${{ matrix.store }} \ CI=true \ - GIT_BRANCH=${{ github.ref_name }} \ go test -tags devcert -run=^$ -bench=. \ -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE,GIT_BRANCH,GITHUB_RUN_ID' \ -timeout 20m ./management/... ./shared/management/... $(go list ./management/... ./shared/management/... | grep -v -e /management/server/http) + env: + GIT_BRANCH: ${{ github.ref_name }} api_benchmark: name: "Management / Benchmark (API)" - needs: [ build-cache ] + needs: [build-cache] if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }} strategy: fail-fast: false matrix: - arch: [ 'amd64' ] - store: [ 'sqlite', 'postgres' ] + arch: ["amd64"] + store: ["sqlite", "postgres"] runs-on: ubuntu-22.04 steps: - name: Create Docker network @@ -566,10 +632,12 @@ jobs: prom/prometheus - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Install Go - uses: actions/setup-go@v5 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false @@ -577,10 +645,10 @@ jobs: - name: Get Go environment run: | echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV - echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV + echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV - name: Cache Go modules - uses: actions/cache/restore@v4 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ${{ env.cache }} @@ -597,7 +665,7 @@ jobs: - name: Login to Docker hub 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 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_TOKEN }} @@ -614,29 +682,32 @@ jobs: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \ NETBIRD_STORE_ENGINE=${{ matrix.store }} \ CI=true \ - GIT_BRANCH=${{ github.ref_name }} \ go test -tags=benchmark \ -run=^$ \ -bench=. \ -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE,GIT_BRANCH,GITHUB_RUN_ID' \ -timeout 20m ./management/server/http/... + env: + GIT_BRANCH: ${{ github.ref_name }} api_integration_test: name: "Management / Integration" - needs: [ build-cache ] + needs: [build-cache] if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }} strategy: fail-fast: false matrix: - arch: [ 'amd64' ] - store: [ 'sqlite', 'postgres'] + arch: ["amd64"] + store: ["sqlite", "postgres"] runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Install Go - uses: actions/setup-go@v5 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false @@ -644,10 +715,10 @@ jobs: - name: Get Go environment run: | echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV - echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV + echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV - name: Cache Go modules - uses: actions/cache/restore@v4 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ${{ env.cache }} @@ -659,6 +730,11 @@ jobs: - name: Install modules run: go mod tidy + - name: Run Mage + uses: magefile/mage-action@a662bd8c29d8106879588cfff83b2faf6e6f59db # v4.0.0 + with: + install-only: true + - name: check git status run: git --no-pager diff --exit-code @@ -667,6 +743,12 @@ jobs: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \ NETBIRD_STORE_ENGINE=${{ matrix.store }} \ CI=true \ - go test -tags=integration \ - -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' \ - -timeout 20m ./management/server/http/... + mage integrationtest:all -gotestflags="-coverprofile=coverage.txt" + + - name: Upload coverage reports to Codecov + if: matrix.arch == 'amd64' + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: netbirdio/netbird + flags: integration,management diff --git a/.github/workflows/golang-test-windows.yml b/.github/workflows/golang-test-windows.yml index 8e672043d..fb7b745d2 100644 --- a/.github/workflows/golang-test-windows.yml +++ b/.github/workflows/golang-test-windows.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - "release-*" pull_request: env: @@ -18,10 +19,12 @@ jobs: runs-on: windows-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Install Go - uses: actions/setup-go@v5 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 id: go with: go-version-file: "go.mod" @@ -33,7 +36,7 @@ jobs: echo "modcache=$(go env GOMODCACHE)" >> $env:GITHUB_ENV - name: Cache Go modules - uses: actions/cache@v4 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ${{ env.cache }} @@ -44,16 +47,15 @@ jobs: ${{ runner.os }}-go- - name: Download wintun - uses: carlosperate/download-file-action@v2 id: download-wintun + uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2 with: - file-url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip - file-name: wintun.zip - location: ${{ env.downloadPath }} - sha256: '07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51' + url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip + destination: ${{ env.downloadPath }}\wintun.zip + sha256: 07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51 - name: Decompressing wintun files - run: tar -zvxf "${{ steps.download-wintun.outputs.file-path }}" -C ${{ env.downloadPath }} + run: tar -xvf "${{ steps.download-wintun.outputs.file-path }}" -C ${{ env.downloadPath }} - run: mv ${{ env.downloadPath }}/wintun/bin/amd64/wintun.dll 'C:\Windows\System32\' @@ -64,10 +66,17 @@ jobs: - run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOCACHE=${{ env.modcache }} - run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe mod tidy - name: Generate test script + # Exclude client/ui: its main.go uses //go:embed all:frontend/dist, + # which fails to compile until the frontend has been built. The Wails UI + # has no Go-side unit tests, and its release pipeline runs `pnpm build` + # before goreleaser. + # `go list -e` lets the listing succeed even though the embed fails to + # resolve; the Where-Object pipeline then drops the broken package by + # path. Without -e, go list aborts with empty stdout. run: | - $packages = go list ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } + $packages = go list -e ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui' } $goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe" - $cmd = "$goExe test -tags=devcert -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1" + $cmd = "$goExe test -tags `"devcert privileged`" -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1" Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd - name: test diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 62dfe9bce..586e1235b 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -15,12 +15,22 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 - - name: codespell - uses: codespell-project/actions-codespell@v2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA - skip: go.mod,go.sum,**/proxy/web/** + persist-credentials: false + - name: codespell + uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2 + with: + ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals,flate,recordin,unparseable + # Non-English UI translations trip codespell on real foreign words + # (de: "Sie", "oder", "ist"). Only en/common.json is the source of + # truth that should be spell-checked. List each translated locale + # dir below and add new ones as languages are added under + # client/ui/i18n/locales/. Single-star globs are matched per path + # segment by codespell and behave the same across versions; the + # recursive "**" form did not take effect with the codespell shipped + # by this action. + skip: go.mod,go.sum,*/proxy/web/*,*pnpm-lock.yaml,*package-lock.json,*/locales/de/*,*/locales/es/*,*/locales/fr/*,*/locales/hu/*,*/locales/it/*,*/locales/pt/*,*/locales/ru/*,*/locales/zh-CN/*,*/i18n/TRANSLATING.md golangci: strategy: fail-fast: false @@ -35,27 +45,38 @@ jobs: display_name: Linux name: ${{ matrix.display_name }} runs-on: ${{ matrix.os }} - timeout-minutes: 15 + timeout-minutes: 25 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Check for duplicate constants if: matrix.os == 'ubuntu-latest' run: | ! awk '/const \(/,/)/{print $0}' management/server/activity/codes.go | grep -o '= [0-9]*' | sort | uniq -d | grep . - name: Install Go - uses: actions/setup-go@v5 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false - name: Install dependencies if: matrix.os == 'ubuntu-latest' - run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev + run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgl1-mesa-dev xorg-dev libpcap-dev + - name: Stub Wails frontend bundle + # client/ui/main.go has //go:embed all:frontend/dist. The + # directory is produced by `pnpm run build` and is gitignored, so + # lint-only runs (no frontend toolchain) need a placeholder file + # for the embed pattern to match. + shell: bash + run: | + mkdir -p client/ui/frontend/dist + touch client/ui/frontend/dist/.embed-placeholder - name: golangci-lint - uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 + uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1 with: version: latest skip-cache: true skip-save-cache: true cache-invalidation-interval: 0 - args: --timeout=12m + args: --timeout=20m diff --git a/.github/workflows/install-script-test.yml b/.github/workflows/install-script-test.yml index 22d002a48..61709501c 100644 --- a/.github/workflows/install-script-test.yml +++ b/.github/workflows/install-script-test.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - "release-*" pull_request: paths: - "release_files/install.sh" @@ -22,7 +23,9 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: run install script env: diff --git a/.github/workflows/mobile-build-validation.yml b/.github/workflows/mobile-build-validation.yml deleted file mode 100644 index 8325fbf2d..000000000 --- a/.github/workflows/mobile-build-validation.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Mobile - -on: - push: - branches: - - main - pull_request: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }} - cancel-in-progress: true - -jobs: - android_build: - name: "Android / Build" - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - name: Install Go - uses: actions/setup-go@v5 - with: - go-version-file: "go.mod" - - name: Setup Android SDK - uses: android-actions/setup-android@v3 - with: - cmdline-tools-version: 8512546 - - name: Setup Java - uses: actions/setup-java@v4 - with: - java-version: "11" - distribution: "adopt" - - name: NDK Cache - id: ndk-cache - uses: actions/cache@v4 - with: - path: /usr/local/lib/android/sdk/ndk - key: ndk-cache-23.1.7779620 - - name: Setup NDK - run: /usr/local/lib/android/sdk/cmdline-tools/7.0/bin/sdkmanager --install "ndk;23.1.7779620" - - name: install gomobile - run: go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20251113184115-a159579294ab - - name: gomobile init - run: gomobile init - - name: build android netbird lib - run: PATH=$PATH:$(go env GOPATH) gomobile bind -o $GITHUB_WORKSPACE/netbird.aar -javapkg=io.netbird.gomobile -ldflags="-checklinkname=0 -X golang.zx2c4.com/wireguard/ipc.socketDirectory=/data/data/io.netbird.client/cache/wireguard -X github.com/netbirdio/netbird/version.version=buildtest" $GITHUB_WORKSPACE/client/android - env: - CGO_ENABLED: 0 - ANDROID_NDK_HOME: /usr/local/lib/android/sdk/ndk/23.1.7779620 - ios_build: - name: "iOS / Build" - runs-on: macos-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - name: Install Go - uses: actions/setup-go@v5 - with: - go-version-file: "go.mod" - - name: install gomobile - run: go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20251113184115-a159579294ab - - name: gomobile init - run: gomobile init - - name: build iOS netbird lib - run: PATH=$PATH:$(go env GOPATH) gomobile bind -target=ios -bundleid=io.netbird.framework -ldflags="-X github.com/netbirdio/netbird/version.version=buildtest" -o ./NetBirdSDK.xcframework ./client/ios/NetBirdSDK - env: - CGO_ENABLED: 0 diff --git a/.github/workflows/no-new-replace.yml b/.github/workflows/no-new-replace.yml new file mode 100644 index 000000000..b906ce450 --- /dev/null +++ b/.github/workflows/no-new-replace.yml @@ -0,0 +1,78 @@ +name: No New Replace Directives + +on: + pull_request: + paths: + - "go.mod" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }} + cancel-in-progress: true + +jobs: + check-replace-directives: + name: check-replace-directives + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Install Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: go.mod + + - name: Compare replace directives against the base branch + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + + # A replace directive only applies when this module is the main + # module. Anything importing netbird as a library, the embedded + # clients among them, resolves the replaced path upstream instead and + # fails to build against whatever the replacement provides. Requiring + # a fork under its own module path avoids that; a replace does not. + # + # go.mod is parsed rather than diffed so that reordering, comments and + # single-line versus block syntax do not register as changes. + # + # Versions are part of the key because a replace can be scoped to one + # version of a module. Keyed on paths alone, retargeting such a + # directive at a different version would read as unchanged. + list_replaces() { + go mod edit -json "$1" \ + | jq -r ' + def ref: .Path + (if (.Version // "") == "" then "" else " " + .Version end); + (.Replace // [])[] | "\(.Old | ref) => \(.New | ref)" + ' \ + | sort + } + + git show "${BASE_SHA}:go.mod" > /tmp/base-go.mod + list_replaces /tmp/base-go.mod > /tmp/base-replaces + list_replaces go.mod > /tmp/head-replaces + + added=$(comm -13 /tmp/base-replaces /tmp/head-replaces) + if [ -n "$added" ]; then + echo "::error::This PR adds a replace directive to go.mod:" + echo "$added" | sed 's/^/ /' + echo "" + echo "A replace directive applies only to the main module, so it does not" + echo "reach anything that imports netbird as a library. Require the module" + echo "under a path you control instead, as done for github.com/netbirdio/go-nat." + exit 1 + fi + + removed=$(comm -23 /tmp/base-replaces /tmp/head-replaces) + if [ -n "$removed" ]; then + echo "This PR removes replace directives:" + echo "$removed" | sed 's/^/ /' + fi + echo "No new replace directives." diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index a2e6ce219..24d81b50f 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -9,13 +9,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Validate PR title prefix - uses: actions/github-script@v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const title = context.payload.pull_request.title; const allowedTags = [ 'management', 'client', + 'android', + 'ios', 'signal', 'proxy', 'relay', diff --git a/.github/workflows/proto-version-check.yml b/.github/workflows/proto-version-check.yml index ea300419d..fd2c2c908 100644 --- a/.github/workflows/proto-version-check.yml +++ b/.github/workflows/proto-version-check.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check for proto tool version changes - uses: actions/github-script@v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const files = await github.paginate(github.rest.pulls.listFiles, { @@ -20,34 +20,83 @@ jobs: per_page: 100, }); - const pbFiles = files.filter(f => f.filename.endsWith('.pb.go')); - const missingPatch = pbFiles.filter(f => !f.patch).map(f => f.filename); - if (missingPatch.length > 0) { - core.setFailed( - `Cannot inspect patch data for:\n` + - missingPatch.map(f => `- ${f}`).join('\n') + - `\nThis can happen with very large PRs. Verify proto versions manually.` - ); + // Cover renamed .pb.go files in addition to plain edits. + // Renamed entries land under the new path with previous_filename + // pointing at the base-side name, so we read the base content + // from the old path when present. + const changedPbFiles = files + .filter(f => (f.status === 'modified' || f.status === 'renamed') + && f.filename.endsWith('.pb.go')) + .map(f => ({ + headPath: f.filename, + basePath: f.previous_filename || f.filename, + })); + if (changedPbFiles.length === 0) { + console.log('No modified or renamed .pb.go files to check'); return; } - const versionPattern = /^[+-]\s*\/\/\s+protoc(?:-gen-go)?\s+v[\d.]+/; - const violations = []; - for (const file of pbFiles) { - const changed = file.patch - .split('\n') - .filter(line => versionPattern.test(line)); - if (changed.length > 0) { + // Matches the generator version headers protoc writes at the top + // of generated files: + // // protoc v3.21.12 + // // protoc-gen-go v1.26.0 + // // - protoc-gen-go-grpc v1.6.1 (grpc files prefix with "- ") + // The optional "- " prefix and the optional -gen-go / -gen-go-grpc + // suffixes keep the *_grpc.pb.go headers in scope. + const versionPattern = /^\s*\/\/\s+(?:-\s+)?protoc(?:-gen-go(?:-grpc)?)?\s+v[\d.]+/; + const baseSha = context.payload.pull_request.base.sha; + const headSha = context.payload.pull_request.head.sha; + + async function getVersionHeader(path, ref) { + try { + const res = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path, + ref, + }); + if (!res.data.content) { + return { ok: false, reason: 'no inline content (file too large)' }; + } + const content = Buffer.from(res.data.content, 'base64').toString('utf8'); + const lines = content + .split('\n') + .slice(0, 20) + .filter(line => versionPattern.test(line)); + return { ok: true, lines }; + } catch (e) { + return { ok: false, reason: e.message }; + } + } + + const violations = []; + for (const file of changedPbFiles) { + const [base, head] = await Promise.all([ + getVersionHeader(file.basePath, baseSha), + getVersionHeader(file.headPath, headSha), + ]); + if (!base.ok || !head.ok) { + core.warning( + `Skipping ${file.headPath}: base=${base.ok ? 'ok' : base.reason}, head=${head.ok ? 'ok' : head.reason}` + ); + continue; + } + if (base.lines.join('\n') !== head.lines.join('\n')) { violations.push({ - file: file.filename, - lines: changed, + file: file.basePath === file.headPath + ? file.headPath + : `${file.basePath} → ${file.headPath}`, + base: base.lines, + head: head.lines, }); } } if (violations.length > 0) { const details = violations.map(v => - `${v.file}:\n${v.lines.map(l => ' ' + l).join('\n')}` + `${v.file}:\n` + + ` base:\n${v.base.map(l => ' ' + l).join('\n') || ' (none)'}\n` + + ` head:\n${v.head.map(l => ' ' + l).join('\n') || ' (none)'}` ).join('\n\n'); core.setFailed( diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5ada1033d..c1bbe9c44 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,13 +6,17 @@ on: - "v*" branches: - main + - "release-*" pull_request: env: - SIGN_PIPE_VER: "v0.1.2" - GORELEASER_VER: "v2.14.3" + SIGN_PIPE_VER: "v0.1.8" + GORELEASER_VER: "v2.16.0" PRODUCT_NAME: "NetBird" COPYRIGHT: "NetBird GmbH" + flags: "" + SKIP_PUBLISH: "true" + SKIP_DOCKER_PUSH: "false" concurrency: group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }} @@ -24,13 +28,15 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Generate FreeBSD port diff - run: bash release_files/freebsd-port-diff.sh + run: bash -x release_files/freebsd-port-diff.sh - name: Generate FreeBSD port issue body - run: bash release_files/freebsd-port-issue-body.sh + run: bash -x release_files/freebsd-port-issue-body.sh - name: Check if diff was generated id: check_diff @@ -51,19 +57,26 @@ jobs: echo "Generated files for version: $VERSION" cat netbird-*.diff + - name: Read Go version from go.mod + id: goversion + run: echo "version=$(awk '/^go / {print $2}' go.mod)" >> "$GITHUB_OUTPUT" + - name: Test FreeBSD port if: steps.check_diff.outputs.diff_exists == 'true' - uses: vmactions/freebsd-vm@v1 + env: + GO_VERSION: ${{ steps.goversion.outputs.version }} + uses: vmactions/freebsd-vm@b84ab5559b5a1bb4b8ee2737d2506a16e1737636 # v1.4.8 with: usesh: true copyback: false release: "15.0" + envs: "GO_VERSION" prepare: | # Install required packages - pkg install -y git curl portlint go + pkg install -y git curl portlint # Install Go for building - GO_TARBALL="go1.25.5.freebsd-amd64.tar.gz" + GO_TARBALL="go${GO_VERSION}.freebsd-amd64.tar.gz" GO_URL="https://go.dev/dl/$GO_TARBALL" curl -LO "$GO_URL" tar -C /usr/local -xzf "$GO_TARBALL" @@ -93,19 +106,19 @@ jobs: # Show patched Makefile version=$(cat security/netbird/Makefile | grep -E '^DISTVERSION=' | awk '{print $NF}') - + cd /usr/ports/security/netbird export BATCH=yes make package pkg add ./work/pkg/netbird-*.pkg - + netbird version | grep "$version" echo "FreeBSD port test completed successfully!" - name: Upload FreeBSD port files if: steps.check_diff.outputs.diff_exists == 'true' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1 with: name: freebsd-port-files path: | @@ -114,30 +127,52 @@ jobs: retention-days: 30 release: - runs-on: ubuntu-latest-m - env: - flags: "" + runs-on: ubuntu-24.04-8-core + outputs: + release_artifact_url: ${{ steps.upload_release.outputs.artifact-url }} + linux_packages_artifact_url: ${{ steps.upload_linux_packages.outputs.artifact-url }} + windows_packages_artifact_url: ${{ steps.upload_windows_packages.outputs.artifact-url }} + macos_packages_artifact_url: ${{ steps.upload_macos_packages.outputs.artifact-url }} + ghcr_images: ${{ steps.tag_and_push_images.outputs.images_markdown }} steps: - - name: Parse semver string - id: semver_parser - uses: booxmedialtd/ws-action-parse-semver@v1 - with: - input_string: ${{ (startsWith(github.ref, 'refs/tags/v') && github.ref) || 'refs/tags/v0.0.0' }} - version_extractor_regex: '\/v(.*)$' - - - if: ${{ !startsWith(github.ref, 'refs/tags/v') }} - run: echo "flags=--snapshot" >> $GITHUB_ENV - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 # It is required for GoReleaser to work properly + persist-credentials: false + + - name: Parse semver string + id: semver_parser + uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2 + + - name: Set snapshot flag + if: ${{ !startsWith(github.ref, 'refs/tags/v') }} + run: | + echo "flags=--snapshot" >> $GITHUB_ENV + + - name: Set build vars + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + run: | + if [[ "x-${{ steps.semver_parser.outputs.prerelease }}" == "x-" && "x-${{ github.repository }}" == "x-netbirdio/netbird" ]]; then + echo "x-${{ github.repository }}" + echo "x-${{ steps.semver_parser.outputs.prerelease }}" + echo "SKIP_PUBLISH=false" >> $GITHUB_ENV + else + echo "x-${{ github.repository }}" + echo "x-${{ steps.semver_parser.outputs.prerelease }}" + fi + + if [[ "x-${{ github.repository }}" != "x-netbirdio/netbird" ]]; then + echo "SKIP_DOCKER_PUSH=true" >> $GITHUB_ENV + fi + - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false - name: Cache Go modules - uses: actions/cache@v4 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ~/go/pkg/mod @@ -147,21 +182,23 @@ jobs: ${{ runner.os }}-go-releaser- - name: Install modules run: go mod tidy + - name: run openapi generator + run: bash shared/management/http/api/generate.sh - name: check git status run: git --no-pager diff --exit-code - name: Set up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 #v4.1.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 #v4.1.0 - name: Login to Docker hub if: github.event_name != 'pull_request' - uses: docker/login-action@v1 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_TOKEN }} - name: Log in to the GitHub container registry if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - uses: docker/login-action@v3 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -178,14 +215,14 @@ jobs: echo "GPG_RPM_KEY_FILE=/tmp/gpg-rpm-signing-key.asc" >> $GITHUB_ENV - name: Install goversioninfo - run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e + run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@b66839b # v1.7.0 - name: Generate windows syso amd64 - run: goversioninfo -icon client/ui/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_amd64.syso + run: goversioninfo -icon client/ui/build/windows/icon.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_amd64.syso - name: Generate windows syso arm64 - run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_arm64.syso + run: goversioninfo -arm -64 -icon client/ui/build/windows/icon.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_arm64.syso - name: Run GoReleaser id: goreleaser - uses: goreleaser/goreleaser-action@v4 + uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 with: version: ${{ env.GORELEASER_VER }} args: release --clean ${{ env.flags }} @@ -196,6 +233,8 @@ jobs: UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }} GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }} NFPM_NETBIRD_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }} + SKIP_PUBLISH: ${{ env.SKIP_PUBLISH }} + SKIP_DOCKER_PUSH: ${{ env.SKIP_DOCKER_PUSH }} - name: Verify RPM signatures run: | docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c ' @@ -213,18 +252,40 @@ jobs: if: always() run: rm -f /tmp/gpg-rpm-signing-key.asc - name: Tag and push images (amd64 only) + id: tag_and_push_images if: | (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || - (github.event_name == 'push' && github.ref == 'refs/heads/main') + (github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release-'))) run: | + set -euo pipefail + + # $GITHUB_REF / $GITHUB_EVENT_NAME are read from the runner + # environment rather than substituted into this script with the + # workflow expression syntax: branch names may legally contain + # $(…), and interpolating github.ref would execute it. resolve_tags() { - if [[ "${{ github.event_name }}" == "pull_request" ]]; then + if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then echo "pr-${{ github.event.pull_request.number }}" - else + elif [[ "$GITHUB_REF" == "refs/heads/main" ]]; then echo "main sha-$(git rev-parse --short HEAD)" + else + # Release branches get an immutable sha-* tag only — the floating + # "main" tag must never move from a release branch. + echo "sha-$(git rev-parse --short HEAD)" fi } + ghcr_package_url() { + local image="$1" package encoded_package + package="${image#ghcr.io/}" + package="${package#*/}" + package="${package%%:*}" + encoded_package="${package//\//%2F}" + echo "https://github.com/orgs/netbirdio/packages/container/package/${encoded_package}" + } + + image_refs=() + tag_and_push() { local src="$1" img_name tag dst img_name="${src%%:*}" @@ -233,36 +294,60 @@ jobs: echo "Tagging ${src} -> ${dst}" docker tag "$src" "$dst" docker push "$dst" + image_refs+=("$dst") done } - export -f tag_and_push resolve_tags + cat > /tmp/goreleaser-artifacts.json <<'JSON' + ${{ steps.goreleaser.outputs.artifacts }} + JSON - 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 + # dockers_v2 artifacts have no top-level goarch field, so match the + # per-platform -amd64 tag suffix instead; it works for both the old + # dockers and the new dockers_v2 image naming. + mapfile -t src_images < <( + jq -r '.[] | select(.type == "Docker Image") | .name | select(startswith("ghcr.io/") and endswith("-amd64"))' /tmp/goreleaser-artifacts.json + ) + + for src in "${src_images[@]}"; do + tag_and_push "$src" + done + + { + echo "images_markdown<> "$GITHUB_OUTPUT" - name: upload non tags for debug purposes - uses: actions/upload-artifact@v4 + id: upload_release + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1 with: name: release path: dist/ retention-days: 7 - name: upload linux packages - uses: actions/upload-artifact@v4 + id: upload_linux_packages + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1 with: name: linux-packages path: dist/netbird_linux** retention-days: 7 - name: upload windows packages - uses: actions/upload-artifact@v4 + id: upload_windows_packages + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1 with: name: windows-packages path: dist/netbird_windows** retention-days: 7 - name: upload macos packages - uses: actions/upload-artifact@v4 + id: upload_macos_packages + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1 with: name: macos-packages path: dist/netbird_darwin** @@ -270,28 +355,43 @@ jobs: release_ui: runs-on: ubuntu-latest + outputs: + release_ui_artifact_url: ${{ steps.upload_release_ui.outputs.artifact-url }} steps: - - name: Parse semver string - id: semver_parser - uses: booxmedialtd/ws-action-parse-semver@v1 - with: - input_string: ${{ (startsWith(github.ref, 'refs/tags/v') && github.ref) || 'refs/tags/v0.0.0' }} - version_extractor_regex: '\/v(.*)$' - - - if: ${{ !startsWith(github.ref, 'refs/tags/v') }} - run: echo "flags=--snapshot" >> $GITHUB_ENV - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 # It is required for GoReleaser to work properly + persist-credentials: false + + - name: Parse semver string + id: semver_parser + uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2 + + - name: Set snapshot flag + if: ${{ !startsWith(github.ref, 'refs/tags/v') }} + run: | + echo "flags=--snapshot" >> $GITHUB_ENV + + - name: Set build vars + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + run: | + if [[ "x-${{ steps.semver_parser.outputs.prerelease }}" == "x-" && "x-${{ github.repository }}" == "x-netbirdio/netbird" ]]; then + echo "x-${{ github.repository }}" + echo "x-${{ steps.semver_parser.outputs.prerelease }}" + echo "SKIP_PUBLISH=false" >> $GITHUB_ENV + else + echo "x-${{ github.repository }}" + echo "x-${{ steps.semver_parser.outputs.prerelease }}" + fi - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false - name: Cache Go modules - uses: actions/cache@v4 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ~/go/pkg/mod @@ -306,8 +406,18 @@ jobs: - name: check git status run: git --no-pager diff --exit-code + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Set up pnpm + uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0 + with: + version: 11 + - name: Install dependencies - run: sudo apt update && sudo apt install -y -q libappindicator3-dev gir1.2-appindicator3-0.1 libxxf86vm-dev gcc-mingw-w64-x86-64 + run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev gcc-mingw-w64-x86-64 - name: Decode GPG signing key if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository @@ -325,14 +435,20 @@ jobs: tar -xf llvm-mingw-20250709-ucrt-ubuntu-22.04-x86_64.tar.xz echo "/tmp/llvm-mingw-20250709-ucrt-ubuntu-22.04-x86_64/bin" >> $GITHUB_PATH - name: Install goversioninfo - run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e + run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@b66839b # v1.7.0 + - name: Install wails3 CLI + # Version derived from go.mod so the binding generator always matches + # the wails runtime the binary links against. + run: | + WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3) + go install github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION - name: Generate windows syso amd64 - run: goversioninfo -64 -icon client/ui/assets/netbird.ico -manifest client/ui/manifest.xml -product-name ${{ env.PRODUCT_NAME }}-"UI" -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/ui/resources_windows_amd64.syso + run: goversioninfo -64 -icon client/ui/build/windows/icon.ico -manifest client/ui/build/windows/wails.exe.manifest -product-name ${{ env.PRODUCT_NAME }}-"UI" -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/ui/resources_windows_amd64.syso - name: Generate windows syso arm64 - run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/ui/manifest.xml -product-name ${{ env.PRODUCT_NAME }}-"UI" -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/ui/resources_windows_arm64.syso + run: goversioninfo -arm -64 -icon client/ui/build/windows/icon.ico -manifest client/ui/build/windows/wails.exe.manifest -product-name ${{ env.PRODUCT_NAME }}-"UI" -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/ui/resources_windows_arm64.syso - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v4 + uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 with: version: ${{ env.GORELEASER_VER }} args: release --config .goreleaser_ui.yaml --clean ${{ env.flags }} @@ -343,6 +459,7 @@ jobs: 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 }} + SKIP_PUBLISH: ${{ env.SKIP_PUBLISH }} - name: Verify RPM signatures run: | docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c ' @@ -360,28 +477,158 @@ jobs: if: always() run: rm -f /tmp/gpg-rpm-signing-key.asc - name: upload non tags for debug purposes - uses: actions/upload-artifact@v4 + id: upload_release_ui + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1 with: name: release-ui path: dist/ retention-days: 3 - release_ui_darwin: - runs-on: macos-latest + release_ui_gtk3: + # Legacy GTK3/WebKit2GTK 4.1 UI build for distros without WebKitGTK 6.0 + # (Ubuntu 22.04, Debian 12, RHEL 9, Fedora <=39). Runs on ubuntu-22.04 so + # the binary links against the oldest supported glibc. + runs-on: ubuntu-22.04 + outputs: + release_ui_gtk3_artifact_url: ${{ steps.upload_release_ui_gtk3.outputs.artifact-url }} steps: - - if: ${{ !startsWith(github.ref, 'refs/tags/v') }} - run: echo "flags=--snapshot" >> $GITHUB_ENV - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 # It is required for GoReleaser to work properly + persist-credentials: false + + - name: Parse semver string + id: semver_parser + uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2 + + - name: Set snapshot flag + if: ${{ !startsWith(github.ref, 'refs/tags/v') }} + run: | + echo "flags=--snapshot" >> $GITHUB_ENV + + - name: Set build vars + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + run: | + if [[ "x-${{ steps.semver_parser.outputs.prerelease }}" == "x-" && "x-${{ github.repository }}" == "x-netbirdio/netbird" ]]; then + echo "x-${{ github.repository }}" + echo "x-${{ steps.semver_parser.outputs.prerelease }}" + echo "SKIP_PUBLISH=false" >> $GITHUB_ENV + else + echo "x-${{ github.repository }}" + echo "x-${{ steps.semver_parser.outputs.prerelease }}" + fi + - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false - name: Cache Go modules - uses: actions/cache@v4 + # Restore-only from the release_ui cache written by trusted runs; the + # module cache is identical (same go.sum) and stale build-cache + # entries just miss. + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: ${{ runner.os }}-ui-go-releaser-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-ui-go-releaser- + + - name: Install modules + run: go mod tidy + + - name: check git status + run: git --no-pager diff --exit-code + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '22' + + - name: Set up pnpm + uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0 + with: + version: 11 + + - name: Install dependencies + run: sudo apt update && sudo apt install -y -q libgtk-3-dev libwebkit2gtk-4.1-dev + + - name: Decode GPG signing key + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + env: + GPG_RPM_PRIVATE_KEY: ${{ secrets.GPG_RPM_PRIVATE_KEY }} + run: | + echo "$GPG_RPM_PRIVATE_KEY" | base64 -d > /tmp/gpg-rpm-signing-key.asc + echo "GPG_RPM_KEY_FILE=/tmp/gpg-rpm-signing-key.asc" >> $GITHUB_ENV + + - name: Install wails3 CLI + # Version derived from go.mod so the binding generator always matches + # the wails runtime the binary links against. + # -tags gtk3: the CLI links the wails runtime's cgo packages, and the + # default tags request gtk4/webkitgtk-6.0 pkg-config entries that do + # not exist on ubuntu-22.04. + run: | + WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3) + go install -tags gtk3 github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 + with: + version: ${{ env.GORELEASER_VER }} + args: release --config .goreleaser_ui_gtk3.yaml --clean ${{ env.flags }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + UPLOAD_DEBIAN_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }} + UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }} + GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }} + NFPM_NETBIRD_UI_RPM_GTK3_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }} + SKIP_PUBLISH: ${{ env.SKIP_PUBLISH }} + - name: Verify RPM signatures + run: | + docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c ' + dnf install -y -q rpm-sign curl >/dev/null 2>&1 + curl -sSL https://pkgs.netbird.io/yum/repodata/repomd.xml.key -o /tmp/rpm-pub.key + rpm --import /tmp/rpm-pub.key + echo "=== Verifying RPM signatures ===" + for rpm_file in /dist/*.rpm; do + [ -f "$rpm_file" ] || continue + echo "--- $(basename $rpm_file) ---" + rpm -K "$rpm_file" + done + ' + - name: Clean up GPG key + if: always() + run: rm -f /tmp/gpg-rpm-signing-key.asc + - name: upload non tags for debug purposes + id: upload_release_ui_gtk3 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1 + with: + name: release-ui-gtk3 + path: dist/ + retention-days: 3 + + release_ui_darwin: + runs-on: macos-latest + outputs: + release_ui_darwin_artifact_url: ${{ steps.upload_release_ui_darwin.outputs.artifact-url }} + steps: + - if: ${{ !startsWith(github.ref, 'refs/tags/v') }} + run: echo "flags=--snapshot" >> $GITHUB_ENV + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 # It is required for GoReleaser to work properly + persist-credentials: false + - name: Set up Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: "go.mod" + cache: false + - name: Cache Go modules + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ~/go/pkg/mod @@ -393,28 +640,293 @@ jobs: run: go mod tidy - name: check git status run: git --no-pager diff --exit-code + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Set up pnpm + uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0 + with: + version: 11 + - name: Install wails3 CLI + # Version derived from go.mod so the binding generator always matches + # the wails runtime the binary links against. + run: | + WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3) + go install github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION - name: Run GoReleaser id: goreleaser - uses: goreleaser/goreleaser-action@v4 + uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 with: version: ${{ env.GORELEASER_VER }} args: release --config .goreleaser_ui_darwin.yaml --clean ${{ env.flags }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: upload non tags for debug purposes - uses: actions/upload-artifact@v4 + id: upload_release_ui_darwin + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1 with: name: release-ui-darwin path: dist/ retention-days: 3 + test_windows_installer: + name: "Windows Installer / Build Test" + runs-on: windows-2022 + needs: [release, release_ui] + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + wintun_arch: amd64 + - arch: arm64 + wintun_arch: arm64 + defaults: + run: + shell: powershell + env: + PackageWorkdir: netbird_windows_${{ matrix.arch }} + downloadPath: '${{ github.workspace }}\temp' + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Parse semver string + id: semver_parser + uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2 + + - name: Add 7-Zip to PATH + run: echo "C:\Program Files\7-Zip" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + - name: Download release artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release + path: release + + - name: Download UI release artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-ui + path: release-ui + + - name: Stage binaries into dist + run: | + $workdir = "dist\${{ env.PackageWorkdir }}" + New-Item -ItemType Directory -Force -Path $workdir | Out-Null + $client = Get-ChildItem -Recurse -Path release -Filter "netbird_*_windows_${{ matrix.arch }}.tar.gz" | Select-Object -First 1 + $ui = Get-ChildItem -Recurse -Path release-ui -Filter "netbird-ui-windows_*_windows_${{ matrix.arch }}.tar.gz" | Select-Object -First 1 + if (-not $client) { Write-Host "::error::client tarball not found for ${{ matrix.arch }}"; exit 1 } + if (-not $ui) { Write-Host "::error::ui tarball not found for ${{ matrix.arch }}"; exit 1 } + Write-Host "Client: $($client.FullName)" + Write-Host "UI: $($ui.FullName)" + tar -zvxf $client.FullName -C $workdir + tar -zvxf $ui.FullName -C $workdir + Get-ChildItem $workdir + + - name: Download wintun + id: download-wintun + uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2 + with: + url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip + destination: ${{ env.downloadPath }}\wintun.zip + sha256: 07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51 + + - name: Decompress wintun files + run: tar -xvf "${{ env.downloadPath }}\wintun.zip" -C ${{ env.downloadPath }} + + - name: Move wintun.dll into dist + run: mv ${{ env.downloadPath }}\wintun\bin\${{ matrix.wintun_arch }}\wintun.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\ + + - name: Download EnVar plugin for NSIS + uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2 + with: + url: https://pkgs.netbird.io/nsis/EnVar_plugin.zip + destination: ${{ github.workspace }}\envar_plugin.zip + sha256: e9aa92de351345ed82795251d838f1ae9041ba35af9d381a5780c7843b01f56a + + - name: Extract EnVar plugin + run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/envar_plugin.zip" + + - name: Download ShellExecAsUser plugin for NSIS (amd64 only) + if: matrix.arch == 'amd64' + uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2 + with: + url: https://pkgs.netbird.io/nsis/ShellExecAsUser_amd64-Unicode.7z + destination: ${{ github.workspace }}\ShellExecAsUser_amd64-Unicode.7z + sha256: 0a55ea25c7330a92cec028eda8afcaf1b1a7092e0dfb77c21c8f654564b4ff9d + + - name: Extract ShellExecAsUser plugin (amd64 only) + if: matrix.arch == 'amd64' + run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/ShellExecAsUser_amd64-Unicode.7z" + + - name: Set up Go for wails3 CLI + uses: actions/setup-go@v5 + with: + go-version-file: "go.mod" + cache: false + + - name: Install wails3 CLI + # Version derived from go.mod so the bootstrapper payload always + # matches the wails runtime the binary links against. + shell: bash + run: | + WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3) + go install github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION + + - name: Stage WebView2 bootstrapper for installers + # Both client/installer.nsis and client/netbird.wxs reference + # client/MicrosoftEdgeWebview2Setup.exe. wails3 writes it there. + # The signing pipeline (netbirdio/sign-pipelines) does the same + # step for release builds; this mirrors it for PR sanity testing. + shell: bash + run: wails3 generate webview2bootstrapper -dir client + + - name: Build NSIS installer + shell: pwsh + env: + APPVER: ${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }}.${{ steps.semver_parser.outputs.patch }}.${{ github.run_id }} + run: | + $nsisPluginDir = "C:\Program Files (x86)\NSIS\Plugins\x86-unicode" + $srcPlugins = "${{ github.workspace }}\NSIS_Plugins\Plugins" + Get-ChildItem -Path $srcPlugins -Recurse -Filter *.dll | + Copy-Item -Destination $nsisPluginDir -Force + & "C:\Program Files (x86)\NSIS\makensis.exe" /V4 "/DARCH=${{ matrix.arch }}" client\installer.nsis + if ($LASTEXITCODE -ne 0) { throw "makensis failed with exit code $LASTEXITCODE" } + + - name: Rename NSIS installer + run: mv netbird-installer.exe netbird_installer_test_windows_${{ matrix.arch }}.exe + + - name: Install WiX + run: | + dotnet tool install --global wix --version 6.0.2 + wix extension add WixToolset.Util.wixext/6.0.2 + + - name: Build MSI installer + env: + NETBIRD_VERSION: "${{ steps.semver_parser.outputs.fullversion }}" + run: wix build -arch ${{ matrix.arch == 'amd64' && 'x64' || 'arm64' }} -ext WixToolset.Util.wixext -o netbird_installer_test_windows_${{ matrix.arch }}.msi .\client\netbird.wxs -d ProcessorArchitecture=${{ matrix.arch == 'amd64' && 'x64' || 'arm64' }} -d ArchSuffix=${{ matrix.arch }} + + - name: Upload installer artifacts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1 + with: + name: windows-installer-test-${{ matrix.arch }} + path: | + netbird_installer_test_windows_${{ matrix.arch }}.exe + netbird_installer_test_windows_${{ matrix.arch }}.msi + retention-days: 3 + + comment_release_artifacts: + name: Comment release artifacts + runs-on: ubuntu-latest + needs: [release, release_ui, release_ui_gtk3, release_ui_darwin] + if: ${{ always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} + permissions: + contents: read + issues: write + pull-requests: write + steps: + - name: Create or update PR comment + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + RELEASE_RESULT: ${{ needs.release.result }} + RELEASE_UI_RESULT: ${{ needs.release_ui.result }} + RELEASE_UI_GTK3_RESULT: ${{ needs.release_ui_gtk3.result }} + RELEASE_UI_DARWIN_RESULT: ${{ needs.release_ui_darwin.result }} + RELEASE_ARTIFACT_URL: ${{ needs.release.outputs.release_artifact_url }} + LINUX_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.linux_packages_artifact_url }} + WINDOWS_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.windows_packages_artifact_url }} + MACOS_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.macos_packages_artifact_url }} + RELEASE_UI_ARTIFACT_URL: ${{ needs.release_ui.outputs.release_ui_artifact_url }} + RELEASE_UI_GTK3_ARTIFACT_URL: ${{ needs.release_ui_gtk3.outputs.release_ui_gtk3_artifact_url }} + RELEASE_UI_DARWIN_ARTIFACT_URL: ${{ needs.release_ui_darwin.outputs.release_ui_darwin_artifact_url }} + GHCR_IMAGES_MARKDOWN: ${{ needs.release.outputs.ghcr_images }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const marker = ''; + const { owner, repo } = context.repo; + const issue_number = context.payload.pull_request.number; + const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`; + const shortSha = context.payload.pull_request.head.sha.slice(0, 7); + + const artifactCell = (url, result) => { + if (url) return `[Download](${url})`; + return result && result !== 'success' ? `_Not available (${result})_` : '_Not available_'; + }; + + const artifacts = [ + ['All release artifacts', process.env.RELEASE_ARTIFACT_URL, process.env.RELEASE_RESULT], + ['Linux packages', process.env.LINUX_PACKAGES_ARTIFACT_URL, process.env.RELEASE_RESULT], + ['Windows packages', process.env.WINDOWS_PACKAGES_ARTIFACT_URL, process.env.RELEASE_RESULT], + ['macOS packages', process.env.MACOS_PACKAGES_ARTIFACT_URL, process.env.RELEASE_RESULT], + ['UI artifacts', process.env.RELEASE_UI_ARTIFACT_URL, process.env.RELEASE_UI_RESULT], + ['UI GTK3 artifacts', process.env.RELEASE_UI_GTK3_ARTIFACT_URL, process.env.RELEASE_UI_GTK3_RESULT], + ['UI macOS artifacts', process.env.RELEASE_UI_DARWIN_ARTIFACT_URL, process.env.RELEASE_UI_DARWIN_RESULT], + ]; + + const artifactRows = artifacts + .map(([name, url, result]) => `| ${name} | ${artifactCell(url, result)} |`) + .join('\n'); + + const ghcrImages = (process.env.GHCR_IMAGES_MARKDOWN || '').trim() || '_No GHCR images were pushed._'; + + const body = [ + marker, + '## Release artifacts', + '', + `Built for PR head \`${shortSha}\` in [workflow run #${process.env.GITHUB_RUN_NUMBER}](${runUrl}).`, + '', + '| Artifact | Link |', + '| --- | --- |', + artifactRows, + '', + '### GHCR images (amd64)', + ghcrImages, + '', + '_This comment is updated by the Release workflow. Artifact links expire according to the workflow retention policy._', + ].join('\n'); + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100, + }); + + const previous = comments.find(comment => + comment.user?.type === 'Bot' && comment.body?.includes(marker) + ); + + if (previous) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: previous.id, + body, + }); + core.info(`Updated release artifacts comment ${previous.id}`); + } else { + const { data } = await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }); + core.info(`Created release artifacts comment ${data.id}`); + } + trigger_signer: runs-on: ubuntu-latest - needs: [release, release_ui, release_ui_darwin] + needs: [release, release_ui, release_ui_gtk3, release_ui_darwin, test_windows_installer] if: startsWith(github.ref, 'refs/tags/') steps: - name: Trigger binaries sign pipelines - uses: benc-uk/workflow-dispatch@v1 + uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2 with: workflow: Sign bin and installer repo: netbirdio/sign-pipelines diff --git a/.github/workflows/sync-main.yml b/.github/workflows/sync-main.yml index e36e35a2d..5805fcf57 100644 --- a/.github/workflows/sync-main.yml +++ b/.github/workflows/sync-main.yml @@ -14,9 +14,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Trigger main branch sync - uses: benc-uk/workflow-dispatch@v1 + uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2 with: workflow: sync-main.yml repo: ${{ secrets.UPSTREAM_REPO }} token: ${{ secrets.NC_GITHUB_TOKEN }} - inputs: '{ "sha": "${{ github.sha }}" }' \ No newline at end of file + inputs: '{ "sha": "${{ github.sha }}" }' diff --git a/.github/workflows/sync-tag.yml b/.github/workflows/sync-tag.yml index 1cc553b12..608f3c6d7 100644 --- a/.github/workflows/sync-tag.yml +++ b/.github/workflows/sync-tag.yml @@ -3,21 +3,50 @@ name: sync tag on: push: tags: - - 'v*' + - "v*" concurrency: group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }} cancel-in-progress: true +# The receiving bump-netbird workflows expect the short tag form +# (e.g. v0.30.0), not refs/tags/v0.30.0 — github.ref_name, not github.ref. jobs: - trigger_sync_tag: + trigger_android_bump: runs-on: ubuntu-latest + if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-') steps: - - name: Trigger release tag sync - uses: benc-uk/workflow-dispatch@v1 + - name: Trigger android-client submodule bump + uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2 with: - workflow: sync-tag.yml + workflow: bump-netbird.yml ref: main - repo: ${{ secrets.UPSTREAM_REPO }} + repo: netbirdio/android-client token: ${{ secrets.NC_GITHUB_TOKEN }} - inputs: '{ "tag": "${{ github.ref_name }}" }' \ No newline at end of file + inputs: '{ "tag": "${{ github.ref_name }}" }' + + trigger_ios_bump: + runs-on: ubuntu-latest + if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-') + steps: + - name: Trigger ios-client submodule bump + uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2 + with: + workflow: bump-netbird.yml + ref: main + repo: netbirdio/ios-client + token: ${{ secrets.NC_GITHUB_TOKEN }} + inputs: '{ "tag": "${{ github.ref_name }}" }' + + trigger_dashboard_bump: + runs-on: ubuntu-latest + if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-') + steps: + - name: Trigger dashboard wasm client bump + uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2 + with: + workflow: bump-netbird.yml + ref: main + repo: netbirdio/dashboard + token: ${{ secrets.NC_GITHUB_TOKEN }} + inputs: '{ "tag": "${{ github.ref_name }}" }' diff --git a/.github/workflows/test-infrastructure-files.yml b/.github/workflows/test-infrastructure-files.yml index e2f950731..1313379ee 100644 --- a/.github/workflows/test-infrastructure-files.yml +++ b/.github/workflows/test-infrastructure-files.yml @@ -4,12 +4,13 @@ on: push: branches: - main + - "release-*" pull_request: paths: - - 'infrastructure_files/**' - - '.github/workflows/test-infrastructure-files.yml' - - 'management/cmd/**' - - 'signal/cmd/**' + - "infrastructure_files/**" + - ".github/workflows/test-infrastructure-files.yml" + - "management/cmd/**" + - "signal/cmd/**" concurrency: group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }} @@ -20,7 +21,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - store: [ 'sqlite', 'postgres', 'mysql' ] + store: ["sqlite", "postgres", "mysql"] services: postgres: image: ${{ (matrix.store == 'postgres') && 'postgres' || '' }} @@ -68,15 +69,17 @@ jobs: run: sudo apt-get install -y curl - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Install Go - uses: actions/setup-go@v5 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" - name: Cache Go modules - uses: actions/cache@v4 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} @@ -139,8 +142,8 @@ jobs: CI_NETBIRD_IDP_MGMT_CLIENT_SECRET: testing.client.secret CI_NETBIRD_SIGNAL_PORT: 12345 CI_NETBIRD_STORE_CONFIG_ENGINE: ${{ matrix.store }} - NETBIRD_STORE_ENGINE_POSTGRES_DSN: '${{ env.NETBIRD_STORE_ENGINE_POSTGRES_DSN }}$' - NETBIRD_STORE_ENGINE_MYSQL_DSN: '${{ env.NETBIRD_STORE_ENGINE_MYSQL_DSN }}$' + NETBIRD_STORE_ENGINE_POSTGRES_DSN: "${{ env.NETBIRD_STORE_ENGINE_POSTGRES_DSN }}$" + NETBIRD_STORE_ENGINE_MYSQL_DSN: "${{ env.NETBIRD_STORE_ENGINE_MYSQL_DSN }}$" CI_NETBIRD_MGMT_IDP_SIGNKEY_REFRESH: false CI_NETBIRD_TURN_EXTERNAL_IP: "1.2.3.4" CI_NETBIRD_MGMT_DISABLE_DEFAULT_POLICY: false @@ -205,7 +208,7 @@ jobs: - name: Build management docker image working-directory: management run: | - docker build -t netbirdio/management:latest . + docker build -t netbirdio/management:latest --build-arg TARGETPLATFORM=. . - name: Build signal binary working-directory: signal @@ -214,7 +217,7 @@ jobs: - name: Build signal docker image working-directory: signal run: | - docker build -t netbirdio/signal:latest . + docker build -t netbirdio/signal:latest --build-arg TARGETPLATFORM=. . - name: Build relay binary working-directory: relay @@ -223,7 +226,7 @@ jobs: - name: Build relay docker image working-directory: relay run: | - docker build -t netbirdio/relay:latest . + docker build -t netbirdio/relay:latest --build-arg TARGETPLATFORM=. . - name: run docker compose up working-directory: infrastructure_files/artifacts @@ -247,76 +250,44 @@ jobs: docker compose exec management ls -l /var/lib/netbird/ | grep -i GeoLite2-City_[0-9]*.mmdb docker compose exec management ls -l /var/lib/netbird/ | grep -i geonames_[0-9]*.db - test-getting-started-script: + test-legacy-getting-started-scripts: runs-on: ubuntu-latest steps: - - name: Install jq - run: sudo apt-get install -y jq - - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - - name: run script with Zitadel PostgreSQL - run: NETBIRD_DOMAIN=use-ip bash -x infrastructure_files/getting-started-with-zitadel.sh - - - name: test Caddy file gen postgres - run: test -f Caddyfile - - - name: test docker-compose file gen postgres - run: test -f docker-compose.yml - - - name: test management.json file gen postgres - run: test -f management.json - - - name: test turnserver.conf file gen postgres + - name: Verify fresh-install session cookie key hardening run: | - set -x - test -f turnserver.conf - grep external-ip turnserver.conf + grep -Fxq ' SESSION_COOKIE_ENCRYPTION_KEY=$(openssl rand -base64 32)' infrastructure_files/getting-started.sh + grep -Fxq ' sessionCookieEncryptionKey: "$SESSION_COOKIE_ENCRYPTION_KEY"' infrastructure_files/getting-started.sh + grep -Fxq ' install -m 600 /dev/null config.yaml' infrastructure_files/getting-started.sh + grep -Fxq ' openssl rand -base64 32' infrastructure_files/getting-started-enterprise.sh + grep -Fxq ' NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY=$(rand_b64_key)' infrastructure_files/getting-started-enterprise.sh + grep -Fxq ' sessionCookieEncryptionKey: "${NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY}"' infrastructure_files/getting-started-enterprise.sh - - name: test zitadel.env file gen postgres - run: test -f zitadel.env - - - name: test dashboard.env file gen postgres - run: test -f dashboard.env - - - name: test relay.env file gen postgres - run: test -f relay.env - - - name: test zdb.env file gen postgres - run: test -f zdb.env - - - name: Postgres run cleanup + - name: Verify Dex retirement notice run: | - docker compose down --volumes --rmi all - rm -rf docker-compose.yml Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json zdb.env + if infrastructure_files/getting-started-with-dex.sh >stdout.txt 2>stderr.txt; then + echo "Expected the retired Dex installer to fail" + exit 1 + fi + test ! -s stdout.txt + grep -Fq "Dex support is not deprecated." stderr.txt + grep -Fq "https://docs.netbird.io/selfhosted/selfhosted-quickstart" stderr.txt + grep -Fq "https://docs.netbird.io/selfhosted/identity-providers/local" stderr.txt + grep -Fq "removed in NetBird v0.80" stderr.txt - - name: run script with Zitadel CockroachDB - run: bash -x infrastructure_files/getting-started-with-zitadel.sh - env: - NETBIRD_DOMAIN: use-ip - ZITADEL_DATABASE: cockroach - - - name: test Caddy file gen CockroachDB - run: test -f Caddyfile - - - name: test docker-compose file gen CockroachDB - run: test -f docker-compose.yml - - - name: test management.json file gen CockroachDB - run: test -f management.json - - - name: test turnserver.conf file gen CockroachDB + - name: Verify Zitadel retirement notice run: | - set -x - test -f turnserver.conf - grep external-ip turnserver.conf - - - name: test zitadel.env file gen CockroachDB - run: test -f zitadel.env - - - name: test dashboard.env file gen CockroachDB - run: test -f dashboard.env - - - name: test relay.env file gen CockroachDB - run: test -f relay.env + if bash infrastructure_files/getting-started-with-zitadel.sh >stdout.txt 2>stderr.txt; then + echo "Expected the retired Zitadel installer to fail" + exit 1 + fi + test ! -s stdout.txt + grep -Fq "Zitadel support and existing Zitadel deployments are not deprecated." stderr.txt + grep -Fq "https://docs.netbird.io/selfhosted/selfhosted-quickstart" stderr.txt + grep -Fq "https://docs.netbird.io/selfhosted/identity-providers/zitadel" stderr.txt + grep -Fq "https://docs.netbird.io/selfhosted/selfhosted-guide" stderr.txt + grep -Fq "removed in NetBird v0.80" stderr.txt diff --git a/.github/workflows/ui-translations.yml b/.github/workflows/ui-translations.yml new file mode 100644 index 000000000..7d3b12f2d --- /dev/null +++ b/.github/workflows/ui-translations.yml @@ -0,0 +1,42 @@ +name: UI Translations + +on: + pull_request: + paths: + - "client/ui/i18n/locales/**" + - "client/ui/i18n/check-translations.mjs" + - ".github/workflows/ui-translations.yml" + push: + branches: + - main + paths: + - "client/ui/i18n/locales/**" + - "client/ui/i18n/check-translations.mjs" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }} + cancel-in-progress: true + +jobs: + check-translations: + name: Check translation key parity + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + # English (en) is the source of truth for translation keys; every other + # locale declared in _index.json must carry the exact same key set. + - name: Check translation key parity + run: node client/ui/i18n/check-translations.mjs diff --git a/.github/workflows/update-docs.yml b/.github/workflows/update-docs.yml index 26f3b8f02..ff4f0a86a 100644 --- a/.github/workflows/update-docs.yml +++ b/.github/workflows/update-docs.yml @@ -3,9 +3,9 @@ name: update docs on: push: tags: - - 'v*' + - "v*" paths: - - 'shared/management/http/api/openapi.yml' + - "shared/management/http/api/openapi.yml" jobs: trigger_docs_api_update: @@ -13,10 +13,10 @@ jobs: if: startsWith(github.ref, 'refs/tags/') steps: - name: Trigger API pages generation - uses: benc-uk/workflow-dispatch@v1 + uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2 with: workflow: generate api pages repo: netbirdio/docs ref: "refs/heads/main" token: ${{ secrets.SIGN_GITHUB_TOKEN }} - inputs: '{ "tag": "${{ github.ref }}" }' \ No newline at end of file + inputs: '{ "tag": "${{ github.ref }}" }' diff --git a/.github/workflows/wasm-build-validation.yml b/.github/workflows/wasm-build-validation.yml index 81ae36e78..5f21472e5 100644 --- a/.github/workflows/wasm-build-validation.yml +++ b/.github/workflows/wasm-build-validation.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - "release-*" pull_request: concurrency: @@ -19,15 +20,17 @@ jobs: GOARCH: wasm steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Install Go - uses: actions/setup-go@v5 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" - name: Install dependencies - run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev + run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgl1-mesa-dev xorg-dev libpcap-dev - name: Install golangci-lint - uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 + uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1 with: version: latest install-mode: binary @@ -42,9 +45,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Install Go - uses: actions/setup-go@v5 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" - name: Build Wasm client @@ -61,8 +66,7 @@ jobs: echo "Size: ${SIZE} bytes (${SIZE_MB} MB)" - if [ ${SIZE} -gt 58720256 ]; then - echo "Wasm binary size (${SIZE_MB}MB) exceeds 56MB limit!" + if [ ${SIZE} -gt 62914560 ]; then + echo "Wasm binary size (${SIZE_MB}MB) exceeds 60MB limit!" exit 1 fi - diff --git a/.gitignore b/.gitignore index a0f128933..305f3cb50 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.claude .idea .run *.iml @@ -33,3 +34,4 @@ infrastructure_files/setup-*.env vendor/ /netbird client/netbird-electron/ +management/server/types/testdata/ diff --git a/.golangci.yaml b/.golangci.yaml index d81ad1377..e350b9de7 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -58,6 +58,11 @@ linters: govet: enable: - nilness + disable: + # The inline analyzer flags x/exp/maps Clone/Clear with //go:fix inline + # directives but cannot perform the rewrite due to generic type + # parameter inference limitations in the Go inliner. + - inline enable-all: false revive: rules: @@ -109,6 +114,16 @@ linters: - linters: - staticcheck text: "QF1012" + # client/ui/main.go uses //go:embed all:frontend/dist; the + # directory is populated by `pnpm build` in the release pipeline + # and missing at lint time, so the embed parses to "no matching + # files found" — surfaced by golangci-lint's typecheck pre-pass. + # Suppress just that one diagnostic; the rest of the package + # (services/, tray.go, grpc.go, ...) still gets linted normally. + - linters: + - typecheck + path: client/ui/main\.go + text: "pattern all:frontend/dist" paths: - third_party$ - builtin$ diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 5ea479148..c5d260376 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,5 +1,7 @@ version: 2 - +env: + - SKIP_PUBLISH={{ if index .Env "SKIP_PUBLISH" }}{{ .Env.SKIP_PUBLISH }}{{ else }}true{{ end }} + - SKIP_DOCKER_PUSH={{ if index .Env "SKIP_DOCKER_PUSH" }}{{ .Env.SKIP_DOCKER_PUSH }}{{ else }}false{{ end }} project_name: netbird builds: - id: netbird-wasm @@ -74,6 +76,8 @@ builds: - amd64 - arm64 - arm + goarm: + - 7 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 }}" @@ -88,6 +92,8 @@ builds: - amd64 - arm64 - arm + goarm: + - 7 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 }}" @@ -102,6 +108,8 @@ builds: - amd64 - arm64 - arm + goarm: + - 7 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 }}" @@ -122,6 +130,8 @@ builds: - amd64 - arm64 - arm + goarm: + - 7 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 }}" @@ -136,6 +146,8 @@ builds: - amd64 - arm64 - arm + goarm: + - 7 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 }}" @@ -150,6 +162,8 @@ builds: - amd64 - arm64 - arm + goarm: + - 7 ldflags: - -s -w -X main.Version={{.Version}} -X main.Commit={{.Commit}} -X main.BuildDate={{.CommitDate}} mod_timestamp: "{{ .CommitTimestamp }}" @@ -170,6 +184,8 @@ builds: - amd64 - arm64 - arm + goarm: + - 7 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 }}" @@ -196,6 +212,7 @@ nfpms: description: Netbird client. homepage: https://netbird.io/ license: BSD-3-Clause + vendor: NetBird id: netbird_deb bindir: /usr/bin builds: @@ -210,6 +227,7 @@ nfpms: description: Netbird client. homepage: https://netbird.io/ license: BSD-3-Clause + vendor: NetBird id: netbird_rpm bindir: /usr/bin builds: @@ -222,670 +240,192 @@ nfpms: rpm: signature: key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}' -dockers: - - image_templates: - - netbirdio/netbird:{{ .Version }}-amd64 - - ghcr.io/netbirdio/netbird:{{ .Version }}-amd64 - ids: - - netbird - goarch: amd64 - use: buildx - dockerfile: client/Dockerfile - extra_files: - - client/netbird-entrypoint.sh - 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:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/netbird:{{ .Version }}-arm64v8 - ids: - - netbird - goarch: arm64 - use: buildx - dockerfile: client/Dockerfile - extra_files: - - client/netbird-entrypoint.sh - 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:{{ .Version }}-arm - - ghcr.io/netbirdio/netbird:{{ .Version }}-arm - ids: - - netbird - goarch: arm - goarm: 6 - use: buildx - dockerfile: client/Dockerfile - extra_files: - - client/netbird-entrypoint.sh - 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/netbird:{{ .Version }}-rootless-amd64 - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-amd64 - ids: - - netbird - goarch: amd64 - use: buildx - dockerfile: client/Dockerfile-rootless - extra_files: - - client/netbird-entrypoint.sh - 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:{{ .Version }}-rootless-arm64v8 - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm64v8 - ids: - - netbird - goarch: arm64 - use: buildx - dockerfile: client/Dockerfile-rootless - extra_files: - - client/netbird-entrypoint.sh - 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:{{ .Version }}-rootless-arm - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm - ids: - - netbird - goarch: arm - goarm: 6 - use: buildx - dockerfile: client/Dockerfile-rootless - extra_files: - - client/netbird-entrypoint.sh - 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/relay:{{ .Version }}-amd64 - - ghcr.io/netbirdio/relay:{{ .Version }}-amd64 - ids: - - netbird-relay - goarch: amd64 - use: buildx - dockerfile: relay/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/relay:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/relay:{{ .Version }}-arm64v8 - ids: - - netbird-relay - goarch: arm64 - use: buildx - dockerfile: relay/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/relay:{{ .Version }}-arm - - ghcr.io/netbirdio/relay:{{ .Version }}-arm - ids: - - netbird-relay - goarch: arm - goarm: 6 - use: buildx - dockerfile: relay/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/signal:{{ .Version }}-amd64 - - ghcr.io/netbirdio/signal:{{ .Version }}-amd64 - ids: - - netbird-signal - goarch: amd64 - use: buildx - dockerfile: signal/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/signal:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/signal:{{ .Version }}-arm64v8 - ids: - - netbird-signal - goarch: arm64 - use: buildx - dockerfile: signal/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/signal:{{ .Version }}-arm - - ghcr.io/netbirdio/signal:{{ .Version }}-arm - ids: - - netbird-signal - goarch: arm - goarm: 6 - use: buildx - dockerfile: signal/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/management:{{ .Version }}-amd64 - - ghcr.io/netbirdio/management:{{ .Version }}-amd64 - ids: - - netbird-mgmt - goarch: amd64 - use: buildx - dockerfile: management/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/management:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/management:{{ .Version }}-arm64v8 - ids: - - netbird-mgmt - goarch: arm64 - use: buildx - dockerfile: management/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/management:{{ .Version }}-arm - - ghcr.io/netbirdio/management:{{ .Version }}-arm - ids: - - netbird-mgmt - goarch: arm - goarm: 6 - use: buildx - dockerfile: management/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/management:{{ .Version }}-debug-amd64 - - ghcr.io/netbirdio/management:{{ .Version }}-debug-amd64 - ids: - - netbird-mgmt - goarch: amd64 - use: buildx - dockerfile: management/Dockerfile.debug - 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/management:{{ .Version }}-debug-arm64v8 - - ghcr.io/netbirdio/management:{{ .Version }}-debug-arm64v8 - ids: - - netbird-mgmt - goarch: arm64 - use: buildx - dockerfile: management/Dockerfile.debug - 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/management:{{ .Version }}-debug-arm - - ghcr.io/netbirdio/management:{{ .Version }}-debug-arm - ids: - - netbird-mgmt - goarch: arm - goarm: 6 - use: buildx - dockerfile: management/Dockerfile.debug - 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/upload:{{ .Version }}-amd64 - - ghcr.io/netbirdio/upload:{{ .Version }}-amd64 - ids: - - netbird-upload - goarch: amd64 - use: buildx - dockerfile: upload-server/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/upload:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/upload:{{ .Version }}-arm64v8 - ids: - - netbird-upload - goarch: arm64 - use: buildx - dockerfile: upload-server/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/upload:{{ .Version }}-arm - - ghcr.io/netbirdio/upload:{{ .Version }}-arm - ids: - - netbird-upload - goarch: arm - goarm: 6 - use: buildx - dockerfile: upload-server/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/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: - - netbirdio/netbird:{{ .Version }}-arm64v8 - - netbirdio/netbird:{{ .Version }}-arm - - netbirdio/netbird:{{ .Version }}-amd64 - - - name_template: netbirdio/netbird:latest - image_templates: - - netbirdio/netbird:{{ .Version }}-arm64v8 - - netbirdio/netbird:{{ .Version }}-arm - - netbirdio/netbird:{{ .Version }}-amd64 - - - name_template: netbirdio/netbird:{{ .Version }}-rootless - image_templates: - - netbirdio/netbird:{{ .Version }}-rootless-arm64v8 - - netbirdio/netbird:{{ .Version }}-rootless-arm - - netbirdio/netbird:{{ .Version }}-rootless-amd64 - - - name_template: netbirdio/netbird:rootless-latest - image_templates: - - netbirdio/netbird:{{ .Version }}-rootless-arm64v8 - - netbirdio/netbird:{{ .Version }}-rootless-arm - - netbirdio/netbird:{{ .Version }}-rootless-amd64 - - - name_template: netbirdio/relay:{{ .Version }} - image_templates: - - netbirdio/relay:{{ .Version }}-arm64v8 - - netbirdio/relay:{{ .Version }}-arm - - netbirdio/relay:{{ .Version }}-amd64 - - - name_template: netbirdio/relay:latest - image_templates: - - netbirdio/relay:{{ .Version }}-arm64v8 - - netbirdio/relay:{{ .Version }}-arm - - netbirdio/relay:{{ .Version }}-amd64 - - - name_template: netbirdio/signal:{{ .Version }} - image_templates: - - netbirdio/signal:{{ .Version }}-arm64v8 - - netbirdio/signal:{{ .Version }}-arm - - netbirdio/signal:{{ .Version }}-amd64 - - - name_template: netbirdio/signal:latest - image_templates: - - netbirdio/signal:{{ .Version }}-arm64v8 - - netbirdio/signal:{{ .Version }}-arm - - netbirdio/signal:{{ .Version }}-amd64 - - - name_template: netbirdio/management:{{ .Version }} - image_templates: - - netbirdio/management:{{ .Version }}-arm64v8 - - netbirdio/management:{{ .Version }}-arm - - netbirdio/management:{{ .Version }}-amd64 - - - name_template: netbirdio/management:latest - image_templates: - - netbirdio/management:{{ .Version }}-arm64v8 - - netbirdio/management:{{ .Version }}-arm - - netbirdio/management:{{ .Version }}-amd64 - - - name_template: netbirdio/management:debug-latest - image_templates: - - netbirdio/management:{{ .Version }}-debug-arm64v8 - - netbirdio/management:{{ .Version }}-debug-arm - - netbirdio/management:{{ .Version }}-debug-amd64 - - name_template: netbirdio/upload:{{ .Version }} - image_templates: - - netbirdio/upload:{{ .Version }}-arm64v8 - - netbirdio/upload:{{ .Version }}-arm - - netbirdio/upload:{{ .Version }}-amd64 - - - name_template: netbirdio/upload:latest - image_templates: - - netbirdio/upload:{{ .Version }}-arm64v8 - - 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 - - ghcr.io/netbirdio/netbird:{{ .Version }}-arm - - ghcr.io/netbirdio/netbird:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/netbird:latest - image_templates: - - ghcr.io/netbirdio/netbird:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/netbird:{{ .Version }}-arm - - ghcr.io/netbirdio/netbird:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/netbird:{{ .Version }}-rootless - image_templates: - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm64v8 - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-amd64 - - - name_template: ghcr.io/netbirdio/netbird:rootless-latest - image_templates: - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm64v8 - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-amd64 - - - name_template: ghcr.io/netbirdio/relay:{{ .Version }} - image_templates: - - ghcr.io/netbirdio/relay:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/relay:{{ .Version }}-arm - - ghcr.io/netbirdio/relay:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/relay:latest - image_templates: - - ghcr.io/netbirdio/relay:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/relay:{{ .Version }}-arm - - ghcr.io/netbirdio/relay:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/signal:{{ .Version }} - image_templates: - - ghcr.io/netbirdio/signal:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/signal:{{ .Version }}-arm - - ghcr.io/netbirdio/signal:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/signal:latest - image_templates: - - ghcr.io/netbirdio/signal:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/signal:{{ .Version }}-arm - - ghcr.io/netbirdio/signal:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/management:{{ .Version }} - image_templates: - - ghcr.io/netbirdio/management:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/management:{{ .Version }}-arm - - ghcr.io/netbirdio/management:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/management:latest - image_templates: - - ghcr.io/netbirdio/management:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/management:{{ .Version }}-arm - - ghcr.io/netbirdio/management:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/management:debug-latest - image_templates: - - ghcr.io/netbirdio/management:{{ .Version }}-debug-arm64v8 - - ghcr.io/netbirdio/management:{{ .Version }}-debug-arm - - ghcr.io/netbirdio/management:{{ .Version }}-debug-amd64 - - - name_template: ghcr.io/netbirdio/upload:{{ .Version }} - image_templates: - - ghcr.io/netbirdio/upload:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/upload:{{ .Version }}-arm - - ghcr.io/netbirdio/upload:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/upload:latest - image_templates: - - 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 +dockers_v2: + - id: netbird + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird + images: + - netbirdio/netbird + - ghcr.io/netbirdio/netbird + tags: + - "{{ .Version }}" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + dockerfile: client/Dockerfile + extra_files: + - client/netbird-entrypoint.sh + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm/6 + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" + - id: netbird-rootless + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird + images: + - netbirdio/netbird + - ghcr.io/netbirdio/netbird + tags: + - "{{ .Version }}-rootless" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}rootless-latest{{ end }}" + dockerfile: client/Dockerfile-rootless + extra_files: + - client/netbird-entrypoint.sh + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm/6 + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" + - id: relay + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-relay + images: + - netbirdio/relay + - ghcr.io/netbirdio/relay + tags: + - "{{ .Version }}" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + dockerfile: relay/Dockerfile + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" + - id: signal + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-signal + images: + - netbirdio/signal + - ghcr.io/netbirdio/signal + tags: + - "{{ .Version }}" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + dockerfile: signal/Dockerfile + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" + - id: management + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-mgmt + images: + - netbirdio/management + - ghcr.io/netbirdio/management + tags: + - "{{ .Version }}" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + dockerfile: management/Dockerfile + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" + - id: upload + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-upload + images: + - netbirdio/upload + - ghcr.io/netbirdio/upload + tags: + - "{{ .Version }}" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + dockerfile: upload-server/Dockerfile + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" + - id: netbird-server + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-server + images: + - netbirdio/netbird-server + - ghcr.io/netbirdio/netbird-server + tags: + - "{{ .Version }}" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + dockerfile: combined/Dockerfile + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" + - id: netbird-proxy + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-proxy + images: + - netbirdio/reverse-proxy + - ghcr.io/netbirdio/reverse-proxy + tags: + - "{{ .Version }}" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + dockerfile: proxy/Dockerfile + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" brews: - ids: - default + skip_upload: "{{ .Env.SKIP_PUBLISH }}" repository: owner: netbirdio name: homebrew-tap @@ -902,6 +442,7 @@ brews: uploads: - name: debian + skip: "{{ .Env.SKIP_PUBLISH }}" ids: - netbird_deb mode: archive @@ -910,6 +451,7 @@ uploads: method: PUT - name: yum + skip: "{{ .Env.SKIP_PUBLISH }}" ids: - netbird_rpm mode: archive @@ -922,9 +464,20 @@ checksum: - glob: ./infrastructure_files/getting-started-with-zitadel.sh - glob: ./release_files/install.sh - glob: ./infrastructure_files/getting-started.sh + - glob: ./infrastructure_files/getting-started-enterprise.sh + - glob: ./infrastructure_files/migrate-to-enterprise.sh release: + # The signing pipeline (netbirdio/sign-pipelines, dispatched by + # trigger_signer) marks the release latest once the Windows and macOS + # artifacts are signed. Without this override goreleaser marks it latest + # at publish time, while those artifacts are still unsigned. + make_latest: false + # Mark x.y.z-rc.* and other prerelease tags as prereleases on GitHub. + prerelease: auto extra_files: - glob: ./infrastructure_files/getting-started-with-zitadel.sh - glob: ./release_files/install.sh - glob: ./infrastructure_files/getting-started.sh + - glob: ./infrastructure_files/getting-started-enterprise.sh + - glob: ./infrastructure_files/migrate-to-enterprise.sh diff --git a/.goreleaser_ui.yaml b/.goreleaser_ui.yaml index 470f1deaa..24903188f 100644 --- a/.goreleaser_ui.yaml +++ b/.goreleaser_ui.yaml @@ -1,6 +1,16 @@ version: 2 - +env: + - SKIP_PUBLISH={{ if index .Env "SKIP_PUBLISH" }}{{ .Env.SKIP_PUBLISH }}{{ else }}true{{ end }} project_name: netbird-ui + +before: + hooks: + # Bindings are gitignored; regenerate before the frontend build so + # the @wailsio/runtime Vite plugin can resolve them (vite refuses to + # build without them). + - sh -c 'cd client/ui && wails3 generate bindings -clean=true -ts' + - sh -c 'cd client/ui/frontend && pnpm install --frozen-lockfile && pnpm build' + builds: - id: netbird-ui dir: client/ui @@ -14,6 +24,8 @@ builds: ldflags: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser mod_timestamp: "{{ .CommitTimestamp }}" + tags: + - production - id: netbird-ui-windows-amd64 dir: client/ui @@ -29,6 +41,8 @@ builds: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser - -H windowsgui mod_timestamp: "{{ .CommitTimestamp }}" + tags: + - production - id: netbird-ui-windows-arm64 dir: client/ui @@ -45,6 +59,8 @@ builds: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser - -H windowsgui mod_timestamp: "{{ .CommitTimestamp }}" + tags: + - production archives: - id: linux-arch @@ -61,6 +77,8 @@ nfpms: - maintainer: Netbird description: Netbird client UI. homepage: https://netbird.io/ + license: BSD-3-Clause + vendor: NetBird id: netbird_ui_deb package_name: netbird-ui builds: @@ -70,16 +88,26 @@ nfpms: scripts: postinstall: "release_files/ui-post-install.sh" contents: - - src: client/ui/build/netbird.desktop - dst: /usr/share/applications/netbird.desktop - - src: client/ui/assets/netbird.png + - src: client/ui/build/linux/netbird.desktop + dst: /usr/share/applications/org.wails.netbird.desktop + - src: client/ui/build/appicon.png dst: /usr/share/pixmaps/netbird.png + # Names the polkit action for the elevation prompt the app raises when an + # unprivileged user changes a privileged setting; without it the dialog + # shows a raw command line. + - src: client/ui/build/linux/polkit/io.netbird.settings.policy + dst: /usr/share/polkit-1/actions/io.netbird.settings.policy dependencies: - - netbird + - netbird (>= 0.75.0) + - libgtk-4-1 (>= 4.14) + - libwebkitgtk-6.0-4 + - xdg-utils - maintainer: Netbird description: Netbird client UI. homepage: https://netbird.io/ + license: BSD-3-Clause + vendor: NetBird id: netbird_ui_rpm package_name: netbird-ui builds: @@ -89,18 +117,28 @@ nfpms: scripts: postinstall: "release_files/ui-post-install.sh" contents: - - src: client/ui/build/netbird.desktop - dst: /usr/share/applications/netbird.desktop - - src: client/ui/assets/netbird.png + - src: client/ui/build/linux/netbird.desktop + dst: /usr/share/applications/org.wails.netbird.desktop + - src: client/ui/build/appicon.png dst: /usr/share/pixmaps/netbird.png + # Names the polkit action for the elevation prompt the app raises when an + # unprivileged user changes a privileged setting; without it the dialog + # shows a raw command line. + - src: client/ui/build/linux/polkit/io.netbird.settings.policy + dst: /usr/share/polkit-1/actions/io.netbird.settings.policy dependencies: - - netbird + - netbird >= 0.75.0 + - (gtk4 >= 4.14 or libgtk-4-1 >= 4.14) + - (webkitgtk6.0 or libwebkitgtk-6_0-4) + - xdg-utils + rpm: signature: key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}' uploads: - name: debian + skip: "{{ .Env.SKIP_PUBLISH }}" ids: - netbird_ui_deb mode: archive @@ -109,9 +147,18 @@ uploads: method: PUT - name: yum + skip: "{{ .Env.SKIP_PUBLISH }}" ids: - netbird_ui_rpm mode: archive target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }} username: dev@wiretrustee.com method: PUT + +release: + # Uploads into the release created by the main .goreleaser.yaml run. + # make_latest stays false everywhere: the signing pipeline + # (netbirdio/sign-pipelines) marks the release latest after the Windows + # and macOS artifacts are signed. + make_latest: false + prerelease: auto diff --git a/.goreleaser_ui_darwin.yaml b/.goreleaser_ui_darwin.yaml index 0a0082075..8ca0e8da6 100644 --- a/.goreleaser_ui_darwin.yaml +++ b/.goreleaser_ui_darwin.yaml @@ -1,6 +1,15 @@ version: 2 project_name: netbird-ui + +before: + hooks: + # Bindings are gitignored; regenerate before the frontend build so + # the @wailsio/runtime Vite plugin can resolve them (vite refuses to + # build without them). + - sh -c 'cd client/ui && wails3 generate bindings -clean=true -ts' + - sh -c 'cd client/ui/frontend && pnpm install --frozen-lockfile && pnpm build' + builds: - id: netbird-ui-darwin dir: client/ui @@ -21,7 +30,7 @@ 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 }}" tags: - - load_wgnt_from_rsrc + - production universal_binaries: - id: netbird-ui-darwin @@ -34,3 +43,11 @@ checksum: name_template: "{{ .ProjectName }}_darwin_checksums.txt" changelog: disable: true + +release: + # Uploads into the release created by the main .goreleaser.yaml run. + # make_latest stays false everywhere: the signing pipeline + # (netbirdio/sign-pipelines) marks the release latest after the Windows + # and macOS artifacts are signed. + make_latest: false + prerelease: auto diff --git a/.goreleaser_ui_gtk3.yaml b/.goreleaser_ui_gtk3.yaml new file mode 100644 index 000000000..a9b2ca650 --- /dev/null +++ b/.goreleaser_ui_gtk3.yaml @@ -0,0 +1,144 @@ +version: 2 +env: + - SKIP_PUBLISH={{ if index .Env "SKIP_PUBLISH" }}{{ .Env.SKIP_PUBLISH }}{{ else }}true{{ end }} +project_name: netbird-ui + +before: + hooks: + # Bindings are gitignored; regenerate before the frontend build so + # the @wailsio/runtime Vite plugin can resolve them (vite refuses to + # build without them). + # -f '-tags gtk3': the generator type-checks client/ui, whose cgo imports + # would otherwise resolve gtk4/webkitgtk-6.0 pkg-config entries that do + # not exist on ubuntu-22.04. + - sh -c 'cd client/ui && wails3 generate bindings -clean=true -ts -f "-tags gtk3"' + - sh -c 'cd client/ui/frontend && pnpm install --frozen-lockfile && pnpm build' + +builds: + # Legacy GTK3 / WebKit2GTK 4.1 build for distros without WebKitGTK 6.0 + # (Ubuntu 22.04, Debian 12, RHEL 9, Fedora <=39). The gtk3 tag flips the + # Wails Linux backend to the GTK3 stack and swaps our GTK4-only XEmbed + # tray host for the pure-Go stub (client/ui/xembed_host_gtk3_linux.go). + # Must be built on the oldest supported glibc (ubuntu-22.04 runner). + - id: netbird-ui-gtk3 + dir: client/ui + binary: netbird-ui + env: + - CGO_ENABLED=1 + goos: + - linux + goarch: + - amd64 + ldflags: + - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser + mod_timestamp: "{{ .CommitTimestamp }}" + tags: + - production + - gtk3 + +archives: + - id: linux-gtk3-arch + name_template: "{{ .ProjectName }}-linux-gtk3_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + builds: + - netbird-ui-gtk3 + +nfpms: + # Mutually-exclusive alternative to the GTK4 netbird-ui package -- both + # ship the same /usr/bin/netbird-ui from the shared stable/yum repos, so + # this one carries its own name and conflicts with the GTK4 package. + - maintainer: Netbird + description: Netbird client UI. + homepage: https://netbird.io/ + license: BSD-3-Clause + vendor: NetBird + id: netbird_ui_deb_gtk3 + package_name: netbird-ui-gtk3 + file_name_template: "{{ .PackageName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + builds: + - netbird-ui-gtk3 + formats: + - deb + scripts: + postinstall: "release_files/ui-post-install.sh" + contents: + - src: client/ui/build/linux/netbird.desktop + dst: /usr/share/applications/org.wails.netbird.desktop + - src: client/ui/build/appicon.png + dst: /usr/share/pixmaps/netbird.png + conflicts: + - netbird-ui + replaces: + - netbird-ui + dependencies: + - netbird (>= 0.75.0) + - libgtk-3-0 + - libwebkit2gtk-4.1-0 + - xdg-utils + + - maintainer: Netbird + description: Netbird client UI. + homepage: https://netbird.io/ + license: BSD-3-Clause + vendor: NetBird + id: netbird_ui_rpm_gtk3 + package_name: netbird-ui-gtk3 + file_name_template: "{{ .PackageName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + builds: + - netbird-ui-gtk3 + formats: + - rpm + scripts: + postinstall: "release_files/ui-post-install.sh" + contents: + - src: client/ui/build/linux/netbird.desktop + dst: /usr/share/applications/org.wails.netbird.desktop + - src: client/ui/build/appicon.png + dst: /usr/share/pixmaps/netbird.png + # No `replaces` here: nfpm maps it to rpm Obsoletes, which would make + # dnf swap installed GTK4 netbird-ui packages for this one on upgrade. + conflicts: + - netbird-ui + dependencies: + - netbird >= 0.75.0 + - (gtk3 or libgtk-3-0) + - (webkit2gtk4.1 or libwebkit2gtk-4_1-0) + - xdg-utils + + rpm: + signature: + key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}' + +# The GTK4 UI job shares project_name, so the default checksum file name would +# collide with it on the shared GitHub release. +checksum: + name_template: "{{ .ProjectName }}_gtk3_checksums.txt" + +changelog: + disable: true + +uploads: + - name: debian + skip: "{{ .Env.SKIP_PUBLISH }}" + ids: + - netbird_ui_deb_gtk3 + mode: archive + target: https://pkgs.wiretrustee.com/debian/pool/{{ .ArtifactName }};deb.distribution=stable;deb.component=main;deb.architecture={{ if .Arm }}armhf{{ else }}{{ .Arch }}{{ end }};deb.package= + username: dev@wiretrustee.com + method: PUT + + - name: yum + skip: "{{ .Env.SKIP_PUBLISH }}" + ids: + - netbird_ui_rpm_gtk3 + mode: archive + target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }} + username: dev@wiretrustee.com + method: PUT + +release: + # Uploads into the release created by the main .goreleaser.yaml run. + # make_latest stays false everywhere: the signing pipeline + # (netbirdio/sign-pipelines) marks the release latest after the Windows + # and macOS artifacts are signed. + make_latest: false + prerelease: auto diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..e69de29bb diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..5497acb15 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,783 @@ +# NetBird Agent Guidelines + +**NetBird** is an open source connectivity platform: a WireGuard®-based overlay +network with a control plane. The **agent** (`client/`) runs on user machines as +a privileged daemon and manages the WireGuard interface, routing, firewall, and +DNS. **Management** (`management/`) is the control plane and REST/gRPC API, +**Signal** (`signal/`) brokers peer handshakes, **Relay** (`relay/`) carries +traffic when a direct tunnel is impossible, and **Proxy** (`proxy/`) is the +identity-aware proxy behind Agent Network. + +This file applies to the whole repository, and is the single source of truth for +agent guidance here. `CLAUDE.md` is a one-line pointer to it — keep the guidance +in this file, not duplicated there. + +## Contents + +- [STOP and ask the user before](#stop-and-ask-the-user-before) +- [Quick reference](#quick-reference) +- [Structure](#structure) +- [Where to look](#where-to-look) +- [Security](#security) +- [Agent conventions](#agent-conventions) +- [Repo-wide principles](#repo-wide-principles) +- [Type safety](#type-safety) +- [Concurrency and lifecycle](#concurrency-and-lifecycle) +- [Error handling](#error-handling) +- [Comments](#comments) +- [Testing](#testing) +- [Pitfalls](#pitfalls) +- [Commits, PRs, releases](#commits-prs-releases) +- [After you push: CI and review bots](#after-you-push-ci-and-review-bots) +- [Discussion and support](#discussion-and-support) + +## STOP and ask the user before + +- **Opening a pull request for anything beyond a trivial fix, without an agreed + ticket.** Ask the user directly: *"Is there a discussion or issue for this + change?"* NetBird is discussion-first — community reports start in + [Discussions](https://github.com/netbirdio/netbird/discussions), DevRel + validates them, and only validated discussions become issues. A PR that + changes behavior with no linked issue may be closed on arrival. If there is no + ticket, offer to draft the discussion post **instead of** the PR, and wait for + the user's call. Only typos, broken links, documentation corrections, and + one-line fixes that already have an issue can skip this. +- **Designing in any high-risk area** (see + [CONTRIBUTING.md](CONTRIBUTING.md#high-risk-areas)): public API and OpenAPI + schema, gRPC protos, behavior existing deployments would notice after an + upgrade, peer connectivity (ICE, NAT traversal, relay selection, WireGuard® or + Rosenpass key handling), client system integration (routing, firewall, DNS, + interface), authentication and authorization, CLI or service flags, config + file format, daemon IPC, store schema and migrations, or a new feature. The + design gets agreed in the ticket before code is written. +- **Writing a store migration or changing a persisted model.** Migrations are + one-way in the field and both the GORM and pgx paths may need the change. +- **Hand-editing generated code.** `*.pb.go`, `*.gen.go`, and mocks are outputs. + Edit the source (`.proto`, `openapi.yml`) and rerun the matching + `generate.sh`. +- **Adding, removing, or bumping a dependency**, and never vendor a fork. +- **Weakening a security control** — authentication, authorization, certificate + verification, privilege dropping, or peer identity checks — even when it is + the fastest way to make a test pass. +- **Force-pushing to `main`**, force-pushing any branch that is already under + review, amending pushed commits, or bypassing hooks with `--no-verify`. + +## Quick reference + +```bash +# Build +go build ./... +cd client && CGO_ENABLED=0 go build . # agent +cd management && go build . # management service +cd signal && go build . # signal service + +# Verify (run before every push) +go fmt ./... +make lint # golangci-lint on files changed vs origin/main (also the pre-push hook) +make lint-all # full-repository lint, matches CI +make test-unit # host-safe unit tests, -tags devcert, no sudo +make test-privileged # privileged-tagged suite in a Docker container with NET_ADMIN +make setup-hooks # wire make lint into .githooks/pre-push + +# Narrow runs +go test ./client/internal/dns/... +go test -race -run TestPeerConn ./client/internal/peer/... +PRIV_RUN=TestNftablesManager PRIV_PKGS=./client/firewall/nftables/... make test-privileged + +# Code generation (never hand-edit the output) +./shared/management/http/api/generate.sh # REST types from openapi.yml +./shared/management/proto/generate.sh +./shared/signal/proto/generate.sh +./client/proto/generate.sh +./flow/proto/generate.sh + +# Run locally (lab only, never on a machine you rely on) +sudo ./client/netbird up --log-level debug --log-file console +sudo ./client/netbird down # teardown: restores routing, firewall, DNS +./signal/signal run --log-level debug --log-file console +./management/management management --log-level debug --log-file console --config ./management.json +``` + +`netbird up` needs root and rewrites the host's routing table, firewall rules, +DNS configuration, and WireGuard® interface. Run it only in a disposable test +environment (a VM, container, or throwaway host) that you can rebuild, never on +a workstation or server whose connectivity matters. Run `sudo netbird down` +before you stop working, before rebuilding the binary, and on every failure +path, so the host's networking state is restored instead of left half-applied. +See [Pitfalls](#pitfalls) for why cleanup on every exit path matters. + +## Structure + +```text +netbird/ +├── client/ NetBird agent +│ ├── cmd/ agent CLI +│ ├── internal/ agent business logic (engine, peer, dns, routemanager, ...) +│ ├── server/ daemon for background execution +│ ├── proto/ daemon gRPC protos +│ ├── iface/ WireGuard® interface management +│ ├── firewall/ nftables, iptables, pf, WFP, userspace backends +│ ├── ssh/ built-in SSH server and client +│ ├── ui/ desktop UI (Wails v3 + React) +│ ├── android/, ios/ mobile bindings +│ ├── wasm/ WebAssembly build +│ └── mdm/, system/ MDM policy, host information +├── management/ control plane +│ └── server/ account, peer, groups, networks, posture, permissions, +│ settings, store, http (REST), idp, integrations, migration +├── signal/ handshake broker (peer/, server/) +├── relay/ relay service (protocol/, server/, healthcheck/) +├── proxy/ identity-aware proxy (llm/, acme/, accesslog/, middleware/, tcp/, udp/) +├── agent-network/ Agent Network overview +├── shared/ imported by both agent and services +│ ├── management/ proto/, client/, http/api (OpenAPI + generated types) +│ ├── signal/ proto/, client/ +│ └── relay/, auth/, sshauth/, metrics/ +├── e2e/ end-to-end suites and harness +├── encryption/, dns/, route/, stun/, sharedsock/, util/, flow/ +├── infrastructure_files/ docker compose and getting-started templates +└── release_files/ files packaged into releases +``` + +## Where to look + +| Task | Location | +| --------------------------- | ------------------------------------------------------------ | +| REST API / OpenAPI | `shared/management/http/api/` + `management/server/http/` | +| Management gRPC protocol | `shared/management/proto/` | +| Signal protocol | `shared/signal/proto/` | +| Daemon IPC protocol | `client/proto/` | +| Peer connection and NAT | `client/internal/peer/` | +| Network map handling | `client/internal/engine.go`, `shared/management/networkmap/` | +| Routing | `client/internal/routemanager/`, `route/` | +| Firewall backends | `client/firewall/` | +| DNS | `client/internal/dns/`, `dns/` | +| WireGuard® interface | `client/iface/` | +| Persistence and migrations | `management/server/store/`, `management/server/migration/` | +| IdP integrations | `management/server/idp/` | +| Permissions model | `management/server/permissions/` | +| LLM routing / Agent Network | `proxy/internal/llm/`, `agent-network/` | +| End-to-end tests | `e2e/` | + +## Security + +### Never fail open + +When a security check — access control, an IP restriction, an auth decision — +hits an error such as an unparseable value, an unavailable lookup, or a state it +does not recognize, it must **deny**. Never skip the check or allow the request +through because the check itself failed, and make the `default` and unknown cases +of a security-related `switch` deny rather than fall through. + +### Daemon RPC input is untrusted + +The agent runs as root (LocalSystem on Windows), so a daemon RPC crosses a +privilege boundary: treat every field as untrusted input rather than as something +the UI or CLI validated on the way in. + +When you add or change an RPC, ask what the handler does with caller input while +running as root. If the answer touches a filesystem path, a URL or host, or a +privileged state change, it needs a gate **in the handler** — a check in the client +that normally calls it is not a check at all. + +- **A caller-supplied path the daemon opens.** Never `os.Open` it as root. + Constrain it, then open it *as the caller* with `ipcauth.OpenOwnedFile`, which + opens `O_NOFOLLOW`, requires a regular file, and refuses a file the caller does + not own — so a symlink or hardlink aimed at a root-only file is rejected. +- **A caller-supplied URL or host the daemon fetches.** Restrict the scheme and + allow only known hosts for unprivileged callers. Prefer a lexical host + allowlist plus TLS verification over "resolve the host, then reject private + IPs": the resolve-then-trust pattern has a DNS-rebinding race (public IP at + check time, attacker IP at connect time), while a name allowlist has no IP + check to race. Never accept `http://` where `https://` is expected. +- **A privileged state change** (SSH root login, management URL, deregistration) + gates on the caller identity from `ipcauth.CallerIdentity(ctx)`. + +Caller identity comes from the kernel — `SO_PEERCRED`, `LOCAL_PEERCRED`, or the +named-pipe client token — and never from an RPC field. When +`ipcauth.CallerIdentity` reports that it could not determine an identity, **deny**; +do not fall back to treating the caller as the transport peer. + +## Agent conventions + +### Three networking modes + +Where packets actually flow depends on the mode the agent is running in. The +three are not interchangeable, so establish which one a change applies to — and +what it should do in the other two — before you write it. + +- **kernel mode** (Linux only): in-kernel WireGuard®. The kernel handles both + peer-to-peer and routed traffic, and ACLs are iptables or nftables rules. The + client programs kernel facilities but never sees the traffic itself. +- **userspace mode** (wireguard-go with a TUN): wireguard-go runs in-process. The + kernel handles peer-to-peer traffic once it leaves the TUN, while routed traffic + — exit nodes and network routes — goes through the userspace forwarder, which + terminates the connection and re-establishes it over OS sockets. Used on + platforms without kernel WireGuard® or when the user opts out. +- **netstack mode**: wireguard-go in-process with no TUN and no kernel + networking. The forwarder does all routing by stitching userspace sockets, and + listeners such as the embedded SSH and DNS servers bind on a gVisor netstack. + Used where the process cannot create a TUN device, such as the embedded client + (`client/embed/`) and the WASM build. + +### The overlay interface is not "WireGuard" + +Do not put "WireGuard" in identifiers or comments unless the code is genuinely +coupled to WireGuard® specifically — a wireguard-go call, a handshake field, a +kernel WireGuard® netlink attribute. For the interface, the host, peers, or +traffic in general, say "the NetBird interface", "the interface", or "the overlay". +Most firewall, routing, and DNS code is transport-agnostic, so a WireGuard® +reference there is simply inaccurate and rots as the transports change. + +### IPv6 is a soft feature + +The IPv6 overlay is opt-in dual-stack, and capability can change at runtime. Treat +it as soft rather than a requirement: + +- Gate local v6 paths on the interface accessor (`wgIface.Address().HasIPv6()`), + not on raw state fields, and skip the v6 path when the host has no v6 rather + than returning an error. +- Treat an empty or unparseable peer v6 address as "no v6 for that peer" and skip + it, keeping the v4 path working. +- Never let a missing v6 break v4. Fail-closed is for security checks; a + capability mismatch skips the v6 work and carries on. + +### Environment variables + +Name the variable in a constant and parse booleans with `strconv.ParseBool` rather +than comparing strings inline, so an unexpected value is logged instead of +silently meaning false: + +```go +const EnvDisableFeature = "NB_DISABLE_FEATURE" + +func isDisabledByEnv() bool { + val := os.Getenv(EnvDisableFeature) + if val == "" { + return false + } + disabled, err := strconv.ParseBool(val) + if err != nil { + log.Warnf("failed to parse %s: %v", EnvDisableFeature, err) + return false + } + return disabled +} +``` + +### Validating against protocol specs + +When a change depends on what a protocol actually mandates, read the specification +text from the [IETF datatracker](https://datatracker.ietf.org/) rather than a +summary, and check that you have the current RFC — the widely cited one for a +protocol is often superseded. Cite the section, not just the document, so a +reviewer can jump straight to the rule. + +## Repo-wide principles + +1. **Run `go fmt` on every modified Go file.** Formatting is not optional. +2. **Zero unaddressed linter warnings.** Fix what `golangci-lint` reports on code + you touch, and delete imports, helpers, and parameters your refactor orphaned. + Exception: unused parameters in shared code may be consumed by builds outside + this repository — do not remove them, ask instead. +3. **Function comments are mandatory for exported functions**, written as full + sentences with a period, starting with the identifier name. +4. **Prefer private functions and constants.** Export only what a caller outside + the package genuinely needs. +5. **Early returns and guard clauses.** Handle errors and edge cases first + instead of nesting `if`/`else` chains. +6. **Split complex functions.** If a function trips a complexity warning, break + it into named helpers rather than silencing the warning. +7. **Avoid LLM-slop tells:** em dashes, hedging narration, restating the diff in + prose, trailing summaries. Defaults, not absolute bans. Applies to code, + comments, commit messages, and PR descriptions alike. +8. **Concurrency: do a two-pass race analysis after every change** that touches + shared state, including reads of existing maps and slices. Guard them with a + mutex (or an atomic or channel where that fits better), keep critical + sections short, and run `go test -race` on the touched packages. See + [Concurrency and lifecycle](#concurrency-and-lifecycle) for the failure modes + to check for. +9. **Cross-platform builds must keep working.** The agent targets Linux, macOS, + Windows, FreeBSD, Android, and iOS. When you add a platform-specific file, + add the counterpart or a build-tagged fallback for the others. +10. **Never hand-edit generated files.** Change the source and regenerate. +11. **Never log secrets** — private keys, setup keys, tokens, PAT values — and + keep peer IPs and hostnames out of logs above debug level. + +## Type safety + +**No bare primitives for domain concepts.** A `string` parameter for an account +ID next to a `string` parameter for a peer ID is two bugs waiting to happen, +because the compiler cannot catch the swap. Declare the type once and use it +throughout, converting only at the boundaries where data enters or leaves — +protobuf, gRPC, HTTP, an external library. + +```go +type ServiceID string +type AccountID string + +// Internal: typed all the way through +func (r *Router) RemoveRoute(host SNIHost, svcID ServiceID) { ... } + +// Proto boundary: convert once, on the way in and on the way out +svcID := ServiceID(mapping.GetId()) +req.ServiceId = string(svcID) +``` + +- **IP addresses are `netip.Addr`**, not `string` and not `net.IP`. Parse at the + boundary and pass the typed value inward. +- **Always `Unmap()`** after parsing an address, after converting from `net.IP`, + and after extracting one from `RemoteAddr()`. This normalizes a v4-mapped v6 + address (`::ffff:10.1.2.3`) to plain v4 so IPv4 rules match it. A stored or + compared mapped address silently fails to match those rules. +- **Ports are `uint16`** internally; use `int` only where a library forces it and + convert immediately. +- **Enums are a typed string with constants**, so the valid set is discoverable + and a typo fails to compile. +- **Map keys follow the same rule**, and must be a real type (`type ServiceID + string`) rather than an alias (`type serviceID = string`) — an alias silently + accepts bare strings. + +## Concurrency and lifecycle + +Beyond the mutex hygiene in the principles above, check for these failure +modes. + +- **Never read a struct field inside a goroutine** when another goroutine may nil + or reassign it. Pass the value as a parameter, or capture it into a local before + launching. This matters most when `Stop()` nils a field without waiting for the + goroutine to finish. + + ```go + go func(ifaceName string) { // good: passed in, cannot be nilled underneath + m.Start(ctx, ifaceName) + }(iface.Name()) + ``` + +- **Never wait on a channel while holding a lock the sender needs.** Copy what you + need out from under the lock, release it, then wait. + + ```go + func (m *Manager) Stop() { + m.mu.Lock() + cancel, done := m.cancel, m.done + m.mu.Unlock() + if cancel != nil { + cancel() + <-done + } + } + ``` + +- **`Stop`/`Close` must be idempotent** — guard on an already-stopped flag or a + nil cancel — and must release the state they guarded. Clear maps and caches; + a cancelled goroutine holding a live map still pins that memory. Note that a + nil map only panics on writes; reads and iteration behave like an empty map, + so where post-close use must be rejected, check the stopped flag explicitly. +- **Publish coupled state only after every fallible step succeeds.** When several + fields form an invariant, build them into locals and assign them to the receiver + at the end. Assigning as you go leaves the object half-initialized when a later + step fails, so a readiness predicate reports ready while a coupled field is nil. + If an earlier step already had an external side effect — a created chain, an + opened handle, an inserted rule — roll it back before returning the error. +- **Clean up what you own on constructor error paths.** Once a constructor has + started something, every later error path must undo it: cancel a goroutine and + wait for it to exit, stop a ticker, close a watcher. The object is never + returned, so its `Close` will never run. +- **A failed `Start` must undo everything it started.** When a component brings up + several subsystems in sequence — connection manager, watchers, routing, DNS, + flow, persisted state — a failure partway through has to tear down the ones + already running, not just close the handle the error came from. Put the + already-started guard *before* that teardown path, so a rejected second `Start` + cannot dismantle the one that is running. + +## Error handling + +Use single-assignment form when the error is only needed inside the `if`: + +```go +// Good +if err := someCall(); err != nil { + return fmt.Errorf("context: %w", err) +} + +// Bad - unnecessary split +err := someCall() +if err != nil { + return fmt.Errorf("context: %w", err) +} +``` + +Use multiple assignment when the value is needed after the block: + +```go +result, err := someCall() +if err != nil { + return fmt.Errorf("context: %w", err) +} +``` + +Add short, meaningful context, and **do not** start `fmt.Errorf` messages with +obvious words like "failed to" or "error": + +```go +// Good +return fmt.Errorf("parse remote address: %w", err) +return fmt.Errorf("listen on %s: %w", addr, err) + +// Bad +return fmt.Errorf("failed to parse remote address: %w", err) +return fmt.Errorf("error listening on %s: %w", addr, err) + +// "failed" is fine in log messages +log.Debugf("failed to parse remote address: %v", err) +``` + +Skip the wrapping when a function only extracts or delegates and the wrap would +add nothing: + +```go +func parseAddr(addr string) (string, int, error) { + host, portStr, err := net.SplitHostPort(addr) + if err != nil { + return "", 0, err + } + // ... +} +``` + +Log the errors you choose not to act on: + +- `log.Debugf()` for errors that do not affect program flow but help debugging. +- `log.Tracef()` for very verbose errors that would otherwise spam logs. +- **Never ignore** errors from writes, network sends, or critical cleanup. +- Close errors may be ignored for read-only operations; log them at debug for + writes. + +**Do not log and return the same error.** It gets reported twice, from two places, +and the second reader cannot tell whether it happened once or twice. Return it and +let the caller decide. The exception is an API handler that has already written a +response. Internal helpers return errors rather than logging and swallowing them. + +**Never return a typed nil as an error.** A nil `*MyError` stored in an `error` +interface is not nil, so `err != nil` is true and callers take the failure path on +success. Return the error only where it is actually set: + +```go +if _, err := conn.Write(buf); err != nil { // good + return err +} +return nil +``` + +**Accumulate with `multierror` when an operation should continue past individual +failures** — teardown, cleanup, or setup where partial success is acceptable. +`client/errors.FormatErrorOrNil` returns nil for an empty accumulator, so callers +still see a plain nil on full success: + +```go +func (m *Manager) Cleanup() error { + var merr *multierror.Error + for _, r := range m.resources { + if err := r.Close(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("close %s: %w", r.Name, err)) + } + } + return nberrors.FormatErrorOrNil(merr) +} +``` + +| Scenario | Approach | Why | +| --------------------- | --------------------- | ----------------------------------------- | +| Cleanup / teardown | Accumulate | Clean up as much as possible | +| Setup with rollback | Abort on first error | Partial state is invalid; undo what stuck | +| Setup with partial OK | Accumulate | Degraded operation is still useful | + +## Comments + +Comment the **why**, never the **what**. Default to no comment, and add one only +when a hidden constraint or workaround would surprise a future reader. Never +reference the current task, PR, or your own changes in a comment. + +```go +// Bad - trailing comments explaining the obvious +defer localConn.Close() // Close the connection +if err != nil { // Check if error occurred + +// Good +defer localConn.Close() + +// Good - explains a non-obvious constraint +// Use incremental checksum update per RFC 1624 for performance. +checksum = updateChecksum(checksum, oldPort, newPort) +``` + +### Length budget + +Neither of these is linter-enforced, so they are conventions the surrounding code +mostly follows rather than hard limits: + +- **Around 90 characters per line.** Wrap the comment rather than running well past + it. +- **Roughly 250 characters per comment**, about three wrapped lines. Doc comments + on exported identifiers may exceed it when the API genuinely needs the + explanation; inline comments inside a function body rarely should. + +The budget is a smell detector, not a rule to game. Do not compress a needed +explanation into cryptic shorthand to fit — if a block of code needs more than +250 characters of prose, the code is doing too much. Fix the code: + +- **Extract a named function.** A well-named function replaces the comment: the + name says *what*, the body shows *how*, and the comment you no longer write + was the *what* anyway. Clean Code calls this "explain yourself in code". +- **Extract a named constant or predicate.** `if isExpiredSetupKey(key)` needs + no comment; `if key.ExpiresAt.Before(now) && !key.Revoked && key.UsageLimit > 0` + does. +- **Keep the surviving comment for the why** — the RFC, the kernel quirk, the + ordering constraint. That part is usually one or two lines. + +### Long switch and if/else chains + +A `switch` whose cases carry multi-line explanations is the usual place this +budget is breached, and the comment is a symptom. In order of preference: + +1. **Extract each case body into a named function.** The case becomes one line, + the name carries the meaning, and the switch reads as a table of contents. +2. **Replace the switch with a lookup table** — `map[Kind]handlerFunc` — when the + branches are uniform. Adding a case stops meaning editing a growing function. +3. **Replace conditional with polymorphism** when branches vary by type and the + same switch shape starts appearing in more than one place. Clean Code's rule + of thumb: tolerate a switch statement if it appears **once**, is buried in a + factory that returns an interface, and no other switch dispatches on the same + type. A second switch over the same enum is the signal to introduce the + interface. + +Do not restructure a switch purely to satisfy the budget when the cases are one +line each and self-evident — a flat, boring `switch` over an enum is fine and +needs no comments at all. + +Explanatory comments in tests are welcome — they document the scenario being set +up, and the 250-character budget does not apply to them. + +## Testing + +- **Unit tests** live beside the code as `_test.go`. `make test-unit` runs the + host-safe set with `-tags devcert` and no sudo. +- **Privileged tests** carry the `privileged` build tag and mutate host + networking. They run through `make test-privileged`, inside a Docker container + with `NET_ADMIN`. Never bypass that harness by running them directly on the + host. +- **End-to-end suites** live in `e2e/` with a shared harness. +- **Test real behavior, not API existence.** Assert on the observable end state + a consumer would see — bytes that arrived, the packet after translation, the + row after the write — not merely that a method exists or returns an error. +- **Avoid mocks for code we own.** Exercise the real store, manager, or + controller and assert what the caller actually receives. +- **`require` for setup and preconditions, `assert` for the conditions under + test.** Use `require` whenever a later line would panic or be meaningless + otherwise. +- **Message guidance:** optional for `NoError`/`Error`; always give context for + comparison, boolean, and collection assertions. +- **Reproduce a bug before fixing it.** Write the test, watch it fail *for the + reason you expect* — a test that fails for an unrelated reason proves nothing — + then apply the fix and confirm it passes. Add the thin surrounding cases while + you are there. +- **Use `t.Setenv`** rather than `os.Setenv` so the previous value is restored on + cleanup. To test the unset case, call `t.Setenv` first to register the restore, + then `os.Unsetenv`. +- **Prefer `t.Cleanup` over `defer`** in any test with parallel subtests: the + parent function returns, running its `defer`s, while parallel subtests are + still suspended. Sequential subtests finish inside `t.Run`, so `defer` is safe + there, but `t.Cleanup` works in both cases. +- **Explanatory comments in tests are welcome.** Describe the scenario being set + up; the comment budget below does not apply to them. + +```go +server, err := StartTestServer() +require.NoError(t, err, "Test server setup must succeed") +defer server.Close() + +result, err := client.DoOperation() +assert.NoError(t, err) +assert.Equal(t, expectedResult, result, "Result should match expected") +``` + +## Pitfalls + +- **The agent runs as root.** Anything touching routing, firewall, DNS, or the + interface can take a user's machine off the network. Prefer a reversible + change and make sure cleanup runs on every exit path. +- **Management has two account loaders** (GORM and pgx). Adding a relation to an + account often means updating both, or it silently comes back empty in + production. +- **`go test ./...` without `-tags devcert` skips tests** that need the + development certificate. Use `make test-unit`. +- **`make lint` only checks the diff against `origin/main`.** CI runs + `make lint-all`; run it too before pushing a large change. +- **Protos are consumed by released clients.** An old agent must keep working + against a new Management, so fields are added, never renumbered or removed. +- **Windows requires the wintun driver**, and the daemon serves a named pipe + (`npipe://netbird`) rather than loopback TCP. Loopback TCP carries no caller + identity, so privileged operations are refused over it. + +## Commits, PRs, releases + +- **PR titles must start with a bracketed tag.** Before you propose a title, + **read [`.github/workflows/pr-title-check.yml`](.github/workflows/pr-title-check.yml) + and take the allowed tags from the `allowedTags` array in that file.** It is + the only source of truth, it changes as components are added, and the check + runs on every title edit — a tag that is not in that array is a red build. Do + not rely on a list memorized from anywhere else, including this file. + + ```text + [client] Authorize daemon IPC callers by their local identity + [management,client] Add MDM policy support + ``` + + Multiple tags are comma-separated inside one pair of brackets. Match the tag + to the component you actually changed, not to the one you read the most. + +- **Use the repository's PR template.** Fill in + [`.github/pull_request_template.md`](.github/pull_request_template.md) rather + than replacing it with your own summary: describe the change, link the issue, + tick the checklist honestly (including "ran locally" and "single purpose"), + and complete the documentation section. Do not tick a box you have not + verified, and do not delete rows that do not apply — the docs gate in CI reads + that section and fails when it is missing. + +- **Keep the PR description short.** Under 1000 words on top of the template's + own text, and usually far less — a few paragraphs. Reviewers read the diff; + the description exists to explain what the diff cannot say for itself. This is + well below what an agent will produce by default, so cut before you post. + +- **Body: why before what.** Lead with the problem and the reason for this + approach, then the shape of the change. No bullet list of files changed, no + per-function walkthrough, no restating the diff in prose, no trailing summary + section, no self-congratulatory closing line. + +- **No `Co-Authored-By` or tool-attribution trailers in the PR description**, + and none in commits either. Contributors own their contributions. Whatever + tooling produced the diff, the person opening the PR is its author: they have + read every line, they can explain why it works, they can answer review + questions without going back to a model, and they are accountable for the + consequences of merging it. Do not add a trailer, footer, or description line + that spreads that ownership onto a tool. + +- **Commit subjects follow the same `[scope] Subject` convention.** Keep the + subject short, and use the body for why before what. No bullet lists of files + changed. + +- **Push review fixes as separate commits.** The PR is squashed on merge, so + there is no reason to rewrite history mid-review; many small commits make the + re-review readable. + +- **Do not force-push a branch that is under review.** A force-push detaches + existing review comments from the lines they were written against, destroys + the "changes since your last review" diff a reviewer relies on, and discards + the CI history that showed which commit broke what. Add commits instead — + including for fixups and reverts. Force-push only when there is no + alternative: a rebase to clear a genuine conflict, or removing a secret or a + large binary that was committed by mistake. When you must, ask the user first, + then say so in a PR comment so reviewers know their anchors moved. Never + force-push `main`, and never force-push a branch you do not own. + +- **One PR, one purpose.** Split refactors out of fixes and fixes out of + features. + +- **Keep the PR small.** Size is the single strongest predictor of how long a PR + waits. Aim for **under ~400 changed lines across under ~20 files**; past + roughly **1000 lines or 50 files** a community PR is likely to be sent back to + be split, or left unreviewed until it is. Large PRs from outside the core team + may be blocked outright when the size was never agreed in the ticket — + reviewing a sprawling change against a privileged networking daemon is a + security risk in itself, not just a time cost. + + Judge the size by hand-written code: exclude generated output, `go.sum`, + vendored files, and test fixtures from the estimate, but do not use their + presence to argue a 3000-line PR is small. + + When a change genuinely cannot be small — a protocol migration, a + cross-component rename — agree the split in the ticket **before** writing + code, and land it as a sequence of PRs that each build, test, and make sense + on their own. Propose that split to the user rather than opening one large PR + and hoping. + + Prefer GitHub's stacked pull requests for such a sequence, rather than + hand-managing base branches: open each PR against the branch below it instead of + `main`, so every PR's diff shows only its own change. Merging a layer retargets + the PRs above it, and branch protections and required checks on the base branch + still apply to each one. + +- **User-facing changes need a docs PR** in + [netbirdio/docs](https://github.com/netbirdio/docs), linked from the PR + description. + +## After you push: CI and review bots + +Opening the PR is not the end of the task. Watch the run, read what the bots +say, and drive the PR to green before you report the work as done. + +```bash +gh pr checks --watch # all checks, live +gh run view --log-failed # only the failing steps +gh pr view --comments # bot and human review comments +``` + +**Never report a change as finished while checks are pending or red**, and never +describe a red PR as passing. If you ran out of turn before CI finished, say +which checks were still running. + +### The checks + +- **Go tests** — `golang-test-{linux,darwin,windows,freebsd}.yml`, sharded per + component. A failure in a component you did not touch is usually a real + interaction, not noise; read the log before assuming flake. +- **golangci-lint** — `golangci-lint.yml` runs the full repository, while + `make lint` only checks your diff. A clean local lint does not guarantee green + CI on a large change. +- **PR Title Check** — `pr-title-check.yml`, see above. +- **Codecov** — uploaded from the Linux test workflow with per-component flags + (`unit,client`, `unit,management`, `unit,relay`, `unit,proxy`, `unit,signal`, + `integration,management`). Coverage on new code should not go backwards. Add + tests for the paths you introduced; do not adjust thresholds or exclude files + to clear the report. +- **CodeRabbit** — configured in [`.coderabbit.yaml`](.coderabbit.yaml): `chill` + profile, auto-review on every non-draft PR, TypeScript/JavaScript/SVG paths + filtered out. Chat auto-reply is on, so `@coderabbitai` in a comment reaches + it. +- **SonarCloud** — project `netbirdio_netbird`, quality gate on new code (bugs, + vulnerabilities, code smells, duplication, coverage). +- **Snyk** — dependency and code scanning. + +Sonar and Snyk report as GitHub App checks rather than workflows in this +repository, so their detail lives on the PR check, not in the Actions logs. + +### Handling bot findings + +- **Read every comment and act on it.** Either fix it, or reply with the reason + it does not apply. Do not bulk-resolve threads to clear the count, and do not + silently ignore a finding because the check is advisory. +- **Bots are frequently wrong here.** NetBird has privileged, platform-specific, + and concurrency-heavy code that static analysis reads poorly. A confident + CodeRabbit or Sonar comment can still be nonsense. Verify the claim against + the code before you change anything — never edit correct code just to silence + a bot. +- **Security findings get the opposite default.** For a Snyk or Sonar + vulnerability, or a CodeRabbit comment about authentication, authorization, + certificate verification, or key handling, assume it is real until you have + disproved it. Surface it to the user rather than dismissing it yourself. +- **A new vulnerable dependency is a stop.** Bumping or replacing dependencies + needs the user's decision, as above. +- **Never change a workflow, threshold, lint exclusion, or bot config to make a + check pass.** If a check is genuinely wrong, say so and let the user decide. +- **Do not paper over flakes with blind re-runs.** Identify the failure first. If + it is a known flake, name it; if you cannot tell, report it as unresolved + rather than re-running until it goes green. + +## Discussion and support + +- Discussions: +- Slack: +- Docs: +- Security: — never in public +- Contribution process: [CONTRIBUTING.md](CONTRIBUTING.md) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..764f406be --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +See [AGENTS.md](AGENTS.md) for the agent guidelines in this repository. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index efc7d9460..aef749cfa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to NetBird -Thanks for your interest in contributing to NetBird. +Thanks for your interest in contributing to NetBird. There are many ways that you can contribute: - Reporting issues @@ -8,11 +8,99 @@ There are many ways that you can contribute: - Sharing use cases in slack or Reddit - Bug fix or feature enhancement -If you haven't already, join our slack workspace [here](https://join.slack.com/t/netbirdio/shared_invite/zt-vrahf41g-ik1v7fV8du6t0RwxSrJ96A), we would love to discuss topics that need community contribution and enhancements to existing features. +If you haven't already, join our slack workspace [here](https://docs.netbird.io/slack-url), we would love to discuss topics that need community contribution and enhancements to existing features. + +## Ticket first, PR second + +**Open a ticket and wait for feedback before you open a pull request.** Every PR +that changes behavior must link to an issue the NetBird team has agreed on. A PR +that arrives without one may be closed and redirected to a discussion, no matter +how good the code is. + +Issues in this repository are maintainer-curated work items, so the flow starts +in [Discussions](https://github.com/netbirdio/netbird/discussions): + +1. **Open a discussion.** Use + [Issue Triage](https://github.com/netbirdio/netbird/discussions/new?category=issue-triage) + for a bug, regression, or unexpected behavior, and + [Ideas & Feature Requests](https://github.com/netbirdio/netbird/discussions/new?category=ideas-feature-requests) + for a feature, enhancement, or integration idea. Setup and usage questions + belong in + [Q&A / Support](https://github.com/netbirdio/netbird/discussions/new?category=q-a-support). + Never report a security vulnerability in public — follow the + [security policy](https://github.com/netbirdio/netbird/security/policy) + instead. +2. **Wait for feedback.** DevRel validates and reproduces the report, and a + maintainer confirms the direction. We may ask for more detail or propose a + different approach. Validated discussions become issues. +3. **Then write the code**, following the approach agreed in the issue, and open + the PR linking that issue. + +Trivial fixes — a typo, a broken link, a documentation correction, or a one-line +fix that already has an issue — can go straight to a PR. Everything else starts +with a ticket. When in doubt, ask in the discussion or on +[Slack](https://docs.netbird.io/slack-url); an hour of conversation up front +regularly saves a week of rework. + +### High-risk areas + +These always need the design discussed and agreed in the issue **before** you +write code: + +- **Public API** — REST / management API, OpenAPI schema, dashboard-facing contracts +- **gRPC protocols** — management, signal, relay, and client daemon protos +- **Functionality behavior** — anything existing deployments would experience differently after an upgrade +- **Peer connectivity** — ICE and NAT traversal, relay selection, WireGuard® and Rosenpass key handling +- **Client system integration** — routing, firewall, DNS, and interface management +- **Authentication and authorization** — IdP integration, tokens, permissions, cryptography +- **CLI / service flags**, configuration file format, and daemon IPC +- **Store and database schema** — models and migrations +- **New features** + +These surfaces are NetBird's contract with operators, self-hosters, and +downstream integrators, and changes to them have compatibility, security, and +release-planning implications. Agreeing on the direction early lets the PR +review focus on implementation rather than design. + +Typical bug fixes, internal refactors, documentation updates, and tests do not +need a design discussion, but should still be tied to an issue so the work is +visible and nobody duplicates it. + +### Using AI coding agents + +We have no policy for or against using an AI agent to write NetBird code. That +choice is yours, and we are not going to interrogate anyone about their tools. + +What we do have is a lot of incoming contributions that were plainly drafted with +one, and enough experience reviewing them to see the same avoidable problems +again and again: no ticket behind the change, a diff far too large to review, a +description longer than the code it describes, an approach that was never going +to be accepted, and an author who cannot answer questions about their own PR. +None of that is caused by the tooling — it is what happens when a tool is pointed +at a repository whose expectations it has never been told. + +So rather than a rule, there is a guide. [AGENTS.md](AGENTS.md) restates the +expectations from this document in the form agents read automatically +(`CLAUDE.md` points to it), so pointing your tool at the repository is usually +enough. Among other things it tells the agent to ask you for the +discussion or issue before drafting a PR, to keep the change small and +single-purpose, to run the tests locally, to use this repository's PR template +and title tags, and to write a description a reviewer can get through. + +The guardrails are the point, and they are the same ones we apply to everyone: an +agreed ticket, a change you have actually run, a diff small enough to review with +care, and an author who can explain it. Whatever wrote the diff, you are its +author — you own every line you submit and the consequences of opening a PR with it. + +We may assess whether a contribution is maintainable and whether its merged code +aligns with our security standards and design expectations. ## Contents - [Contributing to NetBird](#contributing-to-netbird) + - [Ticket first, PR second](#ticket-first-pr-second) + - [High-risk areas](#high-risk-areas) + - [Using AI coding agents](#using-ai-coding-agents) - [Contents](#contents) - [Code of conduct](#code-of-conduct) - [Directory structure](#directory-structure) @@ -23,6 +111,8 @@ If you haven't already, join our slack workspace [here](https://join.slack.com/t - [Build and start](#build-and-start) - [Test suite](#test-suite) - [Checklist before submitting a PR](#checklist-before-submitting-a-pr) + - [When we close a PR](#when-we-close-a-pr) + - [Translations](#translations) - [Other project repositories](#other-project-repositories) - [Contributor License Agreement](#contributor-license-agreement) @@ -35,32 +125,64 @@ unacceptable behavior to community@netbird.io. ## Directory structure -The NetBird project monorepo is organized to maintain most of its individual dependencies code within their directories, except for a few auxiliary or shared packages. +The NetBird project monorepo keeps most of each component's code within its own +directory, except for a few auxiliary or shared packages. Protocol definitions +and the client-side service clients live under [/shared](/shared), because both +the agent and the services import them. -The most important directories are: +**Agent** -- [/.github](/.github) - Github actions workflow files and issue templates - [/client](/client) - NetBird agent code -- [/client/cmd](/client/cmd) - NetBird agent cli code +- [/client/cmd](/client/cmd) - NetBird agent CLI code - [/client/internal](/client/internal) - NetBird agent business logic code -- [/client/proto](/client/proto) - NetBird agent daemon GRPC proto files - [/client/server](/client/server) - NetBird agent daemon code for background execution -- [/client/ui](/client/ui) - NetBird agent UI code -- [/encryption](/encryption) - Contain main encryption code for agent communication -- [/iface](/iface) - Wireguard® interface code -- [/infrastructure_files](/infrastructure_files) - Getting started files containing docker and template scripts +- [/client/proto](/client/proto) - NetBird agent daemon gRPC proto files +- [/client/iface](/client/iface) - WireGuard® interface code +- [/client/firewall](/client/firewall) - Platform firewall backends (nftables, iptables, pf, WFP, userspace) +- [/client/ssh](/client/ssh) - Built-in SSH server and client +- [/client/ui](/client/ui) - NetBird agent UI code (Wails v3 + React) +- [/client/android](/client/android), [/client/ios](/client/ios) - Mobile platform bindings +- [/client/wasm](/client/wasm) - WebAssembly build of the agent +- [/client/mdm](/client/mdm) - MDM-delivered policy handling +- [/client/system](/client/system) - Host and system information collection + +**Control plane services** + - [/management](/management) - Management service code -- [/management/client](/management/client) - Management service client code which is imported by the agent code -- [/management/proto](/management/proto) - Management service GRPC proto files - [/management/server](/management/server) - Management service server code - [/management/server/http](/management/server/http) - Management service REST API code +- [/management/server/store](/management/server/store) - Persistence layer and migrations - [/management/server/idp](/management/server/idp) - Management service IDP management code -- [/release_files](/release_files) - Files that goes into release packages +- [/management/server/peer](/management/server/peer), [/management/server/groups](/management/server/groups), [/management/server/networks](/management/server/networks), [/management/server/posture](/management/server/posture), [/management/server/permissions](/management/server/permissions) - Core domain packages - [/signal](/signal) - Signal service code -- [/signal/client](/signal/client) - Signal service client code which is imported by the agent code - [/signal/peer](/signal/peer) - Signal service peer message logic -- [/signal/proto](/signal/proto) - Signal service GRPC proto files - [/signal/server](/signal/server) - Signal service server code +- [/relay](/relay) - Relay service code +- [/relay/protocol](/relay/protocol) - Relay wire protocol +- [/proxy](/proxy) - Identity-aware proxy used by Agent Network (LLM routing, ACME, access logs) +- [/agent-network](/agent-network) - Agent Network overview and documentation +- [/upload-server](/upload-server) - Debug bundle upload service + +**Shared code** + +- [/shared/management/proto](/shared/management/proto) - Management service gRPC proto files +- [/shared/management/client](/shared/management/client) - Management service client code which is imported by the agent code +- [/shared/management/http/api](/shared/management/http/api) - OpenAPI specification and generated REST API types +- [/shared/signal/proto](/shared/signal/proto) - Signal service gRPC proto files +- [/shared/signal/client](/shared/signal/client) - Signal service client code which is imported by the agent code +- [/shared/relay](/shared/relay) - Relay client and shared relay types +- [/shared/auth](/shared/auth), [/shared/sshauth](/shared/sshauth) - Shared authentication primitives +- [/encryption](/encryption) - Contain main encryption code for agent communication +- [/dns](/dns), [/route](/route), [/stun](/stun), [/sharedsock](/sharedsock), [/util](/util) - Shared networking and utility primitives +- [/flow](/flow) - Flow event protocol shared by the agent and Management + +**Build, test, and packaging** + +- [/.github](/.github) - Github actions workflow files, issue templates, and the pull request template +- [/e2e](/e2e) - End-to-end test suites and harness +- [/infrastructure_files](/infrastructure_files) - Getting started files containing docker and template scripts +- [/release_files](/release_files) - Files that goes into release packages +- [/tools](/tools) - Development and maintenance tooling ## Development setup @@ -70,13 +192,21 @@ dependencies are installed. Here is a short guide on how that can be done. ### Requirements -#### Go 1.21 +#### Go 1.26 Follow the installation guide from https://go.dev/ -#### UI client - Fyne toolkit +#### UI client - Wails v3 + React -We use the fyne toolkit in our UI client. You can follow its requirement guide to have all its dependencies installed: https://developer.fyne.io/started/#prerequisites +The desktop UI client (`client/ui`) is built with [Wails v3](https://v3.wails.io/) and a React frontend rendered in a WebView. To build it you need: + +- Go ≥ 1.26 +- Node ≥ 20 and **pnpm** (`corepack enable && corepack prepare pnpm@latest --activate`) +- The `wails3` CLI: `go install github.com/wailsapp/wails/v3/cmd/wails3@latest` +- The `task` runner: `go install github.com/go-task/task/v3/cmd/task@latest` +- Linux only: `libwebkitgtk-6.0-dev`, `libgtk-4-dev`, `libsoup-3.0-dev` + +All UI build, dev-loop, and cross-compile commands are described in the [UI client](#ui-client) section below. #### gRPC You can follow the instructions from the quickstarter guide https://grpc.io/docs/languages/go/quickstart/#prerequisites and then run the `generate.sh` files located in each `proto` directory to generate changes. @@ -205,6 +335,49 @@ To start NetBird the client in the foreground: sudo ./client up --log-level debug --log-file console ``` > On Windows use a powershell with administrator privileges + +#### UI client + +The desktop UI lives in `client/ui` and is built with Wails v3 (see [Requirements](#ui-client---wails-v3--react)). All commands run from `client/ui`. + +Live-reload development (Vite + Go binary + `*.go` watcher): + +``` +cd client/ui +task dev +``` + +Pass daemon flags after `--`, pointing the UI at the socket the daemon serves: + +``` +task dev -- --daemon-addr=unix:///var/run/netbird.sock # Linux, macOS +task dev -- --daemon-addr=npipe://netbird # Windows +``` + +On Windows the daemon serves a named pipe (`npipe://netbird`). Which path that +ends up being depends on what the daemon may create: as a service or elevated it +serves `\\.\pipe\ProtectedPrefix\Administrators\netbird`, which no unprivileged +process can take from it, and otherwise it falls back to `\\.\pipe\netbird`. +Clients try both and check who owns the pipe before using the plain one. Avoid +`tcp://127.0.0.1:41731`: loopback TCP carries no caller identity, so the daemon +refuses the operations that require an administrator and you will not exercise +those paths. + +Production build (frontend assets embedded into the binary, output in `client/ui/bin/`): + +``` +cd client/ui +task build +``` + +Cross-compile the Windows binary from Linux (requires the mingw-w64 toolchain, e.g. `sudo apt install gcc-mingw-w64-x86-64`): + +``` +CGO_ENABLED=1 task windows:build +``` + +> macOS cross-compile from Linux is not supported (signing and notarization need a real Mac). + #### Signal service To start NetBird's signal, execute: @@ -242,10 +415,10 @@ Create dist directory mkdir -p dist/netbird_windows_amd64 ``` -UI client +UI client (built with Wails v3 — see the [UI client](#ui-client) section above) ```shell -CC=x86_64-w64-mingw32-gcc CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -o netbird-ui.exe -ldflags "-s -w -H windowsgui" ./client/ui -mv netbird-ui.exe ./dist/netbird_windows_amd64/ +(cd client/ui && CGO_ENABLED=1 task windows:build) +mv client/ui/bin/netbird-ui.exe ./dist/netbird_windows_amd64/ ``` Client @@ -274,25 +447,183 @@ The installer `netbird-installer.exe` will be created in root directory. ### Test suite -The tests can be started via: +The host-safe unit tests run as a normal user and leave host networking +untouched: ``` -cd netbird -go test -exec sudo ./... +make test-unit ``` + +Tests that need root and mutate host networking (firewall, routing, interface +management) carry the `privileged` build tag and run inside a +`--privileged --cap-add=NET_ADMIN` Docker container: + +``` +make test-privileged +``` + +Narrow a privileged run with environment variables: + +``` +PRIV_RUN=TestNftablesManager PRIV_PKGS=./client/firewall/nftables/... make test-privileged +``` + +Single packages can be run directly, adding `-race` when the change touches +shared state: + +``` +go test -race ./client/internal/dns/... +``` + > On Windows use a powershell with administrator privileges -> Non-GTK environments will need the `libayatana-appindicator3-dev` (debian/ubuntu) package installed - ## Checklist before submitting a PR -As a critical network service and open-source project, we must enforce a few things before submitting the pull-requests: + +As a critical network service and open source project, we must enforce a few +things before submitting a pull request. The +[pull request template](/.github/pull_request_template.md) mirrors this list — +fill it in rather than deleting it. + +### Link the issue + +The PR description must link the agreed issue (or the validated discussion it +came from). See [Ticket first, PR second](#ticket-first-pr-second). + +### Run it locally + +**If you can't run it, you can't submit it.** Build the affected components and +exercise the change on a real setup — see [Build and start](#build-and-start). +"CI will tell me" is not acceptable for a VPN agent that runs as root on other +people's machines. + +### Green CI, and answer the bots + +We do not start reviewing while CI is red. Get the pipeline green first — a +failing build, lint, or test means the PR is not ready for review. + +Alongside the test workflows, your PR is reviewed by CodeRabbit and scanned by +SonarCloud, Snyk, and Codecov. Read what they report and either fix it or reply +with why it does not apply; please do not resolve the threads without a +response. They are not always right — this codebase has privileged, +platform-specific, and concurrency-heavy paths that static analysis reads poorly +— so push back when a finding is wrong rather than changing correct code to +silence it. Security and dependency findings are the exception: treat those as +real until shown otherwise. Do not edit workflows, thresholds, or scanner +configuration to make a check pass. + +### One PR, one purpose + +Bug fix, refactor, feature: separate PRs. Mixed PRs are slow to review, hard to +revert, and may be closed with a request to split them. + +### Keep it small + +Size is the strongest predictor of how long a PR waits for review. Aim for under +roughly 400 changed lines across under 20 files. Past about 1000 lines or 50 +files, expect to be asked to split the change — and large PRs from outside the +core team may be blocked until the scope has been agreed in a ticket. This is +not only about reviewer time: NetBird's agent runs as root on other people's +machines, and a sprawling diff cannot be reviewed with the care that deserves. + +Measure by hand-written code, excluding generated output, `go.sum`, and +fixtures. If a change genuinely cannot be small — a protocol migration, a +cross-component rename — agree the split in the issue before you start, and land +it as a series of PRs that each build and make sense on their own. + +### Avoid force-pushing during review + +Once a PR is open, push new commits instead of rewriting history. A force-push +detaches existing review comments from their lines, throws away the +"changes since your last review" diff, and loses the CI history that showed +which commit broke what. Since we squash on merge, there is nothing to gain from +a tidy branch history. + +Force-pushing is sometimes unavoidable — rebasing to clear a real conflict, or +removing a secret or large binary committed by mistake. When that happens, leave +a comment on the PR so reviewers know their anchors moved. + +### Quality checks + +Run these from the repository root before pushing: + +```shell +go fmt ./... +make lint # golangci-lint on files changed against origin/main +make lint-all # full-repository lint, matches CI +make test-unit # host-safe unit tests +``` + +`make setup-hooks` wires `make lint` into a pre-push hook so the fast lint runs +automatically. If your change touches privileged paths (firewall, routing, +interface management), also run `make test-privileged`, which executes the +`privileged`-tagged suite inside a Docker container with `NET_ADMIN`. + +### Code standards + - Keep functions as simple as possible, with a single purpose - Use private functions and constants where possible - Comment on any new public functions - Add unit tests for any new public function +- Comment the **why**, not the **what** — explain non-obvious decisions, invariants, and constraints, not the line below +- Keep comments within 90 characters per line and roughly 250 characters per comment; when a block needs more explanation than that, extract a named function instead of writing a longer comment (see [AGENTS.md](AGENTS.md#length-budget)) + +### PR title and commits + +PR titles must start with a bracketed tag, enforced by +[pr-title-check.yml](/.github/workflows/pr-title-check.yml): + +```text +[client] Authorize daemon IPC callers by their local identity +[management,client] Add MDM policy support +``` + +Use a comma-separated list inside a single pair of brackets when a change spans +components. The `allowedTags` array in +[pr-title-check.yml](/.github/workflows/pr-title-check.yml) is the source of +truth — at the time of writing it accepts `management`, `client`, `signal`, +`proxy`, `relay`, `misc`, `infrastructure`, `self-hosted`, and `doc`. + +Commit subjects follow the same convention — keep them short and put the +reasoning in the body, why before what, with no bullet list of files changed. + +Keep the PR description itself under 1000 words on top of the template text. +Reviewers read the diff; the description explains what the diff cannot. > When pushing fixes to the PR comments, please push as separate commits; we will squash the PR before merging, so there is no need to squash it before pushing it, and we are more than okay with 10-100 commits in a single PR. This helps review the fixes to the requested changes. +### Documentation + +User-facing changes need a matching PR in +[netbirdio/docs](https://github.com/netbirdio/docs); link it in the PR +description, or state why documentation is not needed. + +## When we close a PR + +We would rather redirect early than let a PR sit. We may close one if: + +- It changes behavior with no linked issue, or the approach was never agreed with a maintainer +- The change was clearly never run or tested locally +- CI has been red without a response +- It mixes unrelated purposes, or the purpose is not clear +- It is far too large to review and the scope was never agreed in a ticket +- The author cannot answer questions about their own change — including PRs that read as unreviewed model output, where review turns into a relay between the maintainer and an LLM. Tooling is fine; unreviewed output is not, you are responsible for the code you sign your name to +- There has been no activity for 14 days after we requested changes + +A closed PR is not a rejected idea. Take it back to the +[discussion](https://github.com/netbirdio/netbird/discussions), settle the +approach, and reopen the work from there. + +## Translations + +Desktop UI translations are not contributed through pull requests. Translate on +[Crowdin](https://crowdin.com/project/netbird) instead: no ticket needed, just +join the project and pick your language. Crowdin syncs with this repository and +opens the service PRs itself, so hand-edited locale files would conflict with +the next sync. Style, terminology, and review guidance live in +[client/ui/i18n/TRANSLATING.md](client/ui/i18n/TRANSLATING.md). To request a +language the project does not offer yet, ask on the Crowdin project page or in +a [discussion](https://github.com/netbirdio/netbird/discussions). + ## Other project repositories NetBird project is composed of 3 main repositories: diff --git a/Makefile b/Makefile index 5d52b94fa..0a4fad2f2 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: lint lint-all lint-install setup-hooks +.PHONY: lint lint-all lint-install setup-hooks test-unit test-privileged GOLANGCI_LINT := $(shell pwd)/bin/golangci-lint # Install golangci-lint locally if needed @@ -25,3 +25,15 @@ setup-hooks: @git config core.hooksPath .githooks @chmod +x .githooks/pre-push @echo "✅ Git hooks configured! Pre-push will now run 'make lint'" + +# Host-safe unit tests: excludes the privileged-tagged tests (root / system-mutating). +# Runs as a normal user with no sudo and leaves host networking untouched. +test-unit: + @go test -tags devcert -timeout 10m ./... + +# Privileged suite: runs the `privileged`-tagged tests inside a --privileged +# --cap-add=NET_ADMIN container via the ory/dockertest harness. Requires Docker. +# Narrow the run with env vars, e.g.: +# PRIV_RUN=TestNftablesManager PRIV_PKGS=./client/firewall/nftables/... make test-privileged +test-privileged: + @go test -tags 'devcert privileged' -timeout 30m -run TestRunPrivilegedSuiteInDocker -v ./client/testutil/privileged/... diff --git a/README.md b/README.md index dc84af2fd..336332043 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,46 @@
-
-
-

- -

-

- - - - - - -
+

+ NetBird logo +

+

+ + SonarCloud alert status + + + BSD-3 License + - - + NetBird Slack + - - -
+ Community forum + - - + Gurubase: Ask NetBird Guru +

-

- - Start using NetBird at netbird.io + + Start using NetBird at netbird.io +
+ See Documentation +
+ Join our Slack channel or our Community forum +

- See Documentation
- Join our Slack channel or our Community forum -
- -
-
- - 🚀 We are hiring! Join us at careers.netbird.io - -
-
- - New: NetBird terraform provider - + + 🚀 We are hiring! Join us at https://netbird.io/careers +

-
+> ### 🤖 NetBird Agent Network (Beta) +> Identity-aware access control for AI agents — keyless access to LLM APIs and private +> resources over the encrypted NetBird tunnel. See [`agent-network/`](agent-network/) or +> read the docs at **[netbird.ai](https://netbird.ai)**. **NetBird combines a configuration-free peer-to-peer private network and a centralized access control system in a single platform, making it easy to create secure private networks for your organization or home.** @@ -56,92 +48,92 @@ **Secure.** NetBird enables secure remote access by applying granular access policies while allowing you to manage them intuitively from a single place. Works universally on any infrastructure. -### Open Source Network Security in a Single Platform - https://github.com/user-attachments/assets/10cec749-bb56-4ab3-97af-4e38850108d2 -### Self-Host NetBird (Video) +### Self-host NetBird (video) + [![Watch the video](https://img.youtube.com/vi/bZAgpT6nzaQ/0.jpg)](https://youtu.be/bZAgpT6nzaQ) ### Key features -| Connectivity | Management | Security | Automation| Platforms | -|----|----|----|----|----| -|
  • - \[x] Kernel WireGuard
|
  • - \[x] [Admin Web UI](https://github.com/netbirdio/dashboard)
|
  • - \[x] [SSO & MFA support](https://docs.netbird.io/how-to/installation#running-net-bird-with-sso-login)
|
  • - \[x] [Public API](https://docs.netbird.io/api)
|
  • - \[x] Linux
| -|
  • - \[x] Peer-to-peer connections
|
  • - \[x] Auto peer discovery and configuration
  • |
    • - \[x] [Access control - groups & rules](https://docs.netbird.io/how-to/manage-network-access)
    • |
      • - \[x] [Setup keys for bulk network provisioning](https://docs.netbird.io/how-to/register-machines-using-setup-keys)
      • |
        • - \[x] Mac
        • | -|
          • - \[x] Connection relay fallback
          • |
            • - \[x] [IdP integrations](https://docs.netbird.io/selfhosted/identity-providers)
            • |
              • - \[x] [Activity logging](https://docs.netbird.io/how-to/audit-events-logging)
              • |
                • - \[x] [Self-hosting quickstart script](https://docs.netbird.io/selfhosted/selfhosted-quickstart)
                • |
                  • - \[x] Windows
                  • | -|
                    • - \[x] [Routes to external networks](https://docs.netbird.io/how-to/routing-traffic-to-private-networks)
                    • |
                      • - \[x] [Private DNS](https://docs.netbird.io/how-to/manage-dns-in-your-network)
                      • |
                        • - \[x] [Device posture checks](https://docs.netbird.io/how-to/manage-posture-checks)
                        • |
                          • - \[x] IdP groups sync with JWT
                          • |
                            • - \[x] Android
                            • | -|
                              • - \[x] NAT traversal with BPF
                              • |
                                • - \[x] [Multiuser support](https://docs.netbird.io/how-to/add-users-to-your-network)
                                • |
                                  • - \[x] Peer-to-peer encryption
                                  • ||
                                    • - \[x] iOS
                                    • | -|||
                                      • - \[x] [Quantum-resistance with Rosenpass](https://netbird.io/knowledge-hub/the-first-quantum-resistant-mesh-vpn)
                                      • ||
                                        • - \[x] OpenWRT
                                        • | -|||
                                          • - \[x] [Periodic re-authentication](https://docs.netbird.io/how-to/enforce-periodic-user-authentication)
                                          • ||
                                            • - \[x] [Serverless](https://docs.netbird.io/how-to/netbird-on-faas)
                                            • | -|||||
                                              • - \[x] Docker
                                              • | +| Connectivity | Management | Security | Automation | Platforms | +|---|---|---|---|---| +| ✓ [Kernel WireGuard](https://docs.netbird.io/about-netbird/why-wireguard-with-netbird) | ✓ [Admin Web UI](https://github.com/netbirdio/dashboard) | ✓ [SSO & MFA support](https://docs.netbird.io/how-to/installation#running-net-bird-with-sso-login) | ✓ [Public API](https://docs.netbird.io/api) | ✓ [Linux](https://docs.netbird.io/get-started/install/linux) | +| ✓ [Peer-to-peer connections](https://docs.netbird.io/about-netbird/how-netbird-works) | ✓ Auto peer discovery and configuration | ✓ [Access control: groups & rules](https://docs.netbird.io/how-to/manage-network-access) | ✓ [Setup keys for bulk provisioning](https://docs.netbird.io/how-to/register-machines-using-setup-keys) | ✓ [macOS](https://docs.netbird.io/get-started/install/macos) | +| ✓ Connection relay fallback | ✓ [IdP integrations](https://docs.netbird.io/selfhosted/identity-providers) | ✓ [Activity logging](https://docs.netbird.io/how-to/audit-events-logging) | ✓ [Self-hosting quickstart script](https://docs.netbird.io/selfhosted/selfhosted-quickstart) | ✓ [Windows](https://docs.netbird.io/get-started/install/windows) | +| ✓ [Routes to external networks](https://docs.netbird.io/how-to/routing-traffic-to-private-networks) | ✓ [Private DNS](https://docs.netbird.io/how-to/manage-dns-in-your-network) | ✓ [Traffic events](https://docs.netbird.io/manage/activity/traffic-events-logging) | ✓ [IdP groups sync with JWT](https://docs.netbird.io/manage/team/idp-sync) | ✓ [Android](https://docs.netbird.io/get-started/install/android) | +| ✓ [Domain-based DNS routes](https://docs.netbird.io/manage/dns/dns-aliases-for-routed-networks) | ✓ [Custom DNS zones](https://docs.netbird.io/manage/dns/custom-zones) | ✓ [Device posture checks](https://docs.netbird.io/how-to/manage-posture-checks) | ✓ [Terraform provider](https://registry.terraform.io/providers/netbirdio/netbird/latest) | ✓ [Android TV](https://docs.netbird.io/get-started/install/android-tv) | +| ✓ [Exit nodes](https://docs.netbird.io/manage/network-routes/use-cases/exit-nodes) | ✓ [Multiuser support](https://docs.netbird.io/how-to/add-users-to-your-network) | ✓ Peer-to-peer encryption | ✓ [Ansible collection](https://github.com/netbirdio/ansible-netbird) | ✓ [iOS](https://docs.netbird.io/get-started/install/ios) | +| ✓ [IPv6 dual-stack overlay](https://docs.netbird.io/manage/settings/ipv6) | ✓ [Multi-account profile switching](https://docs.netbird.io/client/profiles) | ✓ [SSH with central access policies](https://docs.netbird.io/manage/peers/ssh) | | ✓ [Apple TV](https://docs.netbird.io/get-started/install/tvos) | +| ✓ [Browser SSH & RDP](https://docs.netbird.io/manage/peers/browser-client) | | ✓ [Quantum-resistance with Rosenpass](https://netbird.io/knowledge-hub/the-first-quantum-resistant-mesh-vpn) | | ✓ FreeBSD | +| ✓ [Reverse proxy with auto-TLS](https://docs.netbird.io/manage/reverse-proxy) | | ✓ [Periodic re-authentication](https://docs.netbird.io/how-to/enforce-periodic-user-authentication) | | ✓ [pfSense](https://docs.netbird.io/get-started/install/pfsense) | +| | | | | ✓ [OPNsense](https://docs.netbird.io/get-started/install/opnsense) | +| | | | | ✓ [MikroTik RouterOS](https://docs.netbird.io/use-cases/homelab/client-on-mikrotik-router) | +| | | | | ✓ OpenWRT | +| | | | | ✓ [Synology](https://docs.netbird.io/get-started/install/synology) | +| | | | | ✓ [TrueNAS](https://docs.netbird.io/get-started/install/truenas) | +| | | | | ✓ [Proxmox](https://docs.netbird.io/get-started/install/proxmox-ve) | +| | | | | ✓ [Raspberry Pi](https://docs.netbird.io/get-started/install/raspberrypi) | +| | | | | ✓ [Serverless](https://docs.netbird.io/how-to/netbird-on-faas) | +| | | | | ✓ [Container](https://docs.netbird.io/get-started/install/docker) | ### Quickstart with NetBird Cloud -- Download and install NetBird at [https://app.netbird.io/install](https://app.netbird.io/install) -- Follow the steps to sign-up with Google, Microsoft, GitHub or your email address. -- Check NetBird [admin UI](https://app.netbird.io/). -- Add more machines. +- Download and install NetBird at [https://app.netbird.io/install](https://app.netbird.io/install). +- Follow the steps to sign up with Google, Microsoft, GitHub or your email address. +- Check the NetBird [admin UI](https://app.netbird.io/). ### Quickstart with self-hosted NetBird -> This is the quickest way to try self-hosted NetBird. It should take around 5 minutes to get started if you already have a public domain and a VM. -Follow the [Advanced guide with a custom identity provider](https://docs.netbird.io/selfhosted/selfhosted-guide#advanced-guide-with-a-custom-identity-provider) for installations with different IDPs. +This is the quickest way to try self-hosted NetBird. It should take around 5 minutes to get started if you already have a public domain and a VM. Follow the [Advanced guide with a custom identity provider](https://docs.netbird.io/selfhosted/selfhosted-guide#advanced-guide-with-a-custom-identity-provider) for installations with different IdPs. **Infrastructure requirements:** -- A Linux VM with at least **1CPU** and **2GB** of memory. -- The VM should be publicly accessible on TCP ports **80** and **443** and UDP port: **3478**. -- **Public domain** name pointing to the VM. +- A Linux VM with at least **1 CPU** and **2 GB** of memory. +- The VM should be publicly accessible on TCP ports **80** and **443** and UDP port **3478**. +- A **public domain** name pointing to the VM. **Software requirements:** -- Docker installed on the VM with the docker-compose plugin ([Docker installation guide](https://docs.docker.com/engine/install/)) or docker with docker-compose in version 2 or higher. -- [jq](https://jqlang.github.io/jq/) installed. In most distributions - Usually available in the official repositories and can be installed with `sudo apt install jq` or `sudo yum install jq` -- [curl](https://curl.se/) installed. - Usually available in the official repositories and can be installed with `sudo apt install curl` or `sudo yum install curl` +- Docker with the Compose plugin (Compose v2 or higher). See the [Docker installation guide](https://docs.docker.com/engine/install/). **Steps** - Download and run the installation script: ```bash export NETBIRD_DOMAIN=netbird.example.com; curl -fsSL https://github.com/netbirdio/netbird/releases/latest/download/getting-started.sh | bash ``` -- Once finished, you can manage the resources via `docker-compose` ### A bit on NetBird internals -- Every machine in the network runs [NetBird Agent (or Client)](client/) that manages WireGuard. -- Every agent connects to [Management Service](management/) that holds network state, manages peer IPs, and distributes network updates to agents (peers). -- NetBird agent uses WebRTC ICE implemented in [pion/ice library](https://github.com/pion/ice) to discover connection candidates when establishing a peer-to-peer connection between machines. -- Connection candidates are discovered with the help of [STUN](https://en.wikipedia.org/wiki/STUN) servers. -- Agents negotiate a connection through [Signal Service](signal/) passing p2p encrypted messages with candidates. -- Sometimes the NAT traversal is unsuccessful due to strict NATs (e.g. mobile carrier-grade NAT) and a p2p connection isn't possible. When this occurs the system falls back to a relay server called [TURN](https://en.wikipedia.org/wiki/Traversal_Using_Relays_around_NAT), and a secure WireGuard tunnel is established via the TURN server. - -[Coturn](https://github.com/coturn/coturn) is the one that has been successfully used for STUN and TURN in NetBird setups. +- Every machine in the network runs the [NetBird agent](client/), which manages WireGuard. +- Every agent connects to the [Management Service](management/), which holds network state, manages peer IPs, and distributes updates to agents. +- Agents use ICE (via [pion/ice](https://github.com/pion/ice)) to discover connection candidates for peer-to-peer connections. +- Candidates are discovered with the help of [STUN](https://en.wikipedia.org/wiki/STUN) servers. +- Agents negotiate a connection through the [Signal Service](signal/), exchanging end-to-end encrypted messages with candidates. +- When NAT traversal fails (e.g. mobile carrier-grade NAT) and a direct p2p connection isn't possible, the system falls back to a [Relay Service](relay/) and a secure WireGuard tunnel is established through it.

                                                - + NetBird high-level architecture diagram

                                                See a complete [architecture overview](https://docs.netbird.io/about-netbird/how-netbird-works#architecture) for details. ### 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 +- [NetBird installer script](https://github.com/physk/netbird-installer) +- [netbird-tui](https://github.com/n0pashkov/netbird-tui) - terminal UI for managing NetBird peers, routes, and settings +- [caddy-netbird](https://github.com/lixmal/caddy-netbird) - Caddy plugin that embeds a NetBird client for proxying HTTP and TCP/UDP traffic through NetBird networks **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). ### Support acknowledgement -In November 2022, NetBird joined the [StartUpSecure program](https://www.forschung-it-sicherheit-kommunikationssysteme.de/foerderung/bekanntmachungen/startup-secure) sponsored by The Federal Ministry of Education and Research of The Federal Republic of Germany. Together with [CISPA Helmholtz Center for Information Security](https://cispa.de/en) NetBird brings the security best practices and simplicity to private networking. +In November 2022, NetBird joined the [StartUpSecure program](https://www.forschung-it-sicherheit-kommunikationssysteme.de/foerderung/bekanntmachungen/startup-secure) sponsored by the Federal Ministry of Education and Research of the Federal Republic of Germany. Together with the [CISPA Helmholtz Center for Information Security](https://cispa.de/en), NetBird brings security best practices and simplicity to private networking. ![CISPA_Logo_BLACK_EN_RZ_RGB (1)](https://user-images.githubusercontent.com/700848/203091324-c6d311a0-22b5-4b05-a288-91cbc6cdcc46.png) -### Testimonials -We use open-source technologies like [WireGuard®](https://www.wireguard.com/), [Pion ICE (WebRTC)](https://github.com/pion/ice), [Coturn](https://github.com/coturn/coturn), and [Rosenpass](https://rosenpass.eu). We very much appreciate the work these guys are doing and we'd greatly appreciate if you could support them in any way (e.g., by giving a star or a contribution). +### Acknowledgements +We build on open source technologies like [WireGuard®](https://www.wireguard.com/), [Pion ICE](https://github.com/pion/ice), and [Rosenpass](https://rosenpass.eu). We greatly appreciate the work these projects are doing, and we'd love it if you could support them too (e.g., by starring or contributing). ### Legal -This repository is licensed under BSD-3-Clause license that applies to all parts of the repository except for the directories management/, signal/ and relay/. +This repository is licensed under the BSD-3-Clause license, which applies to all parts of the repository except for the directories management/, signal/ and relay/. Those directories are licensed under the GNU Affero General Public License version 3.0 (AGPLv3). See the respective LICENSE files inside each directory. _WireGuard_ and the _WireGuard_ logo are [registered trademarks](https://www.wireguard.com/trademark-policy/) of Jason A. Donenfeld. diff --git a/SECURITY.md b/SECURITY.md index 745c66e61..cbcc975ba 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,12 +1,70 @@ # Security Policy -NetBird's goal is to provide a secure network. If you find a vulnerability or bug, please report it by opening an issue [here](https://github.com/netbirdio/netbird/issues/new?assignees=&labels=&template=bug-issue-report.md&title=) or by contacting us by email. - -There has yet to be an official bug bounty program for the NetBird project. - -## Supported Versions -- We currently support only the latest version +NetBird's goal is to provide a secure network. The client runs as a privileged service on every machine it is installed on, +so we take reports about it seriously and we publish what we fix. ## Reporting a Vulnerability -Please report security issues to `security@netbird.io` +**Please do not open a public issue for a security vulnerability.** Public issues are visible to everyone, including before +a fix is available. + +Report security issues one of these two ways: + +- **GitHub private vulnerability reporting** — [open a private report](https://github.com/netbirdio/netbird/security/advisories/new) + on this repository. This is the preferred route: it keeps the discussion, the draft advisory, and the credit in one place. +- **Email** — `security@netbird.io`. + +If the finding affects NetBird Cloud or our hosted infrastructure rather than the open source code, email us rather than +filing a repository report. + +### What to include + +A report is easier to act on when it contains: + +- The affected component (client, management, signal, relay, dashboard) and the version or commit you tested +- The platform and configuration, where relevant — operating system, self-hosted or NetBird Cloud, container or host install +- What an attacker needs before they can exploit it: network position, an account, local access, a specific privilege level +- Steps to reproduce, and a proof of concept if you have one +- The impact you believe it has + +Partial reports are still welcome. If you are unsure whether something is a security issue, send it to `security@netbird.io` +and let us make that call. + +## What to expect from us + +- **We acknowledge your report** and tell you whether we can reproduce it. +- **We work with you on severity and scope.** If we assess it differently than you do, we will explain why rather than + silently downgrade it. +- **We fix and release**, then publish a [GitHub Security Advisory](https://github.com/netbirdio/netbird/security/advisories) + naming the affected version range and the patched version. +- **We credit reporters who want to be credited.** Tell us the name or handle you would like used, or that you would rather + stay anonymous. +- **We keep you in the loop** until the advisory is published. + +We ask that you give us a reasonable opportunity to ship a fix before disclosing the issue publicly, and that you avoid +accessing, modifying, or exfiltrating data belonging to other people while testing. Testing against your own installation +or your own account is always fine. + +## Supported Versions + +We support the latest release. Security fixes ship in the next version rather than as backports to older releases, so +upgrading to the current release is how you get them. + +Release notifications are available by watching [releases](https://github.com/netbirdio/netbird/releases). + +## Published advisories + +Every vulnerability we fix is published as a GitHub Security Advisory on the +[advisories page](https://github.com/netbirdio/netbird/security/advisories), including the affected version range, the +patched version, and the reporter's credit. Advisories for the Go module are also distributed through the Go vulnerability +database, so `govulncheck` will report them against your dependencies. + +## Bug bounty + +There is no official bug bounty program for the NetBird project. We credit reporters in advisories, and we are grateful for +the work, but we cannot currently offer payment for reports. + +## Non-security bugs + +For bugs that are not security issues, please use the +[issue tracker](https://github.com/netbirdio/netbird/discussions/new/choose). diff --git a/agent-network/README.md b/agent-network/README.md new file mode 100644 index 000000000..029ada299 --- /dev/null +++ b/agent-network/README.md @@ -0,0 +1,138 @@ +# NetBird Agent Network + +Agent Network is NetBird's access control layer for AI agents and the people who run them. +It gives every agent a real identity, tied to an identity provider (IdP), and governs what it can reach: LLM APIs and +AI gateways it can call, and the internal resources it can access. Traffic flows only over the encrypted NetBird tunnel, +scoped by policy, with no API keys or other credentials to leak. It also gives you control over cost and token usage. + +Because every LLM request passes through an +identity-aware proxy, you can: + +- **Set spending and rate limits** per agent, per user, or per team — with hard caps + that stop requests once a budget is reached. +- **Restrict models and providers** so agents can only call approved (and cost-appropriate) + endpoints, keeping expensive models off-limits unless explicitly allowed. +- **Attribute usage** by tracking token consumption and cost per identity, group, or cost center so every + request is tied back to the agent and person responsible. +- **Reuse your existing AI gateway** — point the proxy at a gateway you already run, + keeping its routing and config in place while it adds identity on top, so you skip + API key distribution. + +https://github.com/user-attachments/assets/44d18286-d8ab-49f8-a457-98ccd66f3268 + +> **Beta.** Agent Network is in beta, but it's stable and already running in +> production environments. It's fully open source and can be self-hosted on your own +> infrastructure, with no vendor lock-in and no data leaving your environment. + +## How it works + +Say you have a simple use case: your Engineering or IT team needs access to Claude Code or Codex, and you want visibility into usage plus the ability to enforce budgets. +How can you do that without creating a dedicated API key for every team? + +With Agent Network you get a private endpoint inside your network, for example: https://mirror.netbird.ai +Teams configure their agents to point to that endpoint instead of using individual API keys directly. + +This endpoint is only reachable when users are connected to your NetBird network and authenticated through your IdP. Otherwise, it is not accessible from the public internet. +You can then use this private endpoint to configure your AI agents, whether that is Claude Code, Codex, or another tool. + +## Quickstart + +Full step-by-step setup: +**https://docs.netbird.io/agent-network/quickstart** + +## Client settings that don't follow the endpoint + +Most of an agent's traffic follows the base URL you hand it, but a few +client-side checks call their vendor directly and never reach the proxy. On a +network that blocks direct egress they fail even though inference works, so +they are worth setting once when you roll the endpoint out. + +For Claude Code: + +- **Fast mode** checks availability against `api.anthropic.com` rather than the + configured base URL. Set `CLAUDE_CODE_SKIP_FAST_MODE_ORG_CHECK=1` when the + agent authenticates with `ANTHROPIC_AUTH_TOKEN` alone (the usual shape when + the proxy injects the real provider key) or when a TLS-inspecting proxy + answers the check itself. Set + `CLAUDE_CODE_SKIP_FAST_MODE_NETWORK_ERRORS=1` when the network refuses the + connection outright. Fast mode is an Anthropic-API feature, so it is + unavailable on a Bedrock- or Vertex-backed endpoint whatever you set. +- **Model discovery** is off by default. Set + `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` for the picker to list the + models your policies authorise; the proxy filters the response to that set. + The client gives discovery a three-second budget and treats any redirect as + a failure, so the endpoint must serve `/v1/models` directly. +- **The WebFetch domain safety check** also calls `api.anthropic.com` directly + and is unaffected by the variables above. + +Allowing direct egress to `api.anthropic.com` covers the network cases but not +the credential one, where the check reaches Anthropic and is rejected because +the agent presents a proxy-issued key. + +## Architecture + +Agent Network is built on two existing NetBird capabilities: + +- **Overlay network** — the encrypted WireGuard mesh between peers. +- **Reverse proxy** — a NetBird peer that terminates LLM requests, establishes the + caller's identity, evaluates policies/limits/guardrails, injects the upstream provider + key server-side, forwards to the API or gateway, and records usage. + +LLM traffic is routed through the proxy's identity-aware pipeline, while internal +resources (databases, internal APIs, self-hosted models) are reached directly over +peer-to-peer WireGuard tunnels, governed by the same identities and access policies. + +image + + +## Where the code lives + +There is no separate "agent-network" service — it reuses the reverse-proxy and management +components: + +- [`proxy/`](../proxy) — the NetBird reverse proxy that serves the agent network endpoint + and runs the per-request middleware pipeline. +- [`management/internals/modules/reverseproxy/`](../management/internals/modules/reverseproxy) + — the management-side control plane: providers, policies, guardrails, limits, routing, + and usage/access logs. + +## Access roles + +Agent Network permissions build on the account permission matrix +([`management/server/permissions/`](../management/server/permissions)). The +`agent_network` area is split into dotted submodules (`agent_network.providers`, +`.policies`, `.guardrails`, `.budgets`, `.usage`, `.logs`, `.settings`); a role may +grant a single submodule or the parent, which cascades to all of them. + +Two roles delegate Agent Network access without account-admin rights: + +- **`agent_network_admin`** — full control over the whole `agent_network` area plus + read-only users, groups, peers, and account info (needed to build policies). + Nothing else in the account. +- **`usage_viewer`** — the regular User baseline plus read on + `agent_network.usage` (the aggregated usage and cost overview) and read-only + access to the resources the usage filters resolve against: users, groups, + peers, and the provider list (connection config redacted — no upstream URLs + or operator-supplied header values). No policies, and no account-wide + request-level access logs; like any caller, it still reads its own requests + through the self-scoped endpoints below. + +Every authenticated user, regardless of role, can read the caller-scoped +self-service endpoint `GET /api/agent-network/agent-config` (the endpoint, providers, +and models the caller's own policies allow — what a local AI tool needs and nothing +more). The regular usage and access-log endpoints self-scope instead of denying: +a caller without the account-wide grant gets their own rows back, so "my usage" +and "my requests" are the same endpoints the admin dashboard uses. The provider +list self-scopes the same way — a caller without the providers grant gets the +providers their own policies authorize, reduced to the display surface, with +each provider's model list cut to what the caller's policy guardrails and the +provider's declared models effectively permit (the same computation the setup +answer and the proxy use). This feeds the dashboard's provider and model +filters. Role +definitions live in +[`management/server/permissions/roles/`](../management/server/permissions/roles). + +## Documentation + +Full documentation, architecture, and quickstart: +**https://docs.netbird.io/agent-network** diff --git a/client/Dockerfile b/client/Dockerfile index 64d5ba04f..478b2d0e2 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.3 +FROM alpine:3.24 # iproute2: busybox doesn't display ip rules properly RUN apk add --no-cache \ bash \ @@ -17,10 +17,11 @@ ENV \ NETBIRD_BIN="/usr/local/bin/netbird" \ NB_LOG_FILE="console,/var/log/netbird/client.log" \ NB_DAEMON_ADDR="unix:///var/run/netbird.sock" \ + NB_ENABLE_CAPTURE="false" \ NB_ENTRYPOINT_SERVICE_TIMEOUT="30" ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ] - -ARG NETBIRD_BINARY=netbird +ARG TARGETPLATFORM +ARG NETBIRD_BINARY=$TARGETPLATFORM/netbird COPY client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh COPY "${NETBIRD_BINARY}" /usr/local/bin/netbird diff --git a/client/Dockerfile-rootless b/client/Dockerfile-rootless index 69d00aaf2..8141af6ed 100644 --- a/client/Dockerfile-rootless +++ b/client/Dockerfile-rootless @@ -4,7 +4,7 @@ # podman build -t localhost/netbird:latest -f client/Dockerfile --ignorefile .dockerignore-client . # podman run --rm -it --cap-add={BPF,NET_ADMIN,NET_RAW} localhost/netbird:latest -FROM alpine:3.22.0 +FROM alpine:3.24 RUN apk add --no-cache \ bash \ @@ -23,10 +23,11 @@ ENV \ NB_DAEMON_ADDR="unix:///var/lib/netbird/netbird.sock" \ NB_LOG_FILE="console,/var/lib/netbird/client.log" \ NB_DISABLE_DNS="true" \ + NB_ENABLE_CAPTURE="false" \ NB_ENTRYPOINT_SERVICE_TIMEOUT="30" ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ] - -ARG NETBIRD_BINARY=netbird +ARG TARGETPLATFORM +ARG NETBIRD_BINARY=$TARGETPLATFORM/netbird COPY client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh COPY "${NETBIRD_BINARY}" /usr/local/bin/netbird diff --git a/client/android/client.go b/client/android/client.go index 37e17a363..e47a1c13d 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -7,13 +7,16 @@ import ( "fmt" "os" "slices" + "strings" "sync" + "sync/atomic" "time" "golang.org/x/exp/maps" log "github.com/sirupsen/logrus" + nbAnonymize "github.com/netbirdio/netbird/client/anonymize" "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/debug" @@ -24,6 +27,7 @@ import ( "github.com/netbirdio/netbird/client/internal/routemanager" "github.com/netbirdio/netbird/client/internal/stdnet" "github.com/netbirdio/netbird/client/net" + "github.com/netbirdio/netbird/client/netevents" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/formatter" "github.com/netbirdio/netbird/route" @@ -31,10 +35,12 @@ import ( types "github.com/netbirdio/netbird/upload-server/types" ) -// ConnectionListener export internal Listener for mobile -type ConnectionListener interface { - peer.Listener -} +// AnonymizeLevelDefault and AnonymizeLevelStrict are the accepted +// anonymizeLevel values for DebugBundle. +const ( + AnonymizeLevelDefault = nbAnonymize.LevelDefaultString + AnonymizeLevelStrict = nbAnonymize.LevelStrictString +) // TunAdapter export internal TunAdapter for mobile type TunAdapter interface { @@ -56,6 +62,12 @@ type DnsReadyListener interface { dns.ReadyListener } +// TunSettings is a snapshot of the settings the TUN device is rebuilt with +type TunSettings struct { + Routes string + SearchDomains string +} + func init() { formatter.SetLogcatFormatter(log.StandardLogger()) } @@ -70,18 +82,51 @@ type Client struct { deviceName string uiVersion string networkChangeListener listener.NetworkChangeListener + // netMgr outlives engine restarts: it mirrors the OS connectivity, not + // the engine lifecycle. Run and RunWithoutLogin inject its state and + // sweeper into each new ConnectClient. + netMgr *netevents.Manager stateMu sync.RWMutex connectClient *internal.ConnectClient config *profilemanager.Config cacheDir string + + // mdmSource holds the per-Client MDM policy source and its change + // detector as one unit. Set by SetMDMPolicyFetcher (called from the + // Kotlin side). Each Run passes the loader to the resolved Config so + // applyMDMPolicy picks up the active overlay. Nil means "MDM + // enforcement off for this Client". + mdmSource atomic.Pointer[mdmSource] + + // Identifies the running profile for the SSO login hint; see profile_state.go. + cfgPath string + + stateChangeMu sync.Mutex + stateChangeSubID string + eventSub *peer.EventSubscription + // Closed to stop the watch goroutines from delivering buffered items to a + // listener that has been removed or replaced. See stopStateChangeWatchLocked. + stateChangeDone chan struct{} + + // Latched "the server wants an interactive login": survives the engine + // restarts that replace the run loop's context state. See Client.Status. + // Guarded by loginRequiredMu together with loginCleared, which counts + // clears so a stale observation cannot re-latch over one. + loginRequiredMu sync.Mutex + loginRequired bool + loginCleared uint64 + + extendMu sync.Mutex + extendCancel context.CancelFunc } -func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cc *internal.ConnectClient) { +func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cfgPath string, cc *internal.ConnectClient) { c.stateMu.Lock() defer c.stateMu.Unlock() c.config = cfg c.cacheDir = cacheDir + c.cfgPath = cfgPath c.connectClient = cc } @@ -91,6 +136,16 @@ func (c *Client) stateSnapshot() (*profilemanager.Config, string, *internal.Conn return c.config, c.cacheDir, c.connectClient } +// authSnapshot returns the config together with the path it was loaded from, in +// one lock: the path identifies the profile whose account email backs the login +// hint, so reading it separately could pair one profile's config with another's +// hint when a profile switch lands in between. +func (c *Client) authSnapshot() (*profilemanager.Config, string, *internal.ConnectClient) { + c.stateMu.RLock() + defer c.stateMu.RUnlock() + return c.config, c.cfgPath, c.connectClient +} + func (c *Client) getConnectClient() *internal.ConnectClient { c.stateMu.RLock() defer c.stateMu.RUnlock() @@ -102,14 +157,17 @@ func NewClient(androidSDKVersion int, deviceName string, uiVersion string, tunAd execWorkaround(androidSDKVersion) net.SetAndroidProtectSocketFn(tunAdapter.ProtectSocket) + system.SetIFaceDiscover(iFaceDiscover) + recorder := peer.NewRecorder("") return &Client{ deviceName: deviceName, uiVersion: uiVersion, tunAdapter: tunAdapter, iFaceDiscover: iFaceDiscover, - recorder: peer.NewRecorder(""), + recorder: recorder, ctxCancelLock: &sync.Mutex{}, networkChangeListener: networkChangeListener, + netMgr: netevents.NewManager(recorder), } } @@ -129,6 +187,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid if err != nil { return err } + c.applyMDMOverlay(cfg) c.recorder.UpdateManagementAddress(cfg.ManagementURL.String()) c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive) @@ -143,16 +202,23 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid defer c.ctxCancel() c.ctxCancelLock.Unlock() - auth := NewAuthWithConfig(ctx, cfg) + auth := NewAuthWithConfig(ctx, cfg, cfgFile) err = auth.login(urlOpener, isAndroidTV) if err != nil { return err } - // todo do not throw error in case of cancelled context ctx = internal.CtxInitState(ctx) - connectClient := internal.NewConnectClient(ctx, cfg, c.recorder) - c.setState(cfg, cacheDir, connectClient) + + connectClient := internal.NewConnectClient(ctx, cfg, c.recorder, + internal.WithNetEvents(c.netMgr)) + c.setState(cfg, cacheDir, cfgFile, connectClient) + // This path runs the interactive SSO flow, so reaching here means the peer + // is authenticated again — release the latch Status() reports from. Clear + // only once the fresh connect client is installed: until then Status() + // still reads the previous run's context state, which holds the NeedsLogin + // that prompted this login, and would re-latch what was just cleared. + c.clearLoginRequired() return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir) } @@ -173,6 +239,7 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR if err != nil { return err } + c.applyMDMOverlay(cfg) c.recorder.UpdateManagementAddress(cfg.ManagementURL.String()) c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive) @@ -186,8 +253,9 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR // todo do not throw error in case of cancelled context ctx = internal.CtxInitState(ctx) - connectClient := internal.NewConnectClient(ctx, cfg, c.recorder) - c.setState(cfg, cacheDir, connectClient) + connectClient := internal.NewConnectClient(ctx, cfg, c.recorder, + internal.WithNetEvents(c.netMgr)) + c.setState(cfg, cacheDir, cfgFile, connectClient) return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir) } @@ -216,9 +284,49 @@ func (c *Client) RenewTun(fd int) error { return e.RenewTun(fd) } +func (c *Client) GetTunSettings() (*TunSettings, error) { + cc := c.getConnectClient() + if cc == nil { + return nil, fmt.Errorf("engine not running") + } + + e := cc.Engine() + if e == nil { + return nil, fmt.Errorf("engine not initialized") + } + + routes, searchDomains := e.TunSettings() + return &TunSettings{ + Routes: strings.Join(routes, ";"), + SearchDomains: strings.Join(searchDomains, ";"), + }, nil +} + +// SetNetworkAvailable feeds OS-reported network availability into the client. +// While unavailable, the internal reconnect loops suspend their attempts and +// the connection listener reports NoNetwork instead of Connecting; when +// availability returns, the loops resume immediately with a fresh backoff. +// Losing the last network also sweeps the registered connections: nothing can +// redial while offline, so the stale sockets would otherwise stay silently +// "connected" until their own timeouts and the client would keep reporting +// Connected with no network at all. +func (c *Client) SetNetworkAvailable(available bool) { + c.netMgr.SetNetworkAvailable(available) +} + +// NotifyNetworkChange marks the management, signal and relay connections +// stale after the OS switched networks and schedules a sweep that cuts +// whatever has not redialed on the new network by then. The engine and the +// TUN device stay untouched. +func (c *Client) NotifyNetworkChange() { + c.netMgr.NotifyNetworkChange() +} + // DebugBundle generates a debug bundle, uploads it, and returns the upload key. -// It works both with and without a running engine. -func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (string, error) { +// It works both with and without a running engine. anonymizeLevel is "default" +// or "strict"; strict also anonymizes internal IP ranges, peer names, and +// WireGuard public keys, and implies anonymize. +func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool, anonymizeLevel string) (string, error) { cfg, cacheDir, cc := c.stateSnapshot() // If the engine hasn't been started, load config from disk @@ -230,6 +338,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin if err != nil { return "", fmt.Errorf("load config: %w", err) } + c.applyMDMOverlay(cfg) cacheDir = platformFiles.CacheDir() } @@ -237,6 +346,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin InternalConfig: cfg, StatusRecorder: c.recorder, TempDir: cacheDir, + StatePath: platformFiles.StateFilePath(), } if cc != nil { @@ -247,6 +357,9 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin deps.SyncResponse = resp if e := cc.Engine(); e != nil { + deps.RefreshStatus = func() { + e.RunHealthProbes(context.Background(), true) + } if cm := e.GetClientMetrics(); cm != nil { deps.ClientMetrics = cm } @@ -257,6 +370,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin deps, debug.BundleConfig{ Anonymize: anonymize, + AnonymizeLevel: nbAnonymize.ParseLevel(anonymizeLevel), IncludeSystemInfo: true, }, ) @@ -274,7 +388,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path) + key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path, false) if err != nil { return "", fmt.Errorf("upload debug bundle: %w", err) } @@ -296,15 +410,37 @@ func (c *Client) SetInfoLogLevel() { // PeersList return with the list of the PeerInfos func (c *Client) PeersList() *PeerInfoArray { + // The recorder only caches transfer counters and handshake times; nothing + // refreshes them on its own, so without this they read as zero. The desktop + // daemon does the same before serving a full peer status. + if err := c.recorder.RefreshWireGuardStats(); err != nil { + log.Debugf("failed to refresh WireGuard stats: %v", err) + } + fullStatus := c.recorder.GetFullStatus() peerInfos := make([]PeerInfo, len(fullStatus.Peers)) for n, p := range fullStatus.Peers { pi := PeerInfo{ - p.IP, - p.FQDN, - int(p.ConnStatus), - PeerRoutes{routes: maps.Keys(p.GetRoutes())}, + IP: p.IP, + IPv6: p.IPv6, + FQDN: p.FQDN, + ConnStatus: int(p.ConnStatus), + Routes: PeerRoutes{routes: maps.Keys(p.GetRoutes())}, + + PubKey: p.PubKey, + Latency: formatDuration(p.Latency), + LatencyMs: p.Latency.Milliseconds(), + BytesRx: p.BytesRx, + BytesTx: p.BytesTx, + ConnStatusUpdate: formatTime(p.ConnStatusUpdate), + Relayed: p.Relayed, + RosenpassEnabled: p.RosenpassEnabled, + LastWireguardHandshake: formatTime(p.LastWireguardHandshake), + LocalIceCandidateType: p.LocalIceCandidateType, + RemoteIceCandidateType: p.RemoteIceCandidateType, + LocalIceCandidateEndpoint: p.LocalIceCandidateEndpoint, + RemoteIceCandidateEndpoint: p.RemoteIceCandidateEndpoint, } peerInfos[n] = pi } @@ -336,43 +472,84 @@ func (c *Client) Networks() *NetworkArray { return nil } + routesMap := routeManager.GetClientRoutesWithNetID() + v6Merged := route.V6ExitMergeSet(routesMap) + resolvedDomains := c.recorder.GetResolvedDomainsStates() + networkArray := &NetworkArray{ items: make([]Network, 0), } - resolvedDomains := c.recorder.GetResolvedDomainsStates() - - for id, routes := range routeManager.GetClientRoutesWithNetID() { + for id, routes := range routesMap { if len(routes) == 0 { continue } - - r := routes[0] - domains := c.getNetworkDomainsFromRoute(r, resolvedDomains) - netStr := r.Network.String() - - if r.IsDynamic() { - netStr = r.Domains.SafeString() - } - - routePeer, err := c.recorder.GetPeer(routes[0].Peer) - if err != nil { - log.Errorf("could not get peer info for %s: %v", routes[0].Peer, err) + if _, skip := v6Merged[id]; skip { continue } - network := Network{ - Name: string(id), - Network: netStr, - Peer: routePeer.FQDN, - Status: routePeer.ConnStatus.String(), - IsSelected: routeSelector.IsSelected(id), - Domains: domains, + + network := c.buildNetwork(id, routes, routeSelector.IsSelected(id), resolvedDomains, v6Merged) + if network == nil { + continue } - networkArray.Add(network) + networkArray.Add(*network) } return networkArray } +func (c *Client) buildNetwork(id route.NetID, routes []*route.Route, selected bool, resolvedDomains map[domain.Domain]peer.ResolvedDomainInfo, v6Merged map[route.NetID]struct{}) *Network { + r := routes[0] + netStr := r.Network.String() + if r.IsDynamic() { + netStr = r.Domains.SafeString() + } + + routePeer, err := c.findBestRoutePeer(routes) + if err != nil { + log.Errorf("could not get peer info for route %s: %v", id, err) + return nil + } + + network := &Network{ + Name: string(id), + Network: netStr, + Peer: routePeer.FQDN, + Status: routePeer.ConnStatus.String(), + IsSelected: selected, + Domains: c.getNetworkDomainsFromRoute(r, resolvedDomains), + } + + if route.IsV4DefaultRoute(r.Network) && route.HasV6ExitPair(id, v6Merged) { + network.Network = "0.0.0.0/0, ::/0" + } + + return network +} + +// findBestRoutePeer returns the peer actively routing traffic for the given +// HA route group. Falls back to the first connected peer, then the first peer. +func (c *Client) findBestRoutePeer(routes []*route.Route) (peer.State, error) { + netStr := routes[0].Network.String() + + fullStatus := c.recorder.GetFullStatus() + for _, p := range fullStatus.Peers { + if _, ok := p.GetRoutes()[netStr]; ok { + return p, nil + } + } + + for _, r := range routes { + p, err := c.recorder.GetPeer(r.Peer) + if err != nil { + continue + } + if p.ConnStatus == peer.StatusConnected { + return p, nil + } + } + return c.recorder.GetPeer(routes[0].Peer) +} + // OnUpdatedHostDNS update the DNS servers addresses for root zones func (c *Client) OnUpdatedHostDNS(list *DNSList) error { dnsServer, err := dns.GetServerDns() @@ -386,7 +563,11 @@ func (c *Client) OnUpdatedHostDNS(list *DNSList) error { // SetConnectionListener set the network connection listener func (c *Client) SetConnectionListener(listener ConnectionListener) { - c.recorder.SetConnectionListener(listener) + if listener == nil { + c.recorder.RemoveConnectionListener() + return + } + c.recorder.SetConnectionListener(connectionListenerAdapter{listener}) } // RemoveConnectionListener remove connection listener @@ -394,10 +575,6 @@ func (c *Client) RemoveConnectionListener() { c.recorder.RemoveConnectionListener() } -func (c *Client) toggleRoute(command routeCommand) error { - return command.toggleRoute() -} - func (c *Client) getRouteManager() (routemanager.Manager, error) { client := c.getConnectClient() if client == nil { @@ -417,22 +594,22 @@ func (c *Client) getRouteManager() (routemanager.Manager, error) { return manager, nil } -func (c *Client) SelectRoute(route string) error { +func (c *Client) SelectRoute(id string) error { manager, err := c.getRouteManager() if err != nil { return err } - return c.toggleRoute(selectRouteCommand{route: route, manager: manager}) + return manager.SelectRoutes([]route.NetID{route.NetID(id)}, true) } -func (c *Client) DeselectRoute(route string) error { +func (c *Client) DeselectRoute(id string) error { manager, err := c.getRouteManager() if err != nil { return err } - return c.toggleRoute(deselectRouteCommand{route: route, manager: manager}) + return manager.DeselectRoutes([]route.NetID{route.NetID(id)}) } // getNetworkDomainsFromRoute extracts domains from a route and enriches each domain @@ -467,3 +644,28 @@ func exportEnvList(list *EnvList) { } } } + +// formatDuration renders a duration for display, trimming the fractional part +// to two digits so latencies read as "12.34ms" rather than "12.345678ms". +func formatDuration(d time.Duration) string { + ds := d.String() + dotIndex := strings.Index(ds, ".") + if dotIndex == -1 { + return ds + } + + endIndex := min(dotIndex+3, len(ds)) + + // Skip the remaining digits so only the unit suffix is appended back. + unitStart := endIndex + for unitStart < len(ds) && ds[unitStart] >= '0' && ds[unitStart] <= '9' { + unitStart++ + } + return ds[:endIndex] + ds[unitStart:] +} + +// formatTime renders a timestamp in UTC using a fixed layout. The zero time is +// passed through as-is so the UI can recognise it and show "never" instead. +func formatTime(t time.Time) string { + return t.UTC().Format("2006-01-02 15:04:05") +} diff --git a/client/android/client_mdm.go b/client/android/client_mdm.go new file mode 100644 index 000000000..d043b85d3 --- /dev/null +++ b/client/android/client_mdm.go @@ -0,0 +1,52 @@ +//go:build android + +package android + +import ( + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" +) + +type mdmSource struct { + loader *mdm.Loader + detector *mdm.ChangeDetector +} + +// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on +// this Client; passing nil disables MDM enforcement. +func (c *Client) SetMDMPolicyFetcher(p PolicyFetcher) { + loader := loaderFor(p) + c.mdmSource.Store(&mdmSource{loader: loader, detector: mdm.NewChangeDetector(loader)}) +} + +// HasMDMPolicyChanged re-reads the managed configuration and reports whether +// it changed since the last observation; call it from the native OS-change +// notification and restart the engine only on true. +func (c *Client) HasMDMPolicyChanged() bool { + src := c.mdmSource.Load() + if src == nil { + return false + } + return src.detector.Changed() +} + +// GetRestrictionsJSON returns the UI enforcement snapshot derived from the +// active MDM policy, in the JSON shape shared with the desktop frontend. +func (c *Client) GetRestrictionsJSON() (string, error) { + return mdm.BuildRestrictions(c.mdmLoader().Load()).JSON() +} + +func (c *Client) applyMDMOverlay(cfg *profilemanager.Config) { + loader := c.mdmLoader() + if cfg == nil || loader == nil { + return + } + cfg.ApplyMDMPolicy(loader.Load()) +} + +func (c *Client) mdmLoader() *mdm.Loader { + if src := c.mdmSource.Load(); src != nil { + return src.loader + } + return nil +} diff --git a/client/android/connection_listener.go b/client/android/connection_listener.go new file mode 100644 index 000000000..77c47574b --- /dev/null +++ b/client/android/connection_listener.go @@ -0,0 +1,41 @@ +//go:build android + +package android + +import ( + "github.com/netbirdio/netbird/client/internal/peer" +) + +// Client state values delivered via ConnectionListener.OnStateChanged, +// re-exported as basic constants so gomobile emits them into the generated +// Java bindings. They mirror peer.ClientState*: append-only, never reorder. +const ( + ClientStateDisconnected = int(peer.ClientStateDisconnected) + ClientStateConnected = int(peer.ClientStateConnected) + ClientStateConnecting = int(peer.ClientStateConnecting) + ClientStateDisconnecting = int(peer.ClientStateDisconnecting) + ClientStateNoNetwork = int(peer.ClientStateNoNetwork) +) + +// ConnectionListener export internal Listener for mobile. It mirrors +// peer.Listener with OnStateChanged taking a plain int (one of the +// ClientState* constants), because gomobile cannot bind named types. +type ConnectionListener interface { + OnStateChanged(state int) + OnConnected() + OnDisconnected() + OnConnecting() + OnDisconnecting() + OnAddressChanged(string, string) + OnPeersListChanged(int) +} + +// connectionListenerAdapter adapts the gomobile-facing ConnectionListener to +// peer.Listener, converting the typed state to the int the binding carries. +type connectionListenerAdapter struct { + ConnectionListener +} + +func (a connectionListenerAdapter) OnStateChanged(state peer.ClientState) { + a.ConnectionListener.OnStateChanged(int(state)) +} diff --git a/client/android/env_list.go b/client/android/env_list.go index a0a4d7040..d0e0a1e78 100644 --- a/client/android/env_list.go +++ b/client/android/env_list.go @@ -10,7 +10,7 @@ var ( EnvKeyNBForceRelay = peer.EnvKeyNBForceRelay // EnvKeyNBLazyConn Exported for Android java client to configure lazy connection - EnvKeyNBLazyConn = lazyconn.EnvEnableLazyConn + EnvKeyNBLazyConn = lazyconn.EnvLazyConn // EnvKeyNBInactivityThreshold Exported for Android java client to configure connection inactivity threshold EnvKeyNBInactivityThreshold = lazyconn.EnvInactivityThreshold diff --git a/client/android/login.go b/client/android/login.go index a9422cdbf..155c6eadd 100644 --- a/client/android/login.go +++ b/client/android/login.go @@ -4,8 +4,12 @@ import ( "context" "fmt" + log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" + "github.com/netbirdio/netbird/client/mobile" "github.com/netbirdio/netbird/client/system" ) @@ -36,15 +40,31 @@ type Auth struct { } // NewAuth instantiate Auth struct and validate the management URL -func NewAuth(cfgPath string, mgmURL string) (*Auth, error) { - inputCfg := profilemanager.ConfigInput{ - ManagementURL: mgmURL, +// +// The configuration at cfgPath is reused when one is already there, and only created when it is +// not. Building a fresh in-memory config unconditionally gives the client a new WireGuard key on +// every call: the peer registers under that key, the key is written out, and any peer registered by +// an earlier call is orphaned on the server. It also breaks a client that enrols and then runs from +// the persisted config, because the identity it registered is not the one it runs with — the +// management stream rejects it with "no peer auth method provided". +// +// Auth is constructed under the active MDM policy: the policy is overlaid on +// the resolved config so the login runs against the enforced values, while +// the persisted config keeps the caller-supplied ones; a caller-supplied +// management URL is ignored while MDM manages that key. A nil fetcher +// disables MDM enforcement. +func NewAuth(cfgPath string, mgmURL string, fetcher PolicyFetcher) (*Auth, error) { + policy := loaderFor(fetcher).Load() + inputCfg := profilemanager.ConfigInput{ConfigPath: cfgPath} + if _, managed := policy.GetString(mdm.KeyManagementURL); !managed { + inputCfg.ManagementURL = mgmURL } - cfg, err := profilemanager.CreateInMemoryConfig(inputCfg) + cfg, err := profilemanager.UpdateOrCreateConfig(inputCfg) if err != nil { return nil, err } + cfg.ApplyMDMPolicy(policy) return &Auth{ ctx: context.Background(), @@ -53,17 +73,18 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) { }, nil } -// NewAuthWithConfig instantiate Auth based on existing config -func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config) *Auth { +// NewAuthWithConfig instantiate Auth based on existing config. cfgPath is the +// file the config was loaded from; it identifies the profile whose account email +// backs the login_hint. +func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config, cfgPath string) *Auth { return &Auth{ - ctx: ctx, - config: config, + ctx: ctx, + config: config, + cfgPath: cfgPath, } } -// SaveConfigIfSSOSupported test the connectivity with the management server by retrieving the server device flow info. -// If it returns a flow info than save the configuration and return true. If it gets a codes.NotFound, it means that SSO -// is not supported and returns false without saving the configuration. For other errors return false. +// SaveConfigIfSSOSupported reports whether the management server supports SSO; the config is already persisted by NewAuth. func (a *Auth) SaveConfigIfSSOSupported(listener SSOListener) { go func() { sso, err := a.saveConfigIfSSOSupported() @@ -87,15 +108,10 @@ func (a *Auth) saveConfigIfSSOSupported() (bool, error) { return false, fmt.Errorf("failed to check SSO support: %v", err) } - if !supportsSSO { - return false, nil - } - - err = profilemanager.WriteOutConfig(a.cfgPath, a.config) - return true, err + return supportsSSO, nil } -// LoginWithSetupKeyAndSaveConfig test the connectivity with the management server with the setup key. +// LoginWithSetupKeyAndSaveConfig registers the peer with the setup key; the config is already persisted by NewAuth. func (a *Auth) LoginWithSetupKeyAndSaveConfig(resultListener ErrListener, setupKey string, deviceName string) { go func() { err := a.loginWithSetupKeyAndSaveConfig(setupKey, deviceName) @@ -120,8 +136,7 @@ func (a *Auth) loginWithSetupKeyAndSaveConfig(setupKey string, deviceName string if err != nil { return fmt.Errorf("login failed: %v", err) } - - return profilemanager.WriteOutConfig(a.cfgPath, a.config) + return nil } // Login try register the client on the server @@ -150,12 +165,14 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error { } jwtToken := "" + email := "" if needsLogin { tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV) if err != nil { return fmt.Errorf("interactive sso login failed: %v", err) } jwtToken = tokenInfo.GetTokenToUse() + email = tokenInfo.Email } err, _ = authClient.Login(a.ctx, "", jwtToken) @@ -163,27 +180,62 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error { return fmt.Errorf("login failed: %v", err) } + // Stored after Login, not before: a rejected token must not leave a hint + // pointing at an account that cannot be used. + if email != "" && a.cfgPath != "" { + if err := mobile.WriteProfileEmail(a.cfgPath, email); err != nil { + log.Warnf("failed to store profile account email: %v", err) + } + } + go urlOpener.OnLoginSuccess() return nil } func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, isAndroidTV bool) (*auth.TokenInfo, error) { - oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV) + oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV, profileLoginHint(a.cfgPath)) if err != nil { return nil, fmt.Errorf("failed to get OAuth flow: %v", err) } - flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO()) + return runOAuthFlow(a.ctx, oAuthFlow, urlOpener, nil) +} + +// profileLoginHint returns the stored account email for the profile at cfgPath. +// An empty hint is deliberate, not a fallback: a fresh profile leaves the +// choice to the IdP. Switching accounts is done by switching or removing +// profiles, not by logging out — logout keeps the email. +func profileLoginHint(cfgPath string) string { + if cfgPath == "" { + return "" + } + return mobile.ReadProfileEmail(cfgPath) +} + +// runOAuthFlow drives an already acquired OAuth flow to a token: requests the +// flow info, presents the verification URL through the opener and waits for +// the browser round-trip. Open is called synchronously — it is what marks the +// surface as opened on the client side, and a fast token's OnLoginSuccess is +// a no-op until it has, so the dismissal would be dropped rather than +// delayed. Openers must therefore not block: they post their UI work and +// return. onWaiting, when set, runs after the URL is shown, right before the +// blocking wait. +func runOAuthFlow(ctx context.Context, flow auth.OAuthFlow, urlOpener URLOpener, onWaiting func()) (*auth.TokenInfo, error) { + flowInfo, err := flow.RequestAuthInfo(ctx) if err != nil { - return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err) + return nil, fmt.Errorf("request auth info: %w", err) } - go urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode) + urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode) - tokenInfo, err := oAuthFlow.WaitToken(a.ctx, flowInfo) + if onWaiting != nil { + onWaiting() + } + + tokenInfo, err := flow.WaitToken(ctx, flowInfo) if err != nil { - return nil, fmt.Errorf("waiting for browser login failed: %v", err) + return nil, fmt.Errorf("wait for token: %w", err) } return &tokenInfo, nil diff --git a/client/android/login_test.go b/client/android/login_test.go new file mode 100644 index 000000000..130a846fc --- /dev/null +++ b/client/android/login_test.go @@ -0,0 +1,51 @@ +package android + +import ( + "path/filepath" + "testing" +) + +// NewAuth must reuse the configuration already at cfgPath rather than building a fresh one. +// +// Creating a new in-memory config on every call gives the client a new WireGuard private key each +// time. The peer registers under that key and the key is written out, so a peer registered by an +// earlier call is orphaned on the server — a client that enrols twice leaves two entries and owns +// neither. It also breaks enrol-then-run: RunWithoutLogin reloads the configuration from disk, so +// the identity that registered is not the identity that runs, and the management stream rejects it +// with "no peer auth method provided, please use a setup key or interactive SSO login". +func TestNewAuth_ReusesPersistedIdentity(t *testing.T) { + cfgPath := filepath.Join(t.TempDir(), "config.json") + + first, err := NewAuth(cfgPath, "https://api.example.com:443", nil) + if err != nil { + t.Fatalf("first NewAuth: %v", err) + } + if first.config.PrivateKey == "" { + t.Fatal("first NewAuth produced no private key") + } + + second, err := NewAuth(cfgPath, "https://api.example.com:443", nil) + if err != nil { + t.Fatalf("second NewAuth: %v", err) + } + + if second.config.PrivateKey != first.config.PrivateKey { + t.Errorf("private key changed between calls: a second enrolment would orphan the peer registered by the first") + } +} + +// A missing configuration is still created, so a first enrolment works unchanged. +func TestNewAuth_CreatesConfigWhenAbsent(t *testing.T) { + cfgPath := filepath.Join(t.TempDir(), "config.json") + + auth, err := NewAuth(cfgPath, "https://api.example.com:443", nil) + if err != nil { + t.Fatalf("NewAuth: %v", err) + } + if auth.config == nil || auth.config.PrivateKey == "" { + t.Fatal("NewAuth did not create a usable configuration") + } + if auth.cfgPath != cfgPath { + t.Errorf("cfgPath = %q, want %q", auth.cfgPath, cfgPath) + } +} diff --git a/client/android/mdm.go b/client/android/mdm.go new file mode 100644 index 000000000..617d8f7cb --- /dev/null +++ b/client/android/mdm.go @@ -0,0 +1,19 @@ +package android + +import ( + "github.com/netbirdio/netbird/client/mdm" +) + +// PolicyFetcher is implemented by the native layer to return the current +// managed configuration as a JSON-encoded object string; "" means no MDM +// source is present. +type PolicyFetcher interface { + FetchJSON() string +} + +func loaderFor(p PolicyFetcher) *mdm.Loader { + if p == nil { + return mdm.NewJSONLoader(nil) + } + return mdm.NewJSONLoader(p.FetchJSON) +} diff --git a/client/android/peer_notifier.go b/client/android/peer_notifier.go index 4ec22f3ab..f525055bb 100644 --- a/client/android/peer_notifier.go +++ b/client/android/peer_notifier.go @@ -12,11 +12,30 @@ const ( ) // PeerInfo describe information about the peers. It designed for the UI usage +// +// The fields below ConnStatus back the peer detail screen. Durations and times +// are pre-formatted into strings so the UI does not have to know Go's layouts; +// Latency is additionally exposed as LatencyMs for colour coding. type PeerInfo struct { IP string + IPv6 string FQDN string ConnStatus int Routes PeerRoutes + + PubKey string + Latency string + LatencyMs int64 + BytesRx int64 + BytesTx int64 + ConnStatusUpdate string + Relayed bool + RosenpassEnabled bool + LastWireguardHandshake string + LocalIceCandidateType string + RemoteIceCandidateType string + LocalIceCandidateEndpoint string + RemoteIceCandidateEndpoint string } func (p *PeerInfo) GetPeerRoutes() *PeerRoutes { diff --git a/client/android/preferences.go b/client/android/preferences.go index c3c8eb3fb..5ce31026c 100644 --- a/client/android/preferences.go +++ b/client/android/preferences.go @@ -1,12 +1,16 @@ package android import ( + "sync/atomic" + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" ) // Preferences exports a subset of the internal config for gomobile type Preferences struct { configInput profilemanager.ConfigInput + mdmLoader atomic.Pointer[mdm.Loader] } // NewPreferences creates a new Preferences instance @@ -14,11 +18,30 @@ func NewPreferences(configPath string) *Preferences { ci := profilemanager.ConfigInput{ ConfigPath: configPath, } - return &Preferences{ci} + return &Preferences{configInput: ci} +} + +// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on +// this Preferences instance; passing nil disables MDM enforcement. +func (p *Preferences) SetMDMPolicyFetcher(f PolicyFetcher) { + p.mdmLoader.Store(loaderFor(f)) +} + +// GetRestrictionsJSON returns the UI enforcement snapshot derived from the +// active MDM policy, in the JSON shape shared with the desktop frontend. +func (p *Preferences) GetRestrictionsJSON() (string, error) { + return mdm.BuildRestrictions(p.policy()).JSON() +} + +func (p *Preferences) policy() *mdm.Policy { + return p.mdmLoader.Load().Load() } // GetManagementURL reads URL from config file func (p *Preferences) GetManagementURL() (string, error) { + if v, ok := p.policy().GetString(mdm.KeyManagementURL); ok { + return mdm.CanonicalURL(v), nil + } if p.configInput.ManagementURL != "" { return p.configInput.ManagementURL, nil } @@ -27,7 +50,7 @@ func (p *Preferences) GetManagementURL() (string, error) { if err != nil { return "", err } - return cfg.ManagementURL.String(), err + return cfg.ManagementURL.String(), nil } // SetManagementURL stores the given URL and waits for commit @@ -53,17 +76,21 @@ func (p *Preferences) SetAdminURL(url string) { p.configInput.AdminURL = url } -// GetPreSharedKey reads pre-shared key from config file -func (p *Preferences) GetPreSharedKey() (string, error) { +// HasPreSharedKey reports whether a pre-shared key is staged, persisted, or +// enforced by MDM; the key itself is never handed to the native layer. +func (p *Preferences) HasPreSharedKey() (bool, error) { + if _, ok := p.policy().GetString(mdm.KeyPreSharedKey); ok { + return true, nil + } if p.configInput.PreSharedKey != nil { - return *p.configInput.PreSharedKey, nil + return *p.configInput.PreSharedKey != "", nil } cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) if err != nil { - return "", err + return false, err } - return cfg.PreSharedKey, err + return cfg.PreSharedKey != "", nil } // SetPreSharedKey stores the given key and waits for commit @@ -78,6 +105,9 @@ func (p *Preferences) SetRosenpassEnabled(enabled bool) { // GetRosenpassEnabled reads Rosenpass enabled status from config file func (p *Preferences) GetRosenpassEnabled() (bool, error) { + if v, ok := p.policy().GetBool(mdm.KeyRosenpassEnabled); ok { + return v, nil + } if p.configInput.RosenpassEnabled != nil { return *p.configInput.RosenpassEnabled, nil } @@ -96,6 +126,9 @@ func (p *Preferences) SetRosenpassPermissive(permissive bool) { // GetRosenpassPermissive reads Rosenpass permissive setting from config file func (p *Preferences) GetRosenpassPermissive() (bool, error) { + if v, ok := p.policy().GetBool(mdm.KeyRosenpassPermissive); ok { + return v, nil + } if p.configInput.RosenpassPermissive != nil { return *p.configInput.RosenpassPermissive, nil } @@ -109,6 +142,9 @@ func (p *Preferences) GetRosenpassPermissive() (bool, error) { // GetDisableClientRoutes reads disable client routes setting from config file func (p *Preferences) GetDisableClientRoutes() (bool, error) { + if v, ok := p.policy().GetBool(mdm.KeyDisableClientRoutes); ok { + return v, nil + } if p.configInput.DisableClientRoutes != nil { return *p.configInput.DisableClientRoutes, nil } @@ -127,6 +163,9 @@ func (p *Preferences) SetDisableClientRoutes(disable bool) { // GetDisableServerRoutes reads disable server routes setting from config file func (p *Preferences) GetDisableServerRoutes() (bool, error) { + if v, ok := p.policy().GetBool(mdm.KeyDisableServerRoutes); ok { + return v, nil + } if p.configInput.DisableServerRoutes != nil { return *p.configInput.DisableServerRoutes, nil } @@ -181,6 +220,9 @@ func (p *Preferences) SetDisableFirewall(disable bool) { // GetServerSSHAllowed reads server SSH allowed setting from config file func (p *Preferences) GetServerSSHAllowed() (bool, error) { + if v, ok := p.policy().GetBool(mdm.KeyAllowServerSSH); ok { + return v, nil + } if p.configInput.ServerSSHAllowed != nil { return *p.configInput.ServerSSHAllowed, nil } @@ -291,6 +333,9 @@ func (p *Preferences) SetEnableSSHRemotePortForwarding(enabled bool) { // GetBlockInbound reads block inbound setting from config file func (p *Preferences) GetBlockInbound() (bool, error) { + if v, ok := p.policy().GetBool(mdm.KeyBlockInbound); ok { + return v, nil + } if p.configInput.BlockInbound != nil { return *p.configInput.BlockInbound, nil } @@ -307,8 +352,52 @@ func (p *Preferences) SetBlockInbound(block bool) { p.configInput.BlockInbound = &block } +// GetDisableIPv6 reads disable IPv6 setting from config file +func (p *Preferences) GetDisableIPv6() (bool, error) { + if p.configInput.DisableIPv6 != nil { + return *p.configInput.DisableIPv6, nil + } + + cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + if err != nil { + return false, err + } + return cfg.DisableIPv6, err +} + +// SetDisableIPv6 stores the given value and waits for commit +func (p *Preferences) SetDisableIPv6(disable bool) { + p.configInput.DisableIPv6 = &disable +} + +// GetRemoteJobsAllowed reads the remote jobs opt-in from config file +func (p *Preferences) GetRemoteJobsAllowed() (bool, error) { + policy := p.policy() + if !policy.HasKey(mdm.KeyRemoteJobsAllowed) && p.configInput.RemoteJobsAllowed != nil { + return *p.configInput.RemoteJobsAllowed, nil + } + + cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + if err != nil { + return false, err + } + cfg.ApplyMDMPolicy(policy) + if cfg.RemoteJobsAllowed == nil { + return false, nil + } + return *cfg.RemoteJobsAllowed, nil +} + +// SetRemoteJobsAllowed stores the given value and waits for commit +func (p *Preferences) SetRemoteJobsAllowed(allowed bool) { + p.configInput.RemoteJobsAllowed = &allowed +} + // Commit writes out the changes to the config file func (p *Preferences) Commit() error { + if err := profilemanager.CheckMDMConflicts(p.configInput, p.policy()); err != nil { + return err + } _, err := profilemanager.UpdateOrCreateConfig(p.configInput) return err } diff --git a/client/android/preferences_test.go b/client/android/preferences_test.go index 2bbccef86..d9f5b1918 100644 --- a/client/android/preferences_test.go +++ b/client/android/preferences_test.go @@ -28,14 +28,13 @@ func TestPreferences_DefaultValues(t *testing.T) { t.Errorf("invalid default management url: %s", defaultVar) } - var preSharedKey string - preSharedKey, err = p.GetPreSharedKey() + hasPSK, err := p.HasPreSharedKey() if err != nil { - t.Fatalf("failed to read default preshared key: %s", err) + t.Fatalf("failed to read default preshared key presence: %s", err) } - if preSharedKey != "" { - t.Errorf("invalid preshared key: %s", preSharedKey) + if hasPSK { + t.Errorf("unexpected preshared key presence on fresh config") } } @@ -65,13 +64,13 @@ func TestPreferences_ReadUncommitedValues(t *testing.T) { } p.SetPreSharedKey(exampleString) - resp, err = p.GetPreSharedKey() + hasPSK, err := p.HasPreSharedKey() if err != nil { - t.Fatalf("failed to read preshared key: %s", err) + t.Fatalf("failed to read preshared key presence: %s", err) } - if resp != exampleString { - t.Errorf("unexpected preshared key: %s", resp) + if !hasPSK { + t.Errorf("expected preshared key presence after staging one") } } @@ -109,12 +108,12 @@ func TestPreferences_Commit(t *testing.T) { t.Errorf("unexpected management url: %s", resp) } - resp, err = p.GetPreSharedKey() + hasPSK, err := p.HasPreSharedKey() if err != nil { - t.Fatalf("failed to read preshared key: %s", err) + t.Fatalf("failed to read preshared key presence: %s", err) } - if resp != examplePresharedKey { - t.Errorf("unexpected preshared key: %s", resp) + if !hasPSK { + t.Errorf("expected preshared key presence after commit") } } diff --git a/client/android/profile_manager.go b/client/android/profile_manager.go index 60e4d5c32..4bc60c453 100644 --- a/client/android/profile_manager.go +++ b/client/android/profile_manager.go @@ -3,42 +3,37 @@ package android import ( - "fmt" - "os" - "path/filepath" - "strings" - - log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mobile" ) const ( - // Android-specific config filename (different from desktop default.json) - defaultConfigFilename = "netbird.cfg" - // Subdirectory for non-default profiles (must match Java Preferences.java) - profilesSubdir = "profiles" - // Android uses a single user context per app (non-empty username required by ServiceManager) + // Android uses a single user context per app. androidUsername = "android" ) -// Profile represents a profile for gomobile +// Profile represents a profile for gomobile. type Profile struct { - Name string + ID string + Name string + // Email is the account this profile last logged in with, "" if it never + // completed an SSO login. Kept across logouts; cleared when the profile is + // removed. See client/mobile/profile_state.go. + Email string IsActive bool } -// ProfileArray wraps profiles for gomobile compatibility +// ProfileArray wraps profiles for gomobile compatibility (gomobile cannot +// bind Go slices directly). type ProfileArray struct { items []*Profile } -// Length returns the number of profiles +// Length returns the number of profiles. func (p *ProfileArray) Length() int { return len(p.items) } -// Get returns the profile at index i +// Get returns the profile at index i, or nil if out of range. func (p *ProfileArray) Get(i int) *Profile { if i < 0 || i >= len(p.items) { return nil @@ -46,212 +41,104 @@ func (p *ProfileArray) Get(i int) *Profile { return p.items[i] } -/* - -/data/data/io.netbird.client/files/ ← configDir parameter -├── netbird.cfg ← Default profile config -├── state.json ← Default profile state -├── active_profile.json ← Active profile tracker (JSON with Name + Username) -└── profiles/ ← Subdirectory for non-default profiles - ├── work.json ← Work profile config - ├── work.state.json ← Work profile state - ├── personal.json ← Personal profile config - └── personal.state.json ← Personal profile state -*/ - -// ProfileManager manages profiles for Android -// It wraps the internal profilemanager to provide Android-specific behavior +// ProfileManager adapts the shared mobile profile manager (client/mobile) to +// gomobile-friendly types. See that package for the on-disk layout and +// semantics. type ProfileManager struct { - configDir string - serviceMgr *profilemanager.ServiceManager + impl *mobile.ProfileManager } -// NewProfileManager creates a new profile manager for Android +// NewProfileManager creates a new profile manager for Android. configDir is +// the app's files directory. func NewProfileManager(configDir string) *ProfileManager { - // Set the default config path for Android (stored in root configDir, not profiles/) - defaultConfigPath := filepath.Join(configDir, defaultConfigFilename) - - // Set global paths for Android - profilemanager.DefaultConfigPathDir = configDir - profilemanager.DefaultConfigPath = defaultConfigPath - profilemanager.ActiveProfileStatePath = filepath.Join(configDir, "active_profile.json") - - // Create ServiceManager with profiles/ subdirectory - // This avoids modifying the global ConfigDirOverride for profile listing - profilesDir := filepath.Join(configDir, profilesSubdir) - serviceMgr := profilemanager.NewServiceManagerWithProfilesDir(defaultConfigPath, profilesDir) - - return &ProfileManager{ - configDir: configDir, - serviceMgr: serviceMgr, - } + return &ProfileManager{impl: mobile.NewProfileManager(configDir, androidUsername)} } -// ListProfiles returns all available profiles +// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on +// this ProfileManager; passing nil disables MDM enforcement. +func (pm *ProfileManager) SetMDMPolicyFetcher(f PolicyFetcher) { + pm.impl.SetMDMLoader(loaderFor(f)) +} + +// ListProfiles returns all available profiles, including the default profile, +// with their active status set. func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) { - // Use ServiceManager (looks in profiles/ directory, checks active_profile.json for IsActive) - internalProfiles, err := pm.serviceMgr.ListProfiles(androidUsername) + profiles, err := pm.impl.ListProfiles() if err != nil { - return nil, fmt.Errorf("failed to list profiles: %w", err) + return nil, err } - // Convert internal profiles to Android Profile type - var profiles []*Profile - for _, p := range internalProfiles { - profiles = append(profiles, &Profile{ - Name: p.Name, - IsActive: p.IsActive, - }) + items := make([]*Profile, 0, len(profiles)) + for i := range profiles { + items = append(items, fromMobileProfile(&profiles[i])) } - - return &ProfileArray{items: profiles}, nil + return &ProfileArray{items: items}, nil } -// GetActiveProfile returns the currently active profile name -func (pm *ProfileManager) GetActiveProfile() (string, error) { - // Use ServiceManager to stay consistent with ListProfiles - // ServiceManager uses active_profile.json - activeState, err := pm.serviceMgr.GetActiveProfileState() +// GetActiveProfile returns the currently active profile. +func (pm *ProfileManager) GetActiveProfile() (*Profile, error) { + p, err := pm.impl.GetActiveProfile() if err != nil { - return "", fmt.Errorf("failed to get active profile: %w", err) + return nil, err } - return activeState.Name, nil + return fromMobileProfile(p), nil } -// SwitchProfile switches to a different profile -func (pm *ProfileManager) SwitchProfile(profileName string) error { - // Use ServiceManager to stay consistent with ListProfiles - // ServiceManager uses active_profile.json - err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: profileName, - Username: androidUsername, - }) - if err != nil { - return fmt.Errorf("failed to switch profile: %w", err) - } - - log.Infof("switched to profile: %s", profileName) - return nil +// SwitchProfile records the given profile ID as the active profile. The caller +// must stop the VPN tunnel before switching. +func (pm *ProfileManager) SwitchProfile(id string) error { + return pm.impl.SwitchProfile(id) } -// AddProfile creates a new profile +// AddProfile creates a new profile with the given display name and a +// generated ID. func (pm *ProfileManager) AddProfile(profileName string) error { - // Use ServiceManager (creates profile in profiles/ directory) - if err := pm.serviceMgr.AddProfile(profileName, androidUsername); err != nil { - return fmt.Errorf("failed to add profile: %w", err) - } - - log.Infof("created new profile: %s", profileName) - return nil + _, err := pm.impl.AddProfile(profileName) + return err } -// LogoutProfile logs out from a profile (clears authentication) -func (pm *ProfileManager) LogoutProfile(profileName string) error { - profileName = sanitizeProfileName(profileName) - - configPath, err := pm.getProfileConfigPath(profileName) - if err != nil { - return err - } - - // Check if profile exists - if _, err := os.Stat(configPath); os.IsNotExist(err) { - return fmt.Errorf("profile '%s' does not exist", profileName) - } - - // Read current config using internal profilemanager - config, err := profilemanager.ReadConfig(configPath) - if err != nil { - return fmt.Errorf("failed to read profile config: %w", err) - } - - // Clear authentication by removing private key and SSH key - config.PrivateKey = "" - config.SSHKey = "" - - // Save config using internal profilemanager - if err := profilemanager.WriteOutConfig(configPath, config); err != nil { - return fmt.Errorf("failed to save config: %w", err) - } - - log.Infof("logged out from profile: %s", profileName) - return nil +// RenameProfile changes the display name of the profile identified by id. The +// on-disk filename (the ID) is left unchanged. +func (pm *ProfileManager) RenameProfile(id string, newName string) error { + return pm.impl.RenameProfile(id, newName) } -// RemoveProfile deletes a profile -func (pm *ProfileManager) RemoveProfile(profileName string) error { - // Use ServiceManager (removes profile from profiles/ directory) - if err := pm.serviceMgr.RemoveProfile(profileName, androidUsername); err != nil { - return fmt.Errorf("failed to remove profile: %w", err) - } - - log.Infof("removed profile: %s", profileName) - return nil +// LogoutProfile clears authentication data for a profile, forcing a re-login. +// The management URL and other settings are preserved. +func (pm *ProfileManager) LogoutProfile(id string) error { + return pm.impl.LogoutProfile(id) } -// getProfileConfigPath returns the config file path for a profile -// This is needed for Android-specific path handling (netbird.cfg for default profile) -func (pm *ProfileManager) getProfileConfigPath(profileName string) (string, error) { - if profileName == "" || profileName == profilemanager.DefaultProfileName { - // Android uses netbird.cfg for default profile instead of default.json - // Default profile is stored in root configDir, not in profiles/ - return filepath.Join(pm.configDir, defaultConfigFilename), nil - } - - // Non-default profiles are stored in profiles subdirectory - // This matches the Java Preferences.java expectation - profileName = sanitizeProfileName(profileName) - profilesDir := filepath.Join(pm.configDir, profilesSubdir) - return filepath.Join(profilesDir, profileName+".json"), nil +// RemoveProfile deletes a profile. The default profile and the active profile +// cannot be removed. +func (pm *ProfileManager) RemoveProfile(id string) error { + return pm.impl.RemoveProfile(id) } -// GetConfigPath returns the config file path for a given profile -// Java should call this instead of constructing paths with Preferences.configFile() -func (pm *ProfileManager) GetConfigPath(profileName string) (string, error) { - return pm.getProfileConfigPath(profileName) +// GetConfigPath returns the config file path for the given profile ID. Java +// should call this instead of constructing paths with Preferences.configFile(). +func (pm *ProfileManager) GetConfigPath(id string) (string, error) { + return pm.impl.GetConfigPath(id) } -// GetStateFilePath returns the state file path for a given profile -// Java should call this instead of constructing paths with Preferences.stateFile() -func (pm *ProfileManager) GetStateFilePath(profileName string) (string, error) { - if profileName == "" || profileName == profilemanager.DefaultProfileName { - return filepath.Join(pm.configDir, "state.json"), nil - } - - profileName = sanitizeProfileName(profileName) - profilesDir := filepath.Join(pm.configDir, profilesSubdir) - return filepath.Join(profilesDir, profileName+".state.json"), nil +// GetStateFilePath returns the state file path for the given profile ID. Java +// should call this instead of constructing paths with Preferences.stateFile(). +func (pm *ProfileManager) GetStateFilePath(id string) (string, error) { + return pm.impl.GetStateFilePath(id) } -// GetActiveConfigPath returns the config file path for the currently active profile -// Java should call this instead of Preferences.getActiveProfileName() + Preferences.configFile() +// GetActiveConfigPath returns the config file path for the currently active +// profile. func (pm *ProfileManager) GetActiveConfigPath() (string, error) { - activeProfile, err := pm.GetActiveProfile() - if err != nil { - return "", fmt.Errorf("failed to get active profile: %w", err) - } - return pm.GetConfigPath(activeProfile) + return pm.impl.GetActiveConfigPath() } -// GetActiveStateFilePath returns the state file path for the currently active profile -// Java should call this instead of Preferences.getActiveProfileName() + Preferences.stateFile() +// GetActiveStateFilePath returns the state file path for the currently active +// profile. func (pm *ProfileManager) GetActiveStateFilePath() (string, error) { - activeProfile, err := pm.GetActiveProfile() - if err != nil { - return "", fmt.Errorf("failed to get active profile: %w", err) - } - return pm.GetStateFilePath(activeProfile) + return pm.impl.GetActiveStateFilePath() } -// sanitizeProfileName removes invalid characters from profile name -func sanitizeProfileName(name string) string { - // Keep only alphanumeric, underscore, and hyphen - var result strings.Builder - for _, r := range name { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || - (r >= '0' && r <= '9') || r == '_' || r == '-' { - result.WriteRune(r) - } - } - return result.String() +func fromMobileProfile(p *mobile.Profile) *Profile { + return &Profile{ID: p.ID, Name: p.Name, Email: p.Email, IsActive: p.IsActive} } diff --git a/client/android/profile_prefs.go b/client/android/profile_prefs.go new file mode 100644 index 000000000..a761ebbcf --- /dev/null +++ b/client/android/profile_prefs.go @@ -0,0 +1,37 @@ +//go:build android + +package android + +import ( + "fmt" + + "github.com/netbirdio/netbird/client/internal/profilemanager" +) + +type prefsStore interface { + Get(namespace string, v any) (bool, error) + Put(namespace string, v any) error +} + +type profilePrefs struct { + prefs *profilemanager.Prefs +} + +func newProfilePrefs(configDir, profileID string) (*profilePrefs, error) { + if configDir == "" || profileID == "" { + return nil, fmt.Errorf("profile prefs require a config dir and profile ID") + } + prefs, err := NewProfileManager(configDir).impl.ProfilePrefs(profileID) + if err != nil { + return nil, err + } + return &profilePrefs{prefs: prefs}, nil +} + +func (p *profilePrefs) Get(namespace string, v any) (bool, error) { + return p.prefs.Get(namespace, v) +} + +func (p *profilePrefs) Put(namespace string, v any) error { + return p.prefs.Put(namespace, v) +} diff --git a/client/android/route_command.go b/client/android/route_command.go deleted file mode 100644 index b47d5ca6c..000000000 --- a/client/android/route_command.go +++ /dev/null @@ -1,67 +0,0 @@ -//go:build android - -package android - -import ( - "fmt" - - log "github.com/sirupsen/logrus" - "golang.org/x/exp/maps" - - "github.com/netbirdio/netbird/client/internal/routemanager" - "github.com/netbirdio/netbird/route" -) - -func executeRouteToggle(id string, manager routemanager.Manager, - operationName string, - routeOperation func(routes []route.NetID, allRoutes []route.NetID) error) error { - netID := route.NetID(id) - routes := []route.NetID{netID} - - log.Debugf("%s with id: %s", operationName, id) - - if err := routeOperation(routes, maps.Keys(manager.GetClientRoutesWithNetID())); err != nil { - log.Debugf("error when %s: %s", operationName, err) - return fmt.Errorf("error %s: %w", operationName, err) - } - - manager.TriggerSelection(manager.GetClientRoutes()) - - return nil -} - -type routeCommand interface { - toggleRoute() error -} - -type selectRouteCommand struct { - route string - manager routemanager.Manager -} - -func (s selectRouteCommand) toggleRoute() error { - routeSelector := s.manager.GetRouteSelector() - if routeSelector == nil { - return fmt.Errorf("no route selector available") - } - - routeOperation := func(routes []route.NetID, allRoutes []route.NetID) error { - return routeSelector.SelectRoutes(routes, true, allRoutes) - } - - return executeRouteToggle(s.route, s.manager, "selecting route", routeOperation) -} - -type deselectRouteCommand struct { - route string - manager routemanager.Manager -} - -func (d deselectRouteCommand) toggleRoute() error { - routeSelector := d.manager.GetRouteSelector() - if routeSelector == nil { - return fmt.Errorf("no route selector available") - } - - return executeRouteToggle(d.route, d.manager, "deselecting route", routeSelector.DeselectRoutes) -} diff --git a/client/android/session.go b/client/android/session.go new file mode 100644 index 000000000..d5da09c93 --- /dev/null +++ b/client/android/session.go @@ -0,0 +1,312 @@ +//go:build android + +package android + +import ( + "context" + "fmt" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/internal/auth" + "github.com/netbirdio/netbird/client/internal/auth/sessionwatch" + "github.com/netbirdio/netbird/client/internal/peer" + cProto "github.com/netbirdio/netbird/client/proto" +) + +// StateChangeListener receives client state notifications. +// +// OnStateChanged is a payload-free wake-up whenever the state snapshot +// changed: connection state, the run-loop status label (e.g. NeedsLogin) or +// the session deadline. It mirrors the daemon's SubscribeStatus stream +// trigger — on each signal the consumer pulls the fresh values via +// Status() / SessionExpiresAtUnix(). +// +// OnSessionExpiring forwards the engine's session-expiry warnings, fired at +// sessionwatch.WarningLead before the deadline and again at FinalWarningLead +// (finalWarning true). The second one is suppressed when the user dismissed +// the first via DismissSessionWarning. The daemon turns the same events into +// its tray notification. +type StateChangeListener interface { + OnStateChanged() + OnSessionExpiring(expiresAtUnix int64, leadMinutes int64, finalWarning bool) +} + +// Status returns the connect run-loop's status label — the same value the +// desktop daemon serves in StatusResponse.Status. "NeedsLogin" means the +// management server rejected the peer and an interactive login is required. +// +// The label is latched: the run loop keeps its status in a per-run context +// state, which a restart replaces with a fresh Idle one, so an engine restart +// (network change, always-on) would otherwise erase the fact that the peer +// still needs to log in. Only a successful interactive login or extend clears +// it — see clearLoginRequired. +func (c *Client) Status() string { + latched, generation := c.loginRequiredState() + if latched { + return string(internal.StatusNeedsLogin) + } + cc := c.getConnectClient() + if cc == nil { + return string(internal.StatusIdle) + } + status := cc.Status() + if status == internal.StatusNeedsLogin { + c.latchLoginRequired(generation) + } + return string(status) +} + +func (c *Client) loginRequiredState() (bool, uint64) { + c.loginRequiredMu.Lock() + defer c.loginRequiredMu.Unlock() + return c.loginRequired, c.loginCleared +} + +// latchLoginRequired records a NeedsLogin observation, unless a clear landed +// while the caller was reading the run loop's status: cc.Status() is read +// outside the lock, so a login or extend completing in that window would +// otherwise be undone by this stale observation, stranding the UI on +// "login required" over a healthy session. +func (c *Client) latchLoginRequired(observedGeneration uint64) { + c.loginRequiredMu.Lock() + defer c.loginRequiredMu.Unlock() + if c.loginCleared != observedGeneration { + return + } + c.loginRequired = true +} + +// clearLoginRequired releases the latch after a successful interactive login +// or session extend, and invalidates any observation already in flight. +func (c *Client) clearLoginRequired() { + c.loginRequiredMu.Lock() + defer c.loginRequiredMu.Unlock() + c.loginRequired = false + c.loginCleared++ +} + +// SessionExpiresAtUnix returns the SSO session deadline as unix seconds, or 0 +// when no deadline is known (not SSO-registered, expiry disabled, or the +// engine has not received one yet). A past value means the session expired. +// Mirror of StatusResponse.sessionExpiresAt on the desktop daemon. +func (c *Client) SessionExpiresAtUnix() int64 { + deadline := c.recorder.GetSessionExpiresAt() + if deadline.IsZero() { + return 0 + } + return deadline.Unix() +} + +// SetStateChangeListener registers the state notification listener. +// Replaces any previously registered listener; remove it with +// RemoveStateChangeListener. +func (c *Client) SetStateChangeListener(listener StateChangeListener) { + c.stateChangeMu.Lock() + defer c.stateChangeMu.Unlock() + c.stopStateChangeWatchLocked() + if listener == nil { + return + } + + // Both subscriptions are buffered (one pending tick, ten pending events), + // so unsubscribing is not enough to stop callbacks: the loops would drain + // what is already queued and deliver it to a listener the caller has + // already removed or replaced. Gate every callback on this registration's + // own signal, which is closed before unsubscribing. + done := make(chan struct{}) + c.stateChangeDone = done + + id, ch := c.recorder.SubscribeToStateChanges() + c.stateChangeSubID = id + // The channel is closed by UnsubscribeFromStateChanges, which ends the + // goroutine. Ticks are coalesced (buffer of one), so a burst of changes + // wakes the listener once. + go func() { + for range ch { + select { + case <-done: + return + default: + } + listener.OnStateChanged() + } + }() + + c.eventSub = c.recorder.SubscribeToEvents() + go watchSessionWarnings(c.eventSub, listener, done) +} + +// RemoveStateChangeListener unregisters the state notification listener. +func (c *Client) RemoveStateChangeListener() { + c.stateChangeMu.Lock() + defer c.stateChangeMu.Unlock() + c.stopStateChangeWatchLocked() +} + +// DismissSessionWarning records the user's "Dismiss" on the first expiry +// warning and suppresses the final one for the current deadline. A refreshed +// deadline re-arms both. No-op while the engine is not running. +func (c *Client) DismissSessionWarning() { + cc := c.getConnectClient() + if cc == nil { + return + } + engine := cc.Engine() + if engine == nil { + return + } + engine.DismissSessionWarning() +} + +// ExtendAuthSession runs the interactive SSO flow to obtain a fresh JWT and +// asks the management server to extend the session deadline. The tunnel is +// untouched: no resync, no reconnect. Async; the result arrives on the +// listener. Mirror of the daemon's RequestExtendAuthSession / +// WaitExtendAuthSession RPC pair, with URLOpener playing the "UI opens the +// browser" role. +// +// Only one flow may be in flight: the PKCE step binds a fixed loopback port, +// so a second concurrent flow would fail on that bind. Call +// CancelExtendAuthSession when the user abandons the browser. +func (c *Client) ExtendAuthSession(urlOpener URLOpener, isAndroidTV bool, resultListener ErrListener) { + ctx, err := c.beginExtend() + if err != nil { + resultListener.OnError(err) + return + } + + go func() { + defer c.endExtend() + if err := c.extendAuthSession(ctx, urlOpener, isAndroidTV); err != nil { + resultListener.OnError(err) + return + } + resultListener.OnSuccess() + }() +} + +// CancelExtendAuthSession aborts an in-flight ExtendAuthSession. The tunnel is +// left alone — unlike the login flow, which cancels the whole client context +// by stopping the engine. Without this the abandoned PKCE wait keeps its +// loopback port for the full flow timeout and blocks every later attempt. +// No-op when no flow is running. +func (c *Client) CancelExtendAuthSession() { + c.extendMu.Lock() + defer c.extendMu.Unlock() + if c.extendCancel != nil { + c.extendCancel() + } +} + +func (c *Client) stopStateChangeWatchLocked() { + // Signal first, unsubscribe second: closing the channels only stops new + // items, and the loops would still hand whatever is buffered to a listener + // that is no longer registered. + if c.stateChangeDone != nil { + close(c.stateChangeDone) + c.stateChangeDone = nil + } + if c.stateChangeSubID != "" { + c.recorder.UnsubscribeFromStateChanges(c.stateChangeSubID) + c.stateChangeSubID = "" + } + if c.eventSub != nil { + // Closes the channel, which ends watchSessionWarnings. + c.recorder.UnsubscribeFromEvents(c.eventSub) + c.eventSub = nil + } +} + +// watchSessionWarnings forwards the engine's session-expiry warnings to the +// listener. The event stream also carries unrelated traffic — network-map +// updates on every sync, DNS and route errors — so everything but an +// AUTHENTICATION event carrying the session-warning marker is dropped. Exits +// when the subscription is closed by UnsubscribeFromEvents, or earlier when +// done is closed — the stream buffers up to ten events, and a deregistered +// listener must not receive the ones already queued. +func watchSessionWarnings(sub *peer.EventSubscription, listener StateChangeListener, done <-chan struct{}) { + for ev := range sub.Events() { + select { + case <-done: + return + default: + } + if ev.GetCategory() != cProto.SystemEvent_AUTHENTICATION { + continue + } + meta := ev.GetMetadata() + if meta[sessionwatch.MetaSessionWarning] != "true" { + // Other AUTHENTICATION events exist (e.g. a deadline rejected as + // out of range); they carry no warning marker. + continue + } + deadline, err := sessionwatch.ParseExpiresAt(meta[sessionwatch.MetaSessionExpiresAt]) + if err != nil { + log.Warnf("session warning event with unparsable deadline: %v", err) + continue + } + lead, err := sessionwatch.ParseLeadMinutes(meta[sessionwatch.MetaSessionLeadMinutes]) + if err != nil { + // Informational only — the deadline above is what drives the UI. + lead = 0 + } + listener.OnSessionExpiring(deadline.Unix(), int64(lead), + meta[sessionwatch.MetaSessionFinal] == "true") + } +} + +func (c *Client) beginExtend() (context.Context, error) { + c.extendMu.Lock() + defer c.extendMu.Unlock() + if c.extendCancel != nil { + return nil, fmt.Errorf("session extend already in progress") + } + ctx, cancel := context.WithCancel(context.Background()) + c.extendCancel = cancel + return ctx, nil +} + +func (c *Client) endExtend() { + c.extendMu.Lock() + defer c.extendMu.Unlock() + if c.extendCancel != nil { + c.extendCancel() + c.extendCancel = nil + } +} + +func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isAndroidTV bool) error { + cfg, cfgPath, cc := c.authSnapshot() + if cfg == nil || cc == nil { + return fmt.Errorf("engine is not running") + } + engine := cc.Engine() + if engine == nil { + return fmt.Errorf("engine is not initialized") + } + + authClient, err := auth.NewAuth(ctx, cfg.PrivateKey, cfg.ManagementURL, cfg) + if err != nil { + return fmt.Errorf("failed to create auth client: %v", err) + } + defer authClient.Close() + + // Passing the config path makes the flow pick up the login_hint: an extend + // renews the session of the account already signed in, so it must not stop to + // offer a choice. + a := NewAuthWithConfig(ctx, cfg, cfgPath) + tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV) + if err != nil { + return fmt.Errorf("interactive sso login failed: %v", err) + } + + if _, err := engine.ExtendAuthSession(ctx, tokenInfo.GetTokenToUse()); err != nil { + return err + } + c.clearLoginRequired() + + go urlOpener.OnLoginSuccess() + return nil +} diff --git a/client/android/split_tunnel.go b/client/android/split_tunnel.go new file mode 100644 index 000000000..60e1ceeb8 --- /dev/null +++ b/client/android/split_tunnel.go @@ -0,0 +1,125 @@ +package android + +// SplitTunnelMode is which of the two selections, if either, the tunnel applies. +// Its values land in the profile's stored preferences, so the constants below +// are append-only and must never be reordered. +type SplitTunnelMode int + +const ( + modeOff SplitTunnelMode = iota + modeExclude + modeInclude +) + +// The same modes as basic ints. gomobile drops a constant whose type is not a +// basic one, so these are what reaches the generated Java bindings, and they +// keep the Android side tied to the values above instead of repeating 0, 1, 2. +const ( + SplitTunnelModeOff = int(modeOff) + SplitTunnelModeExclude = int(modeExclude) + SplitTunnelModeInclude = int(modeInclude) +) + +type splitTunnelSection struct { + Mode SplitTunnelMode `json:"mode"` + Excluded []string `json:"excluded"` + Included []string `json:"included"` +} + +// PackageList wraps []string for gomobile compatibility. +type PackageList struct { + items []string +} + +// NewPackageList creates an empty list to fill via Add. +func NewPackageList() *PackageList { + return &PackageList{} +} + +// Add appends a package name, ignoring empty ones. +func (l *PackageList) Add(s string) { + if s == "" { + return + } + l.items = append(l.items, s) +} + +// Size returns the number of entries. +func (l *PackageList) Size() int { + return len(l.items) +} + +// Get returns the entry at index i, or an empty string when out of range. +func (l *PackageList) Get(i int) string { + if i < 0 || i >= len(l.items) { + return "" + } + return l.items[i] +} + +// SplitTunnelSettings is one profile's choice of which applications the tunnel +// carries. The two selections are kept apart because the platform applies one +// or the other and never both, and so that switching mode does not throw away +// the picks made in the other one. +// +// Mode is an int rather than a SplitTunnelMode because gomobile carries only +// basic types across the binding. It holds one of the SplitTunnelMode* +// constants. +type SplitTunnelSettings struct { + Mode int + Excluded *PackageList + Included *PackageList +} + +// NewSplitTunnelSettings creates settings that carry every application. +func NewSplitTunnelSettings() *SplitTunnelSettings { + return &SplitTunnelSettings{ + Mode: SplitTunnelModeOff, + Excluded: NewPackageList(), + Included: NewPackageList(), + } +} + +func packagesOf(list *PackageList) []string { + if list == nil { + return nil + } + out := make([]string, 0, len(list.items)) + out = append(out, list.items...) + return out +} + +// normalizeSplitTunnelMode maps anything outside the known set to off, so a mode +// written by a newer build degrades to carrying every application rather than to +// some other mode's behaviour. +func normalizeSplitTunnelMode(mode SplitTunnelMode) SplitTunnelMode { + switch mode { + case modeExclude, modeInclude: + return mode + default: + return modeOff + } +} + +func settingsFromSection(section splitTunnelSection) *SplitTunnelSettings { + out := NewSplitTunnelSettings() + out.Mode = int(normalizeSplitTunnelMode(section.Mode)) + for _, pkg := range section.Excluded { + out.Excluded.Add(pkg) + } + for _, pkg := range section.Included { + out.Included.Add(pkg) + } + return out +} + +func sectionFromSettings(settings *SplitTunnelSettings) splitTunnelSection { + if settings == nil { + settings = NewSplitTunnelSettings() + } + return splitTunnelSection{ + Mode: normalizeSplitTunnelMode(SplitTunnelMode(settings.Mode)), + Excluded: packagesOf(settings.Excluded), + Included: packagesOf(settings.Included), + } +} diff --git a/client/android/split_tunnel_store.go b/client/android/split_tunnel_store.go new file mode 100644 index 000000000..f54e0c8ef --- /dev/null +++ b/client/android/split_tunnel_store.go @@ -0,0 +1,34 @@ +//go:build android + +package android + +const splitTunnelNamespace = "split-tunnel" + +// SplitTunnelStore reads and writes a profile's split tunnelling settings. +type SplitTunnelStore struct { + prefs prefsStore +} + +// NewSplitTunnelStore opens the split tunnelling store of the given profile. +func NewSplitTunnelStore(configDir, profileID string) (*SplitTunnelStore, error) { + prefs, err := newProfilePrefs(configDir, profileID) + if err != nil { + return nil, err + } + return &SplitTunnelStore{prefs: prefs}, nil +} + +// Load returns the stored settings, or settings that carry every application +// when the profile has none saved. +func (s *SplitTunnelStore) Load() (*SplitTunnelSettings, error) { + var section splitTunnelSection + if _, err := s.prefs.Get(splitTunnelNamespace, §ion); err != nil { + return nil, err + } + return settingsFromSection(section), nil +} + +// Save replaces the stored settings. +func (s *SplitTunnelStore) Save(settings *SplitTunnelSettings) error { + return s.prefs.Put(splitTunnelNamespace, sectionFromSettings(settings)) +} diff --git a/client/android/split_tunnel_test.go b/client/android/split_tunnel_test.go new file mode 100644 index 000000000..bf6e0267e --- /dev/null +++ b/client/android/split_tunnel_test.go @@ -0,0 +1,151 @@ +package android + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestNormalizeSplitTunnelMode(t *testing.T) { + tests := []struct { + name string + mode SplitTunnelMode + want SplitTunnelMode + }{ + {name: "exclude is kept", mode: modeExclude, want: modeExclude}, + {name: "include is kept", mode: modeInclude, want: modeInclude}, + {name: "off is kept", mode: modeOff, want: modeOff}, + {name: "a mode from a newer build falls back to off", mode: SplitTunnelMode(7), want: modeOff}, + {name: "a negative mode falls back to off", mode: SplitTunnelMode(-1), want: modeOff}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := normalizeSplitTunnelMode(tt.mode); got != tt.want { + t.Errorf("normalizeSplitTunnelMode(%d) = %d, want %d", tt.mode, got, tt.want) + } + }) + } +} + +// The constants the Android side reads must stay the values the store writes: +// gomobile carries the ints below, not the typed constants they mirror. +func TestSplitTunnelModeConstantsMirrorTheTypedOnes(t *testing.T) { + if SplitTunnelModeOff != int(modeOff) { + t.Errorf("off = %d, want %d", SplitTunnelModeOff, modeOff) + } + if SplitTunnelModeExclude != int(modeExclude) { + t.Errorf("exclude = %d, want %d", SplitTunnelModeExclude, modeExclude) + } + if SplitTunnelModeInclude != int(modeInclude) { + t.Errorf("include = %d, want %d", SplitTunnelModeInclude, modeInclude) + } +} + +func TestSettingsFromSection(t *testing.T) { + got := settingsFromSection(splitTunnelSection{ + Mode: modeExclude, + Excluded: []string{"com.example.a", "com.example.b"}, + Included: []string{"com.example.c"}, + }) + + if got.Mode != SplitTunnelModeExclude { + t.Errorf("mode = %d, want %d", got.Mode, SplitTunnelModeExclude) + } + if got.Excluded.Size() != 2 || got.Excluded.Get(0) != "com.example.a" { + t.Errorf("excluded = %v, want the two stored packages", packagesOf(got.Excluded)) + } + if got.Included.Size() != 1 || got.Included.Get(0) != "com.example.c" { + t.Errorf("included = %v, want the stored package", packagesOf(got.Included)) + } +} + +// A profile that has never stored anything decodes into an empty section, and +// must come back as settings that carry every application rather than as nil +// lists the caller would have to guard against. +func TestSettingsFromEmptySectionCarriesEverything(t *testing.T) { + got := settingsFromSection(splitTunnelSection{}) + + if got.Mode != SplitTunnelModeOff { + t.Errorf("mode = %d, want %d", got.Mode, SplitTunnelModeOff) + } + if got.Excluded == nil || got.Included == nil { + t.Fatal("both selections must be usable lists, not nil") + } + if got.Excluded.Size() != 0 || got.Included.Size() != 0 { + t.Errorf("selections = %v/%v, want both empty", packagesOf(got.Excluded), packagesOf(got.Included)) + } +} + +// The section is what the profile's preference file holds, so the mode has to +// survive a JSON round trip as the number the constants name. +func TestSectionEncodesTheModeAsItsNumber(t *testing.T) { + raw, err := json.Marshal(sectionFromSettings(&SplitTunnelSettings{Mode: SplitTunnelModeInclude})) + if err != nil { + t.Fatalf("marshal section: %v", err) + } + + var back splitTunnelSection + if err := json.Unmarshal(raw, &back); err != nil { + t.Fatalf("unmarshal section: %v", err) + } + if back.Mode != modeInclude { + t.Errorf("mode = %d, want %d, from %s", back.Mode, modeInclude, raw) + } +} + +func TestSectionFromSettingsRoundTrip(t *testing.T) { + settings := NewSplitTunnelSettings() + settings.Mode = SplitTunnelModeInclude + settings.Included.Add("com.example.a") + settings.Excluded.Add("com.example.b") + + section := sectionFromSettings(settings) + back := settingsFromSection(section) + + if back.Mode != SplitTunnelModeInclude { + t.Errorf("mode = %d, want %d", back.Mode, SplitTunnelModeInclude) + } + if !reflect.DeepEqual(packagesOf(back.Included), []string{"com.example.a"}) { + t.Errorf("included = %v, want [com.example.a]", packagesOf(back.Included)) + } + // The inactive selection survives, so switching mode back does not make the + // user pick their applications again. + if !reflect.DeepEqual(packagesOf(back.Excluded), []string{"com.example.b"}) { + t.Errorf("excluded = %v, want [com.example.b]", packagesOf(back.Excluded)) + } +} + +// A mode the Java side never sets, such as one left by a newer build, must not +// reach the stored section either. +func TestSectionFromSettingsNormalizesAnUnknownMode(t *testing.T) { + section := sectionFromSettings(&SplitTunnelSettings{Mode: 7}) + + if section.Mode != modeOff { + t.Errorf("mode = %d, want %d", section.Mode, modeOff) + } +} + +func TestSectionFromNilSettings(t *testing.T) { + section := sectionFromSettings(nil) + + if section.Mode != modeOff { + t.Errorf("mode = %d, want %d", section.Mode, modeOff) + } + if len(section.Excluded) != 0 || len(section.Included) != 0 { + t.Errorf("selections = %v/%v, want both empty", section.Excluded, section.Included) + } +} + +func TestPackageListIgnoresEmptyAndBounds(t *testing.T) { + list := NewPackageList() + list.Add("com.example.a") + list.Add("") + + if list.Size() != 1 { + t.Errorf("size = %d, want 1", list.Size()) + } + if list.Get(-1) != "" || list.Get(5) != "" { + t.Error("out of range access must return an empty string") + } +} diff --git a/client/android/ssh_client.go b/client/android/ssh_client.go new file mode 100644 index 000000000..2822b6539 --- /dev/null +++ b/client/android/ssh_client.go @@ -0,0 +1,649 @@ +//go:build android + +package android + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "strconv" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" + gossh "golang.org/x/crypto/ssh" + + "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/internal/auth" + "github.com/netbirdio/netbird/client/internal/profilemanager" + nbssh "github.com/netbirdio/netbird/client/ssh" + "github.com/netbirdio/netbird/client/ssh/detection" +) + +const ( + sshDialTimeout = 30 * time.Second + sshDetectionTimeout = 5 * time.Second +) + +// PasswordRequiredMarker tells Java to prompt for a password and retry. It is +// a string because gomobile flattens errors to their message, so a sentinel +// value would not survive the binding. +const PasswordRequiredMarker = "netbird-ssh-password-required" + +// HostKeyUnknownMarker tells Java to show the fingerprint and, on confirmation, +// retry with TrustHostKey set. The presented fingerprint is appended after the +// marker so the prompt can display it and the retry can guard against a key +// that changed between the two connects. Only regular (non-NetBird) servers +// reach this: NetBird peers verify against the registry. +const HostKeyUnknownMarker = "netbird-ssh-hostkey-unknown" + +var ( + errPasswordRequired = errors.New(PasswordRequiredMarker) + errClientClosed = errors.New("ssh client closed") +) + +// errHostKeyUnknown carries the presented fingerprint so Connect can build the +// marker message the Java side parses. +type errHostKeyUnknown struct { + fingerprint string +} + +func (e *errHostKeyUnknown) Error() string { + return HostKeyUnknownMarker + ":" + e.fingerprint +} + +// SSHTerminalListener receives SSH session events. It is implemented in Java. +// +// All callbacks are invoked from goroutines and may run concurrently with each +// other; the implementation must be safe to call from any thread. +type SSHTerminalListener interface { + OnConnected() + OnData(data []byte) + OnClose(reason string) + OnError(message string) +} + +// SSHClient is a NetBird-aware SSH client exposed to Java via gomobile. +// +// It dials through the running NetBird tunnel and runs a standard SSH session +// on top with PTY enabled. Host-key verification uses the NetBird-provided +// peer SSH host keys, identical to the desktop client. +type SSHClient struct { + nb *Client + mu sync.Mutex + listener SSHTerminalListener + urlOpener URLOpener + + sshClient *gossh.Client + session *gossh.Session + stdin io.WriteCloser + closed bool + + // gen identifies the current connection attempt. Connect and Close bump it, + // so an in-flight dial or a reader left over from a previous connection + // finds itself stale and stays silent instead of publishing OnConnected or + // OnClose for a connection the caller already abandoned. + gen uint64 + dialCancel context.CancelFunc + + // knownHostsConfigDir and knownHostsProfile locate the TOFU store for + // regular SSH servers in the profile's preferences. Java supplies them, + // since an overlay IP is a different host under a different profile. Empty + // until set: without them a regular server cannot be verified and Connect + // refuses one. + knownHostsConfigDir string + knownHostsProfile string + // trustHostKey carries the fingerprint the user confirmed on a previous + // attempt, so the retry accepts exactly that key and persists it. + trustHostKey string +} + +// NewSSHClient creates a new SSH client bound to the running NetBird Client. +func NewSSHClient(c *Client) *SSHClient { + return &SSHClient{nb: c} +} + +// SetListener registers the Java listener. Must be called before Connect to +// receive any events. +func (s *SSHClient) SetListener(l SSHTerminalListener) { + s.mu.Lock() + s.listener = l + s.mu.Unlock() +} + +// SetURLOpener registers the Java URL opener used to display the device-code +// authorization page in a Custom Tabs window when the target peer requires +// JWT authentication. Must be set before Connect to be effective. +func (s *SSHClient) SetURLOpener(opener URLOpener) { + s.mu.Lock() + s.urlOpener = opener + s.mu.Unlock() +} + +// SetKnownHostsStore points the TOFU host-key store at a profile's preferences. +// Must be set before connecting to a regular SSH server; without it such a +// server cannot be verified and Connect refuses one. +func (s *SSHClient) SetKnownHostsStore(configDir, profileID string) { + s.mu.Lock() + s.knownHostsConfigDir = configDir + s.knownHostsProfile = profileID + s.mu.Unlock() +} + +// TrustHostKey records the fingerprint the user confirmed for a regular server, +// so the next Connect accepts that exact key and adds it to the known-hosts +// store. Passing a fingerprint that no longer matches makes the connect fail +// rather than trust a key that changed since the prompt. +func (s *SSHClient) TrustHostKey(fingerprint string) { + s.mu.Lock() + s.trustHostKey = fingerprint + s.mu.Unlock() +} + +// Connect dials the SSH server through the NetBird tunnel and performs the +// SSH handshake. It auto-detects the server type via SSH banner inspection +// and selects the appropriate authentication path: +// +// - NetBird-SSH server requiring JWT: launches the OAuth 2.0 device-code +// flow, opens the verification URL through the registered URLOpener, and +// uses the resulting token as the SSH password. Host-key verification +// uses the NetBird peer registry. +// - NetBird-SSH server without JWT: authenticates with the NetBird SSH +// private key. Host-key verification uses the NetBird peer registry. +// - Regular SSH server (e.g. OpenSSH): authenticates with the NetBird key +// first (so a user-installed NetBird public key works), then falls back +// to the supplied password if non-empty. Host-key verification is +// trust-on-first-use against the per-profile known-hosts store. +// +// The password parameter is only consulted for regular SSH servers. +func (s *SSHClient) Connect(host string, port int, user, password string) error { + if port < 1 || port > 65535 { + return fmt.Errorf("invalid port: %d", port) + } + + cfg, cfgPath, cc := s.nb.authSnapshot() + if cc == nil { + return errors.New("netbird client not running") + } + if cfg == nil { + return errors.New("netbird config not loaded") + } + engine := cc.Engine() + if engine == nil { + return errors.New("netbird engine not available") + } + + s.mu.Lock() + s.gen++ + gen := s.gen + s.mu.Unlock() + + serverType := detectServerType(host, port) + log.Debugf("SSH server type: %s", serverType) + + authMethods, hostKeyCallback, err := s.buildAuth(cfg, cfgPath, engine, serverType, password) + if err != nil { + return err + } + + clientConfig := &gossh.ClientConfig{ + User: user, + Auth: authMethods, + HostKeyCallback: hostKeyCallback, + Timeout: sshDialTimeout, + } + err = s.dialAndHandshake(gen, host, port, clientConfig) + + // An unknown host key is a prompt, not a failure: return the marker intact + // (rootCause would unwrap it) so Java can show the fingerprint and retry. + var unknownHost *errHostKeyUnknown + if errors.As(err, &unknownHost) { + return errors.New(unknownHost.Error()) + } + + // A regular server may still accept a password, so let the caller ask for + // one instead of failing. NetBird servers never use a password, so a + // failure there is genuine. + if err != nil && serverType != detection.ServerTypeNetBirdJWT && + serverType != detection.ServerTypeNetBirdNoJWT && isAuthFailure(err) && + passwordCouldHelp(err, password != "") { + return errPasswordRequired + } + if err != nil { + return rootCause(err) + } + return nil +} + +// StartSession requests a PTY and starts an interactive shell. Output from +// the session is forwarded to the listener via OnData. +func (s *SSHClient) StartSession(cols, rows int) error { + err := s.startSession(cols, rows) + if err != nil { + log.Infof("SSH: start session failed: %v", err) + return rootCause(err) + } + return nil +} + +// Write sends data to the SSH session stdin. +func (s *SSHClient) Write(data []byte) error { + s.mu.Lock() + stdin := s.stdin + s.mu.Unlock() + if stdin == nil { + return errors.New("ssh session not started") + } + if _, err := stdin.Write(data); err != nil { + return fmt.Errorf("write stdin: %w", err) + } + return nil +} + +// Resize updates the PTY window size. +func (s *SSHClient) Resize(cols, rows int) error { + s.mu.Lock() + session := s.session + s.mu.Unlock() + if session == nil { + return errors.New("ssh session not started") + } + return session.WindowChange(rows, cols) +} + +// Reset makes a closed client usable for another Connect: Close leaves the +// one-shot guard set, and clearing it lets the same client back a reconnect. +func (s *SSHClient) Reset() { + s.mu.Lock() + defer s.mu.Unlock() + s.closed = false +} + +// Close terminates the SSH session and underlying connection. Safe to call +// multiple times. +func (s *SSHClient) Close() error { + s.mu.Lock() + s.gen++ + if s.dialCancel != nil { + s.dialCancel() + s.dialCancel = nil + } + sshClient := s.sshClient + session := s.session + stdin := s.stdin + s.sshClient = nil + s.session = nil + s.stdin = nil + notify := !s.closed + s.closed = true + listener := s.listener + s.mu.Unlock() + + if stdin != nil { + if err := stdin.Close(); err != nil { + log.Debugf("ssh: stdin close: %v", err) + } + } + if session != nil { + if err := session.Close(); err != nil && !errors.Is(err, io.EOF) { + log.Debugf("ssh: session close: %v", err) + } + } + var firstErr error + if sshClient != nil { + if err := sshClient.Close(); err != nil { + firstErr = err + } + } + if notify && listener != nil { + listener.OnClose("closed by client") + } + return firstErr +} + +func (s *SSHClient) startSession(cols, rows int) error { + log.Debugf("SSH: starting session %dx%d", cols, rows) + s.mu.Lock() + sshClient := s.sshClient + gen := s.gen + s.mu.Unlock() + + if sshClient == nil { + return errors.New("ssh client not connected") + } + + pty, err := nbssh.StartPTYSession(sshClient, cols, rows) + if err != nil { + return err + } + + s.mu.Lock() + if gen != s.gen { + s.mu.Unlock() + closeQuiet(pty.Session, "stale session") + return errClientClosed + } + s.session = pty.Session + s.stdin = pty.Stdin + s.mu.Unlock() + + readerDone := make(chan string, 2) + go func() { readerDone <- s.readLoop(pty.Stdout, "stdout") }() + go func() { readerDone <- s.readLoop(pty.Stderr, "stderr") }() + go func() { + reason := <-readerDone + if second := <-readerDone; reason == "" { + reason = second + } + s.notifyClose(gen, reason) + }() + log.Debug("SSH: session started, shell running") + return nil +} + +func (s *SSHClient) buildAuth(cfg *profilemanager.Config, cfgPath string, engine *internal.Engine, + serverType detection.ServerType, password string) ([]gossh.AuthMethod, gossh.HostKeyCallback, error) { + + switch serverType { + case detection.ServerTypeNetBirdJWT: + token, err := s.requestJWTToken(cfg, cfgPath) + if err != nil { + return nil, nil, fmt.Errorf("jwt: %w", err) + } + auths := []gossh.AuthMethod{gossh.Password(token)} + return auths, nbssh.CreateHostKeyCallback(nbssh.PeerKeyLookup(engine.GetPeerSSHKey)), nil + + case detection.ServerTypeNetBirdNoJWT: + if cfg.SSHKey == "" { + return nil, nil, errors.New("no NetBird SSH key available") + } + signer, err := gossh.ParsePrivateKey([]byte(cfg.SSHKey)) + if err != nil { + return nil, nil, fmt.Errorf("parse netbird ssh key: %w", err) + } + auths := []gossh.AuthMethod{gossh.PublicKeys(signer)} + return auths, nbssh.CreateHostKeyCallback(nbssh.PeerKeyLookup(engine.GetPeerSSHKey)), nil + + case detection.ServerTypeRegular: + var auths []gossh.AuthMethod + if cfg.SSHKey != "" { + if signer, err := gossh.ParsePrivateKey([]byte(cfg.SSHKey)); err == nil { + auths = append(auths, gossh.PublicKeys(signer)) + } else { + log.Debugf("ssh: parse netbird key for regular auth: %v", err) + } + } + if password != "" { + pw := password + auths = append(auths, gossh.Password(pw)) + auths = append(auths, gossh.KeyboardInteractive(func(_, _ string, questions []string, _ []bool) ([]string, error) { + answers := make([]string, len(questions)) + for i := range questions { + answers[i] = pw + } + return answers, nil + })) + } + if len(auths) == 0 { + // Nothing to offer at all: ask for a password rather than failing, + // so the caller can retry once the user supplies one. + return nil, nil, errPasswordRequired + } + callback, err := s.tofuHostKeyCallback() + if err != nil { + return nil, nil, err + } + return auths, callback, nil + + default: + return nil, nil, fmt.Errorf("unsupported SSH server type: %v", serverType) + } +} + +// tofuHostKeyCallback verifies a regular server's host key against the +// per-profile known-hosts store. An unknown host returns errHostKeyUnknown so +// Java can show the fingerprint and, once confirmed, retry with the key +// trusted; a changed key is rejected outright, as OpenSSH does. When the user +// has confirmed a fingerprint, the callback accepts exactly that key and +// appends it to the store. +func (s *SSHClient) tofuHostKeyCallback() (gossh.HostKeyCallback, error) { + s.mu.Lock() + configDir := s.knownHostsConfigDir + profileID := s.knownHostsProfile + trusted := s.trustHostKey + s.mu.Unlock() + + if configDir == "" || profileID == "" { + return nil, errors.New("no known-hosts store configured for regular SSH") + } + + store, err := openKnownHostsStore(configDir, profileID) + if err != nil { + return nil, fmt.Errorf("load known-hosts store: %w", err) + } + + return func(hostname string, remote net.Addr, key gossh.PublicKey) error { + verdict, err := store.verify(hostname, remote, key) + if err != nil { + return err + } + if verdict == hostKeyMatched { + return nil + } + if verdict == hostKeyChanged { + return fmt.Errorf("SSH host key changed for %s (possible attack)", hostname) + } + + fingerprint := gossh.FingerprintSHA256(key) + if trusted == "" { + return &errHostKeyUnknown{fingerprint: fingerprint} + } + if trusted != fingerprint { + return fmt.Errorf("SSH host key changed since it was confirmed for %s", hostname) + } + if err := store.append(hostname, remote, key); err != nil { + return fmt.Errorf("persist trusted host key: %w", err) + } + // The confirmation is spent: now that the key is stored, a later + // reconnect must verify against the file, not re-accept this fingerprint. + s.mu.Lock() + s.trustHostKey = "" + s.mu.Unlock() + return nil + }, nil +} + +func (s *SSHClient) requestJWTToken(cfg *profilemanager.Config, cfgPath string) (string, error) { + s.mu.Lock() + urlOpener := s.urlOpener + s.mu.Unlock() + if urlOpener == nil { + return "", errors.New("URL opener not configured for JWT auth") + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + flow, err := auth.NewOAuthFlow(ctx, cfg, false, true, profileLoginHint(cfgPath)) + if err != nil { + return "", fmt.Errorf("create oauth flow: %w", err) + } + + // The status callback covers the browser round-trip, which would + // otherwise leave the terminal blank. + tokenInfo, err := runOAuthFlow(ctx, flow, urlOpener, func() { + s.notifyStatus("Waiting for browser authentication...") + }) + if err != nil { + return "", err + } + + token := tokenInfo.GetTokenToUse() + if token == "" { + return "", errors.New("empty token returned by IdP") + } + + // Tells the client the browser round-trip is over so it can dismiss the + // surface it opened, the same way the login and session-extend flows do. + // Without it the Custom Tab stays in front of the terminal even though the + // token has already been collected. + urlOpener.OnLoginSuccess() + + return token, nil +} + +func (s *SSHClient) dialAndHandshake(gen uint64, host string, port int, clientConfig *gossh.ClientConfig) error { + addr := net.JoinHostPort(host, strconv.Itoa(port)) + ctx, cancel := context.WithTimeout(context.Background(), sshDialTimeout) + defer cancel() + + s.mu.Lock() + if gen != s.gen { + s.mu.Unlock() + return errClientClosed + } + s.dialCancel = cancel + s.mu.Unlock() + + var dialer net.Dialer + conn, err := dialer.DialContext(ctx, "tcp", addr) + if err != nil { + return fmt.Errorf("dial %s: %w", addr, err) + } + + client, err := nbssh.Handshake(ctx, conn, addr, clientConfig) + if err != nil { + return err + } + + s.mu.Lock() + if gen != s.gen { + s.mu.Unlock() + closeQuiet(client, "stale ssh client") + return errClientClosed + } + s.sshClient = client + listener := s.listener + s.mu.Unlock() + + if listener != nil { + listener.OnConnected() + } + return nil +} + +func (s *SSHClient) readLoop(r io.Reader, name string) string { + buf := make([]byte, 4096) + for { + n, err := r.Read(buf) + if n > 0 { + s.mu.Lock() + listener := s.listener + s.mu.Unlock() + if listener != nil { + chunk := make([]byte, n) + copy(chunk, buf[:n]) + listener.OnData(chunk) + } + } + if err != nil { + // EOF is a normal shell exit, so report it without a reason. + if errors.Is(err, io.EOF) { + return "" + } + log.Debugf("ssh %s read: %v", name, err) + return rootCause(err).Error() + } + } +} + +// notifyStatus writes a progress line to the terminal through the normal +// output path, so long steps are visible while nothing else is arriving. +func (s *SSHClient) notifyStatus(text string) { + s.mu.Lock() + listener := s.listener + s.mu.Unlock() + if listener != nil { + listener.OnData([]byte("\r\n\x1b[33m" + text + "\x1b[0m\r\n")) + } +} + +func (s *SSHClient) notifyClose(gen uint64, reason string) { + s.mu.Lock() + if gen != s.gen || s.closed { + s.mu.Unlock() + return + } + s.closed = true + listener := s.listener + s.mu.Unlock() + if listener != nil { + listener.OnClose(reason) + } +} + +func closeQuiet(c io.Closer, label string) { + if c == nil { + return + } + if err := c.Close(); err != nil && !errors.Is(err, io.EOF) { + log.Debugf("ssh: close %s: %v", label, err) + } +} + +func detectServerType(host string, port int) detection.ServerType { + ctx, cancel := context.WithTimeout(context.Background(), sshDetectionTimeout) + defer cancel() + + dialer := &net.Dialer{} + serverType, err := detection.DetectSSHServerType(ctx, dialer, host, port) + if err != nil { + log.Debugf("ssh: server detection failed: %v (assuming regular SSH)", err) + return detection.ServerTypeRegular + } + return serverType +} + +// rootCause returns the innermost error of a %w chain, so the terminal shows +// "i/o timeout" rather than every layer that added context on the way up. +func rootCause(err error) error { + for { + // A joined error has no single root, so keep it as-is. + if _, ok := err.(interface{ Unwrap() []error }); ok { + return err + } + next := errors.Unwrap(err) + if next == nil { + return err + } + err = next + } +} + +// isAuthFailure distinguishes credential rejection from dial, timeout and +// host-key errors, which retrying with a password would not fix. +func isAuthFailure(err error) bool { + if errors.Is(err, errPasswordRequired) { + return true + } + var partial *gossh.PartialSuccessError + if errors.As(err, &partial) { + return true + } + return strings.Contains(err.Error(), "unable to authenticate") +} + +// passwordCouldHelp reports whether prompting for a password again can change +// the outcome. gossh lists a method under "attempted methods" only when the +// server offered it, so a supplied password that was never attempted means the +// server does not accept passwords and the real error should surface instead. +func passwordCouldHelp(err error, passwordOffered bool) bool { + if !passwordOffered { + return true + } + msg := err.Error() + return strings.Contains(msg, "password") || strings.Contains(msg, "keyboard-interactive") +} diff --git a/client/android/ssh_known_hosts.go b/client/android/ssh_known_hosts.go new file mode 100644 index 000000000..eea90fd32 --- /dev/null +++ b/client/android/ssh_known_hosts.go @@ -0,0 +1,168 @@ +//go:build android + +package android + +import ( + "bytes" + "net" + "strconv" + "strings" + "sync" + + gossh "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" +) + +const knownHostsNamespace = "ssh" + +const ( + hostKeyUnknown hostKeyVerdict = iota + hostKeyMatched + hostKeyChanged +) + +var knownHostsMu sync.Mutex + +type hostKeyVerdict uint8 + +type knownHostsSection struct { + KnownHosts []string `json:"knownHosts"` +} + +type knownHostsStore struct { + prefs prefsStore +} + +// RemoveKnownHost deletes every known-hosts entry for host:port from the +// profile's store, so a host trusted for a session that is being deleted does +// not linger. Java calls this only once no session targets that host, so a +// shared host stays trusted. A missing entry is not an error: the goal state +// is "absent". +func RemoveKnownHost(configDir, profileID, host string, port int) error { + store, err := openKnownHostsStore(configDir, profileID) + if err != nil { + return err + } + return store.removeHost(host, port) +} + +func openKnownHostsStore(configDir, profileID string) (*knownHostsStore, error) { + prefs, err := newProfilePrefs(configDir, profileID) + if err != nil { + return nil, err + } + return &knownHostsStore{prefs: prefs}, nil +} + +func (st *knownHostsStore) verify(hostname string, remote net.Addr, key gossh.PublicKey) (hostKeyVerdict, error) { + lines, err := st.lines() + if err != nil { + return hostKeyUnknown, err + } + targets := knownHostsTargets(hostname, remote) + + verdict := hostKeyUnknown + for _, line := range lines { + pubKey, ok := knownHostsLineKey(line, targets) + if !ok { + continue + } + if pubKey.Type() == key.Type() && bytes.Equal(pubKey.Marshal(), key.Marshal()) { + return hostKeyMatched, nil + } + verdict = hostKeyChanged + } + return verdict, nil +} + +func (st *knownHostsStore) append(hostname string, remote net.Addr, key gossh.PublicKey) error { + line := knownhosts.Line(knownHostsTargets(hostname, remote), key) + + knownHostsMu.Lock() + defer knownHostsMu.Unlock() + + lines, err := st.lines() + if err != nil { + return err + } + return st.prefs.Put(knownHostsNamespace, knownHostsSection{KnownHosts: append(lines, line)}) +} + +func (st *knownHostsStore) removeHost(host string, port int) error { + target := knownhosts.Normalize(net.JoinHostPort(host, strconv.Itoa(port))) + + knownHostsMu.Lock() + defer knownHostsMu.Unlock() + + lines, err := st.lines() + if err != nil { + return err + } + kept := make([]string, 0, len(lines)) + for _, line := range lines { + if knownHostsLineMatches(line, target) { + continue + } + kept = append(kept, line) + } + if len(kept) == len(lines) { + return nil + } + return st.prefs.Put(knownHostsNamespace, knownHostsSection{KnownHosts: kept}) +} + +func (st *knownHostsStore) lines() ([]string, error) { + var section knownHostsSection + if _, err := st.prefs.Get(knownHostsNamespace, §ion); err != nil { + return nil, err + } + return section.KnownHosts, nil +} + +func knownHostsTargets(hostname string, remote net.Addr) []string { + targets := []string{knownhosts.Normalize(hostname)} + if remote != nil { + if normalized := knownhosts.Normalize(remote.String()); normalized != targets[0] { + targets = append(targets, normalized) + } + } + return targets +} + +func knownHostsLineKey(line string, targets []string) (gossh.PublicKey, bool) { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + return nil, false + } + _, hosts, pubKey, _, _, err := gossh.ParseKnownHosts([]byte(trimmed)) + if err != nil { + return nil, false + } + for _, host := range hosts { + for _, target := range targets { + if host == target { + return pubKey, true + } + } + } + return nil, false +} + +// knownHostsLineMatches reports whether a known-hosts line's address list +// contains the normalized target. Comment and blank lines never match. +func knownHostsLineMatches(line, target string) bool { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + return false + } + fields := strings.Fields(trimmed) + if len(fields) == 0 { + return false + } + for _, addr := range strings.Split(fields[0], ",") { + if addr == target { + return true + } + } + return false +} diff --git a/client/android/ssh_sessions.go b/client/android/ssh_sessions.go new file mode 100644 index 000000000..44b5464e9 --- /dev/null +++ b/client/android/ssh_sessions.go @@ -0,0 +1,104 @@ +//go:build android + +package android + +const ( + sshSessionsNamespace = "ssh-sessions" + maxStoredSSHSessions = 50 +) + +type sshSessionRecord struct { + ID string `json:"id"` + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` +} + +type sshSessionsSection struct { + Sessions []sshSessionRecord `json:"sessions"` +} + +// SSHSessionEntry is one stored SSH session, without any credential. +type SSHSessionEntry struct { + ID string + Host string + Port int + User string +} + +// SSHSessionArray wraps stored SSH sessions for gomobile compatibility. +type SSHSessionArray struct { + items []*SSHSessionEntry +} + +// NewSSHSessionArray creates an empty session array to fill via Add. +func NewSSHSessionArray() *SSHSessionArray { + return &SSHSessionArray{} +} + +// Add appends a session entry, oldest first. +func (a *SSHSessionArray) Add(id, host string, port int, user string) { + a.items = append(a.items, &SSHSessionEntry{ID: id, Host: host, Port: port, User: user}) +} + +// Length returns the number of entries. +func (a *SSHSessionArray) Length() int { + return len(a.items) +} + +// Get returns the entry at index i, or nil when out of range. +func (a *SSHSessionArray) Get(i int) *SSHSessionEntry { + if i < 0 || i >= len(a.items) { + return nil + } + return a.items[i] +} + +// SSHSessionStore reads and writes a profile's stored SSH sessions. +type SSHSessionStore struct { + prefs prefsStore +} + +// NewSSHSessionStore opens the session store of the given profile. +func NewSSHSessionStore(configDir, profileID string) (*SSHSessionStore, error) { + prefs, err := newProfilePrefs(configDir, profileID) + if err != nil { + return nil, err + } + return &SSHSessionStore{prefs: prefs}, nil +} + +// Load returns the stored sessions, oldest first. +func (s *SSHSessionStore) Load() (*SSHSessionArray, error) { + var section sshSessionsSection + if _, err := s.prefs.Get(sshSessionsNamespace, §ion); err != nil { + return nil, err + } + + out := NewSSHSessionArray() + for _, record := range section.Sessions { + if record.ID == "" || record.Host == "" { + continue + } + out.Add(record.ID, record.Host, record.Port, record.User) + } + return out, nil +} + +// Save replaces the stored sessions, keeping only the newest entries when the +// list exceeds the storage cap. +func (s *SSHSessionStore) Save(sessions *SSHSessionArray) error { + var items []*SSHSessionEntry + if sessions != nil { + items = sessions.items + } + if len(items) > maxStoredSSHSessions { + items = items[len(items)-maxStoredSSHSessions:] + } + + records := make([]sshSessionRecord, 0, len(items)) + for _, item := range items { + records = append(records, sshSessionRecord{ID: item.ID, Host: item.Host, Port: item.Port, User: item.User}) + } + return s.prefs.Put(sshSessionsNamespace, sshSessionsSection{Sessions: records}) +} diff --git a/client/anonymize/anonymize.go b/client/anonymize/anonymize.go index 89e653300..c5d43ed55 100644 --- a/client/anonymize/anonymize.go +++ b/client/anonymize/anonymize.go @@ -2,6 +2,7 @@ package anonymize import ( "crypto/rand" + "encoding/base64" "fmt" "math/big" "net" @@ -9,46 +10,148 @@ import ( "net/url" "regexp" "slices" + "strconv" "strings" ) const anonTLD = ".domain" +// Level selects how much the anonymizer redacts. Levels are ordered: a higher +// level redacts strictly more. On the wire (protos, flags) levels travel as +// their string form. +type Level int + +const ( + // LevelDefault anonymizes public IP addresses, IPv6 ULA, domains, and MAC + // addresses. Internal IPv4 ranges (RFC 1918, CGNAT, link-local) are + // preserved so support can reason about the real topology. + LevelDefault Level = iota + // LevelStrict additionally anonymizes internal IP ranges, peer names, and + // WireGuard public keys. + LevelStrict +) + +// LevelDefaultString and LevelStrictString are the wire forms of the levels, +// for boundaries that pass levels as strings (flags, protos, mobile bindings). +const ( + LevelDefaultString = "default" + LevelStrictString = "strict" +) + +// ParseLevel maps s to a Level. Empty means LevelDefault; anything +// unrecognized maps to LevelStrict so an unknown request never yields less +// anonymization than intended. +func ParseLevel(s string) Level { + switch strings.ToLower(s) { + case "", LevelDefaultString: + return LevelDefault + default: + return LevelStrict + } +} + +// String returns the wire form of the level: "default" or "strict". +func (l Level) String() string { + if l >= LevelStrict { + return LevelStrictString + } + return LevelDefaultString +} + +// protectedDomains are NetBird-operated suffixes that stay recognizable in an +// anonymized bundle. At LevelStrict the labels in front of them (the peer +// name) are still replaced, except under netbird.io, which only hosts +// NetBird infrastructure (api, signal, flow), never peer names. +var protectedDomains = []string{"netbird.io", "netbird.selfhosted", "netbird.cloud", "netbird.stage"} + +const infraDomain = "netbird.io" + +var ( + macColonRegex = regexp.MustCompile(`\b[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5}\b`) + macDashRegex = regexp.MustCompile(`\b[0-9a-fA-F]{2}(?:-[0-9a-fA-F]{2}){5}\b`) + wgKeyRegex = regexp.MustCompile(`\b[A-Za-z0-9+/]{43}=`) +) + type Anonymizer struct { ipAnonymizer map[netip.Addr]netip.Addr domainAnonymizer map[string]string - currentAnonIPv4 netip.Addr - currentAnonIPv6 netip.Addr - startAnonIPv4 netip.Addr - startAnonIPv6 netip.Addr + // domainOrder caches the keys of domainAnonymizer sorted longest-first + // for AnonymizeString; it is rebuilt when the map gains entries. + domainOrder []string + labelAnonymizer map[string]string + labelAnonymized map[string]struct{} + labelCounter uint32 + macAnonymizer map[string]string + macCounter uint32 + wgKeyAnonymizer map[string]string + wgKeyAnonymized map[string]struct{} + currentAnonIPv4 netip.Addr + currentAnonIPv6 netip.Addr + startAnonIPv4 netip.Addr + startAnonIPv6 netip.Addr + + // LevelStrict also anonymizes internal ranges (RFC 1918, CGNAT, + // link-local), replacing them from the dedicated internal pools below so + // a reader can still tell an internal address from a public one. + level Level + currentAnonInternalIPv4 netip.Addr + currentAnonInternalIPv6 netip.Addr + startAnonInternalIPv4 netip.Addr + startAnonInternalIPv6 netip.Addr domainKeyRegex *regexp.Regexp } func DefaultAddresses() (netip.Addr, netip.Addr) { - // 198.51.100.0, 100:: - return netip.AddrFrom4([4]byte{198, 51, 100, 0}), netip.AddrFrom16([16]byte{0x01}) + // 198.51.100.0 (RFC 5737 TEST-NET-2), 2001:db8:ffff:: (RFC 3849 documentation, last /48) + // The old start 100:: (discard, RFC 6666) is now used for fake IPs on Android. + return netip.AddrFrom4([4]byte{198, 51, 100, 0}), netip.MustParseAddr("2001:db8:ffff::") +} + +// InternalAddresses returns the pool starts used in strict mode for internal +// ranges. Both are reserved ranges that cannot collide with real addressing: +// 198.18.0.0 (RFC 2544 benchmarking), 2001:db8:1:: (RFC 3849 documentation). +func InternalAddresses() (netip.Addr, netip.Addr) { + return netip.AddrFrom4([4]byte{198, 18, 0, 0}), netip.MustParseAddr("2001:db8:1::") } func NewAnonymizer(startIPv4, startIPv6 netip.Addr) *Anonymizer { + internalIPv4, internalIPv6 := InternalAddresses() return &Anonymizer{ ipAnonymizer: map[netip.Addr]netip.Addr{}, domainAnonymizer: map[string]string{}, + labelAnonymizer: map[string]string{}, + labelAnonymized: map[string]struct{}{}, + macAnonymizer: map[string]string{}, + wgKeyAnonymizer: map[string]string{}, + wgKeyAnonymized: map[string]struct{}{}, currentAnonIPv4: startIPv4, currentAnonIPv6: startIPv6, startAnonIPv4: startIPv4, startAnonIPv6: startIPv6, + level: LevelDefault, + currentAnonInternalIPv4: internalIPv4, + currentAnonInternalIPv6: internalIPv6, + startAnonInternalIPv4: internalIPv4, + startAnonInternalIPv6: internalIPv6, + domainKeyRegex: regexp.MustCompile(`\bdomain=([^\s,:"]+)`), } } +// SetLevel selects the anonymization level. The zero value of a new +// Anonymizer is LevelDefault. +func (a *Anonymizer) SetLevel(level Level) { + a.level = level +} + func (a *Anonymizer) AnonymizeIP(ip netip.Addr) netip.Addr { + // Normalize 4-in-6 addresses so ::ffff:192.168.1.1 classifies and maps + // like 192.168.1.1. + ip = ip.Unmap() + if ip.IsLoopback() || - ip.IsLinkLocalUnicast() || - ip.IsLinkLocalMulticast() || - ip.IsInterfaceLocalMulticast() || - ip.IsPrivate() || ip.IsUnspecified() || ip.IsMulticast() || isWellKnown(ip) || @@ -57,18 +160,100 @@ func (a *Anonymizer) AnonymizeIP(ip netip.Addr) netip.Addr { return ip } + if isInternal(ip) && a.level < LevelStrict { + return ip + } + if _, ok := a.ipAnonymizer[ip]; !ok { - if ip.Is4() { - a.ipAnonymizer[ip] = a.currentAnonIPv4 - a.currentAnonIPv4 = a.currentAnonIPv4.Next() - } else { - a.ipAnonymizer[ip] = a.currentAnonIPv6 - a.currentAnonIPv6 = a.currentAnonIPv6.Next() - } + a.ipAnonymizer[ip] = a.nextAnonIP(ip) } return a.ipAnonymizer[ip] } +func (a *Anonymizer) nextAnonIP(ip netip.Addr) netip.Addr { + // At the strict level, internal addresses (including IPv6 ULA, matched + // by IsPrivate) come from the internal pools so they remain recognizable + // as internal without disclosing the real values. + if a.level >= LevelStrict && (isInternal(ip) || ip.IsPrivate()) { + if ip.Is4() { + anon := a.currentAnonInternalIPv4 + a.currentAnonInternalIPv4 = a.currentAnonInternalIPv4.Next() + return anon + } + anon := a.currentAnonInternalIPv6 + a.currentAnonInternalIPv6 = a.currentAnonInternalIPv6.Next() + return anon + } + + if ip.Is4() { + anon := a.currentAnonIPv4 + a.currentAnonIPv4 = a.currentAnonIPv4.Next() + return anon + } + anon := a.currentAnonIPv6 + a.currentAnonIPv6 = a.currentAnonIPv6.Next() + return anon +} + +// AnonymizeMAC replaces a MAC address with a consistent placeholder from the +// locally administered range starting at 02:00:00:00:00:01, at every +// anonymization level. Broadcast, multicast, all-zero, and already assigned +// placeholder addresses are preserved. The colon and dash spellings of the +// same address share one placeholder; the output keeps the input's separator. +func (a *Anonymizer) AnonymizeMAC(mac string) string { + hw, err := net.ParseMAC(mac) + if err != nil || len(hw) != 6 { + return mac + } + + if isWellKnownMAC(hw) || a.isAnonymizedMAC(hw) { + return mac + } + + key := hw.String() + anon, ok := a.macAnonymizer[key] + if !ok { + a.macCounter++ + anon = fmt.Sprintf("02:00:00:%02x:%02x:%02x", byte(a.macCounter>>16), byte(a.macCounter>>8), byte(a.macCounter)) + a.macAnonymizer[key] = anon + } + + if strings.Contains(mac, "-") { + anon = strings.ReplaceAll(anon, ":", "-") + } + return anon +} + +// isAnonymizedMAC reports whether hw is a placeholder this anonymizer already +// handed out, so a second pass over anonymized output leaves it unchanged. +func (a *Anonymizer) isAnonymizedMAC(hw net.HardwareAddr) bool { + if hw[0] != 0x02 || hw[1] != 0 || hw[2] != 0 { + return false + } + value := uint32(hw[3])<<16 | uint32(hw[4])<<8 | uint32(hw[5]) + return value <= a.macCounter +} + +// AnonymizeWGKey replaces a WireGuard public key with a consistent random +// placeholder of the same shape. Keys are only anonymized at LevelStrict; +// placeholders already handed out pass through unchanged. +func (a *Anonymizer) AnonymizeWGKey(key string) string { + if a.level < LevelStrict || !looksLikeWGKey(key) { + return key + } + if _, ok := a.wgKeyAnonymized[key]; ok { + return key + } + + anon, ok := a.wgKeyAnonymizer[key] + if !ok { + anon = generateAnonymousKey() + a.wgKeyAnonymizer[key] = anon + a.wgKeyAnonymized[anon] = struct{}{} + } + return anon +} + func (a *Anonymizer) AnonymizeUDPAddr(addr net.UDPAddr) net.UDPAddr { // Convert IP to netip.Addr ip, ok := netip.AddrFromSlice(addr.IP) @@ -87,15 +272,20 @@ func (a *Anonymizer) AnonymizeUDPAddr(addr net.UDPAddr) net.UDPAddr { // isInAnonymizedRange checks if an IP is within the range of already assigned anonymized IPs func (a *Anonymizer) isInAnonymizedRange(ip netip.Addr) bool { - if ip.Is4() && ip.Compare(a.startAnonIPv4) >= 0 && ip.Compare(a.currentAnonIPv4) <= 0 { - return true - } else if !ip.Is4() && ip.Compare(a.startAnonIPv6) >= 0 && ip.Compare(a.currentAnonIPv6) <= 0 { - return true + if ip.Is4() { + return inPoolRange(ip, a.startAnonIPv4, a.currentAnonIPv4) || + inPoolRange(ip, a.startAnonInternalIPv4, a.currentAnonInternalIPv4) } - return false + return inPoolRange(ip, a.startAnonIPv6, a.currentAnonIPv6) || + inPoolRange(ip, a.startAnonInternalIPv6, a.currentAnonInternalIPv6) } func (a *Anonymizer) AnonymizeIPString(ip string) string { + // Handle CIDR notation (e.g. "2001:db8::/32") + if prefix, err := netip.ParsePrefix(ip); err == nil { + return a.AnonymizeIP(prefix.Addr()).String() + "/" + strconv.Itoa(prefix.Bits()) + } + addr, err := netip.ParseAddr(ip) if err != nil { return ip @@ -111,14 +301,23 @@ func (a *Anonymizer) AnonymizeDomain(domain string) string { baseDomain = domain[:len(domain)-1] } - if strings.HasSuffix(baseDomain, "netbird.io") || - strings.HasSuffix(baseDomain, "netbird.selfhosted") || - strings.HasSuffix(baseDomain, "netbird.cloud") || - strings.HasSuffix(baseDomain, "netbird.stage") || - strings.HasSuffix(baseDomain, anonTLD) { + if strings.HasSuffix(baseDomain, anonTLD) { return domain } + // A reverse zone names an address prefix, so it follows the address rules, + // which also keeps its digit labels intact. + if zone, ok := a.anonymizeReverseZone(baseDomain); ok { + return withTrailingDot(zone, hasDot) + } + + if suffix := protectedSuffix(baseDomain); suffix != "" { + if a.level < LevelStrict || baseDomain == suffix || suffix == infraDomain { + return domain + } + return withTrailingDot(a.anonymizePeerName(baseDomain, suffix), hasDot) + } + parts := strings.Split(baseDomain, ".") if len(parts) < 2 { return domain @@ -134,12 +333,53 @@ func (a *Anonymizer) AnonymizeDomain(domain string) string { } result := strings.Replace(baseDomain, baseForLookup, anonymized, 1) - if hasDot { - result += "." + if a.level >= LevelStrict && len(parts) > 2 { + prefix := strings.TrimSuffix(baseDomain, "."+baseForLookup) + result = a.anonymizeLabels(prefix, "host") + "." + anonymized + // The full mapping feeds AnonymizeString so seeded FQDNs are caught + // in log lines as a whole, labels included. + a.domainAnonymizer[baseDomain] = result + } + return withTrailingDot(result, hasDot) +} + +// anonymizePeerName replaces the labels in front of a protected suffix with +// numbered peer placeholders, keeping the suffix, and records the full +// mapping for string replacement in logs. The numbering keeps a peer +// recognizable across the whole bundle without disclosing its name. +func (a *Anonymizer) anonymizePeerName(baseDomain, suffix string) string { + prefix := strings.TrimSuffix(baseDomain, "."+suffix) + result := a.anonymizeLabels(prefix, "peer") + "." + suffix + if result != baseDomain { + a.domainAnonymizer[baseDomain] = result } return result } +// anonymizeLabels replaces each dot-separated label with a consistent +// numbered placeholder ("-"). Wildcard labels and +// placeholders already handed out pass through unchanged. +func (a *Anonymizer) anonymizeLabels(prefix, placeholder string) string { + labels := strings.Split(prefix, ".") + for i, label := range labels { + if label == "*" { + continue + } + if _, ok := a.labelAnonymized[label]; ok { + continue + } + anon, ok := a.labelAnonymizer[label] + if !ok { + a.labelCounter++ + anon = fmt.Sprintf("%s-%d", placeholder, a.labelCounter) + a.labelAnonymizer[label] = anon + a.labelAnonymized[anon] = struct{}{} + } + labels[i] = anon + } + return strings.Join(labels, ".") +} + func (a *Anonymizer) AnonymizeURI(uri string) string { u, err := url.Parse(uri) if err != nil { @@ -150,7 +390,7 @@ func (a *Anonymizer) AnonymizeURI(uri string) string { if u.Opaque != "" { host, port, err := net.SplitHostPort(u.Opaque) if err == nil { - anonymizedHost = fmt.Sprintf("%s:%s", a.AnonymizeDomain(host), port) + anonymizedHost = net.JoinHostPort(a.AnonymizeDomain(host), port) } else { anonymizedHost = a.AnonymizeDomain(u.Opaque) } @@ -158,7 +398,7 @@ func (a *Anonymizer) AnonymizeURI(uri string) string { } else if u.Host != "" { host, port, err := net.SplitHostPort(u.Host) if err == nil { - anonymizedHost = fmt.Sprintf("%s:%s", a.AnonymizeDomain(host), port) + anonymizedHost = net.JoinHostPort(a.AnonymizeDomain(host), port) } else { anonymizedHost = a.AnonymizeDomain(u.Host) } @@ -171,17 +411,75 @@ func (a *Anonymizer) AnonymizeString(str string) string { ipv4Regex := regexp.MustCompile(`\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b`) ipv6Regex := regexp.MustCompile(`\b([0-9a-fA-F:]+:+[0-9a-fA-F]{0,4})(?:%[0-9a-zA-Z]+)?(?:\/[0-9]{1,3})?(?::[0-9]{1,5})?\b`) + // Reverse zones go first and are then held out of the passes below: their + // labels are digits, which the address patterns would otherwise consume. + str, restoreZones := a.replaceReverseZones(str) + str = ipv4Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString) str = ipv6Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString) - for domain, anonDomain := range a.domainAnonymizer { - str = strings.ReplaceAll(str, domain, anonDomain) + for _, domain := range a.sortedDomains() { + str = strings.ReplaceAll(str, domain, a.domainAnonymizer[domain]) } str = a.AnonymizeSchemeURI(str) str = a.AnonymizeDNSLogLine(str) - return str + // MAC handling runs after the IP passes so preserved IPv6 addresses are + // already out of the way; the separator guard skips matches embedded in a + // longer colon- or dash-separated sequence (such as an IPv6 tail). + str = a.anonymizeMACsInString(str, macColonRegex, ':') + str = a.anonymizeMACsInString(str, macDashRegex, '-') + + if a.level >= LevelStrict { + str = wgKeyRegex.ReplaceAllStringFunc(str, a.AnonymizeWGKey) + } + + return restoreZones(str) +} + +// sortedDomains returns the domain mappings longest-first, so a full-FQDN +// mapping (strict level) is applied before the base-domain mapping it +// contains. The order is rebuilt only when domainAnonymizer has grown. +func (a *Anonymizer) sortedDomains() []string { + if len(a.domainOrder) == len(a.domainAnonymizer) { + return a.domainOrder + } + + a.domainOrder = a.domainOrder[:0] + for domain := range a.domainAnonymizer { + a.domainOrder = append(a.domainOrder, domain) + } + slices.SortFunc(a.domainOrder, func(x, y string) int { + if d := len(y) - len(x); d != 0 { + return d + } + return strings.Compare(x, y) + }) + return a.domainOrder +} + +// anonymizeMACsInString replaces MAC addresses matched by re, skipping +// matches that directly adjoin another sep so a six-group run inside a longer +// separated sequence is left alone. +func (a *Anonymizer) anonymizeMACsInString(str string, re *regexp.Regexp, sep byte) string { + matches := re.FindAllStringIndex(str, -1) + if len(matches) == 0 { + return str + } + + var b strings.Builder + last := 0 + for _, m := range matches { + if (m[0] > 0 && str[m[0]-1] == sep) || (m[1] < len(str) && str[m[1]] == sep) { + continue + } + b.WriteString(str[last:m[0]]) + b.WriteString(a.AnonymizeMAC(str[m[0]:m[1]])) + last = m[1] + } + b.WriteString(str[last:]) + return b.String() } // AnonymizeSchemeURI finds and anonymizes URIs with ws, wss, rel, rels, stun, stuns, turn, and turns schemes. @@ -232,10 +530,79 @@ func isWellKnown(addr netip.Addr) bool { "128.0.0.0", "8000::", // 2nd split subnet for default routes } - if slices.Contains(wellKnown, addr.String()) { + return slices.Contains(wellKnown, addr.String()) +} + +// isInternal reports whether ip identifies a host only within the local +// network: IPv4 private (RFC 1918), CGNAT (RFC 6598), and link-local (v4 and +// v6). These are preserved at the default level so support can reason about +// the real topology, and replaced from the internal pools at the strict +// level. IPv6 ULA is deliberately not internal: its random global ID uniquely +// fingerprints the network, so it is anonymized at every level. +func isInternal(ip netip.Addr) bool { + return (ip.Is4() && ip.IsPrivate()) || + ip.IsLinkLocalUnicast() || + isCGNAT(ip) +} + +func inPoolRange(ip, start, current netip.Addr) bool { + return ip.Compare(start) >= 0 && ip.Compare(current) <= 0 +} + +// isWellKnownMAC reports whether hw carries no stable host identity: all-zero +// or a group address (broadcast and multicast). +func isWellKnownMAC(hw net.HardwareAddr) bool { + if hw[0]&1 == 1 { return true } + for _, b := range hw { + if b != 0 { + return false + } + } + return true +} +// looksLikeWGKey reports whether s has the shape of a WireGuard key: +// 44 base64 characters decoding to 32 bytes. +func looksLikeWGKey(s string) bool { + if len(s) != 44 || s[43] != '=' { + return false + } + decoded, err := base64.StdEncoding.DecodeString(s) + return err == nil && len(decoded) == 32 +} + +func generateAnonymousKey() string { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return strings.Repeat("A", 43) + "=" + } + return base64.StdEncoding.EncodeToString(buf) +} + +// protectedSuffix returns the protected NetBird suffix baseDomain ends with, +// or empty. The match is label-anchored so an unrelated domain that merely +// ends in the same characters is not preserved. +func protectedSuffix(baseDomain string) string { + for _, d := range protectedDomains { + if baseDomain == d || strings.HasSuffix(baseDomain, "."+d) { + return d + } + } + return "" +} + +func withTrailingDot(domain string, hasDot bool) string { + if hasDot { + return domain + "." + } + return domain +} + +// isCGNAT reports whether addr is in 100.64.0.0/10 (RFC 6598), the range +// NetBird assigns overlay peer addresses from. +func isCGNAT(addr netip.Addr) bool { cgnatRangeStart := netip.AddrFrom4([4]byte{100, 64, 0, 0}) cgnatRange := netip.PrefixFrom(cgnatRangeStart, 10) diff --git a/client/anonymize/anonymize_test.go b/client/anonymize/anonymize_test.go index ff2e48869..7c3c7bcf8 100644 --- a/client/anonymize/anonymize_test.go +++ b/client/anonymize/anonymize_test.go @@ -1,8 +1,11 @@ package anonymize_test import ( + "bytes" + "encoding/base64" "net/netip" "regexp" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -13,7 +16,7 @@ import ( func TestAnonymizeIP(t *testing.T) { startIPv4 := netip.MustParseAddr("198.51.100.0") - startIPv6 := netip.MustParseAddr("100::") + startIPv6 := netip.MustParseAddr("2001:db8:ffff::") anonymizer := anonymize.NewAnonymizer(startIPv4, startIPv6) tests := []struct { @@ -26,9 +29,9 @@ func TestAnonymizeIP(t *testing.T) { {"Second Public IPv4", "4.3.2.1", "198.51.100.1"}, {"Repeated IPv4", "1.2.3.4", "198.51.100.0"}, {"Private IPv4", "192.168.1.1", "192.168.1.1"}, - {"First Public IPv6", "2607:f8b0:4005:805::200e", "100::"}, - {"Second Public IPv6", "a::b", "100::1"}, - {"Repeated IPv6", "2607:f8b0:4005:805::200e", "100::"}, + {"First Public IPv6", "2607:f8b0:4005:805::200e", "2001:db8:ffff::"}, + {"Second Public IPv6", "a::b", "2001:db8:ffff::1"}, + {"Repeated IPv6", "2607:f8b0:4005:805::200e", "2001:db8:ffff::"}, {"Private IPv6", "fe80::1", "fe80::1"}, {"In Range IPv4", "198.51.100.2", "198.51.100.2"}, } @@ -44,6 +47,301 @@ func TestAnonymizeIP(t *testing.T) { } } +func TestParseLevel(t *testing.T) { + tests := []struct { + input string + expect anonymize.Level + }{ + {"", anonymize.LevelDefault}, + {"default", anonymize.LevelDefault}, + {"DEFAULT", anonymize.LevelDefault}, + {"strict", anonymize.LevelStrict}, + {"STRICT", anonymize.LevelStrict}, + // Unknown values must never yield less anonymization than requested. + {"garbage", anonymize.LevelStrict}, + } + + for _, tc := range tests { + t.Run("input="+tc.input, func(t *testing.T) { + assert.Equal(t, tc.expect, anonymize.ParseLevel(tc.input), "parsed level should match") + }) + } +} + +func TestAnonymizeIP_DefaultLevelInternalRanges(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + + tests := []struct { + name string + ip string + expect string + }{ + {"RFC1918 10/8", "10.1.2.3", "10.1.2.3"}, + {"RFC1918 172.16/12", "172.16.5.5", "172.16.5.5"}, + {"RFC1918 192.168/16", "192.168.1.1", "192.168.1.1"}, + {"CGNAT", "100.64.0.5", "100.64.0.5"}, + {"IPv4 link-local", "169.254.1.1", "169.254.1.1"}, + {"IPv6 link-local", "fe80::1", "fe80::1"}, + // ULA is anonymized even at the default level: its random global ID + // uniquely fingerprints the network, unlike shared RFC 1918 space. + {"IPv6 ULA", "fd12:3456:789a::1", "2001:db8:ffff::"}, + // 4-in-6 addresses classify like their unmapped IPv4 form. + {"4-in-6 RFC1918", "::ffff:192.168.1.1", "192.168.1.1"}, + {"4-in-6 CGNAT", "::ffff:100.64.0.5", "100.64.0.5"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := anonymizer.AnonymizeIP(netip.MustParseAddr(tc.ip)) + assert.Equal(t, tc.expect, result.String(), "default level should preserve internal ranges except ULA") + }) + } +} + +func TestAnonymizeIP_StrictLevel(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + anonymizer.SetLevel(anonymize.LevelStrict) + + // Order matters: internal pool addresses are assigned sequentially. + tests := []struct { + name string + ip string + expect string + }{ + {"RFC1918 192.168/16", "192.168.1.1", "198.18.0.0"}, + {"Second RFC1918", "192.168.1.2", "198.18.0.1"}, + {"Repeated RFC1918", "192.168.1.1", "198.18.0.0"}, + {"RFC1918 10/8", "10.1.2.3", "198.18.0.2"}, + {"RFC1918 172.16/12", "172.16.5.5", "198.18.0.3"}, + {"CGNAT", "100.64.0.5", "198.18.0.4"}, + {"IPv4 link-local", "169.254.1.1", "198.18.0.5"}, + {"Public IPv4 uses public pool", "1.2.3.4", "198.51.100.0"}, + {"IPv6 link-local", "fe80::1", "2001:db8:1::"}, + {"IPv6 ULA", "fd12:3456:789a::1", "2001:db8:1::1"}, + {"Public IPv6 uses public pool", "2607:f8b0:4005:805::200e", "2001:db8:ffff::"}, + {"Loopback IPv4", "127.0.0.1", "127.0.0.1"}, + {"Loopback IPv6", "::1", "::1"}, + {"Unspecified", "0.0.0.0", "0.0.0.0"}, + {"Multicast", "224.0.0.251", "224.0.0.251"}, + {"Well known resolver", "8.8.8.8", "8.8.8.8"}, + {"Well known split marker", "128.0.0.0", "128.0.0.0"}, + {"In internal pool range", "198.18.0.3", "198.18.0.3"}, + {"In public pool range", "198.51.100.0", "198.51.100.0"}, + {"4-in-6 repeated RFC1918", "::ffff:192.168.1.1", "198.18.0.0"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := anonymizer.AnonymizeIP(netip.MustParseAddr(tc.ip)) + assert.Equal(t, tc.expect, result.String(), "strict level should replace internal ranges from the internal pools") + }) + } +} + +func TestAnonymizeString_StrictInternalIPs(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + anonymizer.SetLevel(anonymize.LevelStrict) + + input := "route 10.20.30.0/24 via 192.168.1.1 dev eth0 src 100.64.0.7" + firstPass := anonymizer.AnonymizeString(input) + secondPass := anonymizer.AnonymizeString(firstPass) + + assert.NotContains(t, firstPass, "10.20.30.0", "private network address should be anonymized") + assert.NotContains(t, firstPass, "192.168.1.1", "private gateway should be anonymized") + assert.NotContains(t, firstPass, "100.64.0.7", "CGNAT address should be anonymized") + assert.Contains(t, firstPass, "/24", "prefix length should be preserved") + assert.Equal(t, firstPass, secondPass, "second pass should not further anonymize the string") +} + +func TestAnonymizeMAC(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + + first := anonymizer.AnonymizeMAC("aa:bb:cc:dd:ee:0f") + assert.Equal(t, "02:00:00:00:00:01", first, "first MAC should get the first placeholder") + assert.Equal(t, first, anonymizer.AnonymizeMAC("aa:bb:cc:dd:ee:0f"), "repeated MAC should map to the same placeholder") + assert.Equal(t, first, anonymizer.AnonymizeMAC("AA:BB:CC:DD:EE:0F"), "case should not affect the mapping") + assert.Equal(t, "02-00-00-00-00-01", anonymizer.AnonymizeMAC("AA-BB-CC-DD-EE-0F"), "dash form should keep its separator but share the mapping") + + second := anonymizer.AnonymizeMAC("10:22:33:44:55:66") + assert.Equal(t, "02:00:00:00:00:02", second, "second distinct MAC should get the next placeholder") + + tests := []struct { + name string + mac string + }{ + {"Broadcast", "ff:ff:ff:ff:ff:ff"}, + {"IPv4 multicast", "01:00:5e:00:00:fb"}, + {"IPv6 multicast", "33:33:00:00:00:01"}, + {"All zero", "00:00:00:00:00:00"}, + {"Assigned placeholder", "02:00:00:00:00:01"}, + {"Invalid", "not-a-mac"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.mac, anonymizer.AnonymizeMAC(tc.mac), "should be preserved") + }) + } +} + +func TestAnonymizeString_MACAddresses(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + + tests := []struct { + name string + input string + expect string + }{ + { + name: "nftables ether rule", + input: "ether saddr aa:bb:cc:dd:ee:ff drop", + expect: "ether saddr 02:00:00:00:00:01 drop", + }, + { + name: "Windows dash form", + input: "Physical Address : AA-BB-CC-DD-EE-FF", + expect: "Physical Address : 02-00-00-00-00-01", + }, + { + name: "IPv6 address tail is not treated as MAC", + input: "addr fe80:0:11:22:33:44:55:66 scope link", + expect: "addr fe80:0:11:22:33:44:55:66 scope link", + }, + { + name: "broadcast MAC preserved", + input: "dst ff:ff:ff:ff:ff:ff type ARP", + expect: "dst ff:ff:ff:ff:ff:ff type ARP", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := anonymizer.AnonymizeString(tc.input) + assert.Equal(t, tc.expect, result, "MAC addresses should be anonymized at every level") + assert.Equal(t, result, anonymizer.AnonymizeString(result), "second pass should not change the result") + }) + } +} + +func TestAnonymizeWGKey(t *testing.T) { + key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x42}, 32)) + + t.Run("default level preserves keys", func(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + assert.Equal(t, key, anonymizer.AnonymizeWGKey(key), "default level should not touch WireGuard keys") + }) + + t.Run("strict level replaces keys", func(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + anonymizer.SetLevel(anonymize.LevelStrict) + + anon := anonymizer.AnonymizeWGKey(key) + assert.NotEqual(t, key, anon, "strict level should replace the key") + assert.Regexp(t, `^[A-Za-z0-9+/]{43}=$`, anon, "placeholder should keep the WireGuard key shape") + assert.Equal(t, anon, anonymizer.AnonymizeWGKey(key), "repeated key should map to the same placeholder") + assert.Equal(t, anon, anonymizer.AnonymizeWGKey(anon), "an assigned placeholder should pass through unchanged") + + assert.Equal(t, "not-a-key", anonymizer.AnonymizeWGKey("not-a-key"), "non-key values should be preserved") + }) +} + +func TestAnonymizeString_WGKeys(t *testing.T) { + key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x42}, 32)) + input := "peer " + key + " handshake completed" + + t.Run("default level preserves keys", func(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + assert.Equal(t, input, anonymizer.AnonymizeString(input), "default level should not touch WireGuard keys in strings") + }) + + t.Run("strict level replaces keys", func(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + anonymizer.SetLevel(anonymize.LevelStrict) + + firstPass := anonymizer.AnonymizeString(input) + assert.NotContains(t, firstPass, key, "the key should not survive strict anonymization") + assert.Equal(t, anonymizer.AnonymizeWGKey(key), extractKey(t, firstPass), "string replacement should be consistent with AnonymizeWGKey") + assert.Equal(t, firstPass, anonymizer.AnonymizeString(firstPass), "second pass should not change the result") + }) +} + +func extractKey(t *testing.T, logLine string) string { + t.Helper() + fields := strings.Fields(logLine) + require.Len(t, fields, 4, "log line should keep its structure") + return fields[1] +} + +func TestAnonymizeDomain_StrictLevel(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + anonymizer.SetLevel(anonymize.LevelStrict) + + t.Run("netbird peer name", func(t *testing.T) { + result := anonymizer.AnonymizeDomain("my-laptop.netbird.cloud") + assert.Regexp(t, `^peer-\d+\.netbird\.cloud$`, result, "peer name should be anonymized, suffix kept") + assert.NotContains(t, result, "my-laptop", "the peer name should not survive") + assert.Equal(t, result, anonymizer.AnonymizeDomain("my-laptop.netbird.cloud"), "repeated domain should map consistently") + assert.Equal(t, result, anonymizer.AnonymizeDomain(result), "an anonymized domain should pass through unchanged") + }) + + t.Run("bare netbird domain", func(t *testing.T) { + assert.Equal(t, "netbird.cloud", anonymizer.AnonymizeDomain("netbird.cloud"), "the bare protected suffix should be preserved") + }) + + t.Run("netbird infrastructure preserved", func(t *testing.T) { + assert.Equal(t, "api.netbird.io", anonymizer.AnonymizeDomain("api.netbird.io"), + "netbird.io hosts infrastructure, not peer names, and should stay readable") + }) + + t.Run("leading labels of other domains", func(t *testing.T) { + result := anonymizer.AnonymizeDomain("host1.corp.example.com") + assert.Regexp(t, `^host-\d+\.host-\d+\.anon-[a-zA-Z0-9]+\.domain$`, result, "every label should be anonymized") + for _, label := range []string{"host1", "corp", "example"} { + assert.NotContains(t, result, label, "no original label should survive") + } + assert.Equal(t, result, anonymizer.AnonymizeDomain("host1.corp.example.com"), "repeated domain should map consistently") + }) + + t.Run("same label maps consistently across domains", func(t *testing.T) { + first := anonymizer.AnonymizeDomain("shared.one.com") + second := anonymizer.AnonymizeDomain("shared.two.com") + assert.Equal(t, strings.Split(first, ".")[0], strings.Split(second, ".")[0], "the shared host label should get one placeholder") + }) + + t.Run("wildcard label preserved", func(t *testing.T) { + result := anonymizer.AnonymizeDomain("*.example.com") + assert.Regexp(t, `^\*\.anon-[a-zA-Z0-9]+\.domain$`, result, "the wildcard label should stay a wildcard") + }) +} + +func TestAnonymizeDomain_DefaultLevelKeepsPeerNames(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + + assert.Equal(t, "my-laptop.netbird.cloud", anonymizer.AnonymizeDomain("my-laptop.netbird.cloud"), + "default level should preserve netbird FQDNs including the peer name") + assert.Regexp(t, `^sub\.anon-[a-zA-Z0-9]+\.domain$`, anonymizer.AnonymizeDomain("sub.example.com"), + "default level should keep subdomain labels") +} + +func TestAnonymizeString_StrictPeerNames(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + anonymizer.SetLevel(anonymize.LevelStrict) + + // Seed like the bundle generator does from the status: base first, then + // the full FQDN, so replacement must prefer the longer mapping. + anonBase := anonymizer.AnonymizeDomain("example.com") + anonPeer := anonymizer.AnonymizeDomain("peer1.netbird.cloud") + anonHost := anonymizer.AnonymizeDomain("host1.example.com") + + logLine := "connected to peer1.netbird.cloud via host1.example.com endpoint" + firstPass := anonymizer.AnonymizeString(logLine) + assert.NotContains(t, firstPass, "peer1", "the peer name should not survive in logs") + assert.NotContains(t, firstPass, "host1", "the host label should not survive in logs") + assert.Contains(t, firstPass, anonPeer, "the seeded peer mapping should be applied") + assert.Contains(t, firstPass, anonHost, "the seeded host mapping should be applied, not just the base mapping") + assert.NotContains(t, firstPass, "host1."+anonBase, "the base mapping must not preempt the longer FQDN mapping") + assert.Equal(t, firstPass, anonymizer.AnonymizeString(firstPass), "second pass should not change the result") +} + func TestAnonymizeDNSLogLine(t *testing.T) { anonymizer := anonymize.NewAnonymizer(netip.Addr{}, netip.Addr{}) tests := []struct { @@ -274,17 +572,27 @@ func TestAnonymizeString_IPAddresses(t *testing.T) { { name: "IPv6 Address", input: "Access attempted from 2001:db8::ff00:42", - expect: "Access attempted from 100::", + expect: "Access attempted from 2001:db8:ffff::", }, { name: "IPv6 Address with Port", input: "Access attempted from [2001:db8::ff00:42]:8080", - expect: "Access attempted from [100::]:8080", + expect: "Access attempted from [2001:db8:ffff::]:8080", }, { name: "Both IPv4 and IPv6", input: "IPv4: 142.108.0.1 and IPv6: 2001:db8::ff00:43", - expect: "IPv4: 198.51.100.1 and IPv6: 100::1", + expect: "IPv4: 198.51.100.1 and IPv6: 2001:db8:ffff::1", + }, + { + name: "STUN URI with IPv6", + input: "Connecting to stun:[2001:db8::ff00:42]:3478", + expect: "Connecting to stun:[2001:db8:ffff::]:3478", + }, + { + name: "HTTPS URI with IPv6", + input: "Visit https://[2001:db8::ff00:42]:443/path", + expect: "Visit https://[2001:db8:ffff::]:443/path", }, } diff --git a/client/anonymize/reverse_zone.go b/client/anonymize/reverse_zone.go new file mode 100644 index 000000000..b521b71b7 --- /dev/null +++ b/client/anonymize/reverse_zone.go @@ -0,0 +1,174 @@ +package anonymize + +import ( + "encoding/hex" + "net/netip" + "regexp" + "strconv" + "strings" +) + +const ( + reverseZoneSuffixV4 = ".in-addr.arpa" + reverseZoneSuffixV6 = ".ip6.arpa" + + v6Nibbles = 32 + v4Octets = 4 +) + +// reverseZoneRegexes match a reverse zone or a full reverse name in free text. +// They are applied before the address passes of AnonymizeString, whose IPv4 +// pattern would otherwise consume the digit labels of a zone and replace parts +// of it with unrelated addresses. +var reverseZoneRegexes = []*regexp.Regexp{ + regexp.MustCompile(`(?:[0-9]{1,3}\.){1,4}in-addr\.arpa\b`), + regexp.MustCompile(`(?:[0-9a-fA-F]\.){1,32}ip6\.arpa\b`), +} + +// anonymizeReverseZone maps a reverse zone to the zone of the anonymized form +// of the prefix it encodes, so it follows the address rules rather than the +// domain ones: the zone of an address that is preserved is preserved too, and +// the zone of one that is replaced names the replacement. This keeps a reverse +// zone recognizable as such, and consistent with the addresses it belongs to +// elsewhere in the same output. It reports false for anything that is not a +// reverse zone. +func (a *Anonymizer) anonymizeReverseZone(domain string) (string, bool) { + prefix, labelCount, suffix, ok := parseReverseZone(domain) + if !ok { + return "", false + } + + anonymized := a.AnonymizeIP(prefix) + if anonymized == prefix { + return domain, true + } + + return reverseZoneName(anonymized, labelCount) + suffix, true +} + +// replaceReverseZones anonymizes every reverse zone in str and swaps each one +// for a placeholder, returning a function that puts the anonymized zones back. +// The placeholders carry no dots, digits or colons, so no later pass matches +// them. +func (a *Anonymizer) replaceReverseZones(str string) (string, func(string) string) { + var zones []string + + for _, re := range reverseZoneRegexes { + str = re.ReplaceAllStringFunc(str, func(match string) string { + zone, ok := a.anonymizeReverseZone(match) + if !ok { + return match + } + + zones = append(zones, zone) + return reverseZonePlaceholder(len(zones) - 1) + }) + } + + if len(zones) == 0 { + return str, func(s string) string { return s } + } + + return str, func(s string) string { + for i, zone := range zones { + s = strings.ReplaceAll(s, reverseZonePlaceholder(i), zone) + } + return s + } +} + +func reverseZonePlaceholder(index int) string { + return "\x00reversezone" + strconv.Itoa(index) + "\x00" +} + +// parseReverseZone turns a reverse zone into the address of the prefix its +// labels spell backwards, padding the absent low-order part with zeroes, and +// returns the label count and zone suffix so the name can be rebuilt. +func parseReverseZone(domain string) (netip.Addr, int, string, bool) { + lower := strings.ToLower(domain) + + switch { + case strings.HasSuffix(lower, reverseZoneSuffixV4): + labels := strings.Split(strings.TrimSuffix(lower, reverseZoneSuffixV4), ".") + addr, ok := reverseZoneAddrV4(labels) + return addr, len(labels), reverseZoneSuffixV4, ok + case strings.HasSuffix(lower, reverseZoneSuffixV6): + labels := strings.Split(strings.TrimSuffix(lower, reverseZoneSuffixV6), ".") + addr, ok := reverseZoneAddrV6(labels) + return addr, len(labels), reverseZoneSuffixV6, ok + default: + return netip.Addr{}, 0, "", false + } +} + +func reverseZoneAddrV4(labels []string) (netip.Addr, bool) { + if len(labels) == 0 || len(labels) > v4Octets { + return netip.Addr{}, false + } + + var octets [v4Octets]byte + for i, label := range labels { + octet, err := strconv.ParseUint(label, 10, 8) + if err != nil { + return netip.Addr{}, false + } + octets[len(labels)-1-i] = byte(octet) + } + + return netip.AddrFrom4(octets), true +} + +func reverseZoneAddrV6(labels []string) (netip.Addr, bool) { + if len(labels) == 0 || len(labels) > v6Nibbles { + return netip.Addr{}, false + } + + nibbles := make([]byte, 0, v6Nibbles) + for i := len(labels) - 1; i >= 0; i-- { + if len(labels[i]) != 1 || !isHexDigit(labels[i][0]) { + return netip.Addr{}, false + } + nibbles = append(nibbles, labels[i][0]) + } + for len(nibbles) < v6Nibbles { + nibbles = append(nibbles, '0') + } + + var groups []string + for i := 0; i < len(nibbles); i += 4 { + groups = append(groups, string(nibbles[i:i+4])) + } + + addr, err := netip.ParseAddr(strings.Join(groups, ":")) + if err != nil { + return netip.Addr{}, false + } + + return addr, true +} + +// reverseZoneName spells the first labelCount labels of addr backwards, the +// inverse of parseReverseZone, without the zone suffix. +func reverseZoneName(addr netip.Addr, labelCount int) string { + labels := make([]string, 0, labelCount) + + if addr.Is4() { + octets := addr.As4() + for i := labelCount - 1; i >= 0; i-- { + labels = append(labels, strconv.Itoa(int(octets[i]))) + } + return strings.Join(labels, ".") + } + + address := addr.As16() + nibbles := hex.EncodeToString(address[:]) + for i := labelCount - 1; i >= 0; i-- { + labels = append(labels, string(nibbles[i])) + } + + return strings.Join(labels, ".") +} + +func isHexDigit(c byte) bool { + return c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F' +} diff --git a/client/anonymize/reverse_zone_test.go b/client/anonymize/reverse_zone_test.go new file mode 100644 index 000000000..8c3b8954a --- /dev/null +++ b/client/anonymize/reverse_zone_test.go @@ -0,0 +1,171 @@ +package anonymize + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newLeveledAnonymizer(level Level) *Anonymizer { + a := NewAnonymizer(DefaultAddresses()) + a.SetLevel(level) + return a +} + +// TestAnonymizeDomainReverseZone covers reverse zones going through the address +// rules instead of the domain ones, so a zone stays a zone and an address that +// is preserved keeps the zone that names it. +func TestAnonymizeDomainReverseZone(t *testing.T) { + // 100.64.0.0/10 is the overlay range, which is CGNAT: preserved at the + // default level and replaced from the internal pool at the strict one + const overlayZone = "64.100.in-addr.arpa" + + t.Run("overlay zone preserved at the default level", func(t *testing.T) { + a := newLeveledAnonymizer(LevelDefault) + assert.Equal(t, overlayZone, a.AnonymizeDomain(overlayZone), "should keep the zone of a preserved address") + }) + + t.Run("private zone preserved at the default level", func(t *testing.T) { + a := newLeveledAnonymizer(LevelDefault) + assert.Equal(t, "168.192.in-addr.arpa", a.AnonymizeDomain("168.192.in-addr.arpa"), "should keep the zone of a private address") + }) + + t.Run("overlay zone replaced at the strict level", func(t *testing.T) { + a := newLeveledAnonymizer(LevelStrict) + + got := a.AnonymizeDomain(overlayZone) + require.True(t, strings.HasSuffix(got, reverseZoneSuffixV4), "should stay a reverse zone, got %q", got) + assert.NotEqual(t, overlayZone, got, "should replace the encoded prefix") + assert.Len(t, strings.Split(strings.TrimSuffix(got, reverseZoneSuffixV4), "."), 2, + "should keep the label count, got %q", got) + }) + + t.Run("public zone replaced at the default level", func(t *testing.T) { + a := newLeveledAnonymizer(LevelDefault) + + got := a.AnonymizeDomain("113.0.203.in-addr.arpa") + require.True(t, strings.HasSuffix(got, reverseZoneSuffixV4), "should stay a reverse zone, got %q", got) + assert.NotEqual(t, "113.0.203.in-addr.arpa", got, "should replace a public prefix") + }) + + t.Run("zone of an address keeps that address mapping", func(t *testing.T) { + a := newLeveledAnonymizer(LevelDefault) + + anonymizedAddr := a.AnonymizeIPString("203.0.113.7") + got := a.AnonymizeDomain("7.113.0.203.in-addr.arpa") + + octets := strings.Split(anonymizedAddr, ".") + want := octets[3] + "." + octets[2] + "." + octets[1] + "." + octets[0] + reverseZoneSuffixV4 + assert.Equal(t, want, got, "should name the same replacement as the address itself") + }) + + t.Run("ipv6 nibble labels stay single digits", func(t *testing.T) { + a := newLeveledAnonymizer(LevelDefault) + + zone := "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.2.0.0.0" + reverseZoneSuffixV6 + got := a.AnonymizeDomain(zone) + + require.True(t, strings.HasSuffix(got, reverseZoneSuffixV6), "should stay a reverse zone, got %q", got) + labels := strings.Split(strings.TrimSuffix(got, reverseZoneSuffixV6), ".") + assert.Len(t, labels, 28, "should keep every nibble label, got %q", got) + for _, label := range labels { + assert.Len(t, label, 1, "nibble label %q should stay a single digit", label) + } + }) + + t.Run("trailing dot is kept", func(t *testing.T) { + a := newLeveledAnonymizer(LevelDefault) + assert.Equal(t, "64.100.in-addr.arpa.", a.AnonymizeDomain("64.100.in-addr.arpa."), "should keep the trailing dot") + }) + + t.Run("a domain that only looks like a zone is anonymized as a domain", func(t *testing.T) { + a := newLeveledAnonymizer(LevelDefault) + + got := a.AnonymizeDomain("not-a-zone.in-addr.arpa") + assert.NotContains(t, got, "in-addr.arpa", "should fall back to domain anonymization") + }) +} + +// TestAnonymizeStringReverseZone verifies that a zone inside free text, such as +// a DNS log line, is not chewed up by the address passes. The IPv4 pattern +// matches any run of dotted digits, which a reverse zone is made of. +func TestAnonymizeStringReverseZone(t *testing.T) { + t.Run("ipv6 zone survives the address passes", func(t *testing.T) { + a := newLeveledAnonymizer(LevelDefault) + + zone := "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.2.0.0.0" + reverseZoneSuffixV6 + got := a.AnonymizeString("question: domain=" + zone + " type=PTR") + + assert.Contains(t, got, "type=PTR", "should keep the rest of the line") + assert.NotContains(t, got, "198.51.100", "should not rewrite nibble labels as an address") + + labels := strings.Split(strings.TrimSuffix(strings.TrimPrefix(got, "question: domain="), reverseZoneSuffixV6+" type=PTR"), ".") + assert.Len(t, labels, 28, "should keep every nibble label, got %q", got) + }) + + t.Run("preserved ipv4 zone is untouched", func(t *testing.T) { + a := newLeveledAnonymizer(LevelDefault) + + line := "reverse zone 64.100.in-addr.arpa registered" + assert.Equal(t, line, a.AnonymizeString(line), "should keep the zone of a preserved address") + }) + + t.Run("public ipv4 zone is replaced consistently", func(t *testing.T) { + a := newLeveledAnonymizer(LevelDefault) + + got := a.AnonymizeString("zone 113.0.203.in-addr.arpa and address 203.0.113.7") + assert.NotContains(t, got, "113.0.203.in-addr.arpa", "should replace the zone") + assert.NotContains(t, got, "203.0.113.7", "should replace the address") + assert.Contains(t, got, reverseZoneSuffixV4, "should keep the zone suffix") + }) +} + +func TestParseReverseZone(t *testing.T) { + tests := []struct { + name string + zone string + addr string + labels int + }{ + {name: "v4 two labels", zone: "0.100" + reverseZoneSuffixV4, addr: "100.0.0.0", labels: 2}, + {name: "v4 three labels", zone: "1.168.192" + reverseZoneSuffixV4, addr: "192.168.1.0", labels: 3}, + {name: "v4 full address", zone: "7.113.0.203" + reverseZoneSuffixV4, addr: "203.0.113.7", labels: 4}, + { + name: "v6 prefix", + zone: "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.2.0.0.0" + reverseZoneSuffixV6, + addr: "2::", + labels: 28, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + addr, labels, suffix, ok := parseReverseZone(tc.zone) + require.True(t, ok, "should decode the reverse zone") + assert.Equal(t, tc.addr, addr.String(), "should decode to the encoded prefix") + assert.Equal(t, tc.labels, labels, "should count the labels") + assert.Equal(t, tc.zone, reverseZoneName(addr, labels)+suffix, "should re-encode to the original zone") + }) + } +} + +func TestParseReverseZoneRejectsNonZones(t *testing.T) { + tests := []string{ + "example.com", + "in-addr.arpa", + "x.100" + reverseZoneSuffixV4, + "256" + reverseZoneSuffixV4, + "1.2.3.4.5" + reverseZoneSuffixV4, + "ab" + reverseZoneSuffixV6, + "g" + reverseZoneSuffixV6, + } + + for _, zone := range tests { + t.Run(zone, func(t *testing.T) { + _, _, _, ok := parseReverseZone(zone) + assert.False(t, ok, "should reject %q", zone) + }) + } +} diff --git a/client/cmd/capture.go b/client/cmd/capture.go new file mode 100644 index 000000000..95caaa5cd --- /dev/null +++ b/client/cmd/capture.go @@ -0,0 +1,196 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + + "github.com/hashicorp/go-multierror" + "github.com/spf13/cobra" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/durationpb" + + nberrors "github.com/netbirdio/netbird/client/errors" + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/util/capture" +) + +var captureCmd = &cobra.Command{ + Use: "capture", + Short: "Capture packets on the WireGuard interface", + Long: `Captures decrypted packets flowing through the WireGuard interface. + +Default output is human-readable text. Use --pcap or --output for pcap binary. +Requires --enable-capture to be set at service install or reconfigure time. + +Examples: + netbird debug capture + netbird debug capture host 100.64.0.1 and port 443 + netbird debug capture tcp + netbird debug capture icmp + netbird debug capture src host 10.0.0.1 and dst port 80 + netbird debug capture -o capture.pcap + netbird debug capture --pcap | tshark -r - + netbird debug capture --pcap | tcpdump -r - -n`, + Args: cobra.ArbitraryArgs, + RunE: runCapture, +} + +func init() { + debugCmd.AddCommand(captureCmd) + + captureCmd.Flags().Bool("pcap", false, "Force pcap binary output (default when --output is set)") + captureCmd.Flags().BoolP("verbose", "v", false, "Show seq/ack, TTL, window, total length") + captureCmd.Flags().Bool("ascii", false, "Print payload as ASCII after each packet (useful for HTTP)") + captureCmd.Flags().Uint32("snap-len", 0, "Max bytes per packet (0 = full)") + captureCmd.Flags().DurationP("duration", "d", 0, "Capture duration (0 = until interrupted)") + captureCmd.Flags().StringP("output", "o", "", "Write pcap to file instead of stdout") +} + +func runCapture(cmd *cobra.Command, args []string) error { + conn, err := getClient(cmd) + if err != nil { + return err + } + defer func() { + if err := conn.Close(); err != nil { + cmd.PrintErrf(errCloseConnection, err) + } + }() + + client := proto.NewDaemonServiceClient(conn) + + req, err := buildCaptureRequest(cmd, args) + if err != nil { + return err + } + + ctx, cancel := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + stream, err := client.StartCapture(ctx, req) + if err != nil { + return handleCaptureError(err) + } + + // First Recv is the empty acceptance message from the server. If the + // device is unavailable (kernel WG, not connected, capture disabled), + // the server returns an error instead. + if _, err := stream.Recv(); err != nil { + return handleCaptureError(err) + } + + out, cleanup, err := captureOutput(cmd) + if err != nil { + return err + } + + if req.TextOutput { + cmd.PrintErrf("Capturing packets... Press Ctrl+C to stop.\n") + } else { + cmd.PrintErrf("Capturing packets (pcap)... Press Ctrl+C to stop.\n") + } + + streamErr := streamCapture(ctx, cmd, stream, out) + cleanupErr := cleanup() + if streamErr != nil { + return streamErr + } + return cleanupErr +} + +func buildCaptureRequest(cmd *cobra.Command, args []string) (*proto.StartCaptureRequest, error) { + req := &proto.StartCaptureRequest{} + + if len(args) > 0 { + expr := strings.Join(args, " ") + if _, err := capture.ParseFilter(expr); err != nil { + return nil, fmt.Errorf("invalid filter: %w", err) + } + req.FilterExpr = expr + } + + if snap, _ := cmd.Flags().GetUint32("snap-len"); snap > 0 { + req.SnapLen = snap + } + if d, _ := cmd.Flags().GetDuration("duration"); d != 0 { + if d < 0 { + return nil, fmt.Errorf("duration must not be negative") + } + req.Duration = durationpb.New(d) + } + req.Verbose, _ = cmd.Flags().GetBool("verbose") + req.Ascii, _ = cmd.Flags().GetBool("ascii") + + outPath, _ := cmd.Flags().GetString("output") + forcePcap, _ := cmd.Flags().GetBool("pcap") + req.TextOutput = !forcePcap && outPath == "" + + return req, nil +} + +func streamCapture(ctx context.Context, cmd *cobra.Command, stream proto.DaemonService_StartCaptureClient, out io.Writer) error { + for { + pkt, err := stream.Recv() + if err != nil { + if ctx.Err() != nil { + cmd.PrintErrf("\nCapture stopped.\n") + return nil //nolint:nilerr // user interrupted + } + if err == io.EOF { + cmd.PrintErrf("\nCapture finished.\n") + return nil + } + return handleCaptureError(err) + } + if _, err := out.Write(pkt.GetData()); err != nil { + return fmt.Errorf("write output: %w", err) + } + } +} + +// captureOutput returns the writer for capture data and a cleanup function +// that finalizes the file. Errors from the cleanup must be propagated. +func captureOutput(cmd *cobra.Command) (io.Writer, func() error, error) { + outPath, _ := cmd.Flags().GetString("output") + if outPath == "" { + return os.Stdout, func() error { return nil }, nil + } + + f, err := os.CreateTemp(filepath.Dir(outPath), filepath.Base(outPath)+".*.tmp") + if err != nil { + return nil, nil, fmt.Errorf("create output file: %w", err) + } + tmpPath := f.Name() + return f, func() error { + var merr *multierror.Error + if err := f.Close(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("close output file: %w", err)) + } + fi, statErr := os.Stat(tmpPath) + if statErr != nil || fi.Size() == 0 { + if rmErr := os.Remove(tmpPath); rmErr != nil && !os.IsNotExist(rmErr) { + merr = multierror.Append(merr, fmt.Errorf("remove empty output file: %w", rmErr)) + } + return nberrors.FormatErrorOrNil(merr) + } + if err := os.Rename(tmpPath, outPath); err != nil { + merr = multierror.Append(merr, fmt.Errorf("rename output file: %w", err)) + return nberrors.FormatErrorOrNil(merr) + } + cmd.PrintErrf("Wrote %s\n", outPath) + return nberrors.FormatErrorOrNil(merr) + }, nil +} + +func handleCaptureError(err error) error { + if s, ok := status.FromError(err); ok { + return fmt.Errorf("%s", s.Message()) + } + return err +} diff --git a/client/cmd/daemon_error.go b/client/cmd/daemon_error.go new file mode 100644 index 000000000..0d5b1307e --- /dev/null +++ b/client/cmd/daemon_error.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "errors" + "fmt" + "strings" + + "google.golang.org/genproto/googleapis/rpc/errdetails" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/ipcauth" +) + +// daemonCallError prepares a daemon error for display. A refusal the daemon +// raised because the operation needs root/administrator is already guidance +// written for the user, so it is surfaced on its own instead of buried under the +// gRPC envelope and the name of the RPC that hit it. Anything else is wrapped +// with context as usual. +func daemonCallError(context string, err error) error { + if guidance, ok := privilegeGuidance(err); ok { + return errors.New(guidance) + } + return fmt.Errorf("%s: %w", context, err) +} + +// privilegeGuidance renders the daemon's privilege refusal as a summary and the +// command that performs the operation with the privileges it needs. It reports +// false for any other error. +func privilegeGuidance(err error) (string, bool) { + info, ok := privilegeErrorInfo(err) + if !ok { + return "", false + } + + summary := info.GetMetadata()[ipcauth.ErrorMetaSummary] + command := info.GetMetadata()[ipcauth.ErrorMetaCommand] + if summary == "" { + // Detail without a summary: fall back to the status message, which + // carries the same text. + summary = strings.TrimSpace(gstatus.Convert(err).Message()) + } + if command == "" { + return summary, true + } + + return fmt.Sprintf("%s\n\n %s\n", summary, command), true +} + +// privilegeErrorInfo returns the daemon's privilege-refusal detail, if the error +// carries one. +func privilegeErrorInfo(err error) (*errdetails.ErrorInfo, bool) { + if err == nil { + return nil, false + } + + for _, detail := range gstatus.Convert(err).Details() { + info, ok := detail.(*errdetails.ErrorInfo) + if !ok { + continue + } + if info.GetReason() == ipcauth.ErrorReasonPrivilegeRequired && info.GetDomain() == ipcauth.ErrorDomain { + return info, true + } + } + return nil, false +} diff --git a/client/cmd/debug.go b/client/cmd/debug.go index e3d3afe5f..98fe53626 100644 --- a/client/cmd/debug.go +++ b/client/cmd/debug.go @@ -9,6 +9,8 @@ import ( log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "google.golang.org/grpc/status" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/types/known/durationpb" "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/debug" @@ -18,15 +20,17 @@ import ( "github.com/netbirdio/netbird/client/server" mgmProto "github.com/netbirdio/netbird/shared/management/proto" "github.com/netbirdio/netbird/upload-server/types" + "github.com/netbirdio/netbird/version" ) const errCloseConnection = "Failed to close connection: %v" var ( - logFileCount uint32 - systemInfoFlag bool - uploadBundleFlag bool - uploadBundleURLFlag string + logFileCount uint32 + systemInfoFlag bool + uploadBundleFlag bool + uploadBundleURLFlag string + uploadBundleInsecureFlag bool ) var debugCmd = &cobra.Command{ @@ -83,7 +87,79 @@ var persistenceCmd = &cobra.Command{ RunE: setSyncResponsePersistence, } +var debugConfigCmd = &cobra.Command{ + Use: "config", + Example: " netbird debug config", + Short: "Dump the effective configuration", + Long: "Prints the daemon's resolved configuration (after applying defaults, file, env, CLI input, and MDM policy overrides) as JSON. Includes the list of MDM-managed fields.", + RunE: debugConfigDump, +} + +// debugConfigDump implements `netbird debug config`. It resolves the +// active profile, queries the daemon for the effective configuration +// via GetConfig, and prints the resulting GetConfigResponse as JSON +// (via protojson with EmitUnpopulated=true so the output is stable +// across runs and includes zero-valued fields). +// +// Useful for verifying MDM enforcement end-to-end: the response's +// mDMManagedFields array is the single source of truth for "which +// fields is the daemon currently enforcing from the MDM source", and +// every config field side-by-side with that list confirms the merge +// result. Secrets in the response (e.g. PreSharedKey) are already +// redacted by the daemon-side handler. +func debugConfigDump(cmd *cobra.Command, _ []string) error { + pm := profilemanager.NewProfileManager() + activeProf, err := pm.GetActiveProfile() + if err != nil { + return fmt.Errorf("get active profile: %v", err) + } + currUser, err := profilemanager.InvokingUser() + if err != nil { + return fmt.Errorf("get current user: %v", err) + } + + conn, err := getClient(cmd) + if err != nil { + return err + } + defer func() { + if err := conn.Close(); err != nil { + log.Errorf(errCloseConnection, err) + } + }() + + client := proto.NewDaemonServiceClient(conn) + resp, err := client.GetConfig(cmd.Context(), &proto.GetConfigRequest{ + ProfileName: string(activeProf.ID), + Username: currUser.Username, + }) + if err != nil { + return fmt.Errorf("failed to get config: %v", status.Convert(err).Message()) + } + + // Use protojson so well-known fields render correctly; emit defaults so + // the operator sees every field even when zero/empty. + m := protojson.MarshalOptions{Multiline: true, Indent: " ", EmitUnpopulated: true} + out, err := m.Marshal(resp) + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + cmd.Println(string(out)) + return nil +} + +// debugBundle requests the daemon to create a debug bundle and prints +// the resulting local file path and, if uploaded, the uploaded file +// key. It uses the package flags (anonymize, system info, log file +// count, CLI version, optional upload URL) to configure the bundle +// request. Returns an error if the RPC fails or if the daemon reports +// an upload failure reason. func debugBundle(cmd *cobra.Command, _ []string) error { + anonymizeEnabled, anonymizeLevel, err := effectiveAnonymize() + if err != nil { + return err + } + conn, err := getClient(cmd) if err != nil { return err @@ -96,16 +172,19 @@ func debugBundle(cmd *cobra.Command, _ []string) error { client := proto.NewDaemonServiceClient(conn) request := &proto.DebugBundleRequest{ - Anonymize: anonymizeFlag, - SystemInfo: systemInfoFlag, - LogFileCount: logFileCount, + Anonymize: anonymizeEnabled, + AnonymizeLevel: anonymizeLevel.String(), + SystemInfo: systemInfoFlag, + LogFileCount: logFileCount, + CliVersion: version.NetbirdVersion(), } if uploadBundleFlag { request.UploadURL = uploadBundleURLFlag + request.UploadInsecure = uploadBundleInsecureFlag } resp, err := client.DebugBundle(cmd.Context(), request) if err != nil { - return fmt.Errorf("failed to bundle debug: %v", status.Convert(err).Message()) + return daemonCallError("bundle debug", err) } cmd.Printf("Local file:\n%s\n", resp.GetPath()) @@ -155,6 +234,11 @@ func runForDuration(cmd *cobra.Command, args []string) error { return fmt.Errorf("invalid duration format: %v", err) } + anonymizeEnabled, anonymizeLevel, err := effectiveAnonymize() + if err != nil { + return err + } + conn, err := getClient(cmd) if err != nil { return err @@ -239,11 +323,50 @@ func runForDuration(cmd *cobra.Command, args []string) error { }() } + captureStarted := false + if wantCapture, _ := cmd.Flags().GetBool("capture"); wantCapture { + captureTimeout := duration + 30*time.Second + const maxBundleCapture = 10 * time.Minute + if captureTimeout > maxBundleCapture { + captureTimeout = maxBundleCapture + } + _, err := client.StartBundleCapture(cmd.Context(), &proto.StartBundleCaptureRequest{ + Timeout: durationpb.New(captureTimeout), + }) + if err != nil { + cmd.PrintErrf("Failed to start packet capture: %v\n", status.Convert(err).Message()) + } else { + captureStarted = true + cmd.Println("Packet capture started.") + // Safety: always stop on exit, even if the normal stop below runs too. + defer func() { + if captureStarted { + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := client.StopBundleCapture(stopCtx, &proto.StopBundleCaptureRequest{}); err != nil { + cmd.PrintErrf("Failed to stop packet capture: %v\n", err) + } + } + }() + } + } + if waitErr := waitForDurationOrCancel(cmd.Context(), duration, cmd); waitErr != nil { return waitErr } cmd.Println("\nDuration completed") + if captureStarted { + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := client.StopBundleCapture(stopCtx, &proto.StopBundleCaptureRequest{}); err != nil { + cmd.PrintErrf("Failed to stop packet capture: %v\n", err) + } else { + captureStarted = false + cmd.Println("Packet capture stopped.") + } + } + if cpuProfilingStarted { if _, err := client.StopCPUProfile(cmd.Context(), &proto.StopCPUProfileRequest{}); err != nil { cmd.PrintErrf("Failed to stop CPU profiling: %v\n", err) @@ -255,16 +378,19 @@ func runForDuration(cmd *cobra.Command, args []string) error { cmd.Println("Creating debug bundle...") request := &proto.DebugBundleRequest{ - Anonymize: anonymizeFlag, - SystemInfo: systemInfoFlag, - LogFileCount: logFileCount, + Anonymize: anonymizeEnabled, + AnonymizeLevel: anonymizeLevel.String(), + SystemInfo: systemInfoFlag, + LogFileCount: logFileCount, + CliVersion: version.NetbirdVersion(), } if uploadBundleFlag { request.UploadURL = uploadBundleURLFlag + request.UploadInsecure = uploadBundleInsecureFlag } resp, err := client.DebugBundle(cmd.Context(), request) if err != nil { - return fmt.Errorf("failed to bundle debug: %v", status.Convert(err).Message()) + return daemonCallError("bundle debug", err) } if needsRestoreUp { @@ -392,6 +518,7 @@ func generateDebugBundle(config *profilemanager.Config, recorder *peer.Status, c SyncResponse: syncResponse, LogPath: logFilePath, CPUProfile: nil, + DaemonVersion: version.NetbirdVersion(), // acting as daemon }, debug.BundleConfig{ IncludeSystemInfo: true, @@ -411,9 +538,12 @@ func init() { debugBundleCmd.Flags().BoolVarP(&systemInfoFlag, "system-info", "S", true, "Adds system information to the debug bundle") debugBundleCmd.Flags().BoolVarP(&uploadBundleFlag, "upload-bundle", "U", false, "Uploads the debug bundle to a server") debugBundleCmd.Flags().StringVar(&uploadBundleURLFlag, "upload-bundle-url", types.DefaultBundleURL, "Service URL to get an URL to upload the debug bundle") + debugBundleCmd.Flags().BoolVar(&uploadBundleInsecureFlag, "upload-bundle-insecure", false, "Allow uploading to an http or untrusted-TLS upload server (self-hosted); requires root") forCmd.Flags().Uint32VarP(&logFileCount, "log-file-count", "C", 1, "Number of rotated log files to include in debug bundle") forCmd.Flags().BoolVarP(&systemInfoFlag, "system-info", "S", true, "Adds system information to the debug bundle") forCmd.Flags().BoolVarP(&uploadBundleFlag, "upload-bundle", "U", false, "Uploads the debug bundle to a server") forCmd.Flags().StringVar(&uploadBundleURLFlag, "upload-bundle-url", types.DefaultBundleURL, "Service URL to get an URL to upload the debug bundle") + forCmd.Flags().BoolVar(&uploadBundleInsecureFlag, "upload-bundle-insecure", false, "Allow uploading to an http or untrusted-TLS upload server (self-hosted); requires root") + forCmd.Flags().Bool("capture", false, "Capture packets during the debug duration and include in bundle") } diff --git a/client/cmd/jobs.go b/client/cmd/jobs.go new file mode 100644 index 000000000..36aab3570 --- /dev/null +++ b/client/cmd/jobs.go @@ -0,0 +1,13 @@ +package cmd + +// remoteJobsAllowedFlag opts this peer into running remote jobs (e.g. debug +// bundles) requested by the management server. It defaults to false: remote +// jobs are an explicit opt-in, and enabling it is a privileged change (see the +// daemon gate in client/server), mirroring the SSH server opt-in. +const remoteJobsAllowedFlag = "allow-remote-jobs" + +var remoteJobsAllowed bool + +func init() { + upCmd.PersistentFlags().BoolVar(&remoteJobsAllowed, remoteJobsAllowedFlag, false, "Allow the management server to run remote jobs (e.g. debug bundles) on this peer") +} diff --git a/client/cmd/kubernetes.go b/client/cmd/kubernetes.go new file mode 100644 index 000000000..cc91477c6 --- /dev/null +++ b/client/cmd/kubernetes.go @@ -0,0 +1,301 @@ +package cmd + +import ( + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/goccy/go-yaml" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "github.com/netbirdio/netbird/client/proto" +) + +const ( + KubernetesDNSSuffix = "netbird-kubeapi-proxy" +) + +var kubernetesCmd = &cobra.Command{ + Use: "kubernetes", + Short: "Kubernetes cluster commands.", + Long: "Kubernetes cluster commands.", +} + +var kubernetesListCmd = &cobra.Command{ + Use: "list", + RunE: kubernetesList, + Short: "List Kubernetes clusters.", + Long: "List Kubernetes clusters by discovering NetBird peers running netbird-kubeapi-proxy.", +} + +var kubernetesWriteKubeconfigCmd = &cobra.Command{ + Use: "write-kubeconfig", + RunE: kubernetesWriteKubeconfig, + Args: cobra.ExactArgs(1), + Short: "Write kubeconfig for a Kubernetes cluster.", + Long: "Updates kubeconfig in place to allow token-less access to the Kubernetes cluster through NetBird.", +} + +func init() { + kubernetesWriteKubeconfigCmd.Flags().String("kubeconfig", "", "path to kubeconfig file") +} + +func kubernetesList(cmd *cobra.Command, _ []string) error { + conn, err := getClient(cmd) + if err != nil { + return err + } + defer conn.Close() + client := proto.NewDaemonServiceClient(conn) + statusResp, err := client.Status(cmd.Context(), &proto.StatusRequest{GetFullPeerStatus: true}) + if err != nil { + return err + } + + kcs, err := getKubernetesClusters(cmd.Context(), statusResp.FullStatus.Peers, "") + if err != nil { + return err + } + if len(kcs) == 0 { + cmd.Println("No Kubernetes clusters available.") + return nil + } + cmd.Println("Available Kubernetes clusters:") + for _, k := range kcs { + cmd.Printf("\n - Name: %s\n FQDN: %s\n Version: %s\n", k.name, k.url.Host, k.version) + } + return nil +} + +func kubernetesWriteKubeconfig(cmd *cobra.Command, args []string) error { + kubeconfigPath, err := resolveKubeconfigPath(cmd) + if err != nil { + return err + } + + conn, err := getClient(cmd) + if err != nil { + return err + } + defer conn.Close() + client := proto.NewDaemonServiceClient(conn) + statusResp, err := client.Status(cmd.Context(), &proto.StatusRequest{GetFullPeerStatus: true}) + if err != nil { + return err + } + + clusterName := args[0] + kcs, err := getKubernetesClusters(cmd.Context(), statusResp.FullStatus.Peers, clusterName) + if err != nil { + return err + } + if len(kcs) == 0 { + return fmt.Errorf("kubernetes cluster named %s not found", clusterName) + } + if len(kcs) > 1 { + return fmt.Errorf("too many Kubernetes clusters returned") + } + err = writeKubeconfig(kubeconfigPath, kcs[0]) + if err != nil { + return err + } + return nil +} + +type kubernetesCluster struct { + name string + url *url.URL + version string +} + +func getKubernetesClusters(ctx context.Context, peers []*proto.PeerState, nameFilter string) ([]kubernetesCluster, error) { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.TLSClientConfig = &tls.Config{ + InsecureSkipVerify: true, + } + httpClient := &http.Client{ + Transport: transport, + } + resolver := net.Resolver{ + // Required so both DNS records are returned. + // https://github.com/golang/go/issues/17093 + PreferGo: true, + } + + kcs := []kubernetesCluster{} + attempted := map[string]struct{}{} + for _, peer := range peers { + fqdns, err := resolver.LookupAddr(ctx, peer.IP) + if err != nil { + return nil, err + } + for _, fqdn := range fqdns { + if _, ok := attempted[fqdn]; ok { + continue + } + attempted[fqdn] = struct{}{} + comps := strings.Split(fqdn, ".") + if len(comps) < 2 { + continue + } + if comps[1] != KubernetesDNSSuffix { + continue + } + if nameFilter != "" && nameFilter != comps[0] { + continue + } + clusterURL, clusterVersion, err := fingerprintClusters(ctx, httpClient, fqdn) + if err != nil { + log.Debugf("could not fingerprint Kubernetes cluster %s %q", fqdn, err) + continue + } + kc := kubernetesCluster{ + name: comps[0], + url: clusterURL, + version: clusterVersion, + } + if nameFilter != "" { + return []kubernetesCluster{kc}, nil + } + kcs = append(kcs, kc) + } + } + return kcs, nil +} + +func fingerprintClusters(ctx context.Context, httpClient *http.Client, fqdn string) (*url.URL, string, error) { + clusterURL, err := url.Parse("https://" + fqdn) + if err != nil { + return nil, "", err + } + versionURL, err := clusterURL.Parse("/version") + if err != nil { + return nil, "", err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, versionURL.String(), nil) + if err != nil { + return nil, "", err + } + resp, err := httpClient.Do(req) + if err != nil { + return nil, "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, "", fmt.Errorf("expected %d response but got %s", http.StatusOK, resp.Status) + } + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", err + } + versionData := map[string]string{} + err = json.Unmarshal(b, &versionData) + if err != nil { + return nil, "", err + } + version, ok := versionData["gitVersion"] + if !ok { + return nil, "", errors.New("no version found in response") + } + return clusterURL, version, nil +} + +func resolveKubeconfigPath(cmd *cobra.Command) (string, error) { + if cmd.Flags().Changed("kubeconfig") { + path, err := cmd.Flags().GetString("kubeconfig") + if err != nil { + return "", err + } + return path, nil + } + if env := os.Getenv("KUBECONFIG"); env != "" { + return env, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("could not determine home directory: %w", err) + } + return filepath.Join(home, ".kube", "config"), nil +} + +func writeKubeconfig(kubeconfigPath string, kc kubernetesCluster) error { + b, err := os.ReadFile(kubeconfigPath) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + var cfg map[string]any + if err := yaml.Unmarshal(b, &cfg); err != nil { + return err + } + if cfg == nil { + cfg = map[string]any{ + "apiVersion": "v1", + "kind": "Config", + } + } + + cfg["clusters"] = appendWithName(cfg["clusters"], map[string]any{ + "name": kc.name, + "cluster": map[string]any{ + "server": kc.url.String(), + "insecure-skip-tls-verify": true, + }, + }) + cfg["users"] = appendWithName(cfg["users"], map[string]any{ + "name": "netbird", + "user": map[string]any{ + "token": "none", + }, + }) + cfg["contexts"] = appendWithName(cfg["contexts"], map[string]any{ + "name": kc.name, + "context": map[string]any{ + "cluster": kc.name, + "user": "netbird", + "namespace": "default", + }, + }) + cfg["current-context"] = kc.name + + out, err := yaml.Marshal(cfg) + if err != nil { + return err + } + if err := os.WriteFile(kubeconfigPath, out, 0o600); err != nil { + return err + } + return nil +} + +func appendWithName(data any, add map[string]any) any { + if data == nil { + return []any{add} + } + v, ok := data.([]any) + if !ok { + return []any{add} + } + i := slices.IndexFunc(v, func(item any) bool { + m, ok := item.(map[string]any) + if !ok { + return false + } + return m["name"] == add["name"] + }) + if i == -1 { + return append(v, add) + } + v[i] = add + return v +} diff --git a/client/cmd/kubernetes_test.go b/client/cmd/kubernetes_test.go new file mode 100644 index 000000000..c40d20996 --- /dev/null +++ b/client/cmd/kubernetes_test.go @@ -0,0 +1,120 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +func TestFingerprintClusters(t *testing.T) { + t.Parallel() + + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + //nolint: errcheck + w.Write([]byte(`{"gitVersion": "foobar"}`)) + })) + defer srv.Close() + + clusterURL, clusterVersion, err := fingerprintClusters(t.Context(), srv.Client(), srv.Listener.Addr().String()) + require.NoError(t, err) + require.Equal(t, srv.URL, clusterURL.String()) + require.Equal(t, "foobar", clusterVersion) +} + +func TestResolveKubeconfigPath(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Fatalf("could not determine home directory: %v", err) + } + defaultPath := filepath.Join(home, ".kube", "config") + path, err := resolveKubeconfigPath(&cobra.Command{}) + require.NoError(t, err) + require.Equal(t, defaultPath, path) + + flagPath := "flag-path" + cmd := &cobra.Command{} + cmd.Flags().String("kubeconfig", "", "") + err = cmd.Flags().Set("kubeconfig", flagPath) + require.NoError(t, err) + path, err = resolveKubeconfigPath(cmd) + require.NoError(t, err) + require.Equal(t, flagPath, path) + + envPath := "env-path" + t.Setenv("KUBECONFIG", envPath) + path, err = resolveKubeconfigPath(&cobra.Command{}) + require.NoError(t, err) + require.Equal(t, envPath, path) +} + +func TestWriteKubeconfig(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + existing string + }{ + { + name: "empty file", + }, + { + name: "existing content", + existing: `apiVersion: v1 +clusters: +- cluster: + insecure-skip-tls-verify: true + server: https://foobar.com + name: foo +current-context: test +kind: Config +users: [] +`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + kubeconfigPath := filepath.Join(t.TempDir(), "config") + err := os.WriteFile(kubeconfigPath, []byte(tt.existing), 0o644) + require.NoError(t, err) + + kc := kubernetesCluster{ + name: "foo", + url: &url.URL{Scheme: "https", Host: "example.com"}, + } + err = writeKubeconfig(kubeconfigPath, kc) + require.NoError(t, err) + + b, err := os.ReadFile(kubeconfigPath) + require.NoError(t, err) + expected := `apiVersion: v1 +clusters: +- cluster: + insecure-skip-tls-verify: true + server: https://example.com + name: foo +contexts: +- context: + cluster: foo + namespace: default + user: netbird + name: foo +current-context: foo +kind: Config +users: +- name: netbird + user: + token: none +` + require.Equal(t, expected, string(b)) + }) + } + +} diff --git a/client/cmd/login.go b/client/cmd/login.go index 4521a67c9..11867be09 100644 --- a/client/cmd/login.go +++ b/client/cmd/login.go @@ -4,27 +4,38 @@ import ( "context" "fmt" "os" - "os/user" - "runtime" "strings" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" + "golang.org/x/term" "google.golang.org/grpc/codes" gstatus "google.golang.org/grpc/status" "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" + nbnet "github.com/netbirdio/netbird/client/net" "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/client/server" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/util" ) +// extendSessionFlag drives the `netbird login --extend` flow: refresh the +// SSO session expiry on the management server without tearing down the +// tunnel. Mutually exclusive with setup-key login (a setup-key cannot +// refresh an SSO-tracked peer — see auth.errSetupKeyOnSSOExpiredPeer). +var extendSessionFlag bool + func init() { loginCmd.PersistentFlags().BoolVar(&noBrowser, noBrowserFlag, false, noBrowserDesc) + loginCmd.PersistentFlags().BoolVar(&showQR, showQRFlag, false, showQRDesc) loginCmd.PersistentFlags().StringVar(&profileName, profileNameFlag, "", profileNameDesc) loginCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "(DEPRECATED) Netbird config file location") + loginCmd.PersistentFlags().BoolVar(&extendSessionFlag, "extend", false, + "refresh the SSO session expiry without tearing down the tunnel (requires an active connection)") } var loginCmd = &cobra.Command{ @@ -42,7 +53,7 @@ var loginCmd = &cobra.Command{ // nolint ctx = context.WithValue(ctx, system.DeviceNameCtxKey, hostName) } - username, err := user.Current() + username, err := profilemanager.InvokingUser() if err != nil { return fmt.Errorf("get current user: %v", err) } @@ -59,6 +70,16 @@ var loginCmd = &cobra.Command{ return err } + if extendSessionFlag { + if providedSetupKey != "" { + return fmt.Errorf("--extend cannot be combined with a setup key; setup keys can only enrol new peers") + } + if err := doExtendSession(ctx, cmd, activeProf); err != nil { + return fmt.Errorf("extend session failed: %v", err) + } + return nil + } + // workaround to run without service if util.FindFirstLogPath(logFiles) == "" { if err := doForegroundLogin(ctx, cmd, providedSetupKey, activeProf); err != nil { @@ -71,7 +92,7 @@ var loginCmd = &cobra.Command{ return fmt.Errorf("daemon login failed: %v", err) } - cmd.Println("Logging successfully") + cmd.Println("Login successful") return nil }, @@ -94,17 +115,19 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str dnsLabelsReq = dnsLabelsValidated.ToSafeStringList() } + handle := activeProf.ID.String() + loginRequest := proto.LoginRequest{ SetupKey: providedSetupKey, ManagementUrl: managementURL, - IsUnixDesktopClient: isUnixRunningDesktop(), + IsUnixDesktopClient: util.HasGraphicalSession(), Hostname: hostName, DnsLabels: dnsLabelsReq, - ProfileName: &activeProf.Name, + ProfileName: &handle, Username: &username, } - profileState, err := pm.GetProfileState(activeProf.Name) + profileState, err := pm.GetProfileState(activeProf.ID) if err != nil { log.Debugf("failed to get profile state for login hint: %v", err) } else if profileState.Email != "" { @@ -148,13 +171,73 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str return nil } +// doExtendSession drives the daemon's RequestExtendAuthSession / +// WaitExtendAuthSession pair. The user is sent through a regular SSO flow +// (browser + verification URL) and the resulting JWT is forwarded to the +// management server's ExtendAuthSession RPC. The tunnel stays up +// throughout — no Down/Up, no network-map resync. +func doExtendSession(ctx context.Context, cmd *cobra.Command, activeProf *profilemanager.Profile) error { + conn, err := DialClientGRPCServer(ctx, daemonAddr) + if err != nil { + //nolint + return fmt.Errorf("failed to connect to daemon error: %v\n"+ + "If the daemon is not running please run: "+ + "\nnetbird service install \nnetbird service start\n", err) + } + defer conn.Close() + + client := proto.NewDaemonServiceClient(conn) + + // the CLI runs in the user's session, the daemon does not: tell it what we can see + req := &proto.RequestExtendAuthSessionRequest{HasGraphicalSession: util.HasGraphicalSession()} + // Pre-fill the IdP login hint from the resolved profile so the user + // doesn't have to retype their email. Best-effort: we still proceed + // without a hint if the lookup fails. + pm := profilemanager.NewProfileManager() + if profState, perr := pm.GetProfileState(activeProf.ID); perr == nil && profState.Email != "" { + req.Hint = &profState.Email + } + + startResp, err := client.RequestExtendAuthSession(ctx, req) + if err != nil { + return fmt.Errorf("start extend session: %v", err) + } + + uri := startResp.GetVerificationURIComplete() + if uri == "" { + uri = startResp.GetVerificationURI() + } + openURL(cmd, uri, startResp.GetUserCode(), noBrowser, showQR) + + waitResp, err := client.WaitExtendAuthSession(ctx, &proto.WaitExtendAuthSessionRequest{ + DeviceCode: startResp.GetDeviceCode(), + UserCode: startResp.GetUserCode(), + }) + if err != nil { + return fmt.Errorf("wait for extend session: %v", err) + } + + if ts := waitResp.GetSessionExpiresAt(); ts.IsValid() && !ts.AsTime().IsZero() { + deadline := ts.AsTime().Local() + cmd.Printf("Session extended. New expiry: %s\n", deadline.Format("2006-01-02 15:04:05 MST")) + } else { + // Management reported the peer is not eligible (e.g. login + // expiration disabled on the account). Surface that fact + // instead of pretending the call succeeded. + cmd.Println("Session extension call completed, but the management server did not return a new deadline (peer may not be SSO-tracked or login expiration is disabled).") + } + return nil +} + func getActiveProfile(ctx context.Context, pm *profilemanager.ProfileManager, profileName string, username string) (*profilemanager.Profile, error) { // switch profile if provided if profileName != "" { - if err := switchProfileOnDaemon(ctx, pm, profileName, username); err != nil { + prof, err := switchProfileOnDaemon(ctx, pm, profileName, username) + if err != nil { return nil, fmt.Errorf("switch profile: %v", err) } + return prof, nil } activeProf, err := pm.GetActiveProfile() @@ -168,21 +251,19 @@ func getActiveProfile(ctx context.Context, pm *profilemanager.ProfileManager, pr return activeProf, nil } -func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManager, profileName string, username string) error { - err := switchProfile(context.Background(), profileName, username) +func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManager, handle string, username string) (*profilemanager.Profile, error) { + resolvedID, err := switchProfile(ctx, handle, username) if err != nil { - return fmt.Errorf("switch profile on daemon: %v", err) + return nil, fmt.Errorf("switch profile on daemon: %v", err) } - err = pm.SwitchProfile(profileName) - if err != nil { - return fmt.Errorf("switch profile: %v", err) + if err := pm.SwitchProfile(resolvedID); err != nil { + return nil, fmt.Errorf("switch profile: %v", err) } conn, err := DialClientGRPCServer(ctx, daemonAddr) if err != nil { - log.Errorf("failed to connect to service CLI interface %v", err) - return err + return nil, fmt.Errorf("connect to service CLI interface: %w", err) } defer conn.Close() @@ -190,24 +271,28 @@ func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManage status, err := client.Status(ctx, &proto.StatusRequest{}) if err != nil { - return fmt.Errorf("unable to get daemon status: %v", err) + return nil, fmt.Errorf("unable to get daemon status: %v", err) } if status.Status == string(internal.StatusConnected) { if _, err := client.Down(ctx, &proto.DownRequest{}); err != nil { log.Errorf("call service down method: %v", err) - return err + return nil, err } } - return nil + return &profilemanager.Profile{ID: resolvedID}, nil } -func switchProfile(ctx context.Context, profileName string, username string) error { +// switchProfile asks the daemon to switch to the profile identified by +// handle (a name, ID, or unique ID prefix). Returns the resolved profile +// ID so the caller can update the local active-profile state without +// re-resolving the handle. +func switchProfile(ctx context.Context, handle string, username string) (profilemanager.ID, error) { conn, err := DialClientGRPCServer(ctx, daemonAddr) if err != nil { //nolint - return fmt.Errorf("failed to connect to daemon error: %v\n"+ + return "", fmt.Errorf("failed to connect to daemon error: %v\n"+ "If the daemon is not running please run: "+ "\nnetbird service install \nnetbird service start\n", err) } @@ -215,15 +300,15 @@ func switchProfile(ctx context.Context, profileName string, username string) err client := proto.NewDaemonServiceClient(conn) - _, err = client.SwitchProfile(ctx, &proto.SwitchProfileRequest{ - ProfileName: &profileName, + resp, err := client.SwitchProfile(ctx, &proto.SwitchProfileRequest{ + ProfileName: &handle, Username: &username, }) if err != nil { - return fmt.Errorf("switch profile failed: %v", err) + return "", fmt.Errorf("switch profile failed: %w", err) } - return nil + return profilemanager.ID(resp.Id), nil } func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string, activeProf *profilemanager.Profile) error { @@ -246,17 +331,30 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string, if err != nil { return fmt.Errorf("read config file %s: %v", configFilePath, err) } + // CLI standalone login: profilemanager no longer auto-applies MDM, + // so layer in the OS-native policy here. Desktop builds construct + // a Loader with no fetcher — the build-tagged loadPlatform reads + // the registry/plist directly. + config.ApplyMDMPolicy(mdm.NewLoader(nil).Load()) - err = foregroundLogin(ctx, cmd, config, setupKey, activeProf.Name) + // Mirror runInForegroundMode: recover residual state (DNS, firewall, + // ssh config, legacy routing) from a previous unclean shutdown and + // enable advanced routing before dialing management. + if err := server.RestoreResidualState(ctx, profilemanager.NewServiceManager(configFilePath).GetStatePath()); err != nil { + log.Warnf("failed to restore residual state: %v", err) + } + nbnet.Init() + + err = foregroundLogin(ctx, cmd, config, setupKey, activeProf.ID) if err != nil { return fmt.Errorf("foreground login failed: %v", err) } - cmd.Println("Logging successfully") + cmd.Println("Login successful") return nil } func handleSSOLogin(ctx context.Context, cmd *cobra.Command, loginResp *proto.LoginResponse, client proto.DaemonServiceClient, pm *profilemanager.ProfileManager) error { - openURL(cmd, loginResp.VerificationURIComplete, loginResp.UserCode, noBrowser) + openURL(cmd, loginResp.VerificationURIComplete, loginResp.UserCode, noBrowser, showQR) resp, err := client.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{UserCode: loginResp.UserCode, Hostname: hostName}) if err != nil { @@ -275,7 +373,7 @@ func handleSSOLogin(ctx context.Context, cmd *cobra.Command, loginResp *proto.Lo return nil } -func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, setupKey, profileName string) error { +func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, setupKey string, profileID profilemanager.ID) error { authClient, err := auth.NewAuth(ctx, config.PrivateKey, config.ManagementURL, config) if err != nil { return fmt.Errorf("failed to create auth client: %v", err) @@ -289,7 +387,7 @@ func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profileman jwtToken := "" if setupKey == "" && needsLogin { - tokenInfo, err := foregroundGetTokenInfo(ctx, cmd, config, profileName) + tokenInfo, err := foregroundGetTokenInfo(ctx, cmd, config, profileID) if err != nil { return fmt.Errorf("interactive sso login failed: %v", err) } @@ -304,17 +402,17 @@ func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profileman return nil } -func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, profileName string) (*auth.TokenInfo, error) { +func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, profileID profilemanager.ID) (*auth.TokenInfo, error) { hint := "" pm := profilemanager.NewProfileManager() - profileState, err := pm.GetProfileState(profileName) + profileState, err := pm.GetProfileState(profileID) if err != nil { log.Debugf("failed to get profile state for login hint: %v", err) } else if profileState.Email != "" { hint = profileState.Email } - oAuthFlow, err := auth.NewOAuthFlow(ctx, config, isUnixRunningDesktop(), false, hint) + oAuthFlow, err := auth.NewOAuthFlow(ctx, config, util.HasGraphicalSession(), false, hint) if err != nil { return nil, err } @@ -324,7 +422,7 @@ func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *pro return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err) } - openURL(cmd, flowInfo.VerificationURIComplete, flowInfo.UserCode, noBrowser) + openURL(cmd, flowInfo.VerificationURIComplete, flowInfo.UserCode, noBrowser, showQR) tokenInfo, err := oAuthFlow.WaitToken(context.TODO(), flowInfo) if err != nil { @@ -334,7 +432,7 @@ func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *pro return &tokenInfo, nil } -func openURL(cmd *cobra.Command, verificationURIComplete, userCode string, noBrowser bool) { +func openURL(cmd *cobra.Command, verificationURIComplete, userCode string, noBrowser, showQR bool) { var codeMsg string if userCode != "" && !strings.Contains(verificationURIComplete, userCode) { codeMsg = fmt.Sprintf("and enter the code %s to authenticate.", userCode) @@ -348,6 +446,12 @@ func openURL(cmd *cobra.Command, verificationURIComplete, userCode string, noBro verificationURIComplete + " " + codeMsg) } + if showQR { + if f, ok := cmd.OutOrStdout().(*os.File); ok && term.IsTerminal(int(f.Fd())) { + printQRCode(f, verificationURIComplete) + } + } + cmd.Println("") if !noBrowser { @@ -358,14 +462,6 @@ func openURL(cmd *cobra.Command, verificationURIComplete, userCode string, noBro } } -// isUnixRunningDesktop checks if a Linux OS is running desktop environment -func isUnixRunningDesktop() bool { - if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" { - return false - } - return os.Getenv("DESKTOP_SESSION") != "" || os.Getenv("XDG_CURRENT_DESKTOP") != "" -} - func setEnvAndFlags(cmd *cobra.Command) error { SetFlagsFromEnvVars(rootCmd) diff --git a/client/cmd/login_test.go b/client/cmd/login_test.go index 47522e189..0aa1856b1 100644 --- a/client/cmd/login_test.go +++ b/client/cmd/login_test.go @@ -27,7 +27,7 @@ func TestLogin(t *testing.T) { profilemanager.ActiveProfileStatePath = tempDir + "/active_profile.json" sm := profilemanager.ServiceManager{} err = sm.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: "default", + ID: "default", Username: currUser.Username, }) if err != nil { diff --git a/client/cmd/logout.go b/client/cmd/logout.go index 1a5281acb..cf2a4e446 100644 --- a/client/cmd/logout.go +++ b/client/cmd/logout.go @@ -3,11 +3,11 @@ package cmd import ( "context" "fmt" - "os/user" "time" "github.com/spf13/cobra" + "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/proto" ) @@ -37,7 +37,7 @@ var logoutCmd = &cobra.Command{ if profileName != "" { req.ProfileName = &profileName - currUser, err := user.Current() + currUser, err := profilemanager.InvokingUser() if err != nil { return fmt.Errorf("get current user: %v", err) } @@ -46,7 +46,7 @@ var logoutCmd = &cobra.Command{ } if _, err := daemonClient.Logout(ctx, req); err != nil { - return fmt.Errorf("deregister: %v", err) + return daemonCallError("deregister", err) } cmd.Println("Deregistered successfully") diff --git a/client/cmd/profile.go b/client/cmd/profile.go index d6e81760f..2d6653537 100644 --- a/client/cmd/profile.go +++ b/client/cmd/profile.go @@ -2,11 +2,15 @@ package cmd import ( "context" + "errors" "fmt" - "os/user" + "strings" + "text/tabwriter" "time" "github.com/spf13/cobra" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/profilemanager" @@ -14,6 +18,8 @@ import ( "github.com/netbirdio/netbird/util" ) +var profileListShowID bool + var profileCmd = &cobra.Command{ Use: "profile", Short: "Manage NetBird client profiles", @@ -31,27 +37,40 @@ var profileListCmd = &cobra.Command{ var profileAddCmd = &cobra.Command{ Use: "add ", Short: "Add a new profile", - Long: `Add a new profile to the NetBird client. The profile name must be unique.`, + Long: `Add a new profile. Profile name is free-form, a unique ID is generated for the on-disk config file.`, Args: cobra.ExactArgs(1), RunE: addProfileFunc, } +var profileRenameCmd = &cobra.Command{ + Use: "rename ", + Short: "Renames an existing profile", + Long: `Renames an existing profile (by a name, ID, or unique ID prefix). Profile name is free-form.`, + Args: cobra.ExactArgs(2), + RunE: renameProfileFunc, +} + var profileRemoveCmd = &cobra.Command{ - Use: "remove ", - Short: "Remove a profile", - Long: `Remove a profile from the NetBird client. The profile must not be inactive.`, - Args: cobra.ExactArgs(1), - RunE: removeProfileFunc, + Use: "remove ", + Short: "Remove a profile", + Long: `Remove a profile by name, ID, or unique ID prefix.`, + Aliases: []string{"rm"}, + Args: cobra.ExactArgs(1), + RunE: removeProfileFunc, } var profileSelectCmd = &cobra.Command{ - Use: "select ", + Use: "select ", Short: "Select a profile", - Long: `Make the specified profile active. This will switch the client to use the selected profile's configuration.`, + Long: `Make the specified profile active. Accepts a name, ID, or unique ID prefix.`, Args: cobra.ExactArgs(1), RunE: selectProfileFunc, } +func init() { + profileListCmd.Flags().BoolVar(&profileListShowID, "show-id", false, "show the profile ID column") +} + func setupCmd(cmd *cobra.Command) error { SetFlagsFromEnvVars(rootCmd) SetFlagsFromEnvVars(cmd) @@ -65,6 +84,7 @@ func setupCmd(cmd *cobra.Command) error { return nil } + func listProfilesFunc(cmd *cobra.Command, _ []string) error { if err := setupCmd(cmd); err != nil { return err @@ -76,32 +96,40 @@ func listProfilesFunc(cmd *cobra.Command, _ []string) error { } defer conn.Close() - currUser, err := user.Current() + currUser, err := profilemanager.InvokingUser() if err != nil { return fmt.Errorf("get current user: %w", err) } daemonClient := proto.NewDaemonServiceClient(conn) - profiles, err := daemonClient.ListProfiles(cmd.Context(), &proto.ListProfilesRequest{ + resp, err := daemonClient.ListProfiles(cmd.Context(), &proto.ListProfilesRequest{ Username: currUser.Username, }) if err != nil { return err } - // list profiles, add a tick if the profile is active - cmd.Println("Found", len(profiles.Profiles), "profiles:") - for _, profile := range profiles.Profiles { - // use a cross to indicate the passive profiles - activeMarker := "✗" - if profile.IsActive { - activeMarker = "✓" - } - cmd.Println(activeMarker, profile.Name) + tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + if profileListShowID { + fmt.Fprintln(tw, "ID\tNAME\tACTIVE") + } else { + fmt.Fprintln(tw, "NAME\tACTIVE") } - - return nil + for _, profile := range resp.Profiles { + marker := "" + if profile.IsActive { + marker = "✓" + } + name := profilemanager.StripCtrlChars(profile.Name) + id := profilemanager.ID(profile.Id) + if profileListShowID { + fmt.Fprintf(tw, "%s\t%s\t%s\n", id.ShortID(), name, marker) + } else { + fmt.Fprintf(tw, "%s\t%s\n", name, marker) + } + } + return tw.Flush() } func addProfileFunc(cmd *cobra.Command, args []string) error { @@ -109,33 +137,90 @@ func addProfileFunc(cmd *cobra.Command, args []string) error { return err } + currUser, err := profilemanager.InvokingUser() + if err != nil { + return fmt.Errorf("get current user: %w", err) + } + + conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr) + if err != nil { + return fmt.Errorf("connect to service CLI interface: %w", err) + } + defer conn.Close() + + daemonClient := proto.NewDaemonServiceClient(conn) + profileName := args[0] + + id, err := addProfileOnDaemon(cmd.Context(), daemonClient, profileName, currUser.Username) + if err != nil { + return err + } + + dupCount, _ := countProfilesWithName(cmd.Context(), daemonClient, currUser.Username, profileName) + if dupCount > 1 { + cmd.Printf("Warning: %d other profile(s) already use the name %q.\n", dupCount-1, profileName) + cmd.Println("Use `netbird profile list --show-id` to disambiguate later.") + } + + cmd.Printf("Profile added: %s %s\n", id.ShortID(), profilemanager.StripCtrlChars(profileName)) + return nil + +} + +func renameProfileFunc(cmd *cobra.Command, args []string) error { + if err := setupCmd(cmd); err != nil { + return err + } + conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr) if err != nil { return fmt.Errorf("connect to service CLI interface: %w", err) } defer conn.Close() - currUser, err := user.Current() + currUser, err := profilemanager.InvokingUser() if err != nil { return fmt.Errorf("get current user: %w", err) } daemonClient := proto.NewDaemonServiceClient(conn) + handle := args[0] + newProfilename := args[1] - profileName := args[0] - - _, err = daemonClient.AddProfile(cmd.Context(), &proto.AddProfileRequest{ - ProfileName: profileName, - Username: currUser.Username, + resp, err := daemonClient.RenameProfile(cmd.Context(), &proto.RenameProfileRequest{ + Handle: handle, + Username: currUser.Username, + NewProfileName: newProfilename, }) if err != nil { - return err + return wrapAmbiguityError(err, handle) } - cmd.Println("Profile added successfully:", profileName) + dupCount, _ := countProfilesWithName(cmd.Context(), daemonClient, currUser.Username, newProfilename) + if dupCount > 1 { + cmd.Printf("Warning: %d other profile(s) already use the name %q.\n", dupCount-1, newProfilename) + cmd.Println("Use `netbird profile list --show-id` to disambiguate later.") + } + + cmd.Printf("Profile renamed from %s to %s\n", profilemanager.StripCtrlChars(resp.OldProfileName), profilemanager.StripCtrlChars(newProfilename)) + return nil } +func countProfilesWithName(ctx context.Context, c proto.DaemonServiceClient, username, name string) (int, error) { + resp, err := c.ListProfiles(ctx, &proto.ListProfilesRequest{Username: username}) + if err != nil { + return 0, err + } + n := 0 + for _, p := range resp.Profiles { + if p.Name == name { + n++ + } + } + return n, nil +} + func removeProfileFunc(cmd *cobra.Command, args []string) error { if err := setupCmd(cmd); err != nil { return err @@ -147,24 +232,23 @@ func removeProfileFunc(cmd *cobra.Command, args []string) error { } defer conn.Close() - currUser, err := user.Current() + currUser, err := profilemanager.InvokingUser() if err != nil { return fmt.Errorf("get current user: %w", err) } daemonClient := proto.NewDaemonServiceClient(conn) + handle := args[0] - profileName := args[0] - - _, err = daemonClient.RemoveProfile(cmd.Context(), &proto.RemoveProfileRequest{ - ProfileName: profileName, + resp, err := daemonClient.RemoveProfile(cmd.Context(), &proto.RemoveProfileRequest{ + ProfileName: handle, Username: currUser.Username, }) if err != nil { - return err + return wrapAmbiguityError(err, handle) } - cmd.Println("Profile removed successfully:", profileName) + cmd.Printf("Profile removed: %s\n", resp.Id) return nil } @@ -174,9 +258,9 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error { } profileManager := profilemanager.NewProfileManager() - profileName := args[0] + handle := args[0] - currUser, err := user.Current() + currUser, err := profilemanager.InvokingUser() if err != nil { return fmt.Errorf("get current user: %w", err) } @@ -191,32 +275,15 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error { daemonClient := proto.NewDaemonServiceClient(conn) - profiles, err := daemonClient.ListProfiles(ctx, &proto.ListProfilesRequest{ - Username: currUser.Username, + switchResp, err := daemonClient.SwitchProfile(ctx, &proto.SwitchProfileRequest{ + ProfileName: &handle, + Username: &currUser.Username, }) if err != nil { - return fmt.Errorf("list profiles: %w", err) + return wrapAmbiguityError(err, handle) } - var profileExists bool - - for _, profile := range profiles.Profiles { - if profile.Name == profileName { - profileExists = true - break - } - } - - if !profileExists { - return fmt.Errorf("profile %s does not exist", profileName) - } - - if err := switchProfile(cmd.Context(), profileName, currUser.Username); err != nil { - return err - } - - err = profileManager.SwitchProfile(profileName) - if err != nil { + if err := profileManager.SwitchProfile(profilemanager.ID(switchResp.Id)); err != nil { return err } @@ -231,6 +298,46 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error { } } - cmd.Println("Profile switched successfully to:", profileName) + id := profilemanager.ID(switchResp.Id) + cmd.Printf("Profile switched to: %s\n", id.ShortID()) return nil } + +// wrapAmbiguityError turns the daemon's gRPC InvalidArgument errors +// (which carry the resolver's message verbatim) into CLI-friendly text +// that points the user at --show-id. +func wrapAmbiguityError(err error, handle string) error { + if err == nil { + return nil + } + st, ok := gstatus.FromError(err) + if !ok { + return err + } + switch st.Code() { + case codes.InvalidArgument: + msg := st.Message() + if strings.Contains(msg, "ambiguous") { + return errors.New(msg + "\nRun `netbird profile list --show-id` to see IDs, then select by ID prefix:\n netbird profile select|remove ") + } + case codes.NotFound: + return fmt.Errorf("profile %q not found", handle) + } + return err +} + +// addProfileOnDaemon issues the AddProfile RPC on an existing daemon client +// and returns the new profile's ID. It is the single entry point for profile +// creation, shared by `netbird profile add` and the `netbird up --profile +// ` auto-create path. +func addProfileOnDaemon(ctx context.Context, client proto.DaemonServiceClient, profileName, username string) (profilemanager.ID, error) { + resp, err := client.AddProfile(ctx, &proto.AddProfileRequest{ + ProfileName: profileName, + Username: username, + }) + if err != nil { + return "", fmt.Errorf("add profile failed: %w", err) + } + + return profilemanager.ID(resp.Id), nil +} diff --git a/client/cmd/qr.go b/client/cmd/qr.go new file mode 100644 index 000000000..8b2c489ff --- /dev/null +++ b/client/cmd/qr.go @@ -0,0 +1,25 @@ +package cmd + +import ( + "io" + + "github.com/mdp/qrterminal/v3" +) + +// printQRCode prints a QR code for the given URL to the writer. +// Called only when the user explicitly requests QR output via --qr. +func printQRCode(w io.Writer, url string) { + if url == "" { + return + } + qrterminal.GenerateWithConfig(url, qrterminal.Config{ + Level: qrterminal.M, + Writer: w, + HalfBlocks: true, + BlackChar: qrterminal.BLACK_BLACK, + WhiteChar: qrterminal.WHITE_WHITE, + BlackWhiteChar: qrterminal.BLACK_WHITE, + WhiteBlackChar: qrterminal.WHITE_BLACK, + QuietZone: qrterminal.QUIET_ZONE, + }) +} diff --git a/client/cmd/qr_test.go b/client/cmd/qr_test.go new file mode 100644 index 000000000..d12705b9e --- /dev/null +++ b/client/cmd/qr_test.go @@ -0,0 +1,26 @@ +package cmd + +import ( + "bytes" + "testing" +) + +func TestPrintQRCode_EmptyURL(t *testing.T) { + var buf bytes.Buffer + + printQRCode(&buf, "") + + if buf.Len() != 0 { + t.Error("expected no output for empty URL") + } +} + +func TestPrintQRCode_WritesOutput(t *testing.T) { + var buf bytes.Buffer + + printQRCode(&buf, "https://example.com/auth") + + if buf.Len() == 0 { + t.Error("expected QR code output for non-empty URL") + } +} diff --git a/client/cmd/root.go b/client/cmd/root.go index c872fe9f6..be6479440 100644 --- a/client/cmd/root.go +++ b/client/cmd/root.go @@ -20,9 +20,10 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" + "github.com/netbirdio/netbird/client/anonymize" daddr "github.com/netbirdio/netbird/client/internal/daemonaddr" + "github.com/netbirdio/netbird/client/internal/localmetrics" "github.com/netbirdio/netbird/client/internal/profilemanager" ) @@ -31,6 +32,8 @@ const ( dnsResolverAddress = "dns-resolver-address" enableRosenpassFlag = "enable-rosenpass" rosenpassPermissiveFlag = "rosenpass-permissive" + enableLocalMetricsFlag = "enable-local-metrics" + localMetricsAddressFlag = "local-metrics-address" preSharedKeyFlag = "preshared-key" interfaceNameFlag = "interface-name" wireguardPortFlag = "wireguard-port" @@ -70,12 +73,18 @@ var ( autoConnectDisabled bool extraIFaceBlackList []string anonymizeFlag bool + anonymizeLevelFlag string dnsRouteInterval time.Duration - lazyConnEnabled bool - mtu uint16 - profilesDisabled bool - updateSettingsDisabled bool - networksDisabled bool + // lazyConnEnabled is the parse target for the deprecated --enable-lazy-connection + // flag. The flag is inert; the value is no longer read (use NB_LAZY_CONN instead). + lazyConnEnabled bool + mtu uint16 + profilesDisabled bool + updateSettingsDisabled bool + captureEnabled bool + networksDisabled bool + localMetricsEnabled bool + localMetricsAddr string rootCmd = &cobra.Command{ Use: "netbird", @@ -88,13 +97,16 @@ var ( // Don't resolve for service commands — they create the socket, not connect to it. if !isServiceCmd(cmd) { daemonAddr = daddr.ResolveUnixDaemonAddr(daemonAddr) + daemonAddr = daddr.ResolveDaemonAddr(daemonAddr) } return nil }, } ) -// Execute executes the root command. +// Execute runs the appropriate Cobra command for the CLI. +// If the process is the update binary it delegates to updateCmd; otherwise it runs the root command. +// It returns any error produced during command execution. func Execute() error { if isUpdateBinary() { return updateCmd.Execute() @@ -102,6 +114,16 @@ func Execute() error { return rootCmd.Execute() } +// init initialises package-level defaults and configures the root +// Cobra command tree. Sets platform-specific config / log directory +// paths (including legacy Wiretrustee fallbacks) and a default daemon +// address; registers persistent CLI flags (daemon address, +// management / admin URLs, logging, setup key (file and inline, +// mutually exclusive), preshared key, hostname, anonymise, config +// path); attaches top-level and nested subcommands to the root +// command; and registers `up`-specific persistent flags (external IP +// maps, custom DNS resolver address, Rosenpass options, auto-connect +// disabling, lazy connection). func init() { defaultConfigPathDir = "/etc/netbird/" defaultLogFileDir = "/var/log/netbird/" @@ -128,10 +150,10 @@ func init() { defaultDaemonAddr := "unix:///var/run/netbird.sock" if runtime.GOOS == "windows" { - defaultDaemonAddr = "tcp://127.0.0.1:41731" + defaultDaemonAddr = daddr.WindowsPipeAddr } - rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp]://[path|host:port]") + rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp|npipe]://[path|host:port|name]") rootCmd.PersistentFlags().StringVarP(&managementURL, "management-url", "m", "", fmt.Sprintf("Management Service URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultManagementURL)) rootCmd.PersistentFlags().StringVar(&adminURL, "admin-url", "", fmt.Sprintf("Admin Panel URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultAdminURL)) rootCmd.PersistentFlags().StringVarP(&logLevel, "log-level", "l", "info", "sets NetBird log level") @@ -141,8 +163,9 @@ func init() { rootCmd.MarkFlagsMutuallyExclusive("setup-key", "setup-key-file") rootCmd.PersistentFlags().StringVar(&preSharedKey, preSharedKeyFlag, "", "Sets WireGuard PreSharedKey property. If set, then only peers that have the same key can communicate.") rootCmd.PersistentFlags().StringVarP(&hostName, "hostname", "n", "", "Sets a custom hostname for the device") - rootCmd.PersistentFlags().BoolVarP(&anonymizeFlag, "anonymize", "A", false, "anonymize IP addresses and non-netbird.io domains in logs and status output") - rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", defaultConfigPath, "Overrides the default profile file location") + rootCmd.PersistentFlags().BoolVarP(&anonymizeFlag, "anonymize", "A", false, "anonymize public IP addresses, MAC addresses, and non-netbird.io domains in logs and status output; private, CGNAT, and link-local IP ranges are kept (see --anonymize-level strict)") + rootCmd.PersistentFlags().StringVar(&anonymizeLevelFlag, "anonymize-level", "", "anonymization level: \"default\" or \"strict\"; strict also anonymizes private, CGNAT, and link-local IP ranges, peer names, and WireGuard public keys. Setting this flag implies --anonymize") + rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", profilemanager.DefaultConfigPath, "Overrides the default profile file location") rootCmd.AddCommand(upCmd) rootCmd.AddCommand(downCmd) @@ -167,10 +190,17 @@ func init() { logCmd.AddCommand(logLevelCmd) debugCmd.AddCommand(forCmd) debugCmd.AddCommand(persistenceCmd) + debugCmd.AddCommand(debugConfigCmd) + + // kubernetes commands + rootCmd.AddCommand(kubernetesCmd) + kubernetesCmd.AddCommand(kubernetesListCmd) + kubernetesCmd.AddCommand(kubernetesWriteKubeconfigCmd) // profile commands profileCmd.AddCommand(profileListCmd) profileCmd.AddCommand(profileAddCmd) + profileCmd.AddCommand(profileRenameCmd) profileCmd.AddCommand(profileRemoveCmd) profileCmd.AddCommand(profileSelectCmd) @@ -190,7 +220,10 @@ func init() { upCmd.PersistentFlags().BoolVar(&rosenpassEnabled, enableRosenpassFlag, false, "[Experimental] Enable Rosenpass feature. If enabled, the connection will be post-quantum secured via Rosenpass.") upCmd.PersistentFlags().BoolVar(&rosenpassPermissive, rosenpassPermissiveFlag, false, "[Experimental] Enable Rosenpass in permissive mode to allow this peer to accept WireGuard connections without requiring Rosenpass functionality from peers that do not have Rosenpass enabled.") upCmd.PersistentFlags().BoolVar(&autoConnectDisabled, disableAutoConnectFlag, false, "Disables auto-connect feature. If enabled, then the client won't connect automatically when the service starts.") - upCmd.PersistentFlags().BoolVar(&lazyConnEnabled, enableLazyConnectionFlag, false, "[Experimental] Enable the lazy connection feature. If enabled, the client will establish connections on-demand. Note: this setting may be overridden by management configuration.") + upCmd.PersistentFlags().BoolVar(&localMetricsEnabled, enableLocalMetricsFlag, false, "Enables a local Prometheus /metrics endpoint exposing connection state (peers, latency, P2P vs relay).") + upCmd.PersistentFlags().StringVar(&localMetricsAddr, localMetricsAddressFlag, localmetrics.DefaultListenAddress, "Listen address of the local Prometheus /metrics endpoint.") + upCmd.PersistentFlags().BoolVar(&lazyConnEnabled, enableLazyConnectionFlag, false, "Deprecated: no longer used. Lazy connections are controlled by the server and the NB_LAZY_CONN environment variable.") + _ = upCmd.PersistentFlags().MarkDeprecated(enableLazyConnectionFlag, "no longer used; lazy connections are controlled by the server and the NB_LAZY_CONN environment variable") } @@ -246,12 +279,10 @@ func DialClientGRPCServer(ctx context.Context, addr string) (*grpc.ClientConn, e ctx, cancel := context.WithTimeout(ctx, time.Second*10) defer cancel() - return grpc.DialContext( - ctx, - strings.TrimPrefix(addr, "tcp://"), - grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithBlock(), - ) + target, opts := daddr.DialTarget(addr) + opts = append(opts, grpc.WithBlock()) + + return grpc.DialContext(ctx, target, opts...) } // WithBackOff execute function in backoff cycle. @@ -272,6 +303,19 @@ var CLIBackOffSettings = &backoff.ExponentialBackOff{ Clock: backoff.SystemClock, } +// effectiveAnonymize resolves the --anonymize and --anonymize-level flags: +// setting a level implies anonymization, and an invalid level is rejected. +func effectiveAnonymize() (bool, anonymize.Level, error) { + if anonymizeLevelFlag == "" { + return anonymizeFlag, anonymize.LevelDefault, nil + } + level := anonymize.ParseLevel(anonymizeLevelFlag) + if !strings.EqualFold(anonymizeLevelFlag, level.String()) { + return false, anonymize.LevelDefault, fmt.Errorf("invalid anonymize level %q: use %q or %q", anonymizeLevelFlag, anonymize.LevelDefault.String(), anonymize.LevelStrict.String()) + } + return true, level, nil +} + func getSetupKey() (string, error) { if setupKeyPath != "" && setupKey == "" { return getSetupKeyFromFile(setupKeyPath) diff --git a/client/cmd/service.go b/client/cmd/service.go index f1123ce8c..7410d60ea 100644 --- a/client/cmd/service.go +++ b/client/cmd/service.go @@ -5,6 +5,7 @@ package cmd import ( "context" "fmt" + "net/http" "runtime" "strings" "sync" @@ -22,15 +23,26 @@ var serviceCmd = &cobra.Command{ Short: "Manage the NetBird daemon service", } +const defaultJSONSocket = "unix:///var/run/netbird-http.sock" + var ( - serviceName string - serviceEnvVars []string + serviceName string + serviceEnvVars []string + jsonSocket string + enableJSONSocket bool ) type program struct { - ctx context.Context - cancel context.CancelFunc - serv *grpc.Server + ctx context.Context + cancel context.CancelFunc + serv *grpc.Server + jsonServ *http.Server + // jsonClient is the gateway's own connection to the daemon. It is held so + // shutting the gateway down also closes it: nothing else references it once + // the handlers are registered, so its transport goroutines would otherwise + // outlive the server. + jsonClient *grpc.ClientConn + jsonServMu sync.Mutex serverInstance *server.Server serverInstanceMu sync.Mutex } @@ -44,7 +56,10 @@ func init() { serviceCmd.AddCommand(runCmd, startCmd, stopCmd, restartCmd, svcStatusCmd, installCmd, uninstallCmd, reconfigureCmd, resetParamsCmd) serviceCmd.PersistentFlags().BoolVar(&profilesDisabled, "disable-profiles", false, "Disables profiles feature. If enabled, the client will not be able to change or edit any profile. To persist this setting, use: netbird service install --disable-profiles") serviceCmd.PersistentFlags().BoolVar(&updateSettingsDisabled, "disable-update-settings", false, "Disables update settings feature. If enabled, the client will not be able to change or edit any settings. To persist this setting, use: netbird service install --disable-update-settings") + serviceCmd.PersistentFlags().BoolVar(&captureEnabled, "enable-capture", false, "Enables packet capture via 'netbird debug capture'. To persist, use: netbird service install --enable-capture") serviceCmd.PersistentFlags().BoolVar(&networksDisabled, "disable-networks", false, "Disables network selection. If enabled, the client will not allow listing, selecting, or deselecting networks. To persist, use: netbird service install --disable-networks") + serviceCmd.PersistentFlags().BoolVar(&enableJSONSocket, "enable-json-socket", false, "Enables the HTTP/JSON API socket served by grpc-gateway. To persist, use: netbird service install --enable-json-socket") + serviceCmd.PersistentFlags().StringVar(&jsonSocket, "json-socket", defaultJSONSocket, "HTTP/JSON API socket address [unix|tcp]://[path|host:port]. Requires --enable-json-socket to serve. To persist, use: netbird service install --enable-json-socket --json-socket") rootCmd.PersistentFlags().StringVarP(&serviceName, "service", "s", defaultServiceName, "Netbird system service name") serviceEnvDesc := `Sets extra environment variables for the service. ` + diff --git a/client/cmd/service_controller.go b/client/cmd/service_controller.go index 0943b6184..e9a0e055f 100644 --- a/client/cmd/service_controller.go +++ b/client/cmd/service_controller.go @@ -5,9 +5,7 @@ package cmd import ( "context" "fmt" - "net" - "os" - "strings" + "runtime" "time" "github.com/kardianos/service" @@ -16,69 +14,159 @@ import ( "github.com/spf13/cobra" "google.golang.org/grpc" + "github.com/netbirdio/netbird/client/internal/daemonaddr" + "github.com/netbirdio/netbird/client/internal/ipcauth" "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/server" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/util" ) +func validateJSONSocketFlags() error { + if serviceCmd.PersistentFlags().Changed("json-socket") && !enableJSONSocket { + return fmt.Errorf("--json-socket requires --enable-json-socket to configure the daemon JSON gateway") + } + return nil +} + +// daemonServerOptions installs the transport credentials that expose each +// caller's kernel-authenticated identity to the handlers, which is what lets +// the daemon require root/administrator for privileged operations. +// +// The handshake exchanges no bytes, so older CLI and UI binaries still +// interoperate. Callers on a TCP socket carry no identity at all: the daemon +// keeps serving them, and the privileged operations deny them, so a warning is +// logged to make the loss of functionality visible. +func daemonServerOptions(network string) []grpc.ServerOption { + if network == "tcp" { + log.Warnf("daemon is listening on TCP (%s): callers carry no verifiable identity over TCP, "+ + "so privileged operations (SSH root login, SSH auth, enabling the SSH server, management URL changes, "+ + "deregistration) will be denied, and the SSH JWT cache is neither filled nor served. "+ + "Use a unix socket, or npipe:// on Windows", daemonAddr) + return nil + } + + creds := ipcauth.NewTransportCredentials() //nolint:staticcheck + if creds == nil { //nolint:staticcheck // nil only on platforms without a peer-identity primitive + log.Warnf("daemon IPC has no peer-identity primitive on %s: privileged operations will be denied "+ + "and the SSH JWT cache is neither filled nor served", runtime.GOOS) + return nil + } + + return []grpc.ServerOption{grpc.Creds(creds)} +} + func (p *program) Start(svc service.Service) error { // Start should not block. Do the actual work async. log.Info("starting NetBird service") //nolint + if err := validateJSONSocketFlags(); err != nil { + return err + } + // Collect static system and platform information system.UpdateStaticInfoAsync() - // in any case, even if configuration does not exists we run daemon to serve CLI gRPC API. - p.serv = grpc.NewServer() - - split := strings.Split(daemonAddr, "://") - switch split[0] { - case "unix": - // cleanup failed close - stat, err := os.Stat(split[1]) - if err == nil && !stat.IsDir() { - if err := os.Remove(split[1]); err != nil { - log.Debugf("remove socket file: %v", err) - } - } - case "tcp": - default: - return fmt.Errorf("unsupported daemon address protocol: %v", split[0]) + // A daemon installed before named-pipe support has the loopback TCP address + // persisted. Move it to the named pipe so an upgraded daemon can identify + // its callers instead of silently serving an unauthenticated socket. + if migrated, ok := daemonaddr.MigrateLegacy(daemonAddr); ok { + log.Infof("daemon address %q predates named-pipe support, listening on %q so callers can be identified", daemonAddr, migrated) + daemonAddr = migrated } - listen, err := net.Listen(split[0], split[1]) + network, _, err := parseListenAddress(daemonAddr) if err != nil { - return fmt.Errorf("listen daemon interface: %w", err) + return fmt.Errorf("parse daemon address: %w", err) } + + // in any case, even if configuration does not exists we run daemon to serve CLI gRPC API. + p.serv = grpc.NewServer(daemonServerOptions(network)...) + + daemonListener, jsonListener, err := listenDaemonSockets() + if err != nil { + return err + } + go func() { - defer listen.Close() - - if split[0] == "unix" { - if err := os.Chmod(split[1], 0666); err != nil { - log.Errorf("failed setting daemon permissions: %v", split[1]) - return - } - } - - serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, networksDisabled) - if err := serverInstance.Start(); err != nil { - log.Fatalf("failed to start daemon: %v", err) - } - proto.RegisterDaemonServiceServer(p.serv, serverInstance) - - p.serverInstanceMu.Lock() - p.serverInstance = serverInstance - p.serverInstanceMu.Unlock() - - log.Printf("started daemon server: %v", split[1]) - if err := p.serv.Serve(listen); err != nil { - log.Errorf("failed to serve daemon requests: %v", err) + // Fatal here rather than inside serve, so serve's deferred listener + // closes run before the process exits. + if err := p.serve(daemonListener, jsonListener); err != nil { + log.Fatalf("failed to %v", err) } }() return nil } +// listenDaemonSockets opens the daemon control socket and, when it is enabled, the +// JSON gateway socket. The control socket is closed again if the second one fails, +// so a failed start leaves nothing listening. The returned JSON listener is nil +// when the socket is disabled. +func listenDaemonSockets() (*socketListener, *socketListener, error) { + daemonListener, err := listenOnAddress(daemonAddr) + if err != nil { + return nil, nil, fmt.Errorf("listen daemon interface: %w", err) + } + + if !enableJSONSocket { + removeStaleUnixSocketForAddress(jsonSocket) + return daemonListener, nil, nil + } + + jsonListener, err := listenOnAddress(jsonSocket) + if err != nil { + if cerr := daemonListener.Close(); cerr != nil { + log.Debugf("close daemon listener: %v", cerr) + } + return nil, nil, fmt.Errorf("listen daemon JSON interface: %w", err) + } + + return daemonListener, jsonListener, nil +} + +// serve brings up the daemon server on an already-open control socket and blocks +// until it stops. jsonListener is nil when the JSON socket is disabled. A returned +// error means the daemon cannot run at all and the caller is expected to exit; the +// failures it recovers from on its own are logged here. +func (p *program) serve(daemonListener, jsonListener *socketListener) error { + defer daemonListener.Close() + if jsonListener != nil { + defer jsonListener.Close() + } + + // chmodUnixSocket is a no-op for a nil listener and for a non-unix one. + if err := daemonListener.chmodUnixSocket("daemon"); err != nil { + log.Error(err) + return nil + } + if err := jsonListener.chmodUnixSocket("daemon JSON"); err != nil { + log.Error(err) + return nil + } + + serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, captureEnabled, networksDisabled) + if err := serverInstance.Start(); err != nil { + return fmt.Errorf("start daemon: %w", err) + } + proto.RegisterDaemonServiceServer(p.serv, serverInstance) + + p.serverInstanceMu.Lock() + p.serverInstance = serverInstance + p.serverInstanceMu.Unlock() + + if jsonListener == nil { + log.Debug("daemon JSON socket disabled") + } else if err := p.startJSONGateway(jsonListener, daemonAddr); err != nil { + return fmt.Errorf("start daemon JSON server: %w", err) + } + + log.Printf("started daemon server: %v", daemonListener.address) + if err := p.serv.Serve(daemonListener.Listener); err != nil { + log.Errorf("failed to serve daemon requests: %v", err) + } + return nil +} + func (p *program) Stop(srv service.Service) error { p.serverInstanceMu.Lock() if p.serverInstance != nil { @@ -92,6 +180,25 @@ func (p *program) Stop(srv service.Service) error { p.cancel() + p.jsonServMu.Lock() + jsonServ, jsonClient := p.jsonServ, p.jsonClient + p.jsonServMu.Unlock() + if jsonClient != nil { + if err := jsonClient.Close(); err != nil { + log.Debugf("close daemon JSON gateway client: %v", err) + } + } + if jsonServ != nil { + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second) + if err := jsonServ.Shutdown(shutdownCtx); err != nil { + log.Errorf("failed to stop daemon JSON server gracefully: %v", err) + if err := jsonServ.Close(); err != nil { + log.Errorf("failed to close daemon JSON server: %v", err) + } + } + shutdownCancel() + } + if p.serv != nil { p.serv.Stop() } @@ -102,7 +209,7 @@ func (p *program) Stop(srv service.Service) error { } // Common setup for service control commands -func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel context.CancelFunc) (service.Service, error) { +func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel context.CancelFunc, consoleLog bool) (service.Service, error) { // rootCmd env vars are already applied by PersistentPreRunE. SetFlagsFromEnvVars(serviceCmd) @@ -112,8 +219,14 @@ func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel return nil, err } - if err := util.InitLog(logLevel, logFiles...); err != nil { - return nil, fmt.Errorf("init log: %w", err) + if consoleLog { + if err := util.InitLog(logLevel, util.LogConsole); err != nil { + return nil, fmt.Errorf("init log: %w", err) + } + } else { + if err := util.InitLog(logLevel, logFiles...); err != nil { + return nil, fmt.Errorf("init log: %w", err) + } } cfg, err := newSVCConfig() @@ -138,10 +251,13 @@ var runCmd = &cobra.Command{ SetupCloseHandler(ctx, cancel) SetupDebugHandler(ctx, nil, nil, nil, util.FindFirstLogPath(logFiles)) - s, err := setupServiceControlCommand(cmd, ctx, cancel) + s, err := setupServiceControlCommand(cmd, ctx, cancel, false) if err != nil { return err } + if err := validateJSONSocketFlags(); err != nil { + return err + } return s.Run() }, @@ -152,10 +268,13 @@ var startCmd = &cobra.Command{ Short: "starts NetBird service", RunE: func(cmd *cobra.Command, args []string) error { ctx, cancel := context.WithCancel(cmd.Context()) - s, err := setupServiceControlCommand(cmd, ctx, cancel) + s, err := setupServiceControlCommand(cmd, ctx, cancel, false) if err != nil { return err } + if err := validateJSONSocketFlags(); err != nil { + return err + } if err := s.Start(); err != nil { return fmt.Errorf("start service: %w", err) @@ -170,7 +289,7 @@ var stopCmd = &cobra.Command{ Short: "stops NetBird service", RunE: func(cmd *cobra.Command, args []string) error { ctx, cancel := context.WithCancel(cmd.Context()) - s, err := setupServiceControlCommand(cmd, ctx, cancel) + s, err := setupServiceControlCommand(cmd, ctx, cancel, false) if err != nil { return err } @@ -188,10 +307,13 @@ var restartCmd = &cobra.Command{ Short: "restarts NetBird service", RunE: func(cmd *cobra.Command, args []string) error { ctx, cancel := context.WithCancel(cmd.Context()) - s, err := setupServiceControlCommand(cmd, ctx, cancel) + s, err := setupServiceControlCommand(cmd, ctx, cancel, false) if err != nil { return err } + if err := validateJSONSocketFlags(); err != nil { + return err + } if err := s.Restart(); err != nil { return fmt.Errorf("restart service: %w", err) @@ -206,7 +328,7 @@ var svcStatusCmd = &cobra.Command{ Short: "shows NetBird service status", RunE: func(cmd *cobra.Command, args []string) error { ctx, cancel := context.WithCancel(cmd.Context()) - s, err := setupServiceControlCommand(cmd, ctx, cancel) + s, err := setupServiceControlCommand(cmd, ctx, cancel, true) if err != nil { return err } diff --git a/client/cmd/service_installer.go b/client/cmd/service_installer.go index 5ada6f633..ae2dfb9fa 100644 --- a/client/cmd/service_installer.go +++ b/client/cmd/service_installer.go @@ -59,10 +59,18 @@ func buildServiceArguments() []string { args = append(args, "--disable-update-settings") } + if captureEnabled { + args = append(args, "--enable-capture") + } + if networksDisabled { args = append(args, "--disable-networks") } + if enableJSONSocket { + args = append(args, "--enable-json-socket", "--json-socket", jsonSocket) + } + return args } @@ -102,6 +110,10 @@ func configurePlatformSpecificSettings(svcConfig *service.Config) error { // Create fully configured service config for install/reconfigure func createServiceConfigForInstall() (*service.Config, error) { + if err := validateJSONSocketFlags(); err != nil { + return nil, err + } + svcConfig, err := newSVCConfig() if err != nil { return nil, fmt.Errorf("create service config: %w", err) diff --git a/client/cmd/service_json_gateway.go b/client/cmd/service_json_gateway.go new file mode 100644 index 000000000..b6864f338 --- /dev/null +++ b/client/cmd/service_json_gateway.go @@ -0,0 +1,150 @@ +//go:build !ios && !android + +package cmd + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "sync" + "time" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + log "github.com/sirupsen/logrus" + "google.golang.org/grpc" + + "github.com/netbirdio/netbird/client/internal/daemonaddr" + "github.com/netbirdio/netbird/client/internal/ipcauth" + "github.com/netbirdio/netbird/client/proto" +) + +// jsonPeerIdentity is the context key under which the connecting HTTP client's +// identity is stashed for the lifetime of its connection. +type jsonPeerIdentity struct{} + +// jsonPeerIdentityValue pairs the identity with whether it could be read at +// all, so an unreadable identity is forwarded as "unknown" rather than omitted. +type jsonPeerIdentityValue struct { + id ipcauth.Identity + known bool +} + +// jsonConnContext reads the identity of the client connecting to the JSON +// socket and stashes it on the connection's context. The gateway re-dials the +// daemon in-process, so the daemon would otherwise see every JSON request as +// coming from the daemon itself. +func jsonConnContext(ctx context.Context, c net.Conn) context.Context { + value := jsonPeerIdentityValue{} + id, err := ipcauth.ConnIdentity(c) + if err != nil { + log.Warnf("json gateway: cannot read HTTP client identity, privileged operations will be denied for this connection: %v", err) + } else { + value.id = id + value.known = true + } + return context.WithValue(ctx, jsonPeerIdentity{}, value) +} + +// forwardIdentity stamps the HTTP client's identity onto every call the gateway +// makes to the daemon. +// +// It is an interceptor on the gateway's client connection rather than a +// runtime.WithMetadata annotator because grpc-gateway skips annotators when no +// request header maps to metadata, which an HTTP/1.0 request with no Host header +// over a unix socket achieves. The daemon would then receive no marker, see its own +// identity as the transport peer, and authorize the request as the daemon itself. +// An interceptor runs for every RPC whatever the request looked like. +func forwardIdentity(ctx context.Context) context.Context { + value, ok := ctx.Value(jsonPeerIdentity{}).(jsonPeerIdentityValue) + if !ok { + // No ConnContext ran for this request, so forward an unknown identity: + // the daemon must not mistake its own identity for the client's. + return ipcauth.WithForwardedIdentity(ctx, ipcauth.Identity{}, false) + } + return ipcauth.WithForwardedIdentity(ctx, value.id, value.known) +} + +func forwardIdentityUnary(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + return invoker(forwardIdentity(ctx), method, req, reply, cc, opts...) +} + +func forwardIdentityStream(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) { + return streamer(forwardIdentity(ctx), desc, cc, method, opts...) +} + +// reservedHeaderWarning limits the dropped-header warning to the first occurrence. +var reservedHeaderWarning sync.Once + +// jsonIncomingHeaderMatcher keeps an HTTP client from supplying the metadata the +// gateway uses to forward its identity. grpc-gateway turns "Grpc-Metadata-" +// headers into gRPC metadata and joins them ahead of what its annotators add, so +// without this filter a JSON client could send its own x-netbird-fwd-uid and the +// daemon would authorize that instead of the client's real identity. +func jsonIncomingHeaderMatcher(key string) (string, bool) { + mapped, ok := runtime.DefaultHeaderMatcher(key) + if !ok { + return "", false + } + if ipcauth.IsReservedForwardKey(mapped) { + // Warn once: any client can send these on every request, so warning each + // time hands it a way to fill the log. The rest are debug-level. + reservedHeaderWarning.Do(func() { + log.Warnf("json gateway: dropping reserved header %q from a request: only the gateway may set the caller's identity", key) + }) + log.Debugf("json gateway: dropping reserved header %q", key) + return "", false + } + return mapped, true +} + +func (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint string) error { + if jsonListener.network == "tcp" { + log.Warnf("daemon JSON socket is listening on TCP (%s): callers carry no verifiable identity over TCP, "+ + "so privileged operations will be denied for JSON clients", jsonListener.address) + } + + mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher)) + + // grpc.NewClient does not connect until the first request, so registering + // the handler here cannot block daemon startup. + target, opts := daemonaddr.DialTarget(daemonEndpoint) + opts = append(opts, + grpc.WithChainUnaryInterceptor(forwardIdentityUnary), + grpc.WithChainStreamInterceptor(forwardIdentityStream), + ) + conn, err := grpc.NewClient(target, opts...) + if err != nil { + return fmt.Errorf("create daemon client for JSON gateway: %w", err) + } + if err := proto.RegisterDaemonServiceHandler(p.ctx, mux, conn); err != nil { + if cerr := conn.Close(); cerr != nil { + log.Debugf("close daemon client after failed JSON gateway registration: %v", cerr) + } + return err + } + + jsonServer := &http.Server{ + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + BaseContext: func(net.Listener) context.Context { + return p.ctx + }, + ConnContext: jsonConnContext, + } + + p.jsonServMu.Lock() + p.jsonServ = jsonServer + p.jsonClient = conn + p.jsonServMu.Unlock() + + go func() { + log.Printf("started daemon JSON server: %v", jsonListener.address) + if err := jsonServer.Serve(jsonListener.Listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Errorf("failed to serve daemon JSON requests: %v", err) + } + }() + + return nil +} diff --git a/client/cmd/service_json_gateway_test.go b/client/cmd/service_json_gateway_test.go new file mode 100644 index 000000000..dfeef1c46 --- /dev/null +++ b/client/cmd/service_json_gateway_test.go @@ -0,0 +1,261 @@ +//go:build !windows && !ios && !android + +package cmd + +import ( + "context" + "net" + "net/http" + "path/filepath" + "testing" + "time" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/peer" + + "github.com/netbirdio/netbird/client/internal/ipcauth" +) + +// The JSON gateway runs inside the daemon and re-dials it locally, so every JSON +// request reaches a handler with the daemon's own identity as the transport peer. +// The gateway therefore forwards its HTTP client's identity as metadata, and the +// daemon authorizes that instead of itself. These tests drive the real wiring +// (jsonConnContext, forwardIdentity, jsonIncomingHeaderMatcher) and check the +// identity a handler would end up authorizing. + +// daemonSideCtx is what a handler sees for a gateway-relayed call. The transport +// peer must be this process's own identity: the gateway is the daemon, so the two +// cannot differ, and hardcoding root here instead would describe a state that +// never occurs. +func daemonSideCtx(t *testing.T, md metadata.MD) context.Context { + t.Helper() + self, err := ipcauth.CurrentProcessIdentity() + if err != nil { + t.Skipf("cannot read this process's identity: %v", err) + } + ctx := peer.NewContext(context.Background(), &peer.Peer{ + AuthInfo: ipcauth.AuthInfo{ + CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity}, + Identity: self, + }, + }) + return metadata.NewIncomingContext(ctx, md) +} + +// gatewayMetadata reproduces what the daemon receives for a JSON request: the +// mux annotates the context from the request's headers, then the interceptor on the +// gateway's client connection stamps the caller's identity. The order matters, +// since the interceptor must win over anything a header put there. +func gatewayMetadata(t *testing.T, req *http.Request, ctx context.Context) metadata.MD { + t.Helper() + mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher)) + annotated, err := runtime.AnnotateContext(ctx, mux, req, + "/daemon.DaemonService/SetConfig", + runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig")) + if err != nil { + t.Fatalf("annotate: %v", err) + } + + md, ok := metadata.FromOutgoingContext(forwardIdentity(annotated)) + if !ok { + t.Fatal("the interceptor produced no metadata") + } + return md +} + +// clientCtx is the connection context jsonConnContext would have produced for an +// HTTP client whose identity the gateway could read. +func clientCtx(id ipcauth.Identity, known bool) context.Context { + return context.WithValue(context.Background(), jsonPeerIdentity{}, + jsonPeerIdentityValue{id: id, known: known}) +} + +// An HTTP client must not be able to name its own identity. grpc-gateway turns +// Grpc-Metadata- headers into gRPC metadata, so without the header filter and +// the interceptor overwriting the reserved keys, this request would authorize as +// uid 0. +func TestJSONGateway_ForgedIdentityHeaderIsDropped(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Grpc-Metadata-X-Netbird-Fwd-Uid", "0") + req.Header.Set("Grpc-Metadata-X-Netbird-Fwd-Gid", "0") + req.Header.Set("Grpc-Metadata-X-Netbird-Fwd", "1") + req.Header.Set("Grpc-Metadata-X-Netbird-Fwd-Sid", "S-1-5-18") + + caller := ipcauth.Identity{UID: 31000, GID: 31000} + md := gatewayMetadata(t, req, clientCtx(caller, true)) + + id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md)) + if !ok { + t.Fatal("the forwarded identity should be usable") + } + if id.IsPrivileged() { + t.Errorf("forged header was believed: authorized as %v", id) + } + if id.UID != caller.UID { + t.Errorf("authorized as uid %d, want the real client %d", id.UID, caller.UID) + } +} + +// A request with no headers at all (HTTP/1.0 needs no Host, and a unix socket +// yields no host:port) makes grpc-gateway produce no metadata whatsoever and skip +// its annotators: "if len(pairs) == 0 { return ctx, nil, nil }" in +// runtime/context.go. That is why the identity is stamped by an interceptor +// instead. This is the case that previously reached the gate as the daemon itself. +func TestJSONGateway_HeaderlessRequestIsStillMarkedForwarded(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil) + if err != nil { + t.Fatal(err) + } + req.Header = http.Header{} + req.Host = "" + + caller := ipcauth.Identity{UID: 31000, GID: 31000} + ctx := clientCtx(caller, true) + + // Pin the skip path itself: if grpc-gateway ever produced a pair here, this + // test would still pass below while no longer covering what it was written for. + mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher)) + annotated, err := runtime.AnnotateContext(ctx, mux, req, + "/daemon.DaemonService/SetConfig", + runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig")) + if err != nil { + t.Fatalf("annotate: %v", err) + } + if md, ok := metadata.FromOutgoingContext(annotated); ok { + t.Fatalf("grpc-gateway produced metadata %v for a headerless request; "+ + "this test no longer covers the annotator-skip path", md) + } + + md := gatewayMetadata(t, req, ctx) + + id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md)) + if !ok { + t.Fatal("the forwarded identity should be usable") + } + if id.UID != caller.UID || id.IsPrivileged() { + t.Errorf("authorized as %v, want the real client uid %d", id, caller.UID) + } +} + +// When the gateway cannot read its client's identity (a TCP JSON socket, say) it +// forwards the marker alone. The daemon must then report "unidentified" so the +// privileged operations refuse, rather than falling back to the gateway's own +// identity. +func TestJSONGateway_UnreadableClientIdentityIsUnidentified(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil) + if err != nil { + t.Fatal(err) + } + + md := gatewayMetadata(t, req, clientCtx(ipcauth.Identity{}, false)) + + if id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md)); ok { + t.Errorf("a request with no client identity was authorized as %v", id) + } +} + +// A request that never passed through jsonConnContext (no stashed identity) must +// also come out unidentified rather than as the daemon. +func TestJSONGateway_MissingConnContextIsUnidentified(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil) + if err != nil { + t.Fatal(err) + } + + md := gatewayMetadata(t, req, context.Background()) + + if id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md)); ok { + t.Errorf("a request with no connection context was authorized as %v", id) + } +} + +// End to end over a real unix socket: the gateway reads the connecting client's +// identity from the socket itself, so a client cannot present anything else. +func TestJSONGateway_IdentityComesFromTheSocket(t *testing.T) { + mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher)) + + type observed struct { + md metadata.MD + } + seen := make(chan observed, 1) + + srv := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx, err := runtime.AnnotateContext(r.Context(), mux, r, + "/daemon.DaemonService/SetConfig", + runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig")) + if err != nil { + t.Errorf("annotate: %v", err) + return + } + md, _ := metadata.FromOutgoingContext(forwardIdentity(ctx)) + seen <- observed{md: md} + w.WriteHeader(http.StatusOK) + }), + ReadHeaderTimeout: 5 * time.Second, + ConnContext: jsonConnContext, + } + + sock := filepath.Join(t.TempDir(), "http.sock") + ln, err := net.Listen("unix", sock) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := srv.Close(); err != nil { + t.Logf("close server: %v", err) + } + }) + go func() { + if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { + t.Logf("serve: %v", err) + } + }() + + conn, err := net.Dial("unix", sock) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := conn.Close(); err != nil { + t.Logf("close conn: %v", err) + } + }) + + // Forge the identity headers on the wire as well. + request := "POST /daemon.DaemonService/SetConfig HTTP/1.1\r\n" + + "Host: localhost\r\n" + + "Grpc-Metadata-X-Netbird-Fwd: 1\r\n" + + "Grpc-Metadata-X-Netbird-Fwd-Uid: 0\r\n" + + "Content-Length: 0\r\n\r\n" + if _, err := conn.Write([]byte(request)); err != nil { + t.Fatal(err) + } + + select { + case got := <-seen: + self, err := ipcauth.CurrentProcessIdentity() + if err != nil { + t.Skipf("cannot read this process's identity: %v", err) + } + // The socket peer is this test process, so that is the identity the + // gateway must forward, not the uid 0 the request asked for. + if uids := got.md.Get("x-netbird-fwd-uid"); len(uids) != 1 { + t.Fatalf("x-netbird-fwd-uid = %v, want exactly the gateway's own value", uids) + } + id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, got.md)) + if !ok { + t.Fatal("the forwarded identity should be usable") + } + if id.UID != self.UID { + t.Errorf("authorized as uid %d, want the socket peer %d", id.UID, self.UID) + } + case <-time.After(5 * time.Second): + t.Fatal("the gateway never handled the request") + } +} diff --git a/client/cmd/service_json_socket_test.go b/client/cmd/service_json_socket_test.go new file mode 100644 index 000000000..4b39794d7 --- /dev/null +++ b/client/cmd/service_json_socket_test.go @@ -0,0 +1,176 @@ +//go:build !ios && !android + +package cmd + +import ( + "net" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func preserveJSONSocketTestState(t *testing.T) { + t.Helper() + + origJSONSocket := jsonSocket + origEnableJSONSocket := enableJSONSocket + origChanged := map[string]bool{} + serviceCmd.PersistentFlags().VisitAll(func(flag *pflag.Flag) { + origChanged[flag.Name] = flag.Changed + }) + + t.Cleanup(func() { + jsonSocket = origJSONSocket + enableJSONSocket = origEnableJSONSocket + serviceCmd.PersistentFlags().VisitAll(func(flag *pflag.Flag) { + flag.Changed = origChanged[flag.Name] + }) + }) +} + +func TestJSONSocketFlagsArePositiveEnableOnly(t *testing.T) { + assert.NotNil(t, serviceCmd.PersistentFlags().Lookup("enable-json-socket")) + assert.NotNil(t, serviceCmd.PersistentFlags().Lookup("json-socket")) + assert.Nil(t, serviceCmd.PersistentFlags().Lookup("disable-json-socket")) + assert.Equal(t, "false", serviceCmd.PersistentFlags().Lookup("enable-json-socket").DefValue) +} + +func TestBuildServiceArgumentsDefaultDisablesJSONSocket(t *testing.T) { + preserveJSONSocketTestState(t) + + enableJSONSocket = false + jsonSocket = "tcp://127.0.0.1:8080" + + args := buildServiceArguments() + + assert.NotContains(t, args, "--enable-json-socket") + assert.NotContains(t, args, "--json-socket") +} + +func TestBuildServiceArgumentsIncludesJSONSocketWhenEnabled(t *testing.T) { + preserveJSONSocketTestState(t) + + enableJSONSocket = true + jsonSocket = "tcp://127.0.0.1:8080" + + args := buildServiceArguments() + + enableIndex := indexOfArg(args, "--enable-json-socket") + jsonIndex := indexOfArg(args, "--json-socket") + require.NotEqual(t, -1, enableIndex) + require.NotEqual(t, -1, jsonIndex) + require.Less(t, enableIndex, jsonIndex) + require.Less(t, jsonIndex+1, len(args)) + assert.Equal(t, "tcp://127.0.0.1:8080", args[jsonIndex+1]) +} + +func TestJSONSocketWithoutEnableValidation(t *testing.T) { + preserveJSONSocketTestState(t) + + enableJSONSocket = false + require.NoError(t, serviceCmd.PersistentFlags().Set("json-socket", "tcp://127.0.0.1:8080")) + + err := validateJSONSocketFlags() + + require.Error(t, err) + assert.Contains(t, err.Error(), "--enable-json-socket") +} + +func TestJSONSocketWithEnableValidation(t *testing.T) { + preserveJSONSocketTestState(t) + + require.NoError(t, serviceCmd.PersistentFlags().Set("enable-json-socket", "true")) + require.NoError(t, serviceCmd.PersistentFlags().Set("json-socket", "tcp://127.0.0.1:8080")) + + assert.NoError(t, validateJSONSocketFlags()) +} + +func TestJSONSocketServiceParamsPersistEnableAndAddress(t *testing.T) { + preserveJSONSocketTestState(t) + serviceCmd.PersistentFlags().VisitAll(func(flag *pflag.Flag) { + flag.Changed = false + }) + + enableJSONSocket = true + jsonSocket = "tcp://127.0.0.1:8080" + + params := currentServiceParams() + require.True(t, params.EnableJSONSocket) + require.Equal(t, "tcp://127.0.0.1:8080", params.JSONSocket) + + enableJSONSocket = false + jsonSocket = defaultJSONSocket + applyServiceParams(testServiceEnvCommand(), params) + + assert.True(t, enableJSONSocket) + assert.Equal(t, "tcp://127.0.0.1:8080", jsonSocket) +} + +func TestRemoveStaleUnixSocketDoesNotRemoveRegularFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "netbird-http.sock") + require.NoError(t, os.WriteFile(path, []byte("not a socket"), 0600)) + + removeStaleUnixSocket(path) + + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, []byte("not a socket"), data) +} + +func TestRemoveStaleUnixSocketRemovesSocket(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix sockets are not available on Windows") + } + + path := filepath.Join(t.TempDir(), "netbird-http.sock") + addr := &net.UnixAddr{Name: path, Net: "unix"} + listener, err := net.ListenUnix("unix", addr) + require.NoError(t, err) + listener.SetUnlinkOnClose(false) + require.NoError(t, listener.Close()) + + _, err = os.Lstat(path) + require.NoError(t, err, "test setup must leave a stale Unix socket path") + + removeStaleUnixSocket(path) + + _, err = os.Lstat(path) + assert.True(t, os.IsNotExist(err), "expected stale Unix socket to be removed, got %v", err) +} + +func TestRemoveStaleUnixSocketDoesNotRemoveLiveSocket(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix sockets are not available on Windows") + } + + path := filepath.Join(t.TempDir(), "netbird-http.sock") + listener, err := net.Listen("unix", path) + require.NoError(t, err) + defer listener.Close() + + removeStaleUnixSocket(path) + + _, err = os.Lstat(path) + assert.NoError(t, err, "expected live Unix socket to be preserved") +} + +func testServiceEnvCommand() *cobra.Command { + cmd := &cobra.Command{} + cmd.Flags().StringSlice("service-env", nil, "") + return cmd +} + +func indexOfArg(args []string, arg string) int { + for i, candidate := range args { + if candidate == arg { + return i + } + } + return -1 +} diff --git a/client/cmd/service_params.go b/client/cmd/service_params.go index 5a86aebc6..750b22ae6 100644 --- a/client/cmd/service_params.go +++ b/client/cmd/service_params.go @@ -13,6 +13,7 @@ import ( "github.com/spf13/cobra" "github.com/netbirdio/netbird/client/configs" + "github.com/netbirdio/netbird/client/internal/daemonaddr" "github.com/netbirdio/netbird/util" ) @@ -23,12 +24,15 @@ const serviceParamsFile = "service.json" type serviceParams struct { LogLevel string `json:"log_level"` DaemonAddr string `json:"daemon_addr"` + JSONSocket string `json:"json_socket"` ManagementURL string `json:"management_url,omitempty"` ConfigPath string `json:"config_path,omitempty"` LogFiles []string `json:"log_files,omitempty"` DisableProfiles bool `json:"disable_profiles,omitempty"` DisableUpdateSettings bool `json:"disable_update_settings,omitempty"` + EnableCapture bool `json:"enable_capture,omitempty"` DisableNetworks bool `json:"disable_networks,omitempty"` + EnableJSONSocket bool `json:"enable_json_socket,omitempty"` ServiceEnvVars map[string]string `json:"service_env_vars,omitempty"` } @@ -74,12 +78,15 @@ func currentServiceParams() *serviceParams { params := &serviceParams{ LogLevel: logLevel, DaemonAddr: daemonAddr, + JSONSocket: jsonSocket, ManagementURL: managementURL, ConfigPath: configPath, LogFiles: logFiles, DisableProfiles: profilesDisabled, DisableUpdateSettings: updateSettingsDisabled, + EnableCapture: captureEnabled, DisableNetworks: networksDisabled, + EnableJSONSocket: enableJSONSocket, } if len(serviceEnvVars) > 0 { @@ -111,15 +118,29 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) { return } - // For fields with non-empty defaults (log-level, daemon-addr), keep the - // != "" guard so that an older service.json missing the field doesn't - // clobber the default with an empty string. + // For fields with non-empty defaults, keep the != "" guard so that an older + // service.json missing the field doesn't clobber the default with an empty string. if !rootCmd.PersistentFlags().Changed("log-level") && params.LogLevel != "" { logLevel = params.LogLevel } if !rootCmd.PersistentFlags().Changed("daemon-addr") && params.DaemonAddr != "" { daemonAddr = params.DaemonAddr + // An install that predates named-pipe support has the loopback TCP + // address saved. Callers carry no identity over TCP, so move it to the + // pipe instead of restoring a socket the daemon cannot authorize on. + if migrated, ok := daemonaddr.MigrateLegacy(daemonAddr); ok { + cmd.Printf("Moving the saved daemon address from %s to %s so the daemon can identify its callers\n", daemonAddr, migrated) + daemonAddr = migrated + } + } + + if !serviceCmd.PersistentFlags().Changed("json-socket") && params.JSONSocket != "" { + jsonSocket = params.JSONSocket + } + + if !serviceCmd.PersistentFlags().Changed("enable-json-socket") { + enableJSONSocket = params.EnableJSONSocket } // For optional fields where empty means "use default", always apply so @@ -144,6 +165,10 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) { updateSettingsDisabled = params.DisableUpdateSettings } + if !serviceCmd.PersistentFlags().Changed("enable-capture") { + captureEnabled = params.EnableCapture + } + if !serviceCmd.PersistentFlags().Changed("disable-networks") { networksDisabled = params.DisableNetworks } diff --git a/client/cmd/service_params_test.go b/client/cmd/service_params_test.go index 7e04e5abe..94f98a0ce 100644 --- a/client/cmd/service_params_test.go +++ b/client/cmd/service_params_test.go @@ -41,6 +41,8 @@ func TestSaveAndLoadServiceParams(t *testing.T) { params := &serviceParams{ LogLevel: "debug", DaemonAddr: "unix:///var/run/netbird.sock", + JSONSocket: "tcp://127.0.0.1:8080", + EnableJSONSocket: true, ManagementURL: "https://my.server.com", ConfigPath: "/etc/netbird/config.json", LogFiles: []string{"/var/log/netbird/client.log", "console"}, @@ -63,6 +65,8 @@ func TestSaveAndLoadServiceParams(t *testing.T) { assert.Equal(t, params.LogLevel, loaded.LogLevel) assert.Equal(t, params.DaemonAddr, loaded.DaemonAddr) + assert.Equal(t, params.JSONSocket, loaded.JSONSocket) + assert.Equal(t, params.EnableJSONSocket, loaded.EnableJSONSocket) assert.Equal(t, params.ManagementURL, loaded.ManagementURL) assert.Equal(t, params.ConfigPath, loaded.ConfigPath) assert.Equal(t, params.LogFiles, loaded.LogFiles) @@ -101,6 +105,8 @@ func TestLoadServiceParams_InvalidJSON(t *testing.T) { func TestCurrentServiceParams(t *testing.T) { origLogLevel := logLevel origDaemonAddr := daemonAddr + origJSONSocket := jsonSocket + origEnableJSONSocket := enableJSONSocket origManagementURL := managementURL origConfigPath := configPath origLogFiles := logFiles @@ -110,6 +116,8 @@ func TestCurrentServiceParams(t *testing.T) { t.Cleanup(func() { logLevel = origLogLevel daemonAddr = origDaemonAddr + jsonSocket = origJSONSocket + enableJSONSocket = origEnableJSONSocket managementURL = origManagementURL configPath = origConfigPath logFiles = origLogFiles @@ -120,6 +128,8 @@ func TestCurrentServiceParams(t *testing.T) { logLevel = "trace" daemonAddr = "tcp://127.0.0.1:9999" + jsonSocket = "tcp://127.0.0.1:8080" + enableJSONSocket = true managementURL = "https://mgmt.example.com" configPath = "/tmp/test-config.json" logFiles = []string{"/tmp/test.log"} @@ -131,6 +141,8 @@ func TestCurrentServiceParams(t *testing.T) { assert.Equal(t, "trace", params.LogLevel) assert.Equal(t, "tcp://127.0.0.1:9999", params.DaemonAddr) + assert.Equal(t, "tcp://127.0.0.1:8080", params.JSONSocket) + assert.True(t, params.EnableJSONSocket) assert.Equal(t, "https://mgmt.example.com", params.ManagementURL) assert.Equal(t, "/tmp/test-config.json", params.ConfigPath) assert.Equal(t, []string{"/tmp/test.log"}, params.LogFiles) @@ -142,6 +154,8 @@ func TestCurrentServiceParams(t *testing.T) { func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) { origLogLevel := logLevel origDaemonAddr := daemonAddr + origJSONSocket := jsonSocket + origEnableJSONSocket := enableJSONSocket origManagementURL := managementURL origConfigPath := configPath origLogFiles := logFiles @@ -151,6 +165,8 @@ func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) { t.Cleanup(func() { logLevel = origLogLevel daemonAddr = origDaemonAddr + jsonSocket = origJSONSocket + enableJSONSocket = origEnableJSONSocket managementURL = origManagementURL configPath = origConfigPath logFiles = origLogFiles @@ -162,6 +178,8 @@ func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) { // Reset all flags to defaults. logLevel = "info" daemonAddr = "unix:///var/run/netbird.sock" + jsonSocket = defaultJSONSocket + enableJSONSocket = false managementURL = "" configPath = "/etc/netbird/config.json" logFiles = []string{"/var/log/netbird/client.log"} @@ -184,6 +202,8 @@ func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) { saved := &serviceParams{ LogLevel: "debug", DaemonAddr: "tcp://127.0.0.1:5555", + JSONSocket: "tcp://127.0.0.1:8080", + EnableJSONSocket: true, ManagementURL: "https://saved.example.com", ConfigPath: "/saved/config.json", LogFiles: []string{"/saved/client.log"}, @@ -201,6 +221,8 @@ func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) { // All other fields were not Changed, so they should use saved values. assert.Equal(t, "tcp://127.0.0.1:5555", daemonAddr) + assert.Equal(t, "tcp://127.0.0.1:8080", jsonSocket) + assert.True(t, enableJSONSocket) assert.Equal(t, "https://saved.example.com", managementURL) assert.Equal(t, "/saved/config.json", configPath) assert.Equal(t, []string{"/saved/client.log"}, logFiles) @@ -212,14 +234,17 @@ func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) { func TestApplyServiceParams_BooleanRevertToFalse(t *testing.T) { origProfilesDisabled := profilesDisabled origUpdateSettingsDisabled := updateSettingsDisabled + origEnableJSONSocket := enableJSONSocket t.Cleanup(func() { profilesDisabled = origProfilesDisabled updateSettingsDisabled = origUpdateSettingsDisabled + enableJSONSocket = origEnableJSONSocket }) // Simulate current state where booleans are true (e.g. set by previous install). profilesDisabled = true updateSettingsDisabled = true + enableJSONSocket = true // Reset Changed state so flags appear unset. serviceCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { @@ -238,6 +263,7 @@ func TestApplyServiceParams_BooleanRevertToFalse(t *testing.T) { assert.False(t, profilesDisabled, "saved false should override current true") assert.False(t, updateSettingsDisabled, "saved false should override current true") + assert.False(t, enableJSONSocket, "saved false should override current true") } func TestApplyServiceParams_ClearManagementURL(t *testing.T) { @@ -530,12 +556,15 @@ func fieldToGlobalVar(field string) string { m := map[string]string{ "LogLevel": "logLevel", "DaemonAddr": "daemonAddr", + "JSONSocket": "jsonSocket", "ManagementURL": "managementURL", "ConfigPath": "configPath", "LogFiles": "logFiles", "DisableProfiles": "profilesDisabled", "DisableUpdateSettings": "updateSettingsDisabled", + "EnableCapture": "captureEnabled", "DisableNetworks": "networksDisabled", + "EnableJSONSocket": "enableJSONSocket", "ServiceEnvVars": "serviceEnvVars", } if v, ok := m[field]; ok { diff --git a/client/cmd/service_pipe_other.go b/client/cmd/service_pipe_other.go new file mode 100644 index 000000000..c7cc72469 --- /dev/null +++ b/client/cmd/service_pipe_other.go @@ -0,0 +1,14 @@ +//go:build !windows + +package cmd + +import ( + "fmt" + "net" +) + +// listenNamedPipe is Windows-only: no other platform serves the daemon on a +// named pipe. +func listenNamedPipe(string) (net.Listener, string, error) { + return nil, "", fmt.Errorf("named pipes are only supported on Windows") +} diff --git a/client/cmd/service_pipe_windows.go b/client/cmd/service_pipe_windows.go new file mode 100644 index 000000000..b6e860f51 --- /dev/null +++ b/client/cmd/service_pipe_windows.go @@ -0,0 +1,41 @@ +//go:build windows + +package cmd + +import ( + "errors" + "fmt" + "net" + + "github.com/Microsoft/go-winio" + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/daemonaddr" + "github.com/netbirdio/netbird/client/internal/ipcauth" +) + +// listenNamedPipe creates the daemon control pipe and reports the path it ended +// up on. The security descriptor lets any local caller connect, as a Unix socket +// at 0666 does, and the privileged operations are authorized separately from the +// caller's token. +// +// The protected name comes first so that an unprivileged process cannot take the +// name before the service does. Creating it requires being an administrator or +// LocalSystem, so a daemon an ordinary user runs themselves, as in netstack mode, +// falls back to the plain name; clients try both and check who serves them. +func listenNamedPipe(name string) (net.Listener, string, error) { + var errs []error + for _, path := range daemonaddr.PipePaths(name) { + listener, err := winio.ListenPipe(path, &winio.PipeConfig{ + SecurityDescriptor: ipcauth.DefaultPipeSDDL(), + }) + if err != nil { + log.Debugf("not serving the daemon on %s: %v", path, err) + errs = append(errs, fmt.Errorf("%s: %w", path, err)) + continue + } + return listener, path, nil + } + + return nil, "", errors.Join(errs...) +} diff --git a/client/cmd/service_privileged_test.go b/client/cmd/service_privileged_test.go new file mode 100644 index 000000000..075d7f378 --- /dev/null +++ b/client/cmd/service_privileged_test.go @@ -0,0 +1,196 @@ +//go:build privileged + +package cmd + +import ( + "context" + "fmt" + "os" + "runtime" + "testing" + "time" + + "github.com/kardianos/service" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + serviceStartTimeout = 10 * time.Second + serviceStopTimeout = 5 * time.Second + statusPollInterval = 500 * time.Millisecond +) + +// waitForServiceStatus waits for service to reach expected status with timeout +func waitForServiceStatus(expectedStatus service.Status, timeout time.Duration) (bool, error) { + cfg, err := newSVCConfig() + if err != nil { + return false, err + } + + ctxSvc, cancel := context.WithCancel(context.Background()) + defer cancel() + + s, err := newSVC(newProgram(ctxSvc, cancel), cfg) + if err != nil { + return false, err + } + + ctx, timeoutCancel := context.WithTimeout(context.Background(), timeout) + defer timeoutCancel() + + ticker := time.NewTicker(statusPollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return false, fmt.Errorf("timeout waiting for service status %v", expectedStatus) + case <-ticker.C: + status, err := s.Status() + if err != nil { + // Continue polling on transient errors + continue + } + if status == expectedStatus { + return true, nil + } + } + } +} + +// TestServiceLifecycle tests the complete service lifecycle +func TestServiceLifecycle(t *testing.T) { + // TODO: Add support for Windows and macOS + if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" { + t.Skipf("Skipping service lifecycle test on unsupported OS: %s", runtime.GOOS) + } + + if os.Getenv("CONTAINER") == "true" { + t.Skip("Skipping service lifecycle test in container environment") + } + + originalServiceName := serviceName + serviceName = "netbirdtest" + fmt.Sprintf("%d", time.Now().Unix()) + defer func() { + serviceName = originalServiceName + }() + + tempDir := t.TempDir() + configPath = fmt.Sprintf("%s/netbird-test-config.json", tempDir) + logLevel = "info" + daemonAddr = fmt.Sprintf("unix://%s/netbird-test.sock", tempDir) + + // Ensure cleanup even if a subtest fails and Stop/Uninstall subtests don't run. + t.Cleanup(func() { + cfg, err := newSVCConfig() + if err != nil { + t.Errorf("cleanup: create service config: %v", err) + return + } + ctxSvc, cancel := context.WithCancel(context.Background()) + defer cancel() + s, err := newSVC(newProgram(ctxSvc, cancel), cfg) + if err != nil { + t.Errorf("cleanup: create service: %v", err) + return + } + + // If the subtests already cleaned up, there's nothing to do. + if _, err := s.Status(); err != nil { + return + } + + if err := s.Stop(); err != nil { + t.Errorf("cleanup: stop service: %v", err) + } + if err := s.Uninstall(); err != nil { + t.Errorf("cleanup: uninstall service: %v", err) + } + }) + + ctx := context.Background() + + t.Run("Install", func(t *testing.T) { + installCmd.SetContext(ctx) + err := installCmd.RunE(installCmd, []string{}) + require.NoError(t, err) + + cfg, err := newSVCConfig() + require.NoError(t, err) + + ctxSvc, cancel := context.WithCancel(context.Background()) + defer cancel() + + s, err := newSVC(newProgram(ctxSvc, cancel), cfg) + require.NoError(t, err) + + status, err := s.Status() + assert.NoError(t, err) + assert.NotEqual(t, service.StatusUnknown, status) + }) + + t.Run("Start", func(t *testing.T) { + startCmd.SetContext(ctx) + err := startCmd.RunE(startCmd, []string{}) + require.NoError(t, err) + + running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout) + require.NoError(t, err) + assert.True(t, running) + }) + + t.Run("Restart", func(t *testing.T) { + restartCmd.SetContext(ctx) + err := restartCmd.RunE(restartCmd, []string{}) + require.NoError(t, err) + + running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout) + require.NoError(t, err) + assert.True(t, running) + }) + + t.Run("Reconfigure", func(t *testing.T) { + originalLogLevel := logLevel + logLevel = "debug" + defer func() { + logLevel = originalLogLevel + }() + + reconfigureCmd.SetContext(ctx) + err := reconfigureCmd.RunE(reconfigureCmd, []string{}) + require.NoError(t, err) + + running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout) + require.NoError(t, err) + assert.True(t, running) + }) + + t.Run("Stop", func(t *testing.T) { + stopCmd.SetContext(ctx) + err := stopCmd.RunE(stopCmd, []string{}) + require.NoError(t, err) + + stopped, err := waitForServiceStatus(service.StatusStopped, serviceStopTimeout) + require.NoError(t, err) + assert.True(t, stopped) + }) + + t.Run("Uninstall", func(t *testing.T) { + uninstallCmd.SetContext(ctx) + err := uninstallCmd.RunE(uninstallCmd, []string{}) + require.NoError(t, err) + + cfg, err := newSVCConfig() + require.NoError(t, err) + + ctxSvc, cancel := context.WithCancel(context.Background()) + defer cancel() + + s, err := newSVC(newProgram(ctxSvc, cancel), cfg) + require.NoError(t, err) + + _, err = s.Status() + assert.Error(t, err) + }) +} diff --git a/client/cmd/service_socket.go b/client/cmd/service_socket.go new file mode 100644 index 000000000..bf3122f7c --- /dev/null +++ b/client/cmd/service_socket.go @@ -0,0 +1,119 @@ +//go:build !ios && !android + +package cmd + +import ( + "errors" + "fmt" + "net" + "os" + "strings" + "syscall" + "time" + + log "github.com/sirupsen/logrus" +) + +type socketListener struct { + net.Listener + network string + address string +} + +func listenOnAddress(addr string) (*socketListener, error) { + network, address, err := parseListenAddress(addr) + if err != nil { + return nil, err + } + + if network == "npipe" { + listener, path, err := listenNamedPipe(address) //nolint:staticcheck + if err != nil { //nolint:staticcheck // always errors on non-Windows builds + return nil, err + } + return &socketListener{Listener: listener, network: network, address: path}, nil + } + + if network == "unix" { + removeStaleUnixSocket(address) + } + + listener, err := net.Listen(network, address) + if err != nil { + return nil, err + } + + return &socketListener{Listener: listener, network: network, address: address}, nil +} + +func parseListenAddress(addr string) (string, string, error) { + network, address, ok := strings.Cut(addr, "://") + if !ok || network == "" || address == "" { + return "", "", fmt.Errorf("address must be in [unix|tcp|npipe]://[path|host:port|name] format: %q", addr) + } + + switch network { + case "unix", "tcp", "npipe": + return network, address, nil + default: + return "", "", fmt.Errorf("unsupported daemon address protocol: %v", network) + } +} + +func removeStaleUnixSocket(path string) { + stat, err := os.Lstat(path) + if err != nil { + if !os.IsNotExist(err) { + log.Debugf("stat socket file: %v", err) + } + return + } + + if stat.Mode()&os.ModeSocket == 0 { + return + } + + if !isStaleUnixSocket(path) { + return + } + + if err := os.Remove(path); err != nil { + log.Debugf("remove socket file: %v", err) + } +} + +func isStaleUnixSocket(path string) bool { + conn, err := net.DialTimeout("unix", path, 100*time.Millisecond) + if err == nil { + if closeErr := conn.Close(); closeErr != nil { + log.Debugf("close unix socket probe: %v", closeErr) + } + return false + } + + if os.IsNotExist(err) || os.IsPermission(err) || os.IsTimeout(err) { + log.Debugf("not removing unix socket %s after probe error: %v", path, err) + return false + } + + return errors.Is(err, syscall.ECONNREFUSED) +} + +func removeStaleUnixSocketForAddress(addr string) { + network, address, err := parseListenAddress(addr) + if err != nil || network != "unix" { + return + } + removeStaleUnixSocket(address) +} + +func (l *socketListener) chmodUnixSocket(description string) error { + if l == nil || l.network != "unix" { + return nil + } + + if err := os.Chmod(l.address, 0666); err != nil { + return fmt.Errorf("failed setting %s permissions for %s: %w", description, l.address, err) + } + return nil +} diff --git a/client/cmd/service_test.go b/client/cmd/service_test.go index ce6f71550..22eba206d 100644 --- a/client/cmd/service_test.go +++ b/client/cmd/service_test.go @@ -1,16 +1,12 @@ package cmd import ( - "context" - "fmt" "os" "os/signal" "runtime" "syscall" "testing" - "time" - "github.com/kardianos/service" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -31,186 +27,6 @@ func TestMain(m *testing.M) { os.Exit(m.Run()) } -const ( - serviceStartTimeout = 10 * time.Second - serviceStopTimeout = 5 * time.Second - statusPollInterval = 500 * time.Millisecond -) - -// waitForServiceStatus waits for service to reach expected status with timeout -func waitForServiceStatus(expectedStatus service.Status, timeout time.Duration) (bool, error) { - cfg, err := newSVCConfig() - if err != nil { - return false, err - } - - ctxSvc, cancel := context.WithCancel(context.Background()) - defer cancel() - - s, err := newSVC(newProgram(ctxSvc, cancel), cfg) - if err != nil { - return false, err - } - - ctx, timeoutCancel := context.WithTimeout(context.Background(), timeout) - defer timeoutCancel() - - ticker := time.NewTicker(statusPollInterval) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return false, fmt.Errorf("timeout waiting for service status %v", expectedStatus) - case <-ticker.C: - status, err := s.Status() - if err != nil { - // Continue polling on transient errors - continue - } - if status == expectedStatus { - return true, nil - } - } - } -} - -// TestServiceLifecycle tests the complete service lifecycle -func TestServiceLifecycle(t *testing.T) { - // TODO: Add support for Windows and macOS - if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" { - t.Skipf("Skipping service lifecycle test on unsupported OS: %s", runtime.GOOS) - } - - if os.Getenv("CONTAINER") == "true" { - t.Skip("Skipping service lifecycle test in container environment") - } - - originalServiceName := serviceName - serviceName = "netbirdtest" + fmt.Sprintf("%d", time.Now().Unix()) - defer func() { - serviceName = originalServiceName - }() - - tempDir := t.TempDir() - configPath = fmt.Sprintf("%s/netbird-test-config.json", tempDir) - logLevel = "info" - daemonAddr = fmt.Sprintf("unix://%s/netbird-test.sock", tempDir) - - // Ensure cleanup even if a subtest fails and Stop/Uninstall subtests don't run. - t.Cleanup(func() { - cfg, err := newSVCConfig() - if err != nil { - t.Errorf("cleanup: create service config: %v", err) - return - } - ctxSvc, cancel := context.WithCancel(context.Background()) - defer cancel() - s, err := newSVC(newProgram(ctxSvc, cancel), cfg) - if err != nil { - t.Errorf("cleanup: create service: %v", err) - return - } - - // If the subtests already cleaned up, there's nothing to do. - if _, err := s.Status(); err != nil { - return - } - - if err := s.Stop(); err != nil { - t.Errorf("cleanup: stop service: %v", err) - } - if err := s.Uninstall(); err != nil { - t.Errorf("cleanup: uninstall service: %v", err) - } - }) - - ctx := context.Background() - - t.Run("Install", func(t *testing.T) { - installCmd.SetContext(ctx) - err := installCmd.RunE(installCmd, []string{}) - require.NoError(t, err) - - cfg, err := newSVCConfig() - require.NoError(t, err) - - ctxSvc, cancel := context.WithCancel(context.Background()) - defer cancel() - - s, err := newSVC(newProgram(ctxSvc, cancel), cfg) - require.NoError(t, err) - - status, err := s.Status() - assert.NoError(t, err) - assert.NotEqual(t, service.StatusUnknown, status) - }) - - t.Run("Start", func(t *testing.T) { - startCmd.SetContext(ctx) - err := startCmd.RunE(startCmd, []string{}) - require.NoError(t, err) - - running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout) - require.NoError(t, err) - assert.True(t, running) - }) - - t.Run("Restart", func(t *testing.T) { - restartCmd.SetContext(ctx) - err := restartCmd.RunE(restartCmd, []string{}) - require.NoError(t, err) - - running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout) - require.NoError(t, err) - assert.True(t, running) - }) - - t.Run("Reconfigure", func(t *testing.T) { - originalLogLevel := logLevel - logLevel = "debug" - defer func() { - logLevel = originalLogLevel - }() - - reconfigureCmd.SetContext(ctx) - err := reconfigureCmd.RunE(reconfigureCmd, []string{}) - require.NoError(t, err) - - running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout) - require.NoError(t, err) - assert.True(t, running) - }) - - t.Run("Stop", func(t *testing.T) { - stopCmd.SetContext(ctx) - err := stopCmd.RunE(stopCmd, []string{}) - require.NoError(t, err) - - stopped, err := waitForServiceStatus(service.StatusStopped, serviceStopTimeout) - require.NoError(t, err) - assert.True(t, stopped) - }) - - t.Run("Uninstall", func(t *testing.T) { - uninstallCmd.SetContext(ctx) - err := uninstallCmd.RunE(uninstallCmd, []string{}) - require.NoError(t, err) - - cfg, err := newSVCConfig() - require.NoError(t, err) - - ctxSvc, cancel := context.WithCancel(context.Background()) - defer cancel() - - s, err := newSVC(newProgram(ctxSvc, cancel), cfg) - require.NoError(t, err) - - _, err = s.Status() - assert.Error(t, err) - }) -} - // TestServiceEnvVars tests environment variable parsing func TestServiceEnvVars(t *testing.T) { tests := []struct { diff --git a/client/cmd/ssh.go b/client/cmd/ssh.go index 0acf0b133..d6e052e08 100644 --- a/client/cmd/ssh.go +++ b/client/cmd/ssh.go @@ -523,7 +523,7 @@ func parseHostnameAndCommand(args []string) error { } func runSSH(ctx context.Context, addr string, cmd *cobra.Command) error { - target := fmt.Sprintf("%s:%d", addr, port) + target := net.JoinHostPort(strings.Trim(addr, "[]"), strconv.Itoa(port)) c, err := sshclient.Dial(ctx, target, username, sshclient.DialOptions{ KnownHostsFile: knownHostsFile, IdentityFile: identityFile, @@ -787,10 +787,10 @@ func isUnixSocket(path string) bool { return strings.HasPrefix(path, "/") || strings.HasPrefix(path, "./") } -// normalizeLocalHost converts "*" to "0.0.0.0" for binding to all interfaces. +// normalizeLocalHost converts "*" to "" for binding to all interfaces (dual-stack). func normalizeLocalHost(host string) string { if host == "*" { - return "0.0.0.0" + return "" } return host } diff --git a/client/cmd/ssh_test.go b/client/cmd/ssh_test.go index 43291fa87..16ffadb90 100644 --- a/client/cmd/ssh_test.go +++ b/client/cmd/ssh_test.go @@ -527,10 +527,10 @@ func TestParsePortForward(t *testing.T) { { name: "wildcard bind all interfaces", spec: "*:8080:localhost:80", - expectedLocal: "0.0.0.0:8080", + expectedLocal: ":8080", expectedRemote: "localhost:80", expectError: false, - description: "Wildcard * should bind to all interfaces (0.0.0.0)", + description: "Wildcard * should bind to all interfaces (dual-stack)", }, { name: "wildcard for port only", diff --git a/client/cmd/status.go b/client/cmd/status.go index c35a06eb3..f2e5bcc66 100644 --- a/client/cmd/status.go +++ b/client/cmd/status.go @@ -6,12 +6,12 @@ import ( "net" "net/netip" "strings" + "time" "github.com/spf13/cobra" "google.golang.org/grpc/status" "github.com/netbirdio/netbird/client/internal" - "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/proto" nbstatus "github.com/netbirdio/netbird/client/status" "github.com/netbirdio/netbird/util" @@ -20,6 +20,7 @@ import ( var ( detailFlag bool ipv4Flag bool + ipv6Flag bool jsonFlag bool yamlFlag bool ipsFilter []string @@ -42,15 +43,16 @@ func init() { ipsFilterMap = make(map[string]struct{}) prefixNamesFilterMap = make(map[string]struct{}) statusCmd.PersistentFlags().BoolVarP(&detailFlag, "detail", "d", false, "display detailed status information in human-readable format") - statusCmd.PersistentFlags().BoolVar(&jsonFlag, "json", false, "display detailed status information in json format") - statusCmd.PersistentFlags().BoolVar(&yamlFlag, "yaml", false, "display detailed status information in yaml format") - statusCmd.PersistentFlags().BoolVar(&ipv4Flag, "ipv4", false, "display only NetBird IPv4 of this peer, e.g., --ipv4 will output 100.64.0.33") - statusCmd.MarkFlagsMutuallyExclusive("detail", "json", "yaml", "ipv4") - statusCmd.PersistentFlags().StringSliceVar(&ipsFilter, "filter-by-ips", []string{}, "filters the detailed output by a list of one or more IPs, e.g., --filter-by-ips 100.64.0.100,100.64.0.200") - statusCmd.PersistentFlags().StringSliceVar(&prefixNamesFilter, "filter-by-names", []string{}, "filters the detailed output by a list of one or more peer FQDN or hostnames, e.g., --filter-by-names peer-a,peer-b.netbird.cloud") - statusCmd.PersistentFlags().StringVar(&statusFilter, "filter-by-status", "", "filters the detailed output by connection status(idle|connecting|connected), e.g., --filter-by-status connected") - statusCmd.PersistentFlags().StringVar(&connectionTypeFilter, "filter-by-connection-type", "", "filters the detailed output by connection type (P2P|Relayed), e.g., --filter-by-connection-type P2P") - statusCmd.PersistentFlags().StringVar(&checkFlag, "check", "", "run a health check and exit with code 0 on success, 1 on failure (live|ready|startup)") + statusCmd.PersistentFlags().BoolVarP(&jsonFlag, "json", "j", false, "display detailed status information in json format") + statusCmd.PersistentFlags().BoolVarP(&yamlFlag, "yaml", "y", false, "display detailed status information in yaml format") + statusCmd.PersistentFlags().BoolVarP(&ipv4Flag, "ipv4", "4", false, "display only NetBird IPv4 of this peer, e.g., --ipv4 will output 100.64.0.33") + statusCmd.PersistentFlags().BoolVarP(&ipv6Flag, "ipv6", "6", false, "display only NetBird IPv6 of this peer") + statusCmd.MarkFlagsMutuallyExclusive("detail", "json", "yaml", "ipv4", "ipv6") + statusCmd.PersistentFlags().StringSliceVarP(&ipsFilter, "filter-by-ips", "I", []string{}, "filters the detailed output by a list of one or more IPs (v4 or v6), e.g., --filter-by-ips 100.64.0.100,fd00::1") + statusCmd.PersistentFlags().StringSliceVarP(&prefixNamesFilter, "filter-by-names", "N", []string{}, "filters the detailed output by a list of one or more peer FQDN or hostnames, e.g., --filter-by-names peer-a,peer-b.netbird.cloud") + statusCmd.PersistentFlags().StringVarP(&statusFilter, "filter-by-status", "S", "", "filters the detailed output by connection status(idle|connecting|connected), e.g., --filter-by-status connected") + statusCmd.PersistentFlags().StringVarP(&connectionTypeFilter, "filter-by-connection-type", "T", "", "filters the detailed output by connection type (P2P|Relayed), e.g., --filter-by-connection-type P2P") + statusCmd.PersistentFlags().StringVarP(&checkFlag, "check", "C", "", "run a health check and exit with code 0 on success, 1 on failure (live|ready|startup)") } func statusFunc(cmd *cobra.Command, args []string) error { @@ -101,14 +103,32 @@ func statusFunc(cmd *cobra.Command, args []string) error { return nil } - pm := profilemanager.NewProfileManager() - var profName string - if activeProf, err := pm.GetActiveProfile(); err == nil { - profName = activeProf.Name + if ipv6Flag { + ipv6 := resp.GetFullStatus().GetLocalPeerState().GetIpv6() + if ipv6 != "" { + cmd.Print(parseInterfaceIP(ipv6)) + } + return nil + } + + // Resolve the active profile's display name via the daemon, which runs + // as root and can read the per-user profile files. The local profile + // manager only knows the active profile ID, not its display name. + profName := getActiveProfileName(ctx) + + var sessionExpiresAt time.Time + if ts := resp.GetSessionExpiresAt(); ts.IsValid() { + sessionExpiresAt = ts.AsTime().UTC() + } + + anonymizeEnabled, anonymizeLevel, err := effectiveAnonymize() + if err != nil { + return err } var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), nbstatus.ConvertOptions{ - Anonymize: anonymizeFlag, + Anonymize: anonymizeEnabled, + AnonymizeLevel: anonymizeLevel, DaemonVersion: resp.GetDaemonVersion(), DaemonStatus: nbstatus.ParseDaemonStatus(status), StatusFilter: statusFilter, @@ -117,6 +137,7 @@ func statusFunc(cmd *cobra.Command, args []string) error { IPsFilter: ipsFilterMap, ConnectionTypeFilter: connectionTypeFilter, ProfileName: profName, + SessionExpiresAt: sessionExpiresAt, }) var statusOutputString string switch { @@ -157,6 +178,25 @@ func getStatus(ctx context.Context, fullPeerStatus bool, shouldRunProbes bool) ( return resp, nil } +// getActiveProfileName asks the daemon for the active profile's display +// name. The daemon runs as root and can read the per-user profile files to +// resolve the ID to its human-readable name. Returns an empty string on any +// error so status output degrades gracefully. +func getActiveProfileName(ctx context.Context) string { + conn, err := DialClientGRPCServer(ctx, daemonAddr) + if err != nil { + return "" + } + defer conn.Close() + + resp, err := proto.NewDaemonServiceClient(conn).GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}) + if err != nil { + return "" + } + + return resp.GetProfileName() +} + func parseFilters() error { switch strings.ToLower(statusFilter) { case "", "idle", "connecting", "connected": diff --git a/client/cmd/system.go b/client/cmd/system.go index f63432401..b386fe4ae 100644 --- a/client/cmd/system.go +++ b/client/cmd/system.go @@ -8,6 +8,7 @@ const ( disableFirewallFlag = "disable-firewall" blockLANAccessFlag = "block-lan-access" blockInboundFlag = "block-inbound" + disableIPv6Flag = "disable-ipv6" ) var ( @@ -17,6 +18,7 @@ var ( disableFirewall bool blockLANAccess bool blockInbound bool + disableIPv6 bool ) func init() { @@ -39,4 +41,7 @@ func init() { upCmd.PersistentFlags().BoolVar(&blockInbound, blockInboundFlag, false, "Block inbound connections. If enabled, the client will not allow any inbound connections to the local machine nor routed networks.\n"+ "This overrides any policies received from the management service.") + + upCmd.PersistentFlags().BoolVar(&disableIPv6, disableIPv6Flag, false, + "Disable IPv6 overlay. If enabled, the client won't request or use an IPv6 overlay address.") } diff --git a/client/cmd/testutil_test.go b/client/cmd/testutil_test.go index 095a108ff..d78758a45 100644 --- a/client/cmd/testutil_test.go +++ b/client/cmd/testutil_test.go @@ -6,12 +6,12 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" "google.golang.org/grpc" - "github.com/netbirdio/management-integrations/integrations" + "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator" "github.com/netbirdio/netbird/management/internals/modules/permissions" @@ -110,7 +110,7 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp t.Fatal(err) } - iv, _ := integrations.NewIntegratedValidator(ctx, peersmanager, settingsManagerMock, eventStore, cacheStore) + iv, _ := validator.NewIntegratedValidator(ctx, peersmanager, settingsManagerMock, eventStore, cacheStore) metrics, err := telemetry.NewDefaultAppMetrics(ctx) require.NoError(t, err) @@ -125,7 +125,7 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := mgmt.NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersmanager), config) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersmanager), config, nil) accountManager, err := mgmt.BuildManager(ctx, config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore) if err != nil { @@ -136,7 +136,7 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp if err != nil { t.Fatal(err) } - mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &mgmt.MockIntegratedValidator{}, networkMapController, nil) + mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &mgmt.MockIntegratedValidator{}, networkMapController, nil, nil) if err != nil { t.Fatal(err) } @@ -161,7 +161,7 @@ func startClientDaemon( s := grpc.NewServer() server := client.New(ctx, - "", "", false, false, false) + "", "", false, false, false, false) if err := server.Start(); err != nil { t.Fatal(err) } diff --git a/client/cmd/up.go b/client/cmd/up.go index f5766522a..f5fac9749 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -2,10 +2,10 @@ package cmd import ( "context" + "errors" "fmt" "net" "net/netip" - "os/user" "runtime" "strings" "time" @@ -21,7 +21,10 @@ import ( "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" + nbnet "github.com/netbirdio/netbird/client/net" "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/client/server" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/util" @@ -39,15 +42,21 @@ const ( noBrowserFlag = "no-browser" noBrowserDesc = "do not open the browser for SSO login" + showQRFlag = "qr" + showQRDesc = "show QR code for the SSO login URL (useful for headless machines without browser access)" + profileNameFlag = "profile" profileNameDesc = "profile name to use for the login. If not specified, the last used profile will be used." ) +var errDaemonActiveProfileUnsupported = errors.New("daemon does not support active profile lookup") + var ( foregroundMode bool dnsLabels []string dnsLabelsValidated domain.List noBrowser bool + showQR bool profileName string configPath string @@ -80,6 +89,7 @@ func init() { ) upCmd.PersistentFlags().BoolVar(&noBrowser, noBrowserFlag, false, noBrowserDesc) + upCmd.PersistentFlags().BoolVar(&showQR, showQRFlag, false, showQRDesc) upCmd.PersistentFlags().StringVar(&profileName, profileNameFlag, "", profileNameDesc) upCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "(DEPRECATED) NetBird config file location. ") @@ -115,30 +125,25 @@ func upFunc(cmd *cobra.Command, args []string) error { pm := profilemanager.NewProfileManager() - username, err := user.Current() + username, err := profilemanager.InvokingUser() if err != nil { return fmt.Errorf("get current user: %v", err) } + var activeProf *profilemanager.Profile var profileSwitched bool // switch profile if provided if profileName != "" { - err = switchProfile(cmd.Context(), profileName, username.Username) + activeProf, err = switchOrCreateProfile(cmd.Context(), pm, profileName, username.Username) if err != nil { return fmt.Errorf("switch profile: %v", err) } - - err = pm.SwitchProfile(profileName) - if err != nil { - return fmt.Errorf("switch profile: %v", err) - } - profileSwitched = true - } - - activeProf, err := pm.GetActiveProfile() - if err != nil { - return fmt.Errorf("get active profile: %v", err) + } else { + activeProf, err = pm.GetActiveProfile() + if err != nil { + return fmt.Errorf("get active profile: %v", err) + } } if foregroundMode { @@ -147,6 +152,54 @@ func upFunc(cmd *cobra.Command, args []string) error { return runInDaemonMode(ctx, cmd, pm, activeProf, profileSwitched) } +// switchOrCreateProfile switches the active profile to the one identified by +// handle, creating it first when it does not exist yet. This restores the +// pre-0.73 behaviour where `netbird up --profile ` auto-creates a +// missing profile instead of failing. Returns the daemon-resolved profile so +// callers act on it directly instead of re-reading the local state, which is +// not updated under sudo. +func switchOrCreateProfile(ctx context.Context, pm *profilemanager.ProfileManager, handle, username string) (*profilemanager.Profile, error) { + resolvedID, err := switchProfile(ctx, handle, username) + if err != nil { + st, ok := gstatus.FromError(err) + if !ok || st.Code() != codes.NotFound { + return nil, err + } + // Don't fail immediately on a create error: a concurrent run may + // have created the profile between the NotFound above and this + // call, in which case the retried switch still succeeds. Only + // surface the create error if the switch also fails. + _, createErr := createProfile(ctx, handle, username) + if resolvedID, err = switchProfile(ctx, handle, username); err != nil { + if createErr != nil { + return nil, fmt.Errorf("create profile: %w", createErr) + } + return nil, err + } + } + + if err := pm.SwitchProfile(resolvedID); err != nil { + return nil, err + } + return &profilemanager.Profile{ID: resolvedID}, nil +} + +// createProfile dials the daemon and creates a new profile with the given +// display name, returning its generated ID. Use addProfileOnDaemon directly +// when a daemon client is already available to reuse the connection. +func createProfile(ctx context.Context, profileName, username string) (profilemanager.ID, error) { + conn, err := DialClientGRPCServer(ctx, daemonAddr) + if err != nil { + //nolint + return "", fmt.Errorf("failed to connect to daemon error: %v\n"+ + "If the daemon is not running please run: "+ + "\nnetbird service install \nnetbird service start\n", err) + } + defer conn.Close() + + return addProfileOnDaemon(ctx, proto.NewDaemonServiceClient(conn), profileName, username) +} + func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *profilemanager.Profile) error { // override the default profile filepath if provided if configPath != "" { @@ -182,10 +235,32 @@ func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *pr if err != nil { return fmt.Errorf("get config file: %v", err) } + // CLI foreground path runs without the daemon Server: layer in the + // active MDM policy explicitly so a forced ManagementURL / PSK / + // other managed key actually takes effect on this run. + config.ApplyMDMPolicy(mdm.NewLoader(nil).Load()) _, _ = profilemanager.UpdateOldManagementURL(ctx, config, configFilePath) - err = foregroundLogin(ctx, cmd, config, providedSetupKey, activeProf.Name) + // Restore residual state left by a previous run that did not shut down + // cleanly, mirroring what the daemon does before connecting: it recovers + // DNS config (a stale resolv.conf takeover can make the management + // hostname unresolvable), firewall rules, ssh config and legacy routing. + // Route cleanup itself happens at engine start; nbnet.Init() below lets + // the management dial bypass a leftover fwmark rule until then. + // Foreground mode is particularly exposed in containers: a crashed + // container restarts inside the same (pod) network namespace, so stale + // state survives while the process does not. + if err := server.RestoreResidualState(ctx, profilemanager.NewServiceManager(configPath).GetStatePath()); err != nil { + log.Warnf("failed to restore residual state: %v", err) + } + + // Enable advanced routing (as the daemon does on startup) so the + // management dial bypasses a leftover fwmark rule instead of being + // shunted into a stale routing table. + nbnet.Init() + + err = foregroundLogin(ctx, cmd, config, providedSetupKey, activeProf.ID) if err != nil { return fmt.Errorf("foreground login failed: %v", err) } @@ -238,6 +313,30 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager return fmt.Errorf("unable to get daemon status: %v", err) } + // Under sudo the invoking user's local active-profile mirror is never + // written (the SwitchProfile write is a no-op), and plain root has no + // invoking user at all — so the mirror read into activeProf above is stale + // or defaulted and must not drive the daemon. With no --profile to make the + // choice explicit, take the profile the daemon already holds for this user + // instead: it stays on the user's current profile rather than silently + // switching to the mirror's default, and refuses when the daemon is on + // another user's profile. + if profileName == "" && !profilemanager.MirrorIsAuthoritative() { + u, err := profilemanager.InvokingUser() + if err != nil { + return fmt.Errorf("get current user: %v", err) + } + resolved, err := daemonActiveProfileForUser(ctx, client, u.Username) + switch { + case errors.Is(err, errDaemonActiveProfileUnsupported): + log.Warnf("keeping the locally resolved profile: %v", err) + case err != nil: + return err + default: + activeProf = resolved + } + } + if status.Status == string(internal.StatusConnected) { if !profileSwitched { cmd.Println("Already connected") @@ -250,18 +349,18 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager } } - username, err := user.Current() + username, err := profilemanager.InvokingUser() if err != nil { return fmt.Errorf("get current user: %v", err) } // set the new config - req := setupSetConfigReq(customDNSAddressConverted, cmd, activeProf.Name, username.Username) + req := setupSetConfigReq(customDNSAddressConverted, cmd, activeProf.ID.String(), username.Username) if _, err := client.SetConfig(ctx, req); err != nil { if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Unavailable { - log.Warnf("setConfig method is not available in the daemon") + log.Warnf("setConfig method is not available in the daemon: %s", st.Message()) } else { - return fmt.Errorf("call service setConfig method: %v", err) + return daemonCallError("call service setConfig method", err) } } @@ -284,10 +383,11 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ return fmt.Errorf("setup login request: %v", err) } - loginRequest.ProfileName = &activeProf.Name + profileID := activeProf.ID.String() + loginRequest.ProfileName = &profileID loginRequest.Username = &username - profileState, err := pm.GetProfileState(activeProf.Name) + profileState, err := pm.GetProfileState(activeProf.ID) if err != nil { log.Debugf("failed to get profile state for login hint: %v", err) } else if profileState.Email != "" { @@ -314,7 +414,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ } if loginErr != nil { - return fmt.Errorf("login failed: %v", loginErr) + return daemonCallError("login failed", loginErr) } if loginResp.NeedsSSOLogin { @@ -324,35 +424,30 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ } if _, err := client.Up(ctx, &proto.UpRequest{ - ProfileName: &activeProf.Name, + ProfileName: &profileID, Username: &username, }); err != nil { - return fmt.Errorf("call service up method: %v", err) + return daemonCallError("call service up method", err) } return nil } -func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, profileName, username string) *proto.SetConfigRequest { - var req proto.SetConfigRequest - req.ProfileName = profileName - req.Username = username - - req.ManagementUrl = managementURL - req.AdminURL = adminURL - req.NatExternalIPs = natExternalIPs - req.CustomDNSAddress = customDNSAddressConverted - req.ExtraIFaceBlacklist = extraIFaceBlackList - req.DnsLabels = dnsLabelsValidated.ToPunycodeList() - req.CleanDNSLabels = dnsLabels != nil && len(dnsLabels) == 0 - req.CleanNATExternalIPs = natExternalIPs != nil && len(natExternalIPs) == 0 - - if cmd.Flag(enableRosenpassFlag).Changed { - req.RosenpassEnabled = &rosenpassEnabled - } - if cmd.Flag(rosenpassPermissiveFlag).Changed { - req.RosenpassPermissive = &rosenpassPermissive +// setBoolPtrIfChanged points dst at a copy of val when the named bool flag was +// explicitly set on cmd. It collapses the repeated +// "if cmd.Flag(x).Changed { field = &val }" pattern in the request builders into +// a single call, keeping their cognitive complexity within bounds. +func setBoolPtrIfChanged(cmd *cobra.Command, name string, dst **bool, val bool) { + if cmd.Flag(name).Changed { + dst2 := val + *dst = &dst2 } +} + +// setSSHSetConfigFields copies the SSH server flags the user actually +// passed into req, leaving the rest unset so the daemon keeps the +// persisted values. +func setSSHSetConfigFields(req *proto.SetConfigRequest, cmd *cobra.Command) { if cmd.Flag(serverSSHAllowedFlag).Changed { req.ServerSSHAllowed = &serverSSHAllowed } @@ -375,6 +470,31 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro sshJWTCacheTTL32 := int32(sshJWTCacheTTL) req.SshJWTCacheTTL = &sshJWTCacheTTL32 } +} + +func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, profileName, username string) *proto.SetConfigRequest { + var req proto.SetConfigRequest + req.ProfileName = profileName + req.Username = username + + req.ManagementUrl = managementURL + req.AdminURL = adminURL + req.NatExternalIPs = natExternalIPs + req.CustomDNSAddress = customDNSAddressConverted + req.ExtraIFaceBlacklist = extraIFaceBlackList + req.DnsLabels = dnsLabelsValidated.ToPunycodeList() + req.CleanDNSLabels = dnsLabels != nil && len(dnsLabels) == 0 + req.CleanNATExternalIPs = natExternalIPs != nil && len(natExternalIPs) == 0 + + if cmd.Flag(enableRosenpassFlag).Changed { + req.RosenpassEnabled = &rosenpassEnabled + } + if cmd.Flag(rosenpassPermissiveFlag).Changed { + req.RosenpassPermissive = &rosenpassPermissive + } + setSSHSetConfigFields(&req, cmd) + setBoolPtrIfChanged(cmd, remoteJobsAllowedFlag, &req.RemoteJobsAllowed, remoteJobsAllowed) + if cmd.Flag(interfaceNameFlag).Changed { if err := parseInterfaceName(interfaceName); err != nil { log.Errorf("parse interface name: %v", err) @@ -430,8 +550,15 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro req.BlockInbound = &blockInbound } - if cmd.Flag(enableLazyConnectionFlag).Changed { - req.LazyConnectionEnabled = &lazyConnEnabled + if cmd.Flag(disableIPv6Flag).Changed { + req.DisableIpv6 = &disableIPv6 + } + + if cmd.Flag(enableLocalMetricsFlag).Changed { + req.EnableLocalMetrics = &localMetricsEnabled + } + if cmd.Flag(localMetricsAddressFlag).Changed { + req.LocalMetricsAddress = &localMetricsAddr } return &req @@ -458,6 +585,7 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil if cmd.Flag(serverSSHAllowedFlag).Changed { ic.ServerSSHAllowed = &serverSSHAllowed } + setBoolPtrIfChanged(cmd, remoteJobsAllowedFlag, &ic.RemoteJobsAllowed, remoteJobsAllowed) if cmd.Flag(enableSSHRootFlag).Changed { ic.EnableSSHRoot = &enableSSHRoot @@ -547,12 +675,49 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil ic.BlockInbound = &blockInbound } - if cmd.Flag(enableLazyConnectionFlag).Changed { - ic.LazyConnectionEnabled = &lazyConnEnabled + if cmd.Flag(disableIPv6Flag).Changed { + ic.DisableIPv6 = &disableIPv6 } + + if cmd.Flag(enableLocalMetricsFlag).Changed { + ic.LocalMetricsEnabled = &localMetricsEnabled + } + + if cmd.Flag(localMetricsAddressFlag).Changed { + ic.LocalMetricsAddress = &localMetricsAddr + } + return &ic, nil } +// setSSHLoginFields copies the SSH server flags the user actually passed +// into req, leaving the rest unset so the daemon keeps the persisted +// values. +func setSSHLoginFields(req *proto.LoginRequest, cmd *cobra.Command) { + if cmd.Flag(serverSSHAllowedFlag).Changed { + req.ServerSSHAllowed = &serverSSHAllowed + } + if cmd.Flag(enableSSHRootFlag).Changed { + req.EnableSSHRoot = &enableSSHRoot + } + if cmd.Flag(enableSSHSFTPFlag).Changed { + req.EnableSSHSFTP = &enableSSHSFTP + } + if cmd.Flag(enableSSHLocalPortForwardFlag).Changed { + req.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward + } + if cmd.Flag(enableSSHRemotePortForwardFlag).Changed { + req.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward + } + if cmd.Flag(disableSSHAuthFlag).Changed { + req.DisableSSHAuth = &disableSSHAuth + } + if cmd.Flag(sshJWTCacheTTLFlag).Changed { + sshJWTCacheTTL32 := int32(sshJWTCacheTTL) + req.SshJWTCacheTTL = &sshJWTCacheTTL32 + } +} + func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte, cmd *cobra.Command) (*proto.LoginRequest, error) { loginRequest := proto.LoginRequest{ SetupKey: providedSetupKey, @@ -560,7 +725,7 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte NatExternalIPs: natExternalIPs, CleanNATExternalIPs: natExternalIPs != nil && len(natExternalIPs) == 0, CustomDNSAddress: customDNSAddressConverted, - IsUnixDesktopClient: isUnixRunningDesktop(), + IsUnixDesktopClient: util.HasGraphicalSession(), Hostname: hostName, ExtraIFaceBlacklist: extraIFaceBlackList, DnsLabels: dnsLabels, @@ -579,39 +744,21 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte loginRequest.RosenpassPermissive = &rosenpassPermissive } - if cmd.Flag(serverSSHAllowedFlag).Changed { - loginRequest.ServerSSHAllowed = &serverSSHAllowed - } - - if cmd.Flag(enableSSHRootFlag).Changed { - loginRequest.EnableSSHRoot = &enableSSHRoot - } - - if cmd.Flag(enableSSHSFTPFlag).Changed { - loginRequest.EnableSSHSFTP = &enableSSHSFTP - } - - if cmd.Flag(enableSSHLocalPortForwardFlag).Changed { - loginRequest.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward - } - - if cmd.Flag(enableSSHRemotePortForwardFlag).Changed { - loginRequest.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward - } - - if cmd.Flag(disableSSHAuthFlag).Changed { - loginRequest.DisableSSHAuth = &disableSSHAuth - } - - if cmd.Flag(sshJWTCacheTTLFlag).Changed { - sshJWTCacheTTL32 := int32(sshJWTCacheTTL) - loginRequest.SshJWTCacheTTL = &sshJWTCacheTTL32 - } + setSSHLoginFields(&loginRequest, cmd) + setBoolPtrIfChanged(cmd, remoteJobsAllowedFlag, &loginRequest.RemoteJobsAllowed, remoteJobsAllowed) if cmd.Flag(disableAutoConnectFlag).Changed { loginRequest.DisableAutoConnect = &autoConnectDisabled } + if cmd.Flag(enableLocalMetricsFlag).Changed { + loginRequest.EnableLocalMetrics = &localMetricsEnabled + } + + if cmd.Flag(localMetricsAddressFlag).Changed { + loginRequest.LocalMetricsAddress = &localMetricsAddr + } + if cmd.Flag(interfaceNameFlag).Changed { if err := parseInterfaceName(interfaceName); err != nil { return nil, err @@ -661,9 +808,10 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte loginRequest.BlockInbound = &blockInbound } - if cmd.Flag(enableLazyConnectionFlag).Changed { - loginRequest.LazyConnectionEnabled = &lazyConnEnabled + if cmd.Flag(disableIPv6Flag).Changed { + loginRequest.DisableIpv6 = &disableIPv6 } + return &loginRequest, nil } @@ -782,3 +930,31 @@ func isValidAddrPort(input string) bool { _, err := netip.ParseAddrPort(input) return err == nil } + +// daemonActiveProfileForUser returns the profile the daemon currently holds for +// username, for the no --profile case where the local mirror is not +// authoritative (sudo or plain root). It returns that profile when the daemon +// owns it for this user or when the profile is unowned (empty username, as on a +// fresh install), so the caller acts on the daemon's real state instead of the +// stale mirror. It denies with a --profile hint when the daemon is on another +// user's profile, when the lookup fails, or when the daemon reports no active +// profile. Returns errDaemonActiveProfileUnsupported when the daemon predates +// the RPC; the caller keeps the mirror-derived profile in that case. +func daemonActiveProfileForUser(ctx context.Context, client proto.DaemonServiceClient, username string) (*profilemanager.Profile, error) { + active, err := client.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}) + if err != nil { + if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Unimplemented { + return nil, fmt.Errorf("%w: %v", errDaemonActiveProfileUnsupported, err) + } + return nil, fmt.Errorf("pass --profile to choose the profile explicitly: the daemon's active profile could not be verified: %v", err) + } + if active.GetId() == "" { + return nil, fmt.Errorf("pass --profile to choose the profile explicitly: the daemon reported no active profile") + } + if active.GetUsername() != "" && active.GetUsername() != username { + return nil, fmt.Errorf( + "pass --profile to choose the profile explicitly: the daemon's active profile is %q (user %q) but this invocation runs for %q", + active.GetProfileName(), active.GetUsername(), username) + } + return &profilemanager.Profile{ID: profilemanager.ID(active.GetId())}, nil +} diff --git a/client/cmd/up_daemon_test.go b/client/cmd/up_daemon_test.go index 682a45365..ea4cdf162 100644 --- a/client/cmd/up_daemon_test.go +++ b/client/cmd/up_daemon_test.go @@ -29,14 +29,14 @@ func TestUpDaemon(t *testing.T) { } sm := profilemanager.ServiceManager{} - err = sm.AddProfile("test1", currUser.Username) + created, err := sm.AddProfile("test1", currUser.Username) if err != nil { t.Fatalf("failed to add profile: %v", err) return } err = sm.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: "test1", + ID: created.ID, Username: currUser.Username, }) if err != nil { diff --git a/client/cmd/up_test.go b/client/cmd/up_test.go new file mode 100644 index 000000000..9b5f9fbea --- /dev/null +++ b/client/cmd/up_test.go @@ -0,0 +1,88 @@ +package cmd + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/proto" +) + +type fakeActiveProfileClient struct { + proto.DaemonServiceClient + resp *proto.GetActiveProfileResponse + err error +} + +func (f *fakeActiveProfileClient) GetActiveProfile(_ context.Context, _ *proto.GetActiveProfileRequest, _ ...grpc.CallOption) (*proto.GetActiveProfileResponse, error) { + return f.resp, f.err +} + +func TestDaemonActiveProfileForUserReturnsOwnProfile(t *testing.T) { + client := &fakeActiveProfileClient{resp: &proto.GetActiveProfileResponse{Id: "default", ProfileName: "default", Username: "root"}} + prof, err := daemonActiveProfileForUser(context.Background(), client, "root") + require.NoError(t, err) + require.NotNil(t, prof) + assert.Equal(t, profilemanager.ID("default"), prof.ID) +} + +func TestDaemonActiveProfileForUserReturnsUnownedProfile(t *testing.T) { + client := &fakeActiveProfileClient{resp: &proto.GetActiveProfileResponse{Id: "default", ProfileName: "default", Username: ""}} + prof, err := daemonActiveProfileForUser(context.Background(), client, "root") + require.NoError(t, err) + require.NotNil(t, prof) + assert.Equal(t, profilemanager.ID("default"), prof.ID) +} + +func TestDaemonActiveProfileForUserKeepsDaemonProfileOverStaleMirror(t *testing.T) { + client := &fakeActiveProfileClient{resp: &proto.GetActiveProfileResponse{Id: "ab12", ProfileName: "work", Username: "misha"}} + prof, err := daemonActiveProfileForUser(context.Background(), client, "misha") + require.NoError(t, err) + require.NotNil(t, prof) + assert.Equal(t, profilemanager.ID("ab12"), prof.ID) +} + +func TestDaemonActiveProfileForUserRejectsOtherUsersProfile(t *testing.T) { + client := &fakeActiveProfileClient{resp: &proto.GetActiveProfileResponse{Id: "ab12", ProfileName: "work", Username: "misha"}} + prof, err := daemonActiveProfileForUser(context.Background(), client, "root") + require.Error(t, err) + assert.Nil(t, prof) + assert.Contains(t, err.Error(), "--profile") +} + +func TestDaemonActiveProfileForUserRejectsOtherUsersDefaultProfile(t *testing.T) { + client := &fakeActiveProfileClient{resp: &proto.GetActiveProfileResponse{Id: "default", ProfileName: "default", Username: "misha"}} + prof, err := daemonActiveProfileForUser(context.Background(), client, "root") + require.Error(t, err) + assert.Nil(t, prof) + assert.Contains(t, err.Error(), "--profile") +} + +func TestDaemonActiveProfileForUserRejectsLookupError(t *testing.T) { + client := &fakeActiveProfileClient{err: gstatus.Error(codes.Internal, "boom")} + prof, err := daemonActiveProfileForUser(context.Background(), client, "root") + require.Error(t, err) + assert.Nil(t, prof) + assert.Contains(t, err.Error(), "--profile") +} + +func TestDaemonActiveProfileForUserRejectsEmptyResponse(t *testing.T) { + client := &fakeActiveProfileClient{resp: &proto.GetActiveProfileResponse{}} + prof, err := daemonActiveProfileForUser(context.Background(), client, "root") + require.Error(t, err) + assert.Nil(t, prof) + assert.Contains(t, err.Error(), "--profile") +} + +func TestDaemonActiveProfileForUserKeepsMirrorWhenDaemonWithoutRPC(t *testing.T) { + client := &fakeActiveProfileClient{err: gstatus.Error(codes.Unimplemented, "unknown method")} + prof, err := daemonActiveProfileForUser(context.Background(), client, "root") + require.ErrorIs(t, err, errDaemonActiveProfileUnsupported) + assert.Nil(t, prof) +} diff --git a/client/cmd/version.go b/client/cmd/version.go index 249854444..5deeae1a0 100644 --- a/client/cmd/version.go +++ b/client/cmd/version.go @@ -12,7 +12,13 @@ var ( Short: "Print the NetBird's client application version", Run: func(cmd *cobra.Command, args []string) { cmd.SetOut(cmd.OutOrStdout()) - cmd.Println(version.NetbirdVersion()) + out := version.NetbirdVersion() + if version.IsDevelopmentVersion(out) { + if commit := version.NetbirdCommit(); commit != "" { + out += "-" + commit + } + } + cmd.Println(out) }, } ) diff --git a/client/configs/configs.go b/client/configs/configs.go index 8f9c3ba28..a1ecf0feb 100644 --- a/client/configs/configs.go +++ b/client/configs/configs.go @@ -6,6 +6,11 @@ import ( "runtime" ) +// UILogFile is the file name the desktop UI writes its log to. It is defined +// here so the UI (writer), the daemon's RegisterUILog validation, and the debug +// bundle collector all share one definition. +const UILogFile = "gui-client.log" + var StateDir string func init() { diff --git a/client/embed/capture.go b/client/embed/capture.go new file mode 100644 index 000000000..30f9b496f --- /dev/null +++ b/client/embed/capture.go @@ -0,0 +1,65 @@ +package embed + +import ( + "io" + + "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/util/capture" +) + +// CaptureOptions configures a packet capture session. +type CaptureOptions struct { + // Output receives pcap-formatted data. Nil disables pcap output. + Output io.Writer + // TextOutput receives human-readable packet summaries. Nil disables text output. + TextOutput io.Writer + // Filter is a BPF-like filter expression (e.g. "host 10.0.0.1 and tcp port 443"). + // Empty captures all packets. + Filter string + // Verbose adds seq/ack, TTL, window, and total length to text output. + Verbose bool + // ASCII dumps transport payload as printable ASCII after each packet line. + ASCII bool +} + +// CaptureStats reports capture session counters. +type CaptureStats struct { + Packets int64 + Bytes int64 + Dropped int64 +} + +// CaptureSession represents an active packet capture. Call Stop to end the +// capture and flush buffered packets. +type CaptureSession struct { + sess *capture.Session + engine *internal.Engine +} + +// Stop ends the capture, flushes remaining packets, and detaches from the device. +// Safe to call multiple times. +func (cs *CaptureSession) Stop() { + if cs.engine != nil { + _ = cs.engine.SetCapture(nil) + cs.engine = nil + } + if cs.sess != nil { + cs.sess.Stop() + } +} + +// Stats returns current capture counters. +func (cs *CaptureSession) Stats() CaptureStats { + s := cs.sess.Stats() + return CaptureStats{ + Packets: s.Packets, + Bytes: s.Bytes, + Dropped: s.Dropped, + } +} + +// Done returns a channel that is closed when the capture's writer goroutine +// has fully exited and all buffered packets have been flushed. +func (cs *CaptureSession) Done() <-chan struct{} { + return cs.sess.Done() +} diff --git a/client/embed/embed.go b/client/embed/embed.go index 88f7e541c..5a3d540ec 100644 --- a/client/embed/embed.go +++ b/client/embed/embed.go @@ -12,6 +12,7 @@ import ( "sync" "github.com/sirupsen/logrus" + wgdevice "golang.zx2c4.com/wireguard/device" wgnetstack "golang.zx2c4.com/wireguard/tun/netstack" "github.com/netbirdio/netbird/client/iface" @@ -20,10 +21,12 @@ import ( "github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" - sshcommon "github.com/netbirdio/netbird/client/ssh" + "github.com/netbirdio/netbird/client/mdm" + nbssh "github.com/netbirdio/netbird/client/ssh" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/shared/management/domain" mgmProto "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/util/capture" ) var ( @@ -65,7 +68,7 @@ type Options struct { PrivateKey string // ManagementURL overrides the default management server URL ManagementURL string - // PreSharedKey is the pre-shared key for the WireGuard interface + // PreSharedKey is the pre-shared key for the tunnel interface PreSharedKey string // LogOutput is the output destination for logs (defaults to os.Stderr if nil) LogOutput io.Writer @@ -79,11 +82,31 @@ type Options struct { StatePath string // DisableClientRoutes disables the client routes DisableClientRoutes bool + // DisableIPv6 disables IPv6 overlay addressing + DisableIPv6 bool // BlockInbound blocks all inbound connections from peers BlockInbound bool - // WireguardPort is the port for the WireGuard interface. Use 0 for a random port. + // EnableRosenpass enables the Rosenpass post-quantum key exchange. + EnableRosenpass bool + // RosenpassPermissive lets a Rosenpass-enabled peer still connect to peers + // that do not run Rosenpass (falling back to the plain WireGuard PSK). + RosenpassPermissive bool + // BlockLANAccess blocks the embedded peer from reaching the host's + // LAN (RFC 1918, link-local, loopback) when it's used as a routing + // peer. Mirrors profilemanager.ConfigInput.BlockLANAccess. Useful + // when the embedded client must never act as a stepping stone into + // the host's local network (e.g. the proxy's overlay peer). + BlockLANAccess bool + // LazyConnectionEnabled is a tri-state local override for lazy connections, + // mirroring the NB_LAZY_CONN env var. Nil defers to the management feature + // flag; a set value overrides it in both directions. A short-lived client + // that reaches only a few known peers can set this to false, so its peers + // connect eagerly and the first request does not wait for the connection to + // be established. + LazyConnectionEnabled *bool + // WireguardPort is the port for the tunnel interface. Use 0 for a random port. WireguardPort *int - // MTU is the MTU for the WireGuard interface. + // MTU is the MTU for the tunnel interface. // Valid values are in the range 576..8192 bytes. // If non-nil, this value overrides any value stored in the config file. // If nil, the existing config MTU (if non-zero) is preserved; otherwise it defaults to 1280. @@ -91,6 +114,26 @@ type Options struct { MTU *uint16 // DNSLabels defines additional DNS labels configured in the peer. DNSLabels []string + // Performance configures the tunnel's buffer pool cap and batch size. + Performance Performance +} + +// Performance configures the embedded client's tunnel memory/throughput knobs. +// +// These settings are process-global: any non-nil field also becomes the +// default for Clients constructed by later embed.New calls in the same +// process. Nil fields are ignored. +type Performance struct { + // PreallocatedBuffersPerPool caps the per-tunnel buffer pool. Zero + // leaves the pool unbounded. Lower values trade throughput for a + // tighter memory ceiling. May also be changed on a running Client via + // Client.SetPerformance, provided this field was nonzero at construction. + PreallocatedBuffersPerPool *uint32 + // MaxBatchSize overrides the number of packets the tunnel reads or + // writes per syscall, which also bounds eager buffer allocation per + // worker. Zero uses the platform default. Applied at construction + // only; ignored by Client.SetPerformance. + MaxBatchSize *uint32 } // validateCredentials checks that exactly one credential type is provided @@ -170,7 +213,11 @@ func New(opts Options) (*Client, error) { PreSharedKey: &opts.PreSharedKey, DisableServerRoutes: &t, DisableClientRoutes: &opts.DisableClientRoutes, + DisableIPv6: &opts.DisableIPv6, BlockInbound: &opts.BlockInbound, + BlockLANAccess: &opts.BlockLANAccess, + RosenpassEnabled: &opts.EnableRosenpass, + RosenpassPermissive: &opts.RosenpassPermissive, WireguardPort: opts.WireguardPort, MTU: opts.MTU, DNSLabels: parsedLabels, @@ -183,11 +230,31 @@ func New(opts Options) (*Client, error) { if err != nil { return nil, fmt.Errorf("create config: %w", err) } + // Embedded path runs without the daemon Server: apply the active + // MDM policy explicitly so a forced ManagementURL / PSK / other + // managed key takes effect on this embedded engine instance. + config.ApplyMDMPolicy(mdm.NewLoader(nil).Load()) if opts.PrivateKey != "" { config.PrivateKey = opts.PrivateKey } + if opts.LazyConnectionEnabled != nil { + // Runtime-only override, read back through lazyconn.ParseState; a set value + // wins over the management feature flag in both directions. + config.LazyConnection = "off" + if *opts.LazyConnectionEnabled { + config.LazyConnection = "on" + } + } + + if opts.Performance.PreallocatedBuffersPerPool != nil { + wgdevice.SetPreallocatedBuffersPerPool(*opts.Performance.PreallocatedBuffersPerPool) + } + if opts.Performance.MaxBatchSize != nil { + wgdevice.SetMaxBatchSizeOverride(*opts.Performance.MaxBatchSize) + } + return &Client{ deviceName: opts.DeviceName, setupKey: opts.SetupKey, @@ -240,6 +307,12 @@ func (c *Client) Start(startCtx context.Context) error { select { case <-startCtx.Done(): + // ConnectClient.Stop now cancels its own run context and waits for the + // run loop to tear the engine down, so this cancel() is no longer + // required to break the deadlock and could be removed. It is kept as a + // defensive belt-and-suspenders: cancelling the parent context first + // guarantees the run loop is unblocked even if Stop's contract regresses. + cancel() if stopErr := client.Stop(); stopErr != nil { return fmt.Errorf("stop error after context done. Stop error: %w. Context done: %w", stopErr, startCtx.Err()) } @@ -332,7 +405,7 @@ func (c *Client) ListenTCP(address string) (net.Listener, error) { if err != nil { return nil, fmt.Errorf("split host port: %w", err) } - listenAddr := fmt.Sprintf("%s:%s", addr, port) + listenAddr := net.JoinHostPort(addr.String(), port) tcpAddr, err := net.ResolveTCPAddr("tcp", listenAddr) if err != nil { @@ -353,7 +426,7 @@ func (c *Client) ListenUDP(address string) (net.PacketConn, error) { if err != nil { return nil, fmt.Errorf("split host port: %w", err) } - listenAddr := fmt.Sprintf("%s:%s", addr, port) + listenAddr := net.JoinHostPort(addr.String(), port) udpAddr, err := net.ResolveUDPAddr("udp", listenAddr) if err != nil { @@ -401,6 +474,21 @@ func (c *Client) Expose(ctx context.Context, req ExposeRequest) (*ExposeSession, }, nil } +// IdentityForIP looks up a remote peer by its tunnel IP using the +// embedded client's status recorder. Returns the peer's WireGuard public +// key and FQDN. ok=false means the IP doesn't belong to an active peer +// — offline roster peers are treated as unknown, same as foreign IPs. +func (c *Client) IdentityForIP(ip netip.Addr) (pubKey, fqdn string, ok bool) { + if !ip.IsValid() || c.recorder == nil { + return "", "", false + } + state, found := c.recorder.PeerStateByIP(ip.String()) + if !found { + return "", "", false + } + return state.PubKey, state.FQDN, true +} + // Status returns the current status of the client. func (c *Client) Status() (peer.FullStatus, error) { c.mu.Lock() @@ -410,7 +498,7 @@ func (c *Client) Status() (peer.FullStatus, error) { if connect != nil { engine := connect.Engine() if engine != nil { - _ = engine.RunHealthProbes(false) + _ = engine.RunHealthProbes(context.Background(), false) } } @@ -461,12 +549,72 @@ func (c *Client) VerifySSHHostKey(peerAddress string, key []byte) error { return err } - storedKey, found := engine.GetPeerSSHKey(peerAddress) - if !found { - return sshcommon.ErrPeerNotFound + return nbssh.PeerKeyLookup(engine.GetPeerSSHKey).VerifySSHHostKey(peerAddress, key) +} + +// SetPerformance retunes a running Client. Only PreallocatedBuffersPerPool +// takes effect, and only when it was nonzero at construction; +// MaxBatchSize is construction-only and returns an error if set here. +// +// Returns ErrClientNotStarted / ErrEngineNotStarted if the Client is not +// running yet. +func (c *Client) SetPerformance(t Performance) error { + if t.MaxBatchSize != nil { + return errors.New("MaxBatchSize is construction-only and cannot be changed at runtime") + } + engine, err := c.getEngine() + if err != nil { + return err + } + return engine.SetPerformance(internal.Performance{ + PreallocatedBuffersPerPool: t.PreallocatedBuffersPerPool, + }) +} + +// StartCapture begins capturing packets on this client's tunnel device. +// Only one capture can be active at a time; starting a new one stops the previous. +// Call StopCapture (or CaptureSession.Stop) to end it. +func (c *Client) StartCapture(opts CaptureOptions) (*CaptureSession, error) { + engine, err := c.getEngine() + if err != nil { + return nil, err } - return sshcommon.VerifyHostKey(storedKey, key, peerAddress) + var matcher capture.Matcher + if opts.Filter != "" { + m, err := capture.ParseFilter(opts.Filter) + if err != nil { + return nil, fmt.Errorf("parse filter: %w", err) + } + matcher = m + } + + sess, err := capture.NewSession(capture.Options{ + Output: opts.Output, + TextOutput: opts.TextOutput, + Matcher: matcher, + Verbose: opts.Verbose, + ASCII: opts.ASCII, + }) + if err != nil { + return nil, fmt.Errorf("create capture session: %w", err) + } + + if err := engine.SetCapture(sess); err != nil { + sess.Stop() + return nil, fmt.Errorf("set capture: %w", err) + } + + return &CaptureSession{sess: sess, engine: engine}, nil +} + +// StopCapture stops the active capture session if one is running. +func (c *Client) StopCapture() error { + engine, err := c.getEngine() + if err != nil { + return err + } + return engine.SetCapture(nil) } // getEngine safely retrieves the engine from the client with proper locking. diff --git a/client/embed/embed_test.go b/client/embed/embed_test.go new file mode 100644 index 000000000..ce0edde5d --- /dev/null +++ b/client/embed/embed_test.go @@ -0,0 +1,168 @@ +package embed + +import ( + "context" + "net" + "testing" + "time" + + "go.uber.org/mock/gomock" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + + "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" + "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel" + "github.com/netbirdio/netbird/management/internals/modules/peers" + "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager" + "github.com/netbirdio/netbird/management/internals/server/config" + nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + mgmt "github.com/netbirdio/netbird/management/server" + "github.com/netbirdio/netbird/management/server/activity" + nbcache "github.com/netbirdio/netbird/management/server/cache" + "github.com/netbirdio/netbird/management/server/groups" + "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator" + "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" + "github.com/netbirdio/netbird/management/server/job" + "github.com/netbirdio/netbird/management/internals/modules/permissions" + "github.com/netbirdio/netbird/management/server/settings" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/telemetry" + "github.com/netbirdio/netbird/management/server/types" + mgmtProto "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/util" +) + +const testSetupKey = "A2C8E62B-38F5-4553-B31E-DD66C696CEBB" + +// TestClientStartTimeoutRollback reproduces a deadlock between Engine.Start and +// Engine.Stop. The signal endpoint accepts gRPC connections but never serves the +// SignalExchange service, so Engine.Start parks in WaitStreamConnected while +// holding the engine mutex. When the Start context expires, the rollback path +// calls ConnectClient.Stop, which must not block forever acquiring that mutex. +func TestClientStartTimeoutRollback(t *testing.T) { + signalAddr := startBlackholeSignal(t) + mgmAddr := startManagement(t, signalAddr) + + wgPort := 0 + client, err := New(Options{ + DeviceName: "embed-rollback-test", + SetupKey: testSetupKey, + ManagementURL: "http://" + mgmAddr, + WireguardPort: &wgPort, + }) + require.NoError(t, err, "embed client creation must succeed") + + startCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + startErr := make(chan error, 1) + go func() { + startErr <- client.Start(startCtx) + }() + + select { + case err := <-startErr: + require.ErrorIs(t, err, context.DeadlineExceeded) + case <-time.After(60 * time.Second): + t.Fatal("client.Start did not return after its context expired: Engine.Stop deadlocked against Engine.Start waiting for the signal stream") + } +} + +// startBlackholeSignal starts a gRPC server without the SignalExchange service +// registered. Connections succeed, but the signal stream can never be +// established, which keeps Engine.Start parked in WaitStreamConnected. +func startBlackholeSignal(t *testing.T) string { + t.Helper() + + lis, err := net.Listen("tcp", "localhost:0") + require.NoError(t, err) + + s := grpc.NewServer() + go func() { + if err := s.Serve(lis); err != nil { + t.Error(err) + } + }() + t.Cleanup(s.Stop) + + return lis.Addr().String() +} + +func startManagement(t *testing.T, signalAddr string) string { + t.Helper() + + cfg := &config.Config{ + Stuns: []*config.Host{}, + TURNConfig: &config.TURNConfig{}, + Relay: &config.Relay{ + Addresses: []string{"127.0.0.1:1234"}, + CredentialsTTL: util.Duration{Duration: time.Hour}, + Secret: "222222222222222222", + }, + Signal: &config.Host{ + Proto: "http", + URI: signalAddr, + }, + Datadir: t.TempDir(), + HttpConfig: nil, + } + + lis, err := net.Listen("tcp", "localhost:0") + require.NoError(t, err) + + s := grpc.NewServer() + + testStore, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", cfg.Datadir) + require.NoError(t, err) + t.Cleanup(cleanUp) + + eventStore := &activity.InMemoryEventStore{} + + permissionsManager := permissions.NewManager(testStore) + peersManager := peers.NewManager(testStore) + jobManager := job.NewJobManager(nil, testStore, peersManager) + + cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100) + require.NoError(t, err) + + iv, err := validator.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore) + require.NoError(t, err) + metrics, err := telemetry.NewDefaultAppMetrics(context.Background()) + require.NoError(t, err) + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + settingsMockManager := settings.NewMockManager(ctrl) + settingsMockManager.EXPECT(). + GetSettings(gomock.Any(), gomock.Any(), gomock.Any()). + Return(&types.Settings{}, nil). + AnyTimes() + settingsMockManager.EXPECT(). + GetExtraSettings(gomock.Any(), gomock.Any()). + Return(&types.ExtraSettings{}, nil). + AnyTimes() + + groupsManager := groups.NewManagerMock() + + updateManager := update_channel.NewPeersUpdateManager(metrics) + requestBuffer := mgmt.NewAccountRequestBuffer(context.Background(), testStore) + networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(testStore, peersManager), cfg, nil) + accountManager, err := mgmt.BuildManager(context.Background(), cfg, testStore, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + require.NoError(t, err) + + secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, cfg.TURNConfig, cfg.Relay, settingsMockManager, groupsManager) + require.NoError(t, err) + + mgmtServer, err := nbgrpc.NewServer(cfg, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &mgmt.MockIntegratedValidator{}, networkMapController, nil, nil) + require.NoError(t, err) + mgmtProto.RegisterManagementServiceServer(s, mgmtServer) + + go func() { + if err := s.Serve(lis); err != nil { + t.Error(err) + } + }() + t.Cleanup(s.Stop) + + return lis.Addr().String() +} diff --git a/client/firewall/allower_other.go b/client/firewall/allower_other.go new file mode 100644 index 000000000..4d2ec9094 --- /dev/null +++ b/client/firewall/allower_other.go @@ -0,0 +1,11 @@ +//go:build android || (!linux && !windows) + +package firewall + +import "github.com/netbirdio/netbird/client/firewall/uspfilter" + +// interfaceAllower returns no allower: these platforms have no host firewall to +// open for the interface. +func interfaceAllower(IFaceMapper, uint16) uspfilter.InterfaceAllower { + return nil +} diff --git a/client/firewall/allower_windows.go b/client/firewall/allower_windows.go new file mode 100644 index 000000000..b9efa18a4 --- /dev/null +++ b/client/firewall/allower_windows.go @@ -0,0 +1,10 @@ +//go:build windows + +package firewall + +import "github.com/netbirdio/netbird/client/firewall/uspfilter" + +// interfaceAllower returns the Windows netsh-based interface allower. +func interfaceAllower(iface IFaceMapper, _ uint16) uspfilter.InterfaceAllower { + return uspfilter.NewWindowsInterfaceAllower(iface) +} diff --git a/client/firewall/create.go b/client/firewall/create.go index 24f12bc6d..cb68a0d04 100644 --- a/client/firewall/create.go +++ b/client/firewall/create.go @@ -6,8 +6,6 @@ import ( "fmt" "runtime" - log "github.com/sirupsen/logrus" - firewall "github.com/netbirdio/netbird/client/firewall/manager" "github.com/netbirdio/netbird/client/firewall/uspfilter" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" @@ -21,13 +19,11 @@ func NewFirewall(iface IFaceMapper, _ *statemanager.Manager, flowLogger nftypes. } // use userspace packet filtering firewall - fm, err := uspfilter.Create(iface, disableServerRoutes, flowLogger, mtu) - if err != nil { - return nil, err - } - err = fm.AllowNetbird() - if err != nil { - log.Warnf("failed to allow netbird interface traffic: %v", err) - } - return fm, nil + return uspfilter.Create(uspfilter.Config{ + IFace: iface, + DisableServerRoutes: disableServerRoutes, + FlowLogger: flowLogger, + MTU: mtu, + InterfaceAllower: interfaceAllower(iface, mtu), + }) } diff --git a/client/firewall/create_linux.go b/client/firewall/create_linux.go index d916ebad4..d585e85d7 100644 --- a/client/firewall/create_linux.go +++ b/client/firewall/create_linux.go @@ -16,6 +16,7 @@ import ( firewall "github.com/netbirdio/netbird/client/firewall/manager" nbnftables "github.com/netbirdio/netbird/client/firewall/nftables" "github.com/netbirdio/netbird/client/firewall/uspfilter" + "github.com/netbirdio/netbird/client/iface/netstack" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" "github.com/netbirdio/netbird/client/internal/statemanager" ) @@ -29,47 +30,107 @@ const ( NFTABLES ) -// SKIP_NFTABLES_ENV is the environment variable to skip nftables check -const SKIP_NFTABLES_ENV = "NB_SKIP_NFTABLES_CHECK" +// SkipNftablesEnv is the environment variable to skip nftables check +const SkipNftablesEnv = "NB_SKIP_NFTABLES_CHECK" + +// errNoFirewallManager indicates no kernel firewall backend is present, +// as opposed to a backend that exists but failed to create or initialize. +var errNoFirewallManager = errors.New("no firewall manager found") // FWType is the type for the firewall type type FWType int func NewFirewall(iface IFaceMapper, stateManager *statemanager.Manager, flowLogger nftypes.FlowLogger, disableServerRoutes bool, mtu uint16) (firewall.Manager, error) { - // We run in userspace mode and force userspace firewall was requested. We don't attempt native firewall. - if iface.IsUserspaceBind() && forceUserspaceFirewall() { - log.Info("forcing userspace firewall") - return createUserspaceFirewall(iface, nil, disableServerRoutes, flowLogger, mtu) + // Userspace firewall without a native counterpart: routing is handled + // entirely in userspace. The interface is opened in the kernel's foreign + // filter chains via a table-less allower, except in netstack mode where no + // kernel interface exists. + if netstack.IsEnabled() || (iface.IsUserspaceBind() && forceUserspaceFirewall()) { + if netstack.IsEnabled() { + log.Info("netstack mode, using userspace firewall") + } else { + log.Info("forcing userspace firewall") + } + cfg := uspfilter.Config{ + IFace: iface, + DisableServerRoutes: disableServerRoutes, + FlowLogger: flowLogger, + MTU: mtu, + InterfaceAllower: interfaceAllower(iface, mtu), + } + + return uspfilter.Create(cfg) } // Use native firewall for either kernel or userspace, the interface appears identical to netfilter - fm, err := createNativeFirewall(iface, stateManager, disableServerRoutes, mtu) - - // Kernel cannot fall back to anything else, need to return error - if !iface.IsUserspaceBind() { - return fm, err - } - - // Fall back to the userspace packet filter if native is unavailable - if err != nil { - log.Warnf("failed to create native firewall: %v. Proceeding with userspace", err) - return createUserspaceFirewall(iface, nil, disableServerRoutes, flowLogger, mtu) - } - - // Native firewall handles packet filtering, but the userspace WireGuard bind - // needs a device filter for DNS interception hooks. Install a minimal - // hooks-only filter that passes all traffic through to the kernel firewall. - if err := iface.SetFilter(&uspfilter.HooksFilter{}); err != nil { - log.Warnf("failed to set hooks filter, DNS via memory hooks will not work: %v", err) + fm, err := createNativeFirewall(iface, stateManager, mtu) + switch { + case err == nil && !iface.IsUserspaceBind(): + // Nothing to do, fall through + case err == nil && iface.IsUserspaceBind(): + // Native firewall handles packet filtering, but the userspace WireGuard bind + // needs a device filter for DNS interception hooks. Install a minimal + // hooks-only filter that passes all traffic through to the kernel firewall. + if err := iface.SetFilter(&uspfilter.HooksFilter{}); err != nil { + log.Warnf("failed to set hooks filter, DNS via memory hooks will not work: %v", err) + } + case err != nil && !iface.IsUserspaceBind(): + // Kernel cannot fall back to anything else, need to return error + return nil, err + case err != nil && iface.IsUserspaceBind(): + // Fall back to the userspace packet filter if native is unavailable + logNativeFirewallUnavailable(err) + return uspfilter.Create(uspfilter.Config{ + IFace: iface, + DisableServerRoutes: disableServerRoutes, + FlowLogger: flowLogger, + MTU: mtu, + InterfaceAllower: interfaceAllower(iface, mtu), + }) } return fm, nil } -func createNativeFirewall(iface IFaceMapper, stateManager *statemanager.Manager, routes bool, mtu uint16) (firewall.Manager, error) { +// interfaceAllower selects how the userspace firewall opens the interface in +// foreign kernel chains: nftables when available (which also opens foreign nft +// tables), else iptables (the legacy fallback, filter INPUT only), else nil. +// firewalld trust is applied separately by the manager. Netstack has no kernel +// interface to open. +func interfaceAllower(iface IFaceMapper, mtu uint16) uspfilter.InterfaceAllower { + if netstack.IsEnabled() { + return nil + } + + nftAllower, err := nbnftables.NewInterfaceAllower(iface, mtu) + if err == nil { + return nftAllower + } + log.Infof("no nftables interface allower: %v", err) + + iptAllower, err := nbiptables.NewInterfaceAllower(iface) + if err == nil { + return iptAllower + } + log.Infof("no iptables interface allower: %v", err) + + return nil +} + +// logNativeFirewallUnavailable logs the fallback to userspace at info level +// when no kernel firewall backend exists, and at warn level otherwise. +func logNativeFirewallUnavailable(err error) { + if errors.Is(err, errNoFirewallManager) { + log.Infof("no native firewall backend available: %v. Proceeding with userspace", err) + } else { + log.Warnf("failed to create native firewall: %v. Proceeding with userspace", err) + } +} + +func createNativeFirewall(iface IFaceMapper, stateManager *statemanager.Manager, mtu uint16) (firewall.Manager, error) { fm, err := createFW(iface, mtu) if err != nil { - return nil, fmt.Errorf("create firewall: %s", err) + return nil, fmt.Errorf("create firewall: %w", err) } if err = fm.Init(stateManager); err != nil { @@ -88,29 +149,10 @@ func createFW(iface IFaceMapper, mtu uint16) (firewall.Manager, error) { log.Info("creating an nftables firewall manager") return nbnftables.Create(iface, mtu) default: - log.Info("no firewall manager found, trying to use userspace packet filtering firewall") - return nil, errors.New("no firewall manager found") + return nil, errNoFirewallManager } } -func createUserspaceFirewall(iface IFaceMapper, fm firewall.Manager, disableServerRoutes bool, flowLogger nftypes.FlowLogger, mtu uint16) (firewall.Manager, error) { - var errUsp error - if fm != nil { - fm, errUsp = uspfilter.CreateWithNativeFirewall(iface, fm, disableServerRoutes, flowLogger, mtu) - } else { - fm, errUsp = uspfilter.Create(iface, disableServerRoutes, flowLogger, mtu) - } - - if errUsp != nil { - return nil, fmt.Errorf("create userspace firewall: %s", errUsp) - } - - if err := fm.AllowNetbird(); err != nil { - log.Errorf("failed to allow netbird interface traffic: %v", err) - } - return fm, nil -} - // check returns the firewall type based on common lib checks. It returns UNKNOWN if no firewall is found. func check() FWType { useIPTABLES := false @@ -132,35 +174,38 @@ func check() FWType { } } - nf := nftables.Conn{} - if chains, err := nf.ListChains(); err == nil && os.Getenv(SKIP_NFTABLES_ENV) != "true" { - if !useIPTABLES { - return NFTABLES - } - - // search for chains where table is filter - // if we find one, we assume that nftables manager can be used with iptables - for _, chain := range chains { - if chain.Table.Name == "filter" { + // Honor the skip env before probing nftables at all. + if os.Getenv(SkipNftablesEnv) != "true" { + nf := nftables.Conn{} + if chains, err := nf.ListChains(); err == nil { + if !useIPTABLES { return NFTABLES } - } - // check tables for the following constraints: - // 1. there is no chain in nftables for the filter table and there is at least one chain in iptables, we assume that nftables manager can not be used - // 2. there is no tables or more than one table, we assume that nftables manager can be used - // 3. there is only one table and its name is filter, we assume that nftables manager can not be used, since there was no chain in it - // 4. if we find an error we log and continue with iptables check - nbTablesList, err := nf.ListTables() - switch { - case err == nil && len(iptablesChains) > 0: - return IPTABLES - case err == nil && len(nbTablesList) != 1: - return NFTABLES - case err == nil && len(nbTablesList) == 1 && nbTablesList[0].Name == "filter": - return IPTABLES - case err != nil: - log.Errorf("failed to list nftables tables on fw manager discovery: %s", err) + // search for chains where table is filter + // if we find one, we assume that nftables manager can be used with iptables + for _, chain := range chains { + if chain.Table.Name == "filter" { + return NFTABLES + } + } + + // check tables for the following constraints: + // 1. there is no chain in nftables for the filter table and there is at least one chain in iptables, we assume that nftables manager can not be used + // 2. there is no tables or more than one table, we assume that nftables manager can be used + // 3. there is only one table and its name is filter, we assume that nftables manager can not be used, since there was no chain in it + // 4. if we find an error we log and continue with iptables check + nbTablesList, err := nf.ListTables() + switch { + case err == nil && len(iptablesChains) > 0: + return IPTABLES + case err == nil && len(nbTablesList) != 1: + return NFTABLES + case err == nil && len(nbTablesList) == 1 && nbTablesList[0].Name == "filter": + return IPTABLES + case err != nil: + log.Errorf("failed to list nftables tables on fw manager discovery: %s", err) + } } } @@ -176,15 +221,21 @@ func isIptablesClientAvailable(client *iptables.IPTables) bool { return err == nil } +// forceUserspaceFirewall reports whether the userspace firewall is forced. +// NB_FORCE_USERSPACE_ROUTER is an alias: forcing userspace routing implies the +// userspace firewall, since the two are no longer separable. func forceUserspaceFirewall() bool { - val := os.Getenv(EnvForceUserspaceFirewall) + return envForceBool(EnvForceUserspaceFirewall) || envForceBool(uspfilter.EnvForceUserspaceRouter) +} + +func envForceBool(name string) bool { + val := os.Getenv(name) if val == "" { return false } - force, err := strconv.ParseBool(val) if err != nil { - log.Warnf("failed to parse %s: %v", EnvForceUserspaceFirewall, err) + log.Warnf("failed to parse %s: %v", name, err) return false } return force diff --git a/client/firewall/firewalld/firewalld.go b/client/firewall/firewalld/firewalld.go index 188ea61dd..38a4efdbc 100644 --- a/client/firewall/firewalld/firewalld.go +++ b/client/firewall/firewalld/firewalld.go @@ -2,8 +2,8 @@ // its wg interface into firewalld's "trusted" zone. This is required because // firewalld's nftables chains are created with NFT_CHAIN_OWNER on recent // versions, which returns EPERM to any other process that tries to insert -// rules into them. The workaround mirrors what Tailscale does: let firewalld -// itself add the accept rules to its own chains by trusting the interface. +// rules into them. Trusting the interface makes firewalld itself add the +// accept rules to its own chains instead. package firewalld // TrustedZone is the firewalld zone name used for interfaces whose traffic diff --git a/client/firewall/iptables/acl_linux.go b/client/firewall/iptables/acl_linux.go deleted file mode 100644 index e629f7881..000000000 --- a/client/firewall/iptables/acl_linux.go +++ /dev/null @@ -1,529 +0,0 @@ -package iptables - -import ( - "errors" - "fmt" - "net" - "slices" - - "github.com/coreos/go-iptables/iptables" - "github.com/google/uuid" - ipset "github.com/lrh3321/ipset-go" - log "github.com/sirupsen/logrus" - - firewall "github.com/netbirdio/netbird/client/firewall/manager" - "github.com/netbirdio/netbird/client/internal/statemanager" - nbnet "github.com/netbirdio/netbird/client/net" -) - -const ( - tableName = "filter" - - // rules chains contains the effective ACL rules - chainNameInputRules = "NETBIRD-ACL-INPUT" - - // mangleFwdKey is the entries map key for mangle FORWARD guard rules that prevent - // external DNAT from bypassing ACL rules. - mangleFwdKey = "MANGLE-FORWARD" -) - -type aclEntries map[string][][]string - -type entry struct { - spec []string - position int -} - -type aclManager struct { - iptablesClient *iptables.IPTables - wgIface iFaceMapper - entries aclEntries - optionalEntries map[string][]entry - ipsetStore *ipsetStore - - stateManager *statemanager.Manager -} - -func newAclManager(iptablesClient *iptables.IPTables, wgIface iFaceMapper) (*aclManager, error) { - return &aclManager{ - iptablesClient: iptablesClient, - wgIface: wgIface, - entries: make(map[string][][]string), - optionalEntries: make(map[string][]entry), - ipsetStore: newIpsetStore(), - }, nil -} - -func (m *aclManager) init(stateManager *statemanager.Manager) error { - m.stateManager = stateManager - - m.seedInitialEntries() - m.seedInitialOptionalEntries() - - if err := m.cleanChains(); err != nil { - return fmt.Errorf("clean chains: %w", err) - } - - if err := m.createDefaultChains(); err != nil { - return fmt.Errorf("create default chains: %w", err) - } - - m.updateState() - - return nil -} - -func (m *aclManager) AddPeerFiltering( - id []byte, - ip net.IP, - protocol firewall.Protocol, - sPort *firewall.Port, - dPort *firewall.Port, - action firewall.Action, - ipsetName string, -) ([]firewall.Rule, error) { - chain := chainNameInputRules - - ipsetName = transformIPsetName(ipsetName, sPort, dPort, action) - specs := filterRuleSpecs(ip, string(protocol), sPort, dPort, action, ipsetName) - - mangleSpecs := slices.Clone(specs) - mangleSpecs = append(mangleSpecs, - "-i", m.wgIface.Name(), - "-m", "addrtype", "--dst-type", "LOCAL", - "-j", "MARK", "--set-xmark", fmt.Sprintf("%#x", nbnet.PreroutingFwmarkRedirected), - ) - - specs = append(specs, "-j", actionToStr(action)) - if ipsetName != "" { - if ipList, ipsetExists := m.ipsetStore.ipset(ipsetName); ipsetExists { - if err := m.addToIPSet(ipsetName, ip); err != nil { - return nil, fmt.Errorf("add IP to ipset: %w", err) - } - // if ruleset already exists it means we already have the firewall rule - // so we need to update IPs in the ruleset and return new fw.Rule object for ACL manager. - ipList.addIP(ip.String()) - return []firewall.Rule{&Rule{ - ruleID: uuid.New().String(), - ipsetName: ipsetName, - ip: ip.String(), - chain: chain, - specs: specs, - }}, nil - } - - if err := m.flushIPSet(ipsetName); err != nil { - if errors.Is(err, ipset.ErrSetNotExist) { - log.Debugf("flush ipset %s before use: %v", ipsetName, err) - } else { - log.Errorf("flush ipset %s before use: %v", ipsetName, err) - } - } - if err := m.createIPSet(ipsetName); err != nil { - return nil, fmt.Errorf("create ipset: %w", err) - } - if err := m.addToIPSet(ipsetName, ip); err != nil { - return nil, fmt.Errorf("add IP to ipset: %w", err) - } - - ipList := newIpList(ip.String()) - m.ipsetStore.addIpList(ipsetName, ipList) - } - - ok, err := m.iptablesClient.Exists(tableFilter, chain, specs...) - if err != nil { - return nil, fmt.Errorf("failed to check rule: %w", err) - } - if ok { - return nil, fmt.Errorf("rule already exists") - } - - // Insert DROP rules at the beginning, append ACCEPT rules at the end - if action == firewall.ActionDrop { - // Insert at the beginning of the chain (position 1) - err = m.iptablesClient.Insert(tableFilter, chain, 1, specs...) - } else { - err = m.iptablesClient.Append(tableFilter, chain, specs...) - } - if err != nil { - return nil, err - } - - if err := m.iptablesClient.Append(tableMangle, chainRTPRE, mangleSpecs...); err != nil { - log.Errorf("failed to add mangle rule: %v", err) - mangleSpecs = nil - } - - rule := &Rule{ - ruleID: uuid.New().String(), - specs: specs, - mangleSpecs: mangleSpecs, - ipsetName: ipsetName, - ip: ip.String(), - chain: chain, - } - - m.updateState() - - return []firewall.Rule{rule}, nil -} - -// DeletePeerRule from the firewall by rule definition -func (m *aclManager) DeletePeerRule(rule firewall.Rule) error { - r, ok := rule.(*Rule) - if !ok { - return fmt.Errorf("invalid rule type") - } - - shouldDestroyIpset := false - if ipsetList, ok := m.ipsetStore.ipset(r.ipsetName); ok { - // delete IP from ruleset IPs list and ipset - if _, ok := ipsetList.ips[r.ip]; ok { - ip := net.ParseIP(r.ip) - if ip == nil { - return fmt.Errorf("parse IP %s", r.ip) - } - if err := m.delFromIPSet(r.ipsetName, ip); err != nil { - return fmt.Errorf("delete ip from ipset: %w", err) - } - delete(ipsetList.ips, r.ip) - } - - // if after delete, set still contains other IPs, - // no need to delete firewall rule and we should exit here - if len(ipsetList.ips) != 0 { - return nil - } - - // we delete last IP from the set, that means we need to delete - // set itself and associated firewall rule too - m.ipsetStore.deleteIpset(r.ipsetName) - shouldDestroyIpset = true - } - - if err := m.iptablesClient.Delete(tableName, r.chain, r.specs...); err != nil { - return fmt.Errorf("failed to delete rule: %s, %v: %w", r.chain, r.specs, err) - } - - if r.mangleSpecs != nil { - if err := m.iptablesClient.Delete(tableMangle, chainRTPRE, r.mangleSpecs...); err != nil { - log.Errorf("failed to delete mangle rule: %v", err) - } - } - - if shouldDestroyIpset { - if err := m.destroyIPSet(r.ipsetName); err != nil { - if errors.Is(err, ipset.ErrBusy) || errors.Is(err, ipset.ErrSetNotExist) { - log.Debugf("destroy empty ipset: %v", err) - } else { - log.Errorf("destroy empty ipset: %v", err) - } - } - } - - m.updateState() - - return nil -} - -func (m *aclManager) Reset() error { - if err := m.cleanChains(); err != nil { - return fmt.Errorf("clean chains: %w", err) - } - - m.updateState() - - return nil -} - -// todo write less destructive cleanup mechanism -func (m *aclManager) cleanChains() error { - ok, err := m.iptablesClient.ChainExists(tableName, chainNameInputRules) - if err != nil { - log.Debugf("failed to list chains: %s", err) - return err - } - if ok { - for _, rule := range m.entries["INPUT"] { - err := m.iptablesClient.DeleteIfExists(tableName, "INPUT", rule...) - if err != nil { - log.Errorf("failed to delete rule: %v, %s", rule, err) - } - } - - for _, rule := range m.entries["FORWARD"] { - err := m.iptablesClient.DeleteIfExists(tableName, "FORWARD", rule...) - if err != nil { - log.Errorf("failed to delete rule: %v, %s", rule, err) - } - } - - err = m.iptablesClient.ClearAndDeleteChain(tableName, chainNameInputRules) - if err != nil { - log.Debugf("failed to clear and delete %s chain: %s", chainNameInputRules, err) - return err - } - } - - ok, err = m.iptablesClient.ChainExists("mangle", "PREROUTING") - if err != nil { - return fmt.Errorf("list chains: %w", err) - } - if ok { - for _, rule := range m.entries["PREROUTING"] { - err := m.iptablesClient.DeleteIfExists("mangle", "PREROUTING", rule...) - if err != nil { - log.Errorf("failed to delete rule: %v, %s", rule, err) - } - } - } - - for _, rule := range m.entries[mangleFwdKey] { - if err := m.iptablesClient.DeleteIfExists(tableMangle, chainFORWARD, rule...); err != nil { - log.Errorf("failed to delete mangle FORWARD guard rule: %v, %s", rule, err) - } - } - - for _, ipsetName := range m.ipsetStore.ipsetNames() { - if err := m.flushIPSet(ipsetName); err != nil { - if errors.Is(err, ipset.ErrSetNotExist) { - log.Debugf("flush ipset %q during reset: %v", ipsetName, err) - } else { - log.Errorf("flush ipset %q during reset: %v", ipsetName, err) - } - } - if err := m.destroyIPSet(ipsetName); err != nil { - if errors.Is(err, ipset.ErrBusy) || errors.Is(err, ipset.ErrSetNotExist) { - log.Debugf("destroy ipset %q during reset: %v", ipsetName, err) - } else { - log.Errorf("destroy ipset %q during reset: %v", ipsetName, err) - } - } - m.ipsetStore.deleteIpset(ipsetName) - } - - return nil -} - -func (m *aclManager) createDefaultChains() error { - // chain netbird-acl-input-rules - if err := m.iptablesClient.NewChain(tableName, chainNameInputRules); err != nil { - log.Debugf("failed to create '%s' chain: %s", chainNameInputRules, err) - return err - } - - for chainName, rules := range m.entries { - // mangle FORWARD guard rules are handled separately below - if chainName == mangleFwdKey { - continue - } - for _, rule := range rules { - if err := m.iptablesClient.InsertUnique(tableName, chainName, 1, rule...); err != nil { - log.Debugf("failed to create input chain jump rule: %s", err) - return err - } - } - } - - for chainName, entries := range m.optionalEntries { - for _, entry := range entries { - if err := m.iptablesClient.InsertUnique(tableName, chainName, entry.position, entry.spec...); err != nil { - log.Errorf("failed to insert optional entry %v: %v", entry.spec, err) - continue - } - m.entries[chainName] = append(m.entries[chainName], entry.spec) - } - } - clear(m.optionalEntries) - - // Insert mangle FORWARD guard rules to prevent external DNAT bypass. - for _, rule := range m.entries[mangleFwdKey] { - if err := m.iptablesClient.AppendUnique(tableMangle, chainFORWARD, rule...); err != nil { - log.Errorf("failed to add mangle FORWARD guard rule: %v", err) - } - } - - return nil -} - -// seedInitialEntries adds default rules to the entries map, rules are inserted on pos 1, hence the order is reversed. -// We want to make sure our traffic is not dropped by existing rules. - -// The existing FORWARD rules/policies decide outbound traffic towards our interface. -// In case the FORWARD policy is set to "drop", we add an established/related rule to allow return traffic for the inbound rule. -func (m *aclManager) seedInitialEntries() { - established := getConntrackEstablished() - - m.appendToEntries("INPUT", []string{"-i", m.wgIface.Name(), "-j", "DROP"}) - m.appendToEntries("INPUT", []string{"-i", m.wgIface.Name(), "-j", chainNameInputRules}) - m.appendToEntries("INPUT", append([]string{"-i", m.wgIface.Name()}, established...)) - - // Inbound is handled by our ACLs, the rest is dropped. - // For outbound we respect the FORWARD policy. However, we need to allow established/related traffic for inbound rules. - m.appendToEntries("FORWARD", []string{"-i", m.wgIface.Name(), "-j", "DROP"}) - - m.appendToEntries("FORWARD", []string{"-o", m.wgIface.Name(), "-j", chainRTFWDOUT}) - m.appendToEntries("FORWARD", []string{"-i", m.wgIface.Name(), "-j", chainRTFWDIN}) - - // Mangle FORWARD guard: when external DNAT redirects traffic from the wg interface, it - // traverses FORWARD instead of INPUT, bypassing ACL rules. ACCEPT rules in filter FORWARD - // can be inserted above ours. Mangle runs before filter, so these guard rules enforce the - // ACL mark check where it cannot be overridden. - m.appendToEntries(mangleFwdKey, []string{ - "-i", m.wgIface.Name(), - "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", - "-j", "ACCEPT", - }) - m.appendToEntries(mangleFwdKey, []string{ - "-i", m.wgIface.Name(), - "-m", "conntrack", "--ctstate", "DNAT", - "-m", "mark", "!", "--mark", fmt.Sprintf("%#x", nbnet.PreroutingFwmarkRedirected), - "-j", "DROP", - }) -} - -func (m *aclManager) seedInitialOptionalEntries() { - m.optionalEntries["FORWARD"] = []entry{ - { - spec: []string{"-m", "mark", "--mark", fmt.Sprintf("%#x", nbnet.PreroutingFwmarkRedirected), "-j", "ACCEPT"}, - position: 2, - }, - } -} - -func (m *aclManager) appendToEntries(chainName string, spec []string) { - m.entries[chainName] = append(m.entries[chainName], spec) -} - -func (m *aclManager) updateState() { - if m.stateManager == nil { - return - } - - var currentState *ShutdownState - if existing := m.stateManager.GetState(currentState); existing != nil { - if existingState, ok := existing.(*ShutdownState); ok { - currentState = existingState - } - } - if currentState == nil { - currentState = &ShutdownState{} - } - - currentState.Lock() - defer currentState.Unlock() - - currentState.ACLEntries = m.entries - currentState.ACLIPsetStore = m.ipsetStore - - if err := m.stateManager.UpdateState(currentState); err != nil { - log.Errorf("failed to update state: %v", err) - } -} - -// filterRuleSpecs returns the specs of a filtering rule -func filterRuleSpecs(ip net.IP, protocol string, sPort, dPort *firewall.Port, action firewall.Action, ipsetName string) (specs []string) { - // don't use IP matching if IP is 0.0.0.0 - matchByIP := !ip.IsUnspecified() - - if matchByIP { - if ipsetName != "" { - specs = append(specs, "-m", "set", "--set", ipsetName, "src") - } else { - specs = append(specs, "-s", ip.String()) - } - } - if protocol != "all" { - specs = append(specs, "-p", protocol) - } - specs = append(specs, applyPort("--sport", sPort)...) - specs = append(specs, applyPort("--dport", dPort)...) - return specs -} - -func actionToStr(action firewall.Action) string { - if action == firewall.ActionAccept { - return "ACCEPT" - } - return "DROP" -} - -func transformIPsetName(ipsetName string, sPort, dPort *firewall.Port, action firewall.Action) string { - if ipsetName == "" { - return "" - } - - actionSuffix := "" - if action == firewall.ActionDrop { - actionSuffix = "-drop" - } - - switch { - case sPort != nil && dPort != nil: - return ipsetName + "-sport-dport" + actionSuffix - case sPort != nil: - return ipsetName + "-sport" + actionSuffix - case dPort != nil: - return ipsetName + "-dport" + actionSuffix - default: - return ipsetName + actionSuffix - } -} - -func (m *aclManager) createIPSet(name string) error { - opts := ipset.CreateOptions{ - Replace: true, - } - - if err := ipset.Create(name, ipset.TypeHashNet, opts); err != nil { - return fmt.Errorf("create ipset %s: %w", name, err) - } - - log.Debugf("created ipset %s with type hash:net", name) - return nil -} - -func (m *aclManager) addToIPSet(name string, ip net.IP) error { - cidr := uint8(32) - if ip.To4() == nil { - cidr = 128 - } - - entry := &ipset.Entry{ - IP: ip, - CIDR: cidr, - Replace: true, - } - - if err := ipset.Add(name, entry); err != nil { - return fmt.Errorf("add IP to ipset %s: %w", name, err) - } - - return nil -} - -func (m *aclManager) delFromIPSet(name string, ip net.IP) error { - cidr := uint8(32) - if ip.To4() == nil { - cidr = 128 - } - - entry := &ipset.Entry{ - IP: ip, - CIDR: cidr, - } - - if err := ipset.Del(name, entry); err != nil { - return fmt.Errorf("delete IP from ipset %s: %w", name, err) - } - - return nil -} - -func (m *aclManager) flushIPSet(name string) error { - return ipset.Flush(name) -} - -func (m *aclManager) destroyIPSet(name string) error { - return ipset.Destroy(name) -} diff --git a/client/firewall/iptables/chains_linux.go b/client/firewall/iptables/chains_linux.go new file mode 100644 index 000000000..58bfa8c6a --- /dev/null +++ b/client/firewall/iptables/chains_linux.go @@ -0,0 +1,346 @@ +//go:build !android + +package iptables + +import ( + "fmt" + + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + + nberrors "github.com/netbirdio/netbird/client/errors" + firewall "github.com/netbirdio/netbird/client/firewall/manager" + nbnet "github.com/netbirdio/netbird/client/net" +) + +func (r *family) createContainers() error { + for _, chainInfo := range []struct { + chain string + table string + }{ + {chainRTFwdIn, tableFilter}, + {chainRTFwdOut, tableFilter}, + {chainRTPre, tableMangle}, + {chainRTNAT, tableNat}, + {chainRTRdr, tableNat}, + {chainRTMSSClamp, tableMangle}, + } { + // Fallback: clear chains that survived an unclean shutdown. + if ok, _ := r.iptablesClient.ChainExists(chainInfo.table, chainInfo.chain); ok { + if err := r.iptablesClient.ClearAndDeleteChain(chainInfo.table, chainInfo.chain); err != nil { + log.Warnf("clear stale chain %s in %s: %v", chainInfo.chain, chainInfo.table, err) + } + } + if err := r.iptablesClient.NewChain(chainInfo.table, chainInfo.chain); err != nil { + return fmt.Errorf("create chain %s in table %s: %w", chainInfo.chain, chainInfo.table, err) + } + } + + if err := r.insertEstablishedRule(chainRTFwdIn); err != nil { + return fmt.Errorf("insert established rule: %w", err) + } + + if err := r.insertEstablishedRule(chainRTFwdOut); err != nil { + return fmt.Errorf("insert established rule: %w", err) + } + + if err := r.addPostroutingRules(); err != nil { + return fmt.Errorf("add static nat rules: %w", err) + } + + if err := r.addJumpRules(); err != nil { + return fmt.Errorf("add jump rules: %w", err) + } + + if err := r.addMSSClampingRules(); err != nil { + log.Errorf("failed to add MSS clamping rules: %s", err) + } + + return nil +} + +func (r *family) addJumpRules() error { + // Jump to nat chain + natRule := jumpRuleSpec(chainRTNAT) + if err := r.iptablesClient.Insert(tableNat, chainPostrouting, 1, natRule...); err != nil { + return fmt.Errorf("add nat postrouting jump rule: %w", err) + } + r.rules[jumpNATPost] = natRule + + // Jump to mangle prerouting chain + preRule := jumpRuleSpec(chainRTPre) + if err := r.iptablesClient.Insert(tableMangle, chainPrerouting, 1, preRule...); err != nil { + return fmt.Errorf("add mangle prerouting jump rule: %w", err) + } + r.rules[jumpManglePre] = preRule + + // Jump to nat prerouting chain + rdrRule := jumpRuleSpec(chainRTRdr) + if err := r.iptablesClient.Insert(tableNat, chainPrerouting, 1, rdrRule...); err != nil { + return fmt.Errorf("add nat prerouting jump rule: %w", err) + } + r.rules[jumpNATPre] = rdrRule + + return nil +} + +func (r *family) setupDataPlaneMark() error { + var merr *multierror.Error + preRule := []string{ + "-i", r.wgIface.Name(), + "-m", "conntrack", "--ctstate", "NEW", + "-j", "CONNMARK", "--set-mark", fmt.Sprintf("%#x", nbnet.DataPlaneMarkIn), + } + + if err := r.iptablesClient.AppendUnique(tableMangle, chainPrerouting, preRule...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("add mangle prerouting rule: %w", err)) + } else { + r.rules[markManglePre] = preRule + } + + postRule := []string{ + "-o", r.wgIface.Name(), + "-m", "conntrack", "--ctstate", "NEW", + "-j", "CONNMARK", "--set-mark", fmt.Sprintf("%#x", nbnet.DataPlaneMarkOut), + } + + if err := r.iptablesClient.AppendUnique(tableMangle, chainPostrouting, postRule...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("add mangle postrouting rule: %w", err)) + } else { + r.rules[markManglePost] = postRule + } + + return nberrors.FormatErrorOrNil(merr) +} + +// seedInitialEntries adds default rules to the entries map. Rules are +// inserted at position 1, so the order here is reversed. +// +// Existing FORWARD policy decides outbound traffic towards our +// interface. If FORWARD policy is "drop", we add an +// established/related rule to allow return traffic for inbound rules. +func (r *family) seedInitialEntries() { + established := getConntrackEstablished() + + r.appendToEntries(chainInput, []string{"-i", r.wgIface.Name(), "-j", "DROP"}) + r.appendToEntries(chainInput, []string{"-i", r.wgIface.Name(), "-j", chainACLInput}) + r.appendToEntries(chainInput, append([]string{"-i", r.wgIface.Name()}, established...)) + + r.appendToEntries(chainForward, []string{"-i", r.wgIface.Name(), "-j", "DROP"}) + r.appendToEntries(chainForward, []string{"-o", r.wgIface.Name(), "-j", chainRTFwdOut}) + r.appendToEntries(chainForward, []string{"-i", r.wgIface.Name(), "-j", chainRTFwdIn}) + + // Mangle FORWARD guard: when external DNAT redirects traffic from + // the wg interface, it traverses FORWARD instead of INPUT, + // bypassing ACL rules. ACCEPT rules in filter FORWARD can be + // inserted above ours. Mangle runs before filter, so these guard + // rules enforce the ACL mark check where it cannot be overridden. + r.appendToEntries(mangleForwardKey, []string{ + "-i", r.wgIface.Name(), + "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", + "-j", "ACCEPT", + }) + r.appendToEntries(mangleForwardKey, []string{ + "-i", r.wgIface.Name(), + "-m", "conntrack", "--ctstate", "DNAT", + "-m", "mark", "!", "--mark", fmt.Sprintf("%#x", nbnet.PreroutingFwmarkRedirected), + "-j", "DROP", + }) +} + +func (r *family) seedInitialOptionalEntries() { + r.optionalEntries[chainForward] = []entry{ + { + spec: []string{"-m", "mark", "--mark", fmt.Sprintf("%#x", nbnet.PreroutingFwmarkRedirected), "-j", "ACCEPT"}, + position: 2, + }, + } +} + +func (r *family) appendToEntries(chain chainKey, spec ruleSpec) { + r.entries[chain] = append(r.entries[chain], spec) +} + +func (r *family) createDefaultChains() error { + if err := r.iptablesClient.NewChain(tableFilter, chainACLInput); err != nil { + return fmt.Errorf("create %s chain: %w", chainACLInput, err) + } + + for chain, rules := range r.entries { + // mangle FORWARD guard rules are handled separately below + if chain == mangleForwardKey { + continue + } + for _, rule := range rules { + if err := r.iptablesClient.InsertUnique(tableFilter, string(chain), 1, rule...); err != nil { + return fmt.Errorf("insert jump rule into %s: %w", chain, err) + } + } + } + + for chain, entries := range r.optionalEntries { + for _, entry := range entries { + if err := r.iptablesClient.InsertUnique(tableFilter, string(chain), entry.position, entry.spec...); err != nil { + log.Errorf("failed to insert optional entry %v: %v", entry.spec, err) + continue + } + r.entries[chain] = append(r.entries[chain], entry.spec) + } + } + clear(r.optionalEntries) + + // Insert mangle FORWARD guard rules to prevent external DNAT bypass. + for _, rule := range r.entries[mangleForwardKey] { + if err := r.iptablesClient.AppendUnique(tableMangle, chainForward, rule...); err != nil { + log.Errorf("failed to add mangle FORWARD guard rule: %v", err) + } + } + + return nil +} + +func (r *family) cleanUpDefaultForwardRules() error { + var merr *multierror.Error + + // cleanJumpRules removes the OUTPUT jump to NETBIRD-NAT-OUTPUT among + // the others, so the chain below deletes cleanly instead of failing + // with "device or resource busy". + if err := r.cleanJumpRules(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("clean jump rules: %w", err)) + } + + for _, chainInfo := range []struct { + chain string + table string + }{ + {chainRTFwdIn, tableFilter}, + {chainRTFwdOut, tableFilter}, + {chainRTPre, tableMangle}, + {chainRTNAT, tableNat}, + {chainRTRdr, tableNat}, + {chainNATOutput, tableNat}, + {chainRTMSSClamp, tableMangle}, + } { + ok, err := r.iptablesClient.ChainExists(chainInfo.table, chainInfo.chain) + if err != nil { + merr = multierror.Append(merr, fmt.Errorf("check chain %s in table %s: %w", chainInfo.chain, chainInfo.table, err)) + continue + } + if ok { + if err := r.iptablesClient.ClearAndDeleteChain(chainInfo.table, chainInfo.chain); err != nil { + merr = multierror.Append(merr, fmt.Errorf("clear and delete chain %s in table %s: %w", chainInfo.chain, chainInfo.table, err)) + } + } + } + + return nberrors.FormatErrorOrNil(merr) +} + +func (r *family) cleanJumpRules() error { + // locations maps each jump rule to the built-in table and chain it + // was inserted into, plus the netbird chain it targets. + locations := map[firewall.RuleID]struct{ table, chain, target string }{ + jumpNATPost: {tableNat, chainPostrouting, chainRTNAT}, + jumpManglePre: {tableMangle, chainPrerouting, chainRTPre}, + jumpNATPre: {tableNat, chainPrerouting, chainRTRdr}, + jumpMSSClamp: {tableMangle, chainForward, chainRTMSSClamp}, + jumpNATOutput: {tableNat, chainOutput, chainNATOutput}, + } + + var merr *multierror.Error + for ruleID, loc := range locations { + rule, exists := r.rules[ruleID] + if !exists { + // Untracked (e.g. fresh start after an unclean shutdown with no + // restored state): if the target chain survived, remove the stale + // jump to it so the chain can be deleted. + ok, err := r.iptablesClient.ChainExists(loc.table, loc.target) + if err != nil { + merr = multierror.Append(merr, fmt.Errorf("check chain %s in table %s: %w", loc.target, loc.table, err)) + continue + } + if !ok { + continue + } + rule = jumpRuleSpec(loc.target) + } + if err := r.iptablesClient.DeleteIfExists(loc.table, loc.chain, rule...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("delete rule from chain %s in table %s: %w", loc.chain, loc.table, err)) + continue + } + delete(r.rules, ruleID) + } + return nberrors.FormatErrorOrNil(merr) +} + +// jumpRuleSpec builds the iptables rule spec that jumps to target. Create +// and cleanup sites share it so the installed and deleted specs cannot drift. +func jumpRuleSpec(target string) []string { + return []string{"-j", target} +} + +func (r *family) cleanAclChains() error { + var merr *multierror.Error + + if err := r.cleanInputAclChain(); err != nil { + merr = multierror.Append(merr, err) + } + + for _, rule := range r.entries[mangleForwardKey] { + if err := r.iptablesClient.DeleteIfExists(tableMangle, chainForward, rule...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("delete mangle %s guard rule %v: %w", chainForward, rule, err)) + } + } + + return nberrors.FormatErrorOrNil(merr) +} + +func (r *family) cleanInputAclChain() error { + ok, err := r.iptablesClient.ChainExists(tableFilter, chainACLInput) + if err != nil { + return fmt.Errorf("check chain %s: %w", chainACLInput, err) + } + if !ok { + return nil + } + + var merr *multierror.Error + for _, rule := range r.entries[chainInput] { + if err := r.iptablesClient.DeleteIfExists(tableFilter, chainInput, rule...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("delete %s rule %v: %w", chainInput, rule, err)) + } + } + + for _, rule := range r.entries[chainForward] { + if err := r.iptablesClient.DeleteIfExists(tableFilter, chainForward, rule...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("delete %s rule %v: %w", chainForward, rule, err)) + } + } + + if err := r.iptablesClient.ClearAndDeleteChain(tableFilter, chainACLInput); err != nil { + merr = multierror.Append(merr, fmt.Errorf("clear and delete %s chain: %w", chainACLInput, err)) + } + + return nberrors.FormatErrorOrNil(merr) +} + +func (r *family) cleanupDataPlaneMark() error { + var merr *multierror.Error + if preRule, exists := r.rules[markManglePre]; exists { + if err := r.iptablesClient.DeleteIfExists(tableMangle, chainPrerouting, preRule...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove mangle prerouting rule: %w", err)) + } else { + delete(r.rules, markManglePre) + } + } + + if postRule, exists := r.rules[markManglePost]; exists { + if err := r.iptablesClient.DeleteIfExists(tableMangle, chainPostrouting, postRule...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove mangle postrouting rule: %w", err)) + } else { + delete(r.rules, markManglePost) + } + } + + return nberrors.FormatErrorOrNil(merr) +} diff --git a/client/firewall/iptables/dnat_linux.go b/client/firewall/iptables/dnat_linux.go new file mode 100644 index 000000000..eca8386c0 --- /dev/null +++ b/client/firewall/iptables/dnat_linux.go @@ -0,0 +1,302 @@ +//go:build !android + +package iptables + +import ( + "fmt" + "net/netip" + "strconv" + "strings" + + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + + nberrors "github.com/netbirdio/netbird/client/errors" + firewall "github.com/netbirdio/netbird/client/firewall/manager" +) + +func (r *family) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) { + ruleID := rule.ID() + if _, exists := r.rules[ruleID+dnatSuffix]; exists { + return rule, nil + } + + toDestination := rule.TranslatedAddress.String() + switch { + case len(rule.TranslatedPort.Values) == 0: + // no translated port, use original port + case len(rule.TranslatedPort.Values) == 1: + toDestination += fmt.Sprintf(":%d", rule.TranslatedPort.Values[0]) + case rule.TranslatedPort.IsRange && len(rule.TranslatedPort.Values) == 2: + // need the "/originalport" suffix to avoid dnat port randomization + toDestination += fmt.Sprintf(":%d-%d/%d", rule.TranslatedPort.Values[0], rule.TranslatedPort.Values[1], rule.DestinationPort.Values[0]) + default: + return nil, fmt.Errorf("invalid translated port: %v", rule.TranslatedPort) + } + + proto := strings.ToLower(string(rule.Protocol)) + + rules := make(map[firewall.RuleID]ruleInfo, 3) + + // DNAT rule + dnatRule := []string{ + "!", "-i", r.wgIface.Name(), + "-p", proto, + "-j", "DNAT", + "--to-destination", toDestination, + } + dnatRule = append(dnatRule, applyPort("--dport", &rule.DestinationPort)...) + rules[ruleID+dnatSuffix] = ruleInfo{ + table: tableNat, + chain: chainRTRdr, + rule: dnatRule, + } + + // SNAT rule + snatRule := []string{ + "-o", r.wgIface.Name(), + "-p", proto, + "-d", rule.TranslatedAddress.String(), + "-j", "MASQUERADE", + } + snatRule = append(snatRule, applyPort("--dport", &rule.TranslatedPort)...) + rules[ruleID+snatSuffix] = ruleInfo{ + table: tableNat, + chain: chainRTNAT, + rule: snatRule, + } + + // Forward filtering rule, if fwd policy is DROP + forwardRule := []string{ + "-o", r.wgIface.Name(), + "-p", proto, + "-d", rule.TranslatedAddress.String(), + "-j", "ACCEPT", + } + forwardRule = append(forwardRule, applyPort("--dport", &rule.TranslatedPort)...) + rules[ruleID+fwdSuffix] = ruleInfo{ + table: tableFilter, + chain: chainRTFwdOut, + rule: forwardRule, + } + + for key, ruleInfo := range rules { + if err := r.iptablesClient.Append(ruleInfo.table, ruleInfo.chain, ruleInfo.rule...); err != nil { + r.cleanupFailedDNATAdd(rules) + return nil, fmt.Errorf("add rule %s: %w", key, err) + } + r.rules[key] = ruleInfo.rule + } + + if err := r.ipFwdState.RequestForwarding(r.v6); err != nil { + r.cleanupFailedDNATAdd(rules) + return nil, fmt.Errorf("enable forwarding: %w", err) + } + + r.updateState() + return rule, nil +} + +// cleanupFailedDNATAdd removes the bookkeeping written by a partially applied +// AddDNATRule before rolling back the kernel rules, so no entries remain that +// never got a forwarding refcount. rollbackRules re-adds entries it failed to +// remove from the kernel. +func (r *family) cleanupFailedDNATAdd(rules map[firewall.RuleID]ruleInfo) { + for key := range rules { + delete(r.rules, key) + } + if err := r.rollbackRules(rules); err != nil { + log.Errorf("rollback failed: %v", err) + } +} + +func (r *family) rollbackRules(rules map[firewall.RuleID]ruleInfo) error { + var merr *multierror.Error + for key, ruleInfo := range rules { + if err := r.iptablesClient.DeleteIfExists(ruleInfo.table, ruleInfo.chain, ruleInfo.rule...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("rollback rule %s: %w", key, err)) + // On rollback error, add to rules map for next cleanup + r.rules[key] = ruleInfo.rule + } + } + if merr != nil { + r.updateState() + } + return nberrors.FormatErrorOrNil(merr) +} + +func (r *family) DeleteDNATRule(rule firewall.Rule) error { + ruleID := rule.ID() + + _, hadDNAT := r.rules[ruleID+dnatSuffix] + _, hadSNAT := r.rules[ruleID+snatSuffix] + _, hadFWD := r.rules[ruleID+fwdSuffix] + if !hadDNAT && !hadSNAT && !hadFWD { + return nil + } + + var merr *multierror.Error + if dnatRule, exists := r.rules[ruleID+dnatSuffix]; exists { + if err := r.iptablesClient.Delete(tableNat, chainRTRdr, dnatRule...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("delete DNAT rule: %w", err)) + } else { + delete(r.rules, ruleID+dnatSuffix) + } + } + + if snatRule, exists := r.rules[ruleID+snatSuffix]; exists { + if err := r.iptablesClient.Delete(tableNat, chainRTNAT, snatRule...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("delete SNAT rule: %w", err)) + } else { + delete(r.rules, ruleID+snatSuffix) + } + } + + if fwdRule, exists := r.rules[ruleID+fwdSuffix]; exists { + if err := r.iptablesClient.Delete(tableFilter, chainRTFwdOut, fwdRule...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("delete forward rule: %w", err)) + } else { + delete(r.rules, ruleID+fwdSuffix) + } + } + + // Release the refcount only once all rules are gone from the kernel. On + // partial failure the failed entries stay in r.rules so a retry can remove + // them and release then. + if merr == nil { + r.releaseForwarding() + } + + r.updateState() + + return nberrors.FormatErrorOrNil(merr) +} + +// releaseForwarding drops one IP forwarding reference, logging any error. +func (r *family) releaseForwarding() { + if err := r.ipFwdState.ReleaseForwarding(r.v6); err != nil { + log.Errorf("release IP forwarding: %v", err) + } +} + +func (r *family) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { + ruleID := firewall.RuleID(fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort)) + + if _, exists := r.rules[ruleID]; exists { + return nil + } + + dnatRule := []string{ + "-i", r.wgIface.Name(), + "-p", strings.ToLower(protoForFamily(protocol, r.v6)), + "--dport", strconv.Itoa(int(originalPort)), + "-d", localAddr.String(), + "-m", "addrtype", "--dst-type", "LOCAL", + "-j", "DNAT", + "--to-destination", ":" + strconv.Itoa(int(translatedPort)), + } + + info := ruleInfo{ + table: tableNat, + chain: chainRTRdr, + rule: dnatRule, + } + + if err := r.iptablesClient.Append(info.table, info.chain, info.rule...); err != nil { + return fmt.Errorf("add inbound DNAT rule: %w", err) + } + r.rules[ruleID] = info.rule + + r.updateState() + return nil +} + +// RemoveInboundDNAT removes an inbound DNAT rule. +func (r *family) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { + ruleID := firewall.RuleID(fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort)) + + if dnatRule, exists := r.rules[ruleID]; exists { + if err := r.iptablesClient.Delete(tableNat, chainRTRdr, dnatRule...); err != nil { + return fmt.Errorf("delete inbound DNAT rule: %w", err) + } + delete(r.rules, ruleID) + } + + r.updateState() + return nil +} + +// ensureNATOutputChain lazily creates the OUTPUT NAT chain and jump rule on first use. +func (r *family) ensureNATOutputChain() error { + if _, exists := r.rules[jumpNATOutput]; exists { + return nil + } + + chainExists, err := r.iptablesClient.ChainExists(tableNat, chainNATOutput) + if err != nil { + return fmt.Errorf("check chain %s: %w", chainNATOutput, err) + } + if !chainExists { + if err := r.iptablesClient.NewChain(tableNat, chainNATOutput); err != nil { + return fmt.Errorf("create chain %s: %w", chainNATOutput, err) + } + } + + jumpRule := jumpRuleSpec(chainNATOutput) + if err := r.iptablesClient.Insert(tableNat, chainOutput, 1, jumpRule...); err != nil { + if !chainExists { + if delErr := r.iptablesClient.ClearAndDeleteChain(tableNat, chainNATOutput); delErr != nil { + log.Warnf("failed to rollback chain %s: %v", chainNATOutput, delErr) + } + } + return fmt.Errorf("add OUTPUT jump rule: %w", err) + } + r.rules[jumpNATOutput] = jumpRule + + r.updateState() + return nil +} + +// AddOutputDNAT adds an OUTPUT chain DNAT rule for locally-generated traffic. +func (r *family) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { + ruleID := firewall.RuleID(fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort)) + + if _, exists := r.rules[ruleID]; exists { + return nil + } + + if err := r.ensureNATOutputChain(); err != nil { + return err + } + + dnatRule := []string{ + "-p", strings.ToLower(protoForFamily(protocol, localAddr.Is6())), + "--dport", strconv.Itoa(int(originalPort)), + "-d", localAddr.String(), + "-j", "DNAT", + "--to-destination", ":" + strconv.Itoa(int(translatedPort)), + } + + if err := r.iptablesClient.Append(tableNat, chainNATOutput, dnatRule...); err != nil { + return fmt.Errorf("add output DNAT rule: %w", err) + } + r.rules[ruleID] = dnatRule + + r.updateState() + return nil +} + +// RemoveOutputDNAT removes an OUTPUT chain DNAT rule. +func (r *family) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { + ruleID := firewall.RuleID(fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort)) + + if dnatRule, exists := r.rules[ruleID]; exists { + if err := r.iptablesClient.Delete(tableNat, chainNATOutput, dnatRule...); err != nil { + return fmt.Errorf("delete output DNAT rule: %w", err) + } + delete(r.rules, ruleID) + } + + r.updateState() + return nil +} diff --git a/client/firewall/iptables/dnat_refcount_linux_test.go b/client/firewall/iptables/dnat_refcount_linux_test.go new file mode 100644 index 000000000..40ebc6cc3 --- /dev/null +++ b/client/firewall/iptables/dnat_refcount_linux_test.go @@ -0,0 +1,240 @@ +//go:build privileged + +package iptables + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + fw "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/wgaddr" +) + +func iptRefcountIfaceV4() *iFaceMock { + return &iFaceMock{ + NameFunc: func() string { return "wt-refcount" }, + AddressFunc: func() wgaddr.Address { + return wgaddr.Address{ + IP: netip.MustParseAddr("10.20.0.1"), + Network: netip.MustParsePrefix("10.20.0.0/24"), + } + }, + } +} + +func iptRefcountIfaceDual() *iFaceMock { + return &iFaceMock{ + NameFunc: func() string { return "wt-refcount" }, + AddressFunc: func() wgaddr.Address { + return wgaddr.Address{ + IP: netip.MustParseAddr("10.20.0.1"), + Network: netip.MustParsePrefix("10.20.0.0/24"), + IPv6: netip.MustParseAddr("fd00::1"), + IPv6Net: netip.MustParsePrefix("fd00::/64"), + } + }, + } +} + +func newIptRefcountManager(t *testing.T, dual bool) *Manager { + t.Helper() + var ifMock *iFaceMock + if dual { + ifMock = iptRefcountIfaceDual() + } else { + ifMock = iptRefcountIfaceV4() + } + m, err := Create(ifMock, iface.DefaultMTU) + require.NoError(t, err, "create manager") + require.NoError(t, m.Init(nil), "init manager") + t.Cleanup(func() { + require.NoError(t, m.Close(nil), "close manager") + }) + return m +} + +func iptDnatV4(port uint16) fw.ForwardRule { + return fw.ForwardRule{ + Protocol: fw.ProtocolTCP, + DestinationPort: fw.Port{Values: []uint16{port}}, + TranslatedAddress: netip.MustParseAddr("10.20.0.2"), + TranslatedPort: fw.Port{Values: []uint16{80}}, + } +} + +func iptDnatV6(port uint16) fw.ForwardRule { + return fw.ForwardRule{ + Protocol: fw.ProtocolTCP, + DestinationPort: fw.Port{Values: []uint16{port}}, + TranslatedAddress: netip.MustParseAddr("fd00::2"), + TranslatedPort: fw.Port{Values: []uint16{80}}, + } +} + +// TestIptablesRouting_RepeatedEnableSingleReference verifies that EnableRouting +// (called on every network-map update) holds at most one reference per family +// and a single DisableRouting drops both back to zero. +func TestIptablesRouting_RepeatedEnableSingleReference(t *testing.T) { + m := newIptRefcountManager(t, true) + state := m.family4.ipFwdState + + require.NoError(t, m.EnableRouting(), "first enable") + require.NoError(t, m.EnableRouting(), "second enable") + require.NoError(t, m.EnableRouting(), "third enable") + v4, v6 := state.Counts() + assert.Equal(t, 1, v4, "repeated enable holds a single v4 reference") + assert.Equal(t, 1, v6, "repeated enable holds a single v6 reference") + + require.NoError(t, m.DisableRouting(), "disable") + v4, v6 = state.Counts() + assert.Equal(t, 0, v4, "single disable releases the v4 reference") + assert.Equal(t, 0, v6, "single disable releases the v6 reference") +} + +// TestIptablesRouting_DisableKeepsDNATReference verifies that an unpaired +// DisableRouting does not release references held by active DNAT rules. +func TestIptablesRouting_DisableKeepsDNATReference(t *testing.T) { + m := newIptRefcountManager(t, true) + state := m.family4.ipFwdState + + r1, err := m.AddDNATRule(iptDnatV6(9095)) + require.NoError(t, err, "add v6 dnat") + + require.NoError(t, m.DisableRouting(), "unpaired disable") + _, v6 := state.Counts() + assert.Equal(t, 1, v6, "DNAT-held reference survives unpaired DisableRouting") + + require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat") + _, v6 = state.Counts() + assert.Equal(t, 0, v6, "delete releases the DNAT reference") +} + +// TestIptablesDNAT_RefcountBalancedV4 covers a Balanced Add/Delete pair on v4. +func TestIptablesDNAT_RefcountBalancedV4(t *testing.T) { + m := newIptRefcountManager(t, false) + state := m.family4.ipFwdState + + r1, err := m.AddDNATRule(iptDnatV4(7081)) + require.NoError(t, err, "add v4 dnat 1") + v4, v6 := state.Counts() + assert.Equal(t, 1, v4, "v4 refcount after first add") + assert.Equal(t, 0, v6, "v6 refcount unchanged") + + r2, err := m.AddDNATRule(iptDnatV4(7082)) + require.NoError(t, err, "add v4 dnat 2") + v4, v6 = state.Counts() + assert.Equal(t, 2, v4, "v4 refcount after second add") + assert.Equal(t, 0, v6, "v6 refcount unchanged") + + require.NoError(t, m.DeleteDNATRule(r1)) + v4, v6 = state.Counts() + assert.Equal(t, 1, v4, "v4 refcount after first delete") + assert.Equal(t, 0, v6, "v6 refcount unchanged") + + require.NoError(t, m.DeleteDNATRule(r2)) + v4, v6 = state.Counts() + assert.Equal(t, 0, v4, "v4 refcount after second delete") + assert.Equal(t, 0, v6, "v6 refcount unchanged") +} + +// TestIptablesDNAT_RefcountBalancedV6 checks the v6 path increments v6 only and +// decrements back to zero. +func TestIptablesDNAT_RefcountBalancedV6(t *testing.T) { + m := newIptRefcountManager(t, true) + require.NotNil(t, m.family6, "v6 family") + require.Same(t, m.family4.ipFwdState, m.family6.ipFwdState, "shared state") + state := m.family4.ipFwdState + + r1, err := m.AddDNATRule(iptDnatV6(9081)) + require.NoError(t, err, "add v6 dnat 1") + v4, v6 := state.Counts() + assert.Equal(t, 0, v4) + assert.Equal(t, 1, v6, "v6 refcount after first add") + + r2, err := m.AddDNATRule(iptDnatV6(9082)) + require.NoError(t, err, "add v6 dnat 2") + v4, v6 = state.Counts() + assert.Equal(t, 0, v4, "v4 refcount unchanged") + assert.Equal(t, 2, v6, "v6 refcount after second add") + + require.NoError(t, m.DeleteDNATRule(r1)) + v4, v6 = state.Counts() + assert.Equal(t, 0, v4, "v4 refcount unchanged") + assert.Equal(t, 1, v6, "v6 refcount after first delete") + + require.NoError(t, m.DeleteDNATRule(r2)) + v4, v6 = state.Counts() + assert.Equal(t, 0, v4) + assert.Equal(t, 0, v6, "v6 refcount after second delete") +} + +// TestIptablesDNAT_DuplicateAddNoLeak verifies the duplicate-rule path returns +// without bumping the refcount. +func TestIptablesDNAT_DuplicateAddNoLeak(t *testing.T) { + m := newIptRefcountManager(t, true) + state := m.family4.ipFwdState + + rule := iptDnatV4(7083) + r1, err := m.AddDNATRule(rule) + require.NoError(t, err) + v4, _ := state.Counts() + assert.Equal(t, 1, v4) + + _, err = m.AddDNATRule(rule) + require.NoError(t, err, "duplicate add") + v4, _ = state.Counts() + assert.Equal(t, 1, v4, "duplicate add must not increment") + + require.NoError(t, m.DeleteDNATRule(r1)) + v4, _ = state.Counts() + assert.Equal(t, 0, v4, "single delete must drop to zero") +} + +// TestIptablesDNAT_DeleteMissingNoUnderflow verifies Delete on an unknown rule +// neither errors nor releases the refcount. +func TestIptablesDNAT_DeleteMissingNoUnderflow(t *testing.T) { + m := newIptRefcountManager(t, true) + state := m.family4.ipFwdState + + phantom := iptDnatV4(7099) + require.NoError(t, m.DeleteDNATRule(&phantom), "delete missing v4") + v4, v6 := state.Counts() + assert.Equal(t, 0, v4) + assert.Equal(t, 0, v6) + + phantom6 := iptDnatV6(9099) + require.NoError(t, m.DeleteDNATRule(&phantom6), "delete missing v6") + v4, v6 = state.Counts() + assert.Equal(t, 0, v4) + assert.Equal(t, 0, v6) + + r1, err := m.AddDNATRule(iptDnatV4(7100)) + require.NoError(t, err) + v4, _ = state.Counts() + assert.Equal(t, 1, v4, "real add still increments after phantom delete") + require.NoError(t, m.DeleteDNATRule(r1)) +} + +// TestIptablesDNAT_DoubleDeleteNoUnderflow verifies a second Delete on the same +// rule is a no-op. +func TestIptablesDNAT_DoubleDeleteNoUnderflow(t *testing.T) { + m := newIptRefcountManager(t, true) + state := m.family4.ipFwdState + + r1, err := m.AddDNATRule(iptDnatV6(9083)) + require.NoError(t, err) + _, v6 := state.Counts() + assert.Equal(t, 1, v6) + + require.NoError(t, m.DeleteDNATRule(r1), "first delete") + _, v6 = state.Counts() + assert.Equal(t, 0, v6) + + require.NoError(t, m.DeleteDNATRule(r1), "second delete must be no-op") + _, v6 = state.Counts() + assert.Equal(t, 0, v6, "double delete must not underflow") +} diff --git a/client/firewall/iptables/family_linux.go b/client/firewall/iptables/family_linux.go new file mode 100644 index 000000000..c5ed8cc20 --- /dev/null +++ b/client/firewall/iptables/family_linux.go @@ -0,0 +1,258 @@ +//go:build !android + +package iptables + +import ( + "fmt" + "maps" + "net/netip" + + "github.com/coreos/go-iptables/iptables" + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + + nberrors "github.com/netbirdio/netbird/client/errors" + firewall "github.com/netbirdio/netbird/client/firewall/manager" + nbid "github.com/netbirdio/netbird/client/internal/acl/id" + "github.com/netbirdio/netbird/client/internal/routemanager/ipfwdstate" + "github.com/netbirdio/netbird/client/internal/routemanager/refcounter" + "github.com/netbirdio/netbird/client/internal/statemanager" +) + +// constants needed to manage and create iptable rules +const ( + tableFilter = "filter" + tableNat = "nat" + tableMangle = "mangle" + + // chainACLInput is the peer ACL chain that holds installed + // peer-filtering rules. + chainACLInput = "NETBIRD-ACL-INPUT" + + // mangleForwardKey is the entries map key for mangle FORWARD guard + // rules that prevent external DNAT from bypassing ACL rules. + mangleForwardKey chainKey = "MANGLE-FORWARD" + + chainInput = "INPUT" + chainPostrouting = "POSTROUTING" + chainPrerouting = "PREROUTING" + chainForward = "FORWARD" + chainRTNAT = "NETBIRD-RT-NAT" + chainRTFwdIn = "NETBIRD-RT-FWD-IN" + chainRTFwdOut = "NETBIRD-RT-FWD-OUT" + chainRTPre = "NETBIRD-RT-PRE" + chainRTRdr = "NETBIRD-RT-RDR" + chainNATOutput = "NETBIRD-NAT-OUTPUT" + chainRTMSSClamp = "NETBIRD-RT-MSSCLAMP" + + jumpManglePre = "jump-mangle-pre" + jumpNATPre = "jump-nat-pre" + jumpNATPost = "jump-nat-post" + jumpNATOutput = "jump-nat-output" + jumpMSSClamp = "jump-mss-clamp" + markManglePre = "mark-mangle-pre" + markManglePost = "mark-mangle-post" + matchSet = "--match-set" + + dnatSuffix firewall.RuleID = "_dnat" + snatSuffix firewall.RuleID = "_snat" + fwdSuffix firewall.RuleID = "_fwd" + + // ipv4TCPHeaderSize is the minimum IPv4 (20) + TCP (20) header size for MSS calculation. + ipv4TCPHeaderSize = 40 + // ipv6TCPHeaderSize is the minimum IPv6 (40) + TCP (20) header size for MSS calculation. + ipv6TCPHeaderSize = 60 +) + +type ruleInfo struct { + chain string + table string + rule []string +} + +type routeRules map[firewall.RuleID][]string + +// ruleSpec is a single iptables rule expressed as its argument list +// (e.g. {"-i", "wg0", "-j", "DROP"}). +type ruleSpec []string + +// chainKey identifies the chain a seeded entry belongs to. It holds +// built-in chain names ("INPUT", "FORWARD", "PREROUTING") plus the +// synthetic mangleForwardKey bucket for the mangle FORWARD guard rules. +type chainKey string + +// aclEntries maps a chain to the rules seeded into it to jump into or +// guard the netbird ACL chains. +type aclEntries map[chainKey][]ruleSpec + +type entry struct { + spec ruleSpec + position int +} + +// ipsetCounter is the shared hash:net refcounter used by peer and +// route ACLs alike. The ipset library does not support comments, so +// the key is just the set name (string). +type ipsetCounter = refcounter.Counter[string, []netip.Prefix, struct{}] + +// family holds the per-address-family iptables state. One instance +// handles route ACLs, peer ACLs, NAT, DNAT, and MSS clamping for a +// single family; the top-level Manager owns one for v4 and another +// for v6. +type family struct { + iptablesClient *iptables.IPTables + wgIface iFaceMapper + v6 bool + + // Peer ACL chain bookkeeping. + entries aclEntries + optionalEntries map[chainKey][]entry + + // filters holds peer + route filter rules keyed by content hash. + // AddFilterRule writes here; DeleteFilterRule looks up by id. + filters map[nbid.RuleID]*Rule + ipsetCounter *ipsetCounter + // ipsetSupported records whether the kernel can create the hash:net + // sets the source matches rely on; probed once at init. When false, + // multi-source rules expand to one rule per source prefix. + ipsetSupported bool + + // rules holds NAT, jump, and MSS-clamping rules (auxiliary + // plumbing that isn't a filter rule). + rules routeRules + + // Routing / NAT. + legacyManagement bool + mtu uint16 + ipFwdState *ipfwdstate.IPForwardingState + + stateManager *statemanager.Manager +} + +func newFamily(iptablesClient *iptables.IPTables, wgIface iFaceMapper, mtu uint16) (*family, error) { + r := &family{ + iptablesClient: iptablesClient, + wgIface: wgIface, + v6: iptablesClient.Proto() == iptables.ProtocolIPv6, + entries: make(aclEntries), + optionalEntries: make(map[chainKey][]entry), + filters: make(map[nbid.RuleID]*Rule), + rules: make(routeRules), + mtu: mtu, + ipFwdState: ipfwdstate.NewIPForwardingState(wgIface.Name()), + } + + r.ipsetCounter = refcounter.New( + func(name string, sources []netip.Prefix) (struct{}, error) { + return struct{}{}, r.createIpSet(name, sources) + }, + func(name string, _ struct{}) error { + return r.deleteIpSet(name) + }, + ) + + return r, nil +} + +// init wires the family to the state manager and installs both the +// route ACL containers and the peer ACL chain skeleton. +func (r *family) init(stateManager *statemanager.Manager) error { + r.stateManager = stateManager + + r.ipsetSupported = r.probeIPSetSupport() + + if err := r.cleanUpDefaultForwardRules(); err != nil { + log.Errorf("failed to clean up rules from FORWARD chain: %s", err) + } + + if err := r.createContainers(); err != nil { + return fmt.Errorf("create containers: %w", err) + } + + if err := r.setupDataPlaneMark(); err != nil { + log.Errorf("failed to set up data plane mark: %v", err) + } + + r.seedInitialEntries() + r.seedInitialOptionalEntries() + + if err := r.cleanAclChains(); err != nil { + return fmt.Errorf("clean acl chains: %w", err) + } + if err := r.createDefaultChains(); err != nil { + return fmt.Errorf("create default chains: %w", err) + } + + r.updateState() + + return nil +} + +// Reset tears down all firewall state owned by this family. ACL +// chain cleanup runs before route-chain cleanup because the route +// chains are still referenced by FORWARD jumps installed during +// seedInitialEntries; deleting them first would trip EBUSY. +func (r *family) Reset() error { + var merr *multierror.Error + + if err := r.cleanAclChains(); err != nil { + merr = multierror.Append(merr, err) + } + + if err := r.cleanUpDefaultForwardRules(); err != nil { + merr = multierror.Append(merr, err) + } + + if err := r.ipsetCounter.Flush(); err != nil { + merr = multierror.Append(merr, err) + } + + if err := r.cleanupDataPlaneMark(); err != nil { + merr = multierror.Append(merr, err) + } + + clear(r.rules) + clear(r.filters) + r.updateState() + + return nberrors.FormatErrorOrNil(merr) +} + +func (r *family) updateState() { + if r.stateManager == nil { + return + } + + var currentState *ShutdownState + if existing := r.stateManager.GetState(currentState); existing != nil { + if existingState, ok := existing.(*ShutdownState); ok { + currentState = existingState + } + } + if currentState == nil { + currentState = &ShutdownState{} + } + + currentState.Lock() + defer currentState.Unlock() + + // Clone the rule maps so the persisted state holds a private snapshot. + // The live maps keep being mutated by subsequent rule operations while + // the state manager marshals the state from its periodic-save goroutine. + // Sharing the maps by reference races the two and aborts the process with + // a concurrent map iteration and write. The ipset counter guards itself + // during marshaling, so it can be shared directly. + if r.v6 { + currentState.RouteRules6 = maps.Clone(r.rules) + currentState.RouteIPsetCounter6 = r.ipsetCounter + currentState.ACLEntries6 = maps.Clone(r.entries) + } else { + currentState.RouteRules = maps.Clone(r.rules) + currentState.RouteIPsetCounter = r.ipsetCounter + currentState.ACLEntries = maps.Clone(r.entries) + } + + if err := r.stateManager.UpdateState(currentState); err != nil { + log.Errorf("failed to update state: %v", err) + } +} diff --git a/client/firewall/iptables/filter_linux.go b/client/firewall/iptables/filter_linux.go new file mode 100644 index 000000000..dc606da2d --- /dev/null +++ b/client/firewall/iptables/filter_linux.go @@ -0,0 +1,430 @@ +//go:build !android + +package iptables + +import ( + "fmt" + "net/netip" + "slices" + "strconv" + "strings" + + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + + nberrors "github.com/netbirdio/netbird/client/errors" + firewall "github.com/netbirdio/netbird/client/firewall/manager" + nbid "github.com/netbirdio/netbird/client/internal/acl/id" + nbnet "github.com/netbirdio/netbird/client/net" +) + +// AddFilterRule installs a packet-filtering rule. With destination +// empty, the rule goes to the peer ACL input chain plus a paired +// mangle PREROUTING rule for the redirect mark. With destination set +// (prefix or named set), it goes to the route ACL forward chain. +// Multi-source rules collapse to one iptables rule via the shared +// hash:net ipset. +func (r *family) AddFilterRule( + id []byte, + sources []netip.Prefix, + destination firewall.Network, + proto firewall.Protocol, + sPort *firewall.Port, + dPort *firewall.Port, + action firewall.Action, +) (firewall.Rule, error) { + ruleID := nbid.GenerateRuleID(sources, destination, proto, sPort, dPort, action) + if existing, ok := r.filters[ruleID]; ok { + return existing, nil + } + + rule, err := r.installFilterRules(ruleID, sources, destination, proto, sPort, dPort, action, r.ipsetSupported) + if err != nil { + return nil, err + } + + r.filters[ruleID] = rule + r.updateState() + return rule, nil +} + +// installFilterRules resolves the source matches and installs one +// iptables rule per match. It is more than one rule only when useIPSet +// is false and a multi-source rule has to be expanded per prefix. +func (r *family) installFilterRules( + ruleID nbid.RuleID, + sources []netip.Prefix, + destination firewall.Network, + proto firewall.Protocol, + sPort *firewall.Port, + dPort *firewall.Port, + action firewall.Action, + useIPSet bool, +) (*Rule, error) { + srcMatches, err := r.applySourceMatches(sources, useIPSet) + if err != nil { + return nil, fmt.Errorf("apply source match: %w", err) + } + + rule, err := r.installFilterRule(ruleID, srcMatches, destination, proto, sPort, dPort, action) + if err != nil { + for _, srcMatch := range srcMatches { + r.dropSourceMatch(srcMatch) + } + return nil, err + } + return rule, nil +} + +func (r *family) hasRule(id nbid.RuleID) bool { + _, ok := r.filters[id] + return ok +} + +// hasDNATRule reports whether this family owns the DNAT rule set for +// the given user id. DNAT rules live in r.rules under the well-known +// "_dnat" key; the lookup here is used by Manager.DeleteDNATRule +// to pick the right family. +func (r *family) hasDNATRule(id firewall.RuleID) bool { + _, ok := r.rules[id+dnatSuffix] + return ok +} + +// DeleteFilterRule removes a previously installed filter rule. The +// rule's stored chain/table identify where to delete from; source set +// references are recovered from the spec via findSets and dropped +// from the shared ipset counter. +func (r *family) DeleteFilterRule(rule firewall.Rule) error { + ruleID := rule.ID() + pr, ok := r.filters[ruleID] + if !ok { + log.Debugf("filter rule %s not found", ruleID) + return nil + } + + // DeleteIfExists keeps the deletes idempotent so a retry after a + // partial failure does not error on the parts already removed. + var merr *multierror.Error + for _, fs := range pr.allSpecs() { + if err := r.iptablesClient.DeleteIfExists(tableFilter, pr.chain, fs.specs...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("delete rule from %s: %w", pr.chain, err)) + } + if fs.mangleSpecs != nil { + if err := r.iptablesClient.DeleteIfExists(tableMangle, chainRTPre, fs.mangleSpecs...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("delete mangle rule: %w", err)) + } + } + } + if merr != nil { + // Leave the rule tracked so the caller retries the remaining part. + return nberrors.FormatErrorOrNil(merr) + } + + // The rule is gone from iptables, so untrack it regardless of how the + // refcount decrement goes, but surface decrement failures so callers + // see the ipset desync. Only the primary spec can reference sets: the + // per-prefix expansion never uses them. + delete(r.filters, ruleID) + r.updateState() + if err := r.decrementSetCounter(pr.specs); err != nil { + return fmt.Errorf("drop source set references: %w", err) + } + return nil +} + +// findSets scans an iptables rule spec for "-m set --match-set +// " fragments and returns the named sets in occurrence order. +// Used at delete time to drop ipsetCounter references. +func findSets(rule []string) []string { + var sets []string + for i, arg := range rule { + if arg == "-m" && i+3 < len(rule) && rule[i+1] == "set" && rule[i+2] == matchSet { + sets = append(sets, rule[i+3]) + } + } + return sets +} + +// sourceNetwork classifies a source-prefix list into the firewall.Network +// shape the rest of the spec-builder consumes: empty for match-any, a +// single prefix inline, or an ipset for multiple sources. +func sourceNetwork(sources []netip.Prefix) firewall.Network { + switch { + case len(sources) == 0: + return firewall.Network{} + case len(sources) == 1 && sources[0].Bits() == 0: + return firewall.Network{} + case len(sources) == 1: + return firewall.Network{Prefix: sources[0]} + default: + return firewall.Network{Set: firewall.NewPrefixSet(sources)} + } +} + +// applySourceMatches returns one source match fragment per iptables +// rule needed for the sources: normally a single fragment (a set match, +// a direct -s match, or nil for match-any), and one -s fragment per +// prefix when a multi-source rule cannot use ipset. Per-prefix rules +// are the only form a kernel without the ipset modules can express. +func (r *family) applySourceMatches(sources []netip.Prefix, useIPSet bool) ([][]string, error) { + network := sourceNetwork(sources) + if !network.IsSet() || useIPSet { + match, err := r.applySourceMatch(network, sources) + if err != nil { + return nil, err + } + return [][]string{match}, nil + } + + matches := make([][]string, 0, len(sources)) + for _, source := range sources { + matches = append(matches, []string{"-s", source.String()}) + } + return matches, nil +} + +// applySourceMatch returns the iptables match fragment for the rule's +// source. For a Set it increments the shared ipset's refcount; for a +// Prefix it emits a direct -s match; for the wildcard it returns nil. +func (r *family) applySourceMatch(network firewall.Network, prefixes []netip.Prefix) ([]string, error) { + switch { + case network.IsSet(): + if r.ipsetCounter == nil { + return nil, fmt.Errorf("multi-source peer rule requires shared ipset counter") + } + name := r.ipsetName(network.Set.HashedName()) + if _, err := r.ipsetCounter.Increment(name, prefixes); err != nil { + return nil, fmt.Errorf("ipset increment %s: %w", name, err) + } + return []string{"-m", "set", matchSet, name, "src"}, nil + case network.IsPrefix(): + return []string{"-s", network.Prefix.String()}, nil + default: + return nil, nil + } +} + +// dropSourceMatch undoes whatever applySourceMatch reserved when +// installing a rule fails. Safe to call when the spec is empty or holds +// only inline matchers. Decrement errors are logged but not returned: +// the install error is what the caller needs to see. +func (r *family) dropSourceMatch(srcMatch []string) { + if r.ipsetCounter == nil { + return + } + for _, name := range findSets(srcMatch) { + if _, err := r.ipsetCounter.Decrement(name); err != nil { + log.Errorf("rollback ipset decrement %s: %v", name, err) + } + } +} + +// decrementSetCounter drops ipset references owned by a raw rule spec +// stored in r.rules (NAT / legacy route entries). It returns an error +// aggregate so the caller surfaces decrement failures. +func (r *family) decrementSetCounter(rule []string) error { + if r.ipsetCounter == nil { + return nil + } + var merr *multierror.Error + for _, name := range findSets(rule) { + if _, err := r.ipsetCounter.Decrement(name); err != nil { + merr = multierror.Append(merr, fmt.Errorf("decrement counter: %w", err)) + } + } + return nberrors.FormatErrorOrNil(merr) +} + +// installFilterRule assembles and writes the iptables filter-chain +// rules for one filter rule, one per source match fragment. With +// destination empty the rules land in the peer ACL input chain and each +// gets a paired mangle PREROUTING rule for the redirect mark. With +// destination set the rules land in the route ACL forward chain and +// there is no mangle pairing. +func (r *family) installFilterRule( + ruleID nbid.RuleID, + srcMatches [][]string, + destination firewall.Network, + protocol firewall.Protocol, + sPort, dPort *firewall.Port, + action firewall.Action, +) (*Rule, error) { + isRoute := !destination.IsZero() + + proto := protoForFamily(protocol, r.v6) + + var destExp []string + if isRoute { + var err error + destExp, err = r.applyNetwork("-d", destination, nil) + if err != nil { + return nil, fmt.Errorf("apply network -d: %w", err) + } + } + matchSpecs := filterMatchSpecs(proto, sPort, dPort) + + chain := chainACLInput + if isRoute { + chain = chainRTFwdIn + } + + var installed []filterSpecs + for _, srcMatch := range srcMatches { + specs := slices.Clone(srcMatch) + specs = append(specs, destExp...) + specs = append(specs, matchSpecs...) + + var mangleSpecs []string + if !isRoute { + mangleSpecs = slices.Clone(specs) + mangleSpecs = append(mangleSpecs, + "-i", r.wgIface.Name(), + "-m", "addrtype", "--dst-type", "LOCAL", + "-j", "MARK", "--set-xmark", fmt.Sprintf("%#x", nbnet.PreroutingFwmarkRedirected), + ) + } + + specs = append(specs, "-j", actionToStr(action)) + + if err := r.insertFilterRule(chain, action, specs); err != nil { + // Leave nothing half-installed: the caller sees an error, so a + // partial rule would silently keep matching without being tracked. + r.removeFilterSpecs(chain, installed) + r.dropSourceMatch(destExp) + return nil, fmt.Errorf("install filter rule on %s: %w", chain, err) + } + + // The mangle redirect-mark rule is best effort: the filter rule itself + // is what enforces the ACL, so a mangle failure must not undo it. Drop + // the spec so teardown does not try to remove a rule that was not added. + if mangleSpecs != nil { + if err := r.iptablesClient.Append(tableMangle, chainRTPre, mangleSpecs...); err != nil { + log.Errorf("add mangle rule: %v", err) + mangleSpecs = nil + } + } + + installed = append(installed, filterSpecs{specs: specs, mangleSpecs: mangleSpecs}) + } + + return &Rule{ + id: ruleID, + specs: installed[0].specs, + mangleSpecs: installed[0].mangleSpecs, + extraRules: installed[1:], + chain: chain, + v6: r.v6, + }, nil +} + +// insertFilterRule writes one assembled rule spec into the given ACL +// chain. Peer ACL drops are inserted at position 1 so they precede the +// chain's catch-all; route ACL drops are inserted at position 2 to sit +// immediately after the established/related accept rule. +func (r *family) insertFilterRule(chain string, action firewall.Action, specs []string) error { + if action == firewall.ActionDrop { + pos := 1 + if chain == chainRTFwdIn { + pos = 2 + } + return r.iptablesClient.Insert(tableFilter, chain, pos, specs...) + } + return r.iptablesClient.Append(tableFilter, chain, specs...) +} + +// removeFilterSpecs deletes the already-installed rules of a partially +// applied filter rule. +func (r *family) removeFilterSpecs(chain string, installed []filterSpecs) { + for _, fs := range installed { + if err := r.iptablesClient.DeleteIfExists(tableFilter, chain, fs.specs...); err != nil { + log.Debugf("delete partial filter rule: %v", err) + } + if fs.mangleSpecs != nil { + if err := r.iptablesClient.DeleteIfExists(tableMangle, chainRTPre, fs.mangleSpecs...); err != nil { + log.Debugf("delete partial mangle rule: %v", err) + } + } + } +} + +// applyNetwork resolves a firewall.Network into the iptables match +// fragment for the given direction flag (-s or -d). Set networks +// increment the shared ipset refcount; prefixes emit a direct match; +// an empty network returns no spec ("match any"). +func (r *family) applyNetwork(flag string, network firewall.Network, prefixes []netip.Prefix) ([]string, error) { + direction := "src" + if flag == "-d" { + direction = "dst" + } + + if network.IsSet() { + // A destination set is populated later from DNS results, so unlike a + // source set it cannot be expanded into per-prefix rules. Without + // ipset such a rule is not expressible; report it instead of + // installing something broader than the policy allows. + if flag == "-d" && !r.ipsetSupported { + return nil, fmt.Errorf("destination set %s requires ipset (ip_set_hash_net and xt_set)", network.Set.HashedName()) + } + + name := r.ipsetName(network.Set.HashedName()) + if _, err := r.ipsetCounter.Increment(name, prefixes); err != nil { + return nil, fmt.Errorf("create or get ipset: %w", err) + } + + return []string{"-m", "set", matchSet, name, direction}, nil + } + if network.IsPrefix() { + return []string{flag, network.Prefix.String()}, nil + } + + // nolint:nilnil + return nil, nil +} + +// protoForFamily translates ICMP to ICMPv6 for ip6tables. +// ip6tables requires "ipv6-icmp" (or "icmpv6") instead of "icmp". +func protoForFamily(protocol firewall.Protocol, v6 bool) string { + if v6 && protocol == firewall.ProtocolICMP { + return "ipv6-icmp" + } + return string(protocol) +} + +// filterMatchSpecs returns the proto/port match fragment for a +// filtering rule. The source match (-s or -m set) is built by the +// caller and prepended. +func filterMatchSpecs(protocol string, sPort, dPort *firewall.Port) (specs []string) { + if protocol != "all" { + specs = append(specs, "-p", protocol) + } + specs = append(specs, applyPort("--sport", sPort)...) + specs = append(specs, applyPort("--dport", dPort)...) + return specs +} + +func actionToStr(action firewall.Action) string { + if action == firewall.ActionAccept { + return "ACCEPT" + } + return "DROP" +} + +func applyPort(flag string, port *firewall.Port) []string { + if port == nil { + return nil + } + + if port.IsRange && len(port.Values) == 2 { + return []string{flag, fmt.Sprintf("%d:%d", port.Values[0], port.Values[1])} + } + + if len(port.Values) > 1 { + portList := make([]string, len(port.Values)) + for i, p := range port.Values { + portList[i] = strconv.Itoa(int(p)) + } + return []string{"-m", "multiport", flag, strings.Join(portList, ",")} + } + + return []string{flag, strconv.Itoa(int(port.Values[0]))} +} diff --git a/client/firewall/iptables/interface_allower_linux.go b/client/firewall/iptables/interface_allower_linux.go new file mode 100644 index 000000000..40e9728e2 --- /dev/null +++ b/client/firewall/iptables/interface_allower_linux.go @@ -0,0 +1,93 @@ +package iptables + +import ( + "fmt" + + "github.com/coreos/go-iptables/iptables" + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + + nberrors "github.com/netbirdio/netbird/client/errors" +) + +// InterfaceAllower opens the NetBird interface on the iptables filter INPUT +// chain so the host firewall doesn't drop traffic the userspace firewall +// handles. It is the fallback used when nftables is unavailable (an +// iptables-legacy host). +// +// It opens INPUT only: the userspace router never forwards in the kernel. +// firewalld trust is handled by the uspfilter manager, not here. +type InterfaceAllower struct { + ifaceName string + ipt4 *iptables.IPTables + // ipt6 is nil when the interface has no IPv6 overlay address. + ipt6 *iptables.IPTables +} + +// NewInterfaceAllower builds an iptables allower for the interface. It returns +// an error when iptables is unavailable, so the caller can fall back to +// firewalld trust. +func NewInterfaceAllower(wgIface iFaceMapper) (*InterfaceAllower, error) { + ipt4, err := iptables.NewWithProtocol(iptables.ProtocolIPv4) + if err != nil { + return nil, fmt.Errorf("iptables not available: %w", err) + } + if _, err := ipt4.ListChains(tableFilter); err != nil { + return nil, fmt.Errorf("iptables filter table not available: %w", err) + } + + a := &InterfaceAllower{ifaceName: wgIface.Name(), ipt4: ipt4} + + // Missing v6 must not break the v4 path: open v4 only and continue. + if wgIface.Address().HasIPv6() { + ipt6, err := iptables.NewWithProtocol(iptables.ProtocolIPv6) + if err != nil { + log.Warnf("ip6tables not available, opening interface on v4 only: %v", err) + } else if _, err := ipt6.ListChains(tableFilter); err != nil { + log.Warnf("ip6tables filter table not available, opening interface on v4 only: %v", err) + } else { + a.ipt6 = ipt6 + } + } + + return a, nil +} + +// Apply inserts the interface accept rule on the filter INPUT chain. It removes +// any stale rule first so an unclean exit (e.g. SIGKILL, where Close never ran) +// is recovered deterministically rather than accumulating duplicates. +func (a *InterfaceAllower) Apply() error { + var merr *multierror.Error + for _, ipt := range a.clients() { + if err := ipt.DeleteIfExists(tableFilter, chainInput, a.inputRule()...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("clean stale interface accept rule: %w", err)) + } + if err := ipt.Insert(tableFilter, chainInput, 1, a.inputRule()...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("add interface accept rule: %w", err)) + } + } + return nberrors.FormatErrorOrNil(merr) +} + +// Close removes the interface accept rule. +func (a *InterfaceAllower) Close() error { + var merr *multierror.Error + for _, ipt := range a.clients() { + if err := ipt.DeleteIfExists(tableFilter, chainInput, a.inputRule()...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove interface accept rule: %w", err)) + } + } + return nberrors.FormatErrorOrNil(merr) +} + +func (a *InterfaceAllower) inputRule() []string { + return []string{"-i", a.ifaceName, "-j", "ACCEPT"} +} + +func (a *InterfaceAllower) clients() []*iptables.IPTables { + clients := []*iptables.IPTables{a.ipt4} + if a.ipt6 != nil { + clients = append(clients, a.ipt6) + } + return clients +} diff --git a/client/firewall/iptables/ipset_linux.go b/client/firewall/iptables/ipset_linux.go new file mode 100644 index 000000000..2a3685af7 --- /dev/null +++ b/client/firewall/iptables/ipset_linux.go @@ -0,0 +1,131 @@ +//go:build !android + +package iptables + +import ( + "fmt" + "net/netip" + + "github.com/google/uuid" + "github.com/hashicorp/go-multierror" + "github.com/lrh3321/ipset-go" + log "github.com/sirupsen/logrus" + + nberrors "github.com/netbirdio/netbird/client/errors" + firewall "github.com/netbirdio/netbird/client/firewall/manager" +) + +// probeIPSetSupport checks whether the kernel can create the ipset type +// used for source and destination matches. On kernels lacking the +// required ipset hash module, set creation fails (e.g. "invalid +// argument"), which would otherwise fail every multi-source rule and +// leave traffic the policy permits blocked by the catch-all drop. When +// unsupported, multi-source rules fall back to one rule per prefix. +func (r *family) probeIPSetSupport() bool { + // Use a unique name so concurrent processes don't collide and we only ever + // destroy the set we created ourselves. ipset names are limited to 31 chars, + // so use a short random suffix. + probeName := "nb-probe-" + uuid.New().String()[:8] + + if err := r.createIPSet(probeName); err != nil { + log.Warnf("ipset is not available (failed to create probe set: %v); "+ + "falling back to per-IP iptables ACL rules. Ensure the kernel provides "+ + "the ipset hash:net module (ip_set_hash_net) for better performance with large rule sets", err) + return false + } + + if err := r.destroyIPSet(probeName); err != nil { + log.Debugf("destroy ipset probe set %q: %v", probeName, err) + } + + return true +} + +func (r *family) createIpSet(setName string, sources []netip.Prefix) error { + if err := r.createIPSet(setName); err != nil { + return fmt.Errorf("create set %s: %w", setName, err) + } + + for _, prefix := range sources { + if err := r.addPrefixToIPSet(setName, prefix); err != nil { + // The refcounter records nothing when this callback errors, + // so destroy the set or it leaks in the kernel. A partial + // source set would also fail-open for deny rules, so the + // rule must fail rather than install with a missing source. + if derr := r.destroyIPSet(setName); derr != nil { + log.Warnf("rollback ipset %s after add failure: %v", setName, derr) + } + return fmt.Errorf("add element to set %s: %w", setName, err) + } + } + + return nil +} + +func (r *family) deleteIpSet(setName string) error { + if err := r.destroyIPSet(setName); err != nil { + return fmt.Errorf("destroy set %s: %w", setName, err) + } + + log.Debugf("deleted unused ipset %s", setName) + return nil +} + +func (r *family) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error { + name := r.ipsetName(set.HashedName()) + var merr *multierror.Error + for _, prefix := range prefixes { + if err := r.addPrefixToIPSet(name, prefix); err != nil { + merr = multierror.Append(merr, fmt.Errorf("add prefix to ipset: %w", err)) + } + } + if merr == nil { + log.Debugf("updated set %s with prefixes %v", name, prefixes) + } + + return nberrors.FormatErrorOrNil(merr) +} + +func (r *family) ipsetName(name string) string { + if r.v6 { + return name + "-v6" + } + return name +} + +func (r *family) createIPSet(name string) error { + opts := ipset.CreateOptions{ + Replace: true, + } + if r.v6 { + opts.Family = ipset.FamilyIPV6 + } + + if err := ipset.Create(name, ipset.TypeHashNet, opts); err != nil { + return fmt.Errorf("create ipset %s: %w", name, err) + } + + log.Debugf("created ipset %s with type hash:net", name) + return nil +} + +func (r *family) addPrefixToIPSet(name string, prefix netip.Prefix) error { + addr := prefix.Addr() + ip := addr.AsSlice() + + entry := &ipset.Entry{ + IP: ip, + CIDR: uint8(prefix.Bits()), + Replace: true, + } + + if err := ipset.Add(name, entry); err != nil { + return fmt.Errorf("add prefix to ipset %s: %w", name, err) + } + + return nil +} + +func (r *family) destroyIPSet(name string) error { + return ipset.Destroy(name) +} diff --git a/client/firewall/iptables/manager_linux.go b/client/firewall/iptables/manager_linux.go index 7d8cd7f8c..49b88f1ea 100644 --- a/client/firewall/iptables/manager_linux.go +++ b/client/firewall/iptables/manager_linux.go @@ -3,7 +3,6 @@ package iptables import ( "context" "fmt" - "net" "net/netip" "sync" @@ -18,16 +17,21 @@ import ( "github.com/netbirdio/netbird/client/internal/statemanager" ) -// Manager of iptables firewall +// Manager of iptables firewall. Per-family state (peer ACLs, route +// ACLs, NAT, DNAT, MSS clamping) lives on family; Manager dispatches +// by family and provides the public firewall.Manager surface. type Manager struct { mutex sync.Mutex wgIface iFaceMapper ipv4Client *iptables.IPTables - aclMgr *aclManager - router *router + family4 *family rawSupported bool + + // IPv6 counterparts, nil when no v6 overlay + ipv6Client *iptables.IPTables + family6 *family } // iFaceMapper defines subset methods of interface required for manager @@ -48,25 +52,51 @@ func Create(wgIface iFaceMapper, mtu uint16) (*Manager, error) { ipv4Client: iptablesClient, } - m.router, err = newRouter(iptablesClient, wgIface, mtu) + m.family4, err = newFamily(iptablesClient, wgIface, mtu) if err != nil { - return nil, fmt.Errorf("create router: %w", err) + return nil, fmt.Errorf("create family: %w", err) } - m.aclMgr, err = newAclManager(iptablesClient, wgIface) - if err != nil { - return nil, fmt.Errorf("create acl manager: %w", err) + if wgIface.Address().HasIPv6() { + if err := m.createIPv6Components(wgIface, mtu); err != nil { + return nil, fmt.Errorf("create IPv6 firewall: %w", err) + } } return m, nil } +func (m *Manager) createIPv6Components(wgIface iFaceMapper, mtu uint16) error { + ip6Client, err := iptables.NewWithProtocol(iptables.ProtocolIPv6) + if err != nil { + return fmt.Errorf("init ip6tables: %w", err) + } + + family6, err := newFamily(ip6Client, wgIface, mtu) + if err != nil { + return fmt.Errorf("create v6 family: %w", err) + } + + // Share the same IP forwarding state with the v4 family, since the + // forwarding refcounter is per-family but shared between both families. + family6.ipFwdState = m.family4.ipFwdState + + m.ipv6Client = ip6Client + m.family6 = family6 + + return nil +} + +func (m *Manager) hasIPv6() bool { + return m.ipv6Client != nil +} + func (m *Manager) Init(stateManager *statemanager.Manager) error { state := &ShutdownState{ InterfaceState: &InterfaceState{ NameStr: m.wgIface.Name(), WGAddress: m.wgIface.Address(), - MTU: m.router.mtu, + MTU: m.family4.mtu, }, } stateManager.RegisterState(state) @@ -74,13 +104,8 @@ func (m *Manager) Init(stateManager *statemanager.Manager) error { log.Errorf("failed to update state: %v", err) } - if err := m.router.init(stateManager); err != nil { - return fmt.Errorf("router init: %w", err) - } - - if err := m.aclMgr.init(stateManager); err != nil { - // TODO: cleanup router - return fmt.Errorf("acl manager init: %w", err) + if err := m.initChains(stateManager); err != nil { + return err } if err := m.initNoTrackChain(); err != nil { @@ -103,55 +128,78 @@ func (m *Manager) Init(stateManager *statemanager.Manager) error { return nil } -// AddPeerFiltering adds a rule to the firewall -// -// Comment will be ignored because some system this feature is not supported -func (m *Manager) AddPeerFiltering( - id []byte, - ip net.IP, - proto firewall.Protocol, - sPort *firewall.Port, - dPort *firewall.Port, - action firewall.Action, - ipsetName string, -) ([]firewall.Rule, error) { - m.mutex.Lock() - defer m.mutex.Unlock() +// initChains initializes the per-family firewall state for both +// address families, rolling back on failure. +func (m *Manager) initChains(stateManager *statemanager.Manager) error { + type initStep struct { + name string + r *family + } - return m.aclMgr.AddPeerFiltering(id, ip, proto, sPort, dPort, action, ipsetName) + steps := []initStep{{"v4", m.family4}} + if m.hasIPv6() { + steps = append(steps, initStep{"v6", m.family6}) + } + + var initialized []initStep + for _, s := range steps { + if err := s.r.init(stateManager); err != nil { + for i := len(initialized) - 1; i >= 0; i-- { + if rerr := initialized[i].r.Reset(); rerr != nil { + log.Warnf("rollback %s: %v", initialized[i].name, rerr) + } + } + return fmt.Errorf("%s init: %w", s.name, err) + } + initialized = append(initialized, s) + } + return nil } -func (m *Manager) AddRouteFiltering( +// AddFilterRule installs a packet-filtering rule. See firewall.Manager +// docs for destination semantics. Sources are a single address family; +// the rule is dispatched to the matching v4 / v6 backend. +func (m *Manager) AddFilterRule( id []byte, sources []netip.Prefix, destination firewall.Network, proto firewall.Protocol, - sPort, dPort *firewall.Port, + sPort *firewall.Port, + dPort *firewall.Port, action firewall.Action, ) (firewall.Rule, error) { - m.mutex.Lock() - defer m.mutex.Unlock() - - if destination.IsPrefix() && !destination.Prefix.Addr().Is4() { - return nil, fmt.Errorf("unsupported IP version: %s", destination.Prefix.Addr().String()) + if len(sources) == 0 { + return nil, firewall.ErrNoSources } - return m.router.AddRouteFiltering(id, sources, destination, proto, sPort, dPort, action) -} - -// DeletePeerRule from the firewall by rule definition -func (m *Manager) DeletePeerRule(rule firewall.Rule) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.aclMgr.DeletePeerRule(rule) + fam := m.family4 + if isIPv6Rule(sources, destination) { + if !m.hasIPv6() { + return nil, fmt.Errorf("add filtering: %w", firewall.ErrIPv6NotInitialized) + } + fam = m.family6 + } + return fam.AddFilterRule(id, sources, destination, proto, sPort, dPort, action) } -func (m *Manager) DeleteRouteRule(rule firewall.Rule) error { +// DeleteFilterRule removes a rule previously added via AddFilterRule. +// The rule is looked up by id in each family's filter cache. +func (m *Manager) DeleteFilterRule(rule firewall.Rule) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.DeleteRouteRule(rule) + id := rule.ID() + if m.family4.hasRule(id) { + return m.family4.DeleteFilterRule(rule) + } + if m.hasIPv6() && m.family6.hasRule(id) { + return m.family6.DeleteFilterRule(rule) + } + log.Debugf("filter rule %s not found in any family", id) + return nil } func (m *Manager) IsServerRouteSupported() bool { @@ -166,18 +214,68 @@ func (m *Manager) AddNatRule(pair firewall.RouterPair) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.AddNatRule(pair) + if pair.Destination.IsPrefix() && pair.Destination.Prefix.Addr().Is6() { + if !m.hasIPv6() { + return fmt.Errorf("add NAT rule: %w", firewall.ErrIPv6NotInitialized) + } + return m.family6.AddNatRule(pair) + } + + if err := m.family4.AddNatRule(pair); err != nil { + return err + } + + // Dynamic routes need NAT in both tables since resolved IPs can be + // either v4 or v6. This covers both DomainSet (modern) and the legacy + // wildcard 0.0.0.0/0 destination where the client resolves DNS. + if m.hasIPv6() && pair.Dynamic { + v6Pair := firewall.ToV6NatPair(pair) + if err := m.family6.AddNatRule(v6Pair); err != nil { + return fmt.Errorf("add v6 NAT rule: %w", err) + } + } + + return nil } func (m *Manager) RemoveNatRule(pair firewall.RouterPair) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.RemoveNatRule(pair) + if pair.Destination.IsPrefix() && pair.Destination.Prefix.Addr().Is6() { + if !m.hasIPv6() { + return nil + } + return m.family6.RemoveNatRule(pair) + } + + var merr *multierror.Error + + if err := m.family4.RemoveNatRule(pair); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove v4 NAT rule: %w", err)) + } + + if m.hasIPv6() && pair.Dynamic { + v6Pair := firewall.ToV6NatPair(pair) + if err := m.family6.RemoveNatRule(v6Pair); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove v6 NAT rule: %w", err)) + } + } + + return nberrors.FormatErrorOrNil(merr) } func (m *Manager) SetLegacyManagement(isLegacy bool) error { - return firewall.SetLegacyManagement(m.router, isLegacy) + m.mutex.Lock() + defer m.mutex.Unlock() + + if err := firewall.SetLegacyManagement(m.family4, isLegacy); err != nil { + return err + } + if m.hasIPv6() { + return firewall.SetLegacyManagement(m.family6, isLegacy) + } + return nil } // Reset firewall to the default state @@ -191,11 +289,14 @@ func (m *Manager) Close(stateManager *statemanager.Manager) error { merr = multierror.Append(merr, fmt.Errorf("cleanup notrack chain: %w", err)) } - if err := m.aclMgr.Reset(); err != nil { - merr = multierror.Append(merr, fmt.Errorf("reset acl manager: %w", err)) + if m.hasIPv6() { + if err := m.family6.Reset(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("reset v6 family: %w", err)) + } } - if err := m.router.Reset(); err != nil { - merr = multierror.Append(merr, fmt.Errorf("reset router: %w", err)) + + if err := m.family4.Reset(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("reset family: %w", err)) } // Appending to merr intentionally blocks DeleteState below so ShutdownState @@ -214,30 +315,6 @@ func (m *Manager) Close(stateManager *statemanager.Manager) error { return nberrors.FormatErrorOrNil(merr) } -// AllowNetbird allows netbird interface traffic. -// This is called when USPFilter wraps the native firewall, adding blanket accept -// rules so that packet filtering is handled in userspace instead of by netfilter. -func (m *Manager) AllowNetbird() error { - _, err := m.AddPeerFiltering( - nil, - net.IP{0, 0, 0, 0}, - firewall.ProtocolALL, - nil, - nil, - firewall.ActionAccept, - "", - ) - if err != nil { - return fmt.Errorf("allow netbird interface traffic: %w", err) - } - - if err := firewalld.TrustInterface(m.wgIface.Name()); err != nil { - log.Warnf("failed to trust interface in firewalld: %v", err) - } - - return nil -} - // Flush doesn't need to be implemented for this manager func (m *Manager) Flush() error { return nil } @@ -247,17 +324,12 @@ func (m *Manager) SetLogLevel(log.Level) { } func (m *Manager) EnableRouting() error { - if err := m.router.ipFwdState.RequestForwarding(); err != nil { - return fmt.Errorf("enable IP forwarding: %w", err) - } - return nil + // v6 only when the overlay actually has v6. + return m.family4.ipFwdState.RequestRouting(m.hasIPv6()) } func (m *Manager) DisableRouting() error { - if err := m.router.ipFwdState.ReleaseForwarding(); err != nil { - return fmt.Errorf("disable IP forwarding: %w", err) - } - return nil + return m.family4.ipFwdState.ReleaseRouting() } // AddDNATRule adds a DNAT rule @@ -265,7 +337,13 @@ func (m *Manager) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) m.mutex.Lock() defer m.mutex.Unlock() - return m.router.AddDNATRule(rule) + if rule.TranslatedAddress.Is6() { + if !m.hasIPv6() { + return nil, fmt.Errorf("add DNAT rule: %w", firewall.ErrIPv6NotInitialized) + } + return m.family6.AddDNATRule(rule) + } + return m.family4.AddDNATRule(rule) } // DeleteDNATRule deletes a DNAT rule @@ -273,7 +351,10 @@ func (m *Manager) DeleteDNATRule(rule firewall.Rule) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.DeleteDNATRule(rule) + if m.hasIPv6() && !m.family4.hasDNATRule(rule.ID()) { + return m.family6.DeleteDNATRule(rule) + } + return m.family4.DeleteDNATRule(rule) } // UpdateSet updates the set with the given prefixes @@ -281,44 +362,87 @@ func (m *Manager) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.UpdateSet(set, prefixes) + var v4Prefixes, v6Prefixes []netip.Prefix + for _, p := range prefixes { + if p.Addr().Is6() { + v6Prefixes = append(v6Prefixes, p) + } else { + v4Prefixes = append(v4Prefixes, p) + } + } + + if err := m.family4.UpdateSet(set, v4Prefixes); err != nil { + return err + } + + if m.hasIPv6() && len(v6Prefixes) > 0 { + if err := m.family6.UpdateSet(set, v6Prefixes); err != nil { + return fmt.Errorf("update v6 set: %w", err) + } + } + + return nil } // AddInboundDNAT adds an inbound DNAT rule redirecting traffic from NetBird peers to local services. -func (m *Manager) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { +func (m *Manager) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.AddInboundDNAT(localAddr, protocol, sourcePort, targetPort) + if localAddr.Is6() { + if !m.hasIPv6() { + return fmt.Errorf("add inbound DNAT: %w", firewall.ErrIPv6NotInitialized) + } + return m.family6.AddInboundDNAT(localAddr, protocol, originalPort, translatedPort) + } + return m.family4.AddInboundDNAT(localAddr, protocol, originalPort, translatedPort) } // RemoveInboundDNAT removes an inbound DNAT rule. -func (m *Manager) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { +func (m *Manager) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.RemoveInboundDNAT(localAddr, protocol, sourcePort, targetPort) + if localAddr.Is6() { + if !m.hasIPv6() { + return fmt.Errorf("remove inbound DNAT: %w", firewall.ErrIPv6NotInitialized) + } + return m.family6.RemoveInboundDNAT(localAddr, protocol, originalPort, translatedPort) + } + return m.family4.RemoveInboundDNAT(localAddr, protocol, originalPort, translatedPort) } // AddOutputDNAT adds an OUTPUT chain DNAT rule for locally-generated traffic. -func (m *Manager) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { +func (m *Manager) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.AddOutputDNAT(localAddr, protocol, sourcePort, targetPort) + if localAddr.Is6() { + if !m.hasIPv6() { + return fmt.Errorf("add output DNAT: %w", firewall.ErrIPv6NotInitialized) + } + return m.family6.AddOutputDNAT(localAddr, protocol, originalPort, translatedPort) + } + return m.family4.AddOutputDNAT(localAddr, protocol, originalPort, translatedPort) } // RemoveOutputDNAT removes an OUTPUT chain DNAT rule. -func (m *Manager) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { +func (m *Manager) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.RemoveOutputDNAT(localAddr, protocol, sourcePort, targetPort) + if localAddr.Is6() { + if !m.hasIPv6() { + return fmt.Errorf("remove output DNAT: %w", firewall.ErrIPv6NotInitialized) + } + return m.family6.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort) + } + return m.family4.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort) } const ( chainNameRaw = "NETBIRD-RAW" - chainOUTPUT = "OUTPUT" + chainOutput = "OUTPUT" tableRaw = "raw" ) @@ -393,15 +517,15 @@ func (m *Manager) initNoTrackChain() error { jumpRule := []string{"-j", chainNameRaw} - if err := m.ipv4Client.InsertUnique(tableRaw, chainOUTPUT, 1, jumpRule...); err != nil { + if err := m.ipv4Client.InsertUnique(tableRaw, chainOutput, 1, jumpRule...); err != nil { if delErr := m.ipv4Client.DeleteChain(tableRaw, chainNameRaw); delErr != nil { log.Debugf("delete orphan chain: %v", delErr) } return fmt.Errorf("add output jump rule: %w", err) } - if err := m.ipv4Client.InsertUnique(tableRaw, chainPREROUTING, 1, jumpRule...); err != nil { - if delErr := m.ipv4Client.DeleteIfExists(tableRaw, chainOUTPUT, jumpRule...); delErr != nil { + if err := m.ipv4Client.InsertUnique(tableRaw, chainPrerouting, 1, jumpRule...); err != nil { + if delErr := m.ipv4Client.DeleteIfExists(tableRaw, chainOutput, jumpRule...); delErr != nil { log.Debugf("delete output jump rule: %v", delErr) } if delErr := m.ipv4Client.DeleteChain(tableRaw, chainNameRaw); delErr != nil { @@ -428,11 +552,11 @@ func (m *Manager) cleanupNoTrackChain() error { jumpRule := []string{"-j", chainNameRaw} - if err := m.ipv4Client.DeleteIfExists(tableRaw, chainOUTPUT, jumpRule...); err != nil { + if err := m.ipv4Client.DeleteIfExists(tableRaw, chainOutput, jumpRule...); err != nil { return fmt.Errorf("remove output jump rule: %w", err) } - if err := m.ipv4Client.DeleteIfExists(tableRaw, chainPREROUTING, jumpRule...); err != nil { + if err := m.ipv4Client.DeleteIfExists(tableRaw, chainPrerouting, jumpRule...); err != nil { return fmt.Errorf("remove prerouting jump rule: %w", err) } @@ -447,3 +571,13 @@ func (m *Manager) cleanupNoTrackChain() error { func getConntrackEstablished() []string { return []string{"-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "ACCEPT"} } + +// isIPv6Rule reports whether the rule belongs to the IPv6 family, from +// the destination prefix when set, otherwise from the (single-family) +// sources. +func isIPv6Rule(sources []netip.Prefix, destination firewall.Network) bool { + if destination.IsPrefix() { + return destination.Prefix.Addr().Is6() + } + return len(sources) > 0 && sources[0].Addr().Is6() +} diff --git a/client/firewall/iptables/manager_linux_test.go b/client/firewall/iptables/manager_linux_test.go index cc4bda0e0..9f53352e1 100644 --- a/client/firewall/iptables/manager_linux_test.go +++ b/client/firewall/iptables/manager_linux_test.go @@ -1,18 +1,23 @@ +//go:build privileged + package iptables import ( "fmt" "net/netip" + "slices" "strings" "testing" "time" "github.com/coreos/go-iptables/iptables" + "github.com/lrh3321/ipset-go" "github.com/stretchr/testify/require" fw "github.com/netbirdio/netbird/client/firewall/manager" "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/shared/management/domain" ) var ifaceMock = &iFaceMock{ @@ -65,47 +70,37 @@ func TestIptablesManager(t *testing.T) { time.Sleep(time.Second) }() - var rule2 []fw.Rule + var rule2 fw.Rule t.Run("add second rule", func(t *testing.T) { ip := netip.MustParseAddr("10.20.0.3") port := &fw.Port{ IsRange: true, Values: []uint16{8043, 8046}, } - rule2, err = manager.AddPeerFiltering(nil, ip.AsSlice(), "tcp", port, nil, fw.ActionAccept, "") + rule2, err = manager.AddFilterRule(nil, pfx(ip.AsSlice()), fw.Network{}, "tcp", port, nil, fw.ActionAccept) require.NoError(t, err, "failed to add rule") - for _, r := range rule2 { - rr := r.(*Rule) - checkRuleSpecs(t, ipv4Client, rr.chain, true, rr.specs...) - } + rr := rule2.(*Rule) + checkRuleSpecs(t, ipv4Client, rr.chain, true, rr.specs...) }) t.Run("delete second rule", func(t *testing.T) { - for _, r := range rule2 { - err := manager.DeletePeerRule(r) - require.NoError(t, err, "failed to delete rule") - } - - require.Empty(t, manager.aclMgr.ipsetStore.ipsets, "rulesets index after removed second rule must be empty") + require.NoError(t, manager.DeleteFilterRule(rule2), "failed to delete rule") }) t.Run("reset check", func(t *testing.T) { // add second rule ip := netip.MustParseAddr("10.20.0.3") port := &fw.Port{Values: []uint16{5353}} - _, err = manager.AddPeerFiltering(nil, ip.AsSlice(), "udp", nil, port, fw.ActionAccept, "") + _, err = manager.AddFilterRule(nil, pfx(ip.AsSlice()), fw.Network{}, "udp", nil, port, fw.ActionAccept) require.NoError(t, err, "failed to add rule") err = manager.Close(nil) require.NoError(t, err, "failed to reset") - ok, err := ipv4Client.ChainExists("filter", chainNameInputRules) + ok, err := ipv4Client.ChainExists("filter", chainACLInput) require.NoError(t, err, "failed check chain exists") - - if ok { - require.NoErrorf(t, err, "chain '%v' still exists after Close", chainNameInputRules) - } + require.Falsef(t, ok, "chain %q still exists after Close", chainACLInput) }) } @@ -126,15 +121,13 @@ func TestIptablesManagerDenyRules(t *testing.T) { ip := netip.MustParseAddr("10.20.0.3") port := &fw.Port{Values: []uint16{22}} - rule, err := manager.AddPeerFiltering(nil, ip.AsSlice(), "tcp", nil, port, fw.ActionDrop, "deny-ssh") + rule, err := manager.AddFilterRule(nil, pfx(ip.AsSlice()), fw.Network{}, "tcp", nil, port, fw.ActionDrop) require.NoError(t, err, "failed to add deny rule") - require.NotEmpty(t, rule, "deny rule should not be empty") + require.NotNil(t, rule, "deny rule should not be nil") // Verify the rule was added by checking iptables - for _, r := range rule { - rr := r.(*Rule) - checkRuleSpecs(t, ipv4Client, rr.chain, true, rr.specs...) - } + rr := rule.(*Rule) + checkRuleSpecs(t, ipv4Client, rr.chain, true, rr.specs...) }) t.Run("deny rule precedence test", func(t *testing.T) { @@ -142,36 +135,40 @@ func TestIptablesManagerDenyRules(t *testing.T) { port := &fw.Port{Values: []uint16{80}} // Add accept rule first - _, err := manager.AddPeerFiltering(nil, ip.AsSlice(), "tcp", nil, port, fw.ActionAccept, "accept-http") + _, err := manager.AddFilterRule(nil, pfx(ip.AsSlice()), fw.Network{}, "tcp", nil, port, fw.ActionAccept) require.NoError(t, err, "failed to add accept rule") // Add deny rule second for same IP/port - this should take precedence - _, err = manager.AddPeerFiltering(nil, ip.AsSlice(), "tcp", nil, port, fw.ActionDrop, "deny-http") + _, err = manager.AddFilterRule(nil, pfx(ip.AsSlice()), fw.Network{}, "tcp", nil, port, fw.ActionDrop) require.NoError(t, err, "failed to add deny rule") // Inspect the actual iptables rules to verify deny rule comes before accept rule - rules, err := ipv4Client.List("filter", chainNameInputRules) + rules, err := ipv4Client.List("filter", chainACLInput) require.NoError(t, err, "failed to list iptables rules") // Debug: print all rules - t.Logf("All iptables rules in chain %s:", chainNameInputRules) + t.Logf("All iptables rules in chain %s:", chainACLInput) for i, rule := range rules { t.Logf(" [%d] %s", i, rule) } + // Single-source rules emit a direct `-s /32 ... --dport 80` + // match. Match on that shape instead of the legacy + // per-(action,port) ipset names ("deny-http"/"accept-http") + // that this test predates. + srcMatch := fmt.Sprintf("-s %s/32", ip) var denyRuleIndex, acceptRuleIndex = -1, -1 for i, rule := range rules { - if strings.Contains(rule, "DROP") { - t.Logf("Found DROP rule at index %d: %s", i, rule) - if strings.Contains(rule, "deny-http") && strings.Contains(rule, "80") { - denyRuleIndex = i - } + if !strings.Contains(rule, srcMatch) || !strings.Contains(rule, "--dport 80") { + continue } - if strings.Contains(rule, "ACCEPT") { + if strings.Contains(rule, "-j DROP") { + t.Logf("Found DROP rule at index %d: %s", i, rule) + denyRuleIndex = i + } + if strings.Contains(rule, "-j ACCEPT") { t.Logf("Found ACCEPT rule at index %d: %s", i, rule) - if strings.Contains(rule, "accept-http") && strings.Contains(rule, "80") { - acceptRuleIndex = i - } + acceptRuleIndex = i } } @@ -196,7 +193,6 @@ func TestIptablesManagerIPSet(t *testing.T) { }, } - // just check on the local interface manager, err := Create(mock, iface.DefaultMTU) require.NoError(t, err) require.NoError(t, manager.Init(nil)) @@ -210,27 +206,39 @@ func TestIptablesManagerIPSet(t *testing.T) { time.Sleep(time.Second) }() - var rule2 []fw.Rule - t.Run("add second rule", func(t *testing.T) { + var rule2 fw.Rule + t.Run("single source uses direct -s match (no ipset)", func(t *testing.T) { ip := netip.MustParseAddr("10.20.0.3") port := &fw.Port{ Values: []uint16{443}, } - rule2, err = manager.AddPeerFiltering(nil, ip.AsSlice(), "tcp", port, nil, fw.ActionAccept, "default") - for _, r := range rule2 { - require.NoError(t, err, "failed to add rule") - require.Equal(t, r.(*Rule).ipsetName, "default-sport", "ipset name must be set") - require.Equal(t, r.(*Rule).ip, "10.20.0.3", "ipset IP must be set") - } + rule2, err = manager.AddFilterRule(nil, pfx(ip.AsSlice()), fw.Network{}, "tcp", port, nil, fw.ActionAccept) + require.NoError(t, err, "failed to add rule") + require.NotNil(t, rule2) + require.Contains(t, rule2.(*Rule).specs, "-s", + "single-source rule should use direct -s match, not an ipset") + require.Empty(t, findSets(rule2.(*Rule).specs), + "single-source rule should not allocate a shared ipset") }) - t.Run("delete second rule", func(t *testing.T) { - for _, r := range rule2 { - err := manager.DeletePeerRule(r) - require.NoError(t, err, "failed to delete rule") + t.Run("delete single-source rule", func(t *testing.T) { + require.NoError(t, manager.DeleteFilterRule(rule2), "failed to delete rule") + }) - require.Empty(t, manager.aclMgr.ipsetStore.ipsets, "rulesets index after removed second rule must be empty") + t.Run("multi-source uses shared ipset", func(t *testing.T) { + sources := []netip.Prefix{ + netip.PrefixFrom(netip.MustParseAddr("10.20.0.3"), 32), + netip.PrefixFrom(netip.MustParseAddr("10.20.0.4"), 32), + netip.PrefixFrom(netip.MustParseAddr("10.20.0.5"), 32), } + port := &fw.Port{Values: []uint16{8080}} + multi, err := manager.AddFilterRule(nil, sources, fw.Network{}, "tcp", nil, port, fw.ActionAccept) + require.NoError(t, err, "failed to add multi-source rule") + require.NotNil(t, multi, "multi-source rule must produce one iptables rule") + sets := findSets(multi.(*Rule).specs) + require.Len(t, sets, 1, "multi-source rule must reference exactly one ipset") + + require.NoError(t, manager.DeleteFilterRule(multi)) }) t.Run("reset check", func(t *testing.T) { @@ -239,9 +247,324 @@ func TestIptablesManagerIPSet(t *testing.T) { }) } +// TestIptablesFilterIPSetFallback verifies that when the kernel lacks +// ipset support, a multi-source rule falls back to one iptables rule +// per source prefix instead of silently leaving the chain empty. See +// discussion #6125. +func TestIptablesFilterIPSetFallback(t *testing.T) { + ipv4Client, err := iptables.NewWithProtocol(iptables.ProtocolIPv4) + require.NoError(t, err) + + manager, err := Create(ifaceMock, iface.DefaultMTU) + require.NoError(t, err) + require.NoError(t, manager.Init(nil)) + + defer func() { + require.NoError(t, manager.Close(nil)) + }() + + // Simulate a kernel without the ipset hash module. + manager.family4.ipsetSupported = false + + sources := []netip.Prefix{ + netip.MustParsePrefix("10.20.0.42/32"), + netip.MustParsePrefix("10.20.0.43/32"), + } + port := &fw.Port{Values: []uint16{22}} + + rule, err := manager.AddFilterRule(nil, sources, fw.Network{}, "tcp", nil, port, fw.ActionAccept) + require.NoError(t, err, "AddFilterRule should succeed via fallback") + + rr := rule.(*Rule) + all := rr.allSpecs() + require.Len(t, all, len(sources), "each source prefix needs its own rule") + for i, fs := range all { + joined := strings.Join(fs.specs, " ") + require.Contains(t, joined, "-s "+sources[i].String(), "fallback rule must match by source prefix") + require.NotContains(t, joined, matchSet, "fallback rule must not use ipset matching") + + // The rule must actually be present in the ACL chain (not silently dropped). + checkRuleSpecs(t, ipv4Client, rr.chain, true, fs.specs...) + + // Every expanded peer rule keeps its own redirect-mark pairing. + require.NotNil(t, fs.mangleSpecs, "peer rule must carry a mangle pairing") + checkTableRuleSpecs(t, ipv4Client, tableMangle, chainRTPre, true, fs.mangleSpecs...) + } + + require.NoError(t, manager.DeleteFilterRule(rule), "failed to delete fallback rule") + for _, fs := range all { + checkRuleSpecs(t, ipv4Client, rr.chain, false, fs.specs...) + checkTableRuleSpecs(t, ipv4Client, tableMangle, chainRTPre, false, fs.mangleSpecs...) + } +} + +// TestIptablesFilterDestinationSetRequiresIPSet documents that a dynamic +// (domain) destination cannot be expressed without ipset: its prefixes are only +// known after DNS resolution, so there is nothing to expand into per-prefix +// rules. The call must report that rather than install a broader rule than the +// policy allows. +func TestIptablesFilterDestinationSetRequiresIPSet(t *testing.T) { + manager, err := Create(ifaceMock, iface.DefaultMTU) + require.NoError(t, err) + require.NoError(t, manager.Init(nil)) + + defer func() { + require.NoError(t, manager.Close(nil)) + }() + + manager.family4.ipsetSupported = false + + destination := fw.Network{Set: fw.NewDomainSet(domain.List{"example.com"})} + + _, err = manager.AddFilterRule(nil, []netip.Prefix{netip.MustParsePrefix("172.16.0.0/16")}, + destination, fw.ProtocolALL, nil, nil, fw.ActionAccept) + require.Error(t, err, "a domain destination is not expressible without ipset") + require.ErrorContains(t, err, "requires ipset") +} + +// TestIptablesNatRuleDropsSourceSetOnDestinationFailure covers a marking rule +// whose source set is created but whose destination set is not: the source +// reference has to go back, or the set it created stays in the kernel with a +// count nothing will ever drop. +func TestIptablesNatRuleDropsSourceSetOnDestinationFailure(t *testing.T) { + manager, err := Create(ifaceMock, iface.DefaultMTU) + require.NoError(t, err) + require.NoError(t, manager.Init(nil)) + + defer func() { + require.NoError(t, manager.Close(nil)) + }() + + sourceSet := fw.NewPrefixSet([]netip.Prefix{ + netip.MustParsePrefix("100.0.0.0/16"), + netip.MustParsePrefix("10.10.0.0/16"), + }) + destSet := fw.NewDomainSet(domain.List{"example.org"}) + + // Poison the destination set's name so its hash:net creation fails after + // the source set has already been created. + poisoned := manager.family4.ipsetName(destSet.HashedName()) + require.NoError(t, ipset.Create(poisoned, ipset.TypeHashIP, ipset.CreateOptions{})) + t.Cleanup(func() { + if err := ipset.Destroy(poisoned); err != nil { + t.Logf("destroy poisoned set %s: %v", poisoned, err) + } + }) + + pair := fw.RouterPair{ + ID: "nat-source-set-test", + Source: fw.Network{Set: sourceSet}, + Destination: fw.Network{Set: destSet}, + Masquerade: true, + Dynamic: true, + } + + require.Error(t, manager.AddNatRule(pair), "the destination set must fail to be created") + + _, ok := manager.family4.ipsetCounter.Get(manager.family4.ipsetName(sourceSet.HashedName())) + require.False(t, ok, "the source set reference must be released") +} + +// TestIptablesNatRuleReAddKeepsSetReferences re-adds the same NAT rule the way +// a repeated network-map update does. The marking rule's set references must not +// grow, or RemoveNatRule can never drop the count to zero and the set stays in +// the kernel for the rest of the process lifetime. +func TestIptablesNatRuleReAddKeepsSetReferences(t *testing.T) { + manager, err := Create(ifaceMock, iface.DefaultMTU) + require.NoError(t, err) + require.NoError(t, manager.Init(nil)) + + defer func() { + require.NoError(t, manager.Close(nil)) + }() + + set := fw.NewDomainSet(domain.List{"example.com"}) + pair := fw.RouterPair{ + ID: "nat-reference-test", + Source: fw.Network{Prefix: netip.MustParsePrefix("100.0.0.0/16")}, + Destination: fw.Network{Set: set}, + Masquerade: true, + Dynamic: true, + } + + require.NoError(t, manager.AddNatRule(pair), "add nat rule") + name := manager.family4.ipsetName(set.HashedName()) + first, ok := manager.family4.ipsetCounter.Get(name) + require.True(t, ok, "the marking rule must hold a reference to its set") + + require.NoError(t, manager.AddNatRule(pair), "re-add nat rule") + second, ok := manager.family4.ipsetCounter.Get(name) + require.True(t, ok, "the set must still be referenced") + require.Equal(t, first.Count, second.Count, "re-adding the same rule must not add references") + + require.NoError(t, manager.RemoveNatRule(pair), "remove nat rule") + _, ok = manager.family4.ipsetCounter.Get(name) + require.False(t, ok, "removing the rule must drop the last reference") +} + +// TestIptablesRouteFilterIPSetFallback covers the route ACL side of the +// fallback: with a destination set, the expanded per-source rules land +// in the route forward chain and are all removed on delete. +func TestIptablesRouteFilterIPSetFallback(t *testing.T) { + ipv4Client, err := iptables.NewWithProtocol(iptables.ProtocolIPv4) + require.NoError(t, err) + + manager, err := Create(ifaceMock, iface.DefaultMTU) + require.NoError(t, err) + require.NoError(t, manager.Init(nil)) + + defer func() { + require.NoError(t, manager.Close(nil)) + }() + + manager.family4.ipsetSupported = false + + sources := []netip.Prefix{ + netip.MustParsePrefix("172.16.0.0/16"), + netip.MustParsePrefix("192.168.0.0/16"), + } + destination := fw.Network{Prefix: netip.MustParsePrefix("10.0.0.0/8")} + port := &fw.Port{Values: []uint16{443}} + + rule, err := manager.AddFilterRule(nil, sources, destination, "tcp", nil, port, fw.ActionAccept) + require.NoError(t, err, "route ACL must install without ipset") + + rr := rule.(*Rule) + require.Equal(t, chainRTFwdIn, rr.chain, "route rule must land in the forward chain") + + all := rr.allSpecs() + require.Len(t, all, len(sources), "each source prefix needs its own rule") + for i, fs := range all { + joined := strings.Join(fs.specs, " ") + require.Contains(t, joined, "-s "+sources[i].String(), "fallback rule must match by source prefix") + require.NotContains(t, joined, matchSet, "fallback rule must not use ipset matching") + require.Nil(t, fs.mangleSpecs, "route rules have no mangle pairing") + + checkRuleSpecs(t, ipv4Client, rr.chain, true, fs.specs...) + } + + require.NoError(t, manager.DeleteFilterRule(rule), "failed to delete fallback rule") + for _, fs := range all { + checkRuleSpecs(t, ipv4Client, rr.chain, false, fs.specs...) + } +} + +// TestIptablesCloseRemovesAllState exercises a spread of rule kinds and then +// asserts Close puts every table it touches back exactly as it found it. A +// leaked chain, jump, or ipset survives the daemon and nothing can remove it +// afterwards, since the tracking that knew about it is gone. +func TestIptablesCloseRemovesAllState(t *testing.T) { + ipv4Client, err := iptables.NewWithProtocol(iptables.ProtocolIPv4) + require.NoError(t, err) + + before := snapshotIptables(t, ipv4Client) + + manager, err := Create(ifaceMock, iface.DefaultMTU) + require.NoError(t, err) + require.NoError(t, manager.Init(nil)) + + // A failed assertion below returns before the Close under test, which would + // leave this test's chains and sets in the kernel for the next one. + t.Cleanup(func() { + if err := manager.Close(nil); err != nil { + t.Logf("close after failure: %v", err) + } + }) + + sources := []netip.Prefix{ + netip.MustParsePrefix("10.20.0.42/32"), + netip.MustParsePrefix("10.20.0.43/32"), + } + + // A multi-source peer rule: shared ipset plus the mangle redirect pairing. + _, err = manager.AddFilterRule(nil, sources, fw.Network{}, "tcp", + nil, &fw.Port{Values: []uint16{22}}, fw.ActionAccept) + require.NoError(t, err, "add peer rule") + + // A route rule with a dynamic destination: a second set, in the forward chain. + _, err = manager.AddFilterRule(nil, sources, + fw.Network{Set: fw.NewDomainSet(domain.List{"example.com"})}, + fw.ProtocolALL, nil, nil, fw.ActionDrop) + require.NoError(t, err, "add route rule") + + // NAT marking for a routed destination, both directions. + pair := fw.RouterPair{ + ID: "cleanup-test", + Source: fw.Network{Prefix: netip.MustParsePrefix("100.0.0.0/16")}, + Destination: fw.Network{Prefix: netip.MustParsePrefix("192.168.55.0/24")}, + Masquerade: true, + } + require.NoError(t, manager.AddNatRule(pair), "add nat rule") + require.NoError(t, manager.EnableRouting(), "enable routing") + + // A DNAT redirect, which also holds a forwarding reference. + dnat := fw.ForwardRule{ + Protocol: fw.ProtocolTCP, + DestinationPort: fw.Port{Values: []uint16{8080}}, + TranslatedAddress: netip.MustParseAddr("10.20.0.44"), + TranslatedPort: fw.Port{Values: []uint16{80}}, + } + _, err = manager.AddDNATRule(dnat) + require.NoError(t, err, "add dnat rule") + + require.NotEqual(t, before, snapshotIptables(t, ipv4Client), "the manager must have installed state") + + // Everything above stays in place, so Close is what has to remove it. + require.NoError(t, manager.Close(nil), "close") + + after := snapshotIptables(t, ipv4Client) + require.Equal(t, before.chains, after.chains, "Close must remove every chain it created") + require.Equal(t, before.rules, after.rules, "Close must remove every rule it created") + require.Equal(t, before.sets, after.sets, "Close must destroy every ipset it created") +} + +// iptablesState is a snapshot of the tables the manager writes to, used to +// compare the kernel before and after a manager lifetime. +type iptablesState struct { + chains map[string][]string + rules map[string][]string + sets []string +} + +func snapshotIptables(t *testing.T, client *iptables.IPTables) iptablesState { + t.Helper() + + state := iptablesState{ + chains: map[string][]string{}, + rules: map[string][]string{}, + } + + for _, table := range []string{tableFilter, tableNat, tableMangle, tableRaw} { + chains, err := client.ListChains(table) + require.NoErrorf(t, err, "list chains in %s", table) + slices.Sort(chains) + state.chains[table] = chains + + for _, chain := range chains { + rules, err := client.List(table, chain) + require.NoErrorf(t, err, "list rules in %s/%s", table, chain) + state.rules[table+"/"+chain] = rules + } + } + + sets, err := ipset.ListAll() + require.NoError(t, err, "list ipsets") + for _, set := range sets { + state.sets = append(state.sets, set.SetName) + } + slices.Sort(state.sets) + + return state +} + func checkRuleSpecs(t *testing.T, ipv4Client *iptables.IPTables, chainName string, mustExists bool, rulespec ...string) { t.Helper() - exists, err := ipv4Client.Exists("filter", chainName, rulespec...) + checkTableRuleSpecs(t, ipv4Client, tableFilter, chainName, mustExists, rulespec...) +} + +func checkTableRuleSpecs(t *testing.T, ipv4Client *iptables.IPTables, table, chainName string, mustExists bool, rulespec ...string) { + t.Helper() + exists, err := ipv4Client.Exists(table, chainName, rulespec...) require.NoError(t, err, "failed to check rule") require.Falsef(t, !exists && mustExists, "rule '%v' does not exist", rulespec) require.Falsef(t, exists && !mustExists, "rule '%v' exist", rulespec) @@ -281,7 +604,7 @@ func TestIptablesCreatePerformance(t *testing.T) { start := time.Now() for i := 0; i < testMax; i++ { port := &fw.Port{Values: []uint16{uint16(1000 + i)}} - _, err = manager.AddPeerFiltering(nil, ip.AsSlice(), "tcp", nil, port, fw.ActionAccept, "") + _, err = manager.AddFilterRule(nil, pfx(ip.AsSlice()), fw.Network{}, "tcp", nil, port, fw.ActionAccept) require.NoError(t, err, "failed to add rule") } diff --git a/client/firewall/iptables/router_linux.go b/client/firewall/iptables/router_linux.go deleted file mode 100644 index a7c4f67dd..000000000 --- a/client/firewall/iptables/router_linux.go +++ /dev/null @@ -1,1111 +0,0 @@ -//go:build !android - -package iptables - -import ( - "fmt" - "net/netip" - "strconv" - "strings" - - "github.com/coreos/go-iptables/iptables" - "github.com/hashicorp/go-multierror" - ipset "github.com/lrh3321/ipset-go" - log "github.com/sirupsen/logrus" - - nberrors "github.com/netbirdio/netbird/client/errors" - firewall "github.com/netbirdio/netbird/client/firewall/manager" - nbid "github.com/netbirdio/netbird/client/internal/acl/id" - "github.com/netbirdio/netbird/client/internal/routemanager/ipfwdstate" - "github.com/netbirdio/netbird/client/internal/routemanager/refcounter" - "github.com/netbirdio/netbird/client/internal/statemanager" - nbnet "github.com/netbirdio/netbird/client/net" -) - -// constants needed to manage and create iptable rules -const ( - tableFilter = "filter" - tableNat = "nat" - tableMangle = "mangle" - - chainPOSTROUTING = "POSTROUTING" - chainPREROUTING = "PREROUTING" - chainFORWARD = "FORWARD" - chainRTNAT = "NETBIRD-RT-NAT" - chainRTFWDIN = "NETBIRD-RT-FWD-IN" - chainRTFWDOUT = "NETBIRD-RT-FWD-OUT" - chainRTPRE = "NETBIRD-RT-PRE" - chainRTRDR = "NETBIRD-RT-RDR" - chainNATOutput = "NETBIRD-NAT-OUTPUT" - chainRTMSSCLAMP = "NETBIRD-RT-MSSCLAMP" - routingFinalForwardJump = "ACCEPT" - routingFinalNatJump = "MASQUERADE" - - jumpManglePre = "jump-mangle-pre" - jumpNatPre = "jump-nat-pre" - jumpNatPost = "jump-nat-post" - jumpNatOutput = "jump-nat-output" - jumpMSSClamp = "jump-mss-clamp" - markManglePre = "mark-mangle-pre" - markManglePost = "mark-mangle-post" - matchSet = "--match-set" - - dnatSuffix = "_dnat" - snatSuffix = "_snat" - fwdSuffix = "_fwd" - - // ipTCPHeaderMinSize represents minimum IP (20) + TCP (20) header size for MSS calculation - ipTCPHeaderMinSize = 40 -) - -type ruleInfo struct { - chain string - table string - rule []string -} - -type routeFilteringRuleParams struct { - Source firewall.Network - Destination firewall.Network - Proto firewall.Protocol - SPort *firewall.Port - DPort *firewall.Port - Direction firewall.RuleDirection - Action firewall.Action -} - -type routeRules map[string][]string - -// the ipset library currently does not support comments, so we use the name only (string) -type ipsetCounter = refcounter.Counter[string, []netip.Prefix, struct{}] - -type router struct { - iptablesClient *iptables.IPTables - rules routeRules - ipsetCounter *ipsetCounter - wgIface iFaceMapper - legacyManagement bool - mtu uint16 - - stateManager *statemanager.Manager - ipFwdState *ipfwdstate.IPForwardingState -} - -func newRouter(iptablesClient *iptables.IPTables, wgIface iFaceMapper, mtu uint16) (*router, error) { - r := &router{ - iptablesClient: iptablesClient, - rules: make(map[string][]string), - wgIface: wgIface, - mtu: mtu, - ipFwdState: ipfwdstate.NewIPForwardingState(), - } - - r.ipsetCounter = refcounter.New( - func(name string, sources []netip.Prefix) (struct{}, error) { - return struct{}{}, r.createIpSet(name, sources) - }, - func(name string, _ struct{}) error { - return r.deleteIpSet(name) - }, - ) - - return r, nil -} - -func (r *router) init(stateManager *statemanager.Manager) error { - r.stateManager = stateManager - - if err := r.cleanUpDefaultForwardRules(); err != nil { - log.Errorf("failed to clean up rules from FORWARD chain: %s", err) - } - - if err := r.createContainers(); err != nil { - return fmt.Errorf("create containers: %w", err) - } - - if err := r.setupDataPlaneMark(); err != nil { - log.Errorf("failed to set up data plane mark: %v", err) - } - - r.updateState() - - return nil -} - -func (r *router) AddRouteFiltering( - id []byte, - sources []netip.Prefix, - destination firewall.Network, - proto firewall.Protocol, - sPort *firewall.Port, - dPort *firewall.Port, - action firewall.Action, -) (firewall.Rule, error) { - ruleKey := nbid.GenerateRouteRuleKey(sources, destination, proto, sPort, dPort, action) - if _, ok := r.rules[string(ruleKey)]; ok { - return ruleKey, nil - } - - var source firewall.Network - if len(sources) > 1 { - source.Set = firewall.NewPrefixSet(sources) - } else if len(sources) > 0 { - source.Prefix = sources[0] - } - - params := routeFilteringRuleParams{ - Source: source, - Destination: destination, - Proto: proto, - SPort: sPort, - DPort: dPort, - Action: action, - } - - rule, err := r.genRouteRuleSpec(params, sources) - if err != nil { - return nil, fmt.Errorf("generate route rule spec: %w", err) - } - - // Insert DROP rules at the beginning, append ACCEPT rules at the end - if action == firewall.ActionDrop { - // after the established rule - err = r.iptablesClient.Insert(tableFilter, chainRTFWDIN, 2, rule...) - } else { - err = r.iptablesClient.Append(tableFilter, chainRTFWDIN, rule...) - } - - if err != nil { - return nil, fmt.Errorf("add route rule: %v", err) - } - - r.rules[string(ruleKey)] = rule - - r.updateState() - - return ruleKey, nil -} - -func (r *router) DeleteRouteRule(rule firewall.Rule) error { - ruleKey := rule.ID() - - if rule, exists := r.rules[ruleKey]; exists { - if err := r.iptablesClient.Delete(tableFilter, chainRTFWDIN, rule...); err != nil { - return fmt.Errorf("delete route rule: %v", err) - } - delete(r.rules, ruleKey) - - if err := r.decrementSetCounter(rule); err != nil { - return fmt.Errorf("decrement ipset counter: %w", err) - } - } else { - log.Debugf("route rule %s not found", ruleKey) - } - - r.updateState() - - return nil -} - -func (r *router) decrementSetCounter(rule []string) error { - sets := r.findSets(rule) - var merr *multierror.Error - for _, setName := range sets { - if _, err := r.ipsetCounter.Decrement(setName); err != nil { - merr = multierror.Append(merr, fmt.Errorf("decrement counter: %w", err)) - } - } - - return nberrors.FormatErrorOrNil(merr) -} - -func (r *router) findSets(rule []string) []string { - var sets []string - for i, arg := range rule { - if arg == "-m" && i+3 < len(rule) && rule[i+1] == "set" && rule[i+2] == matchSet { - sets = append(sets, rule[i+3]) - } - } - return sets -} - -func (r *router) createIpSet(setName string, sources []netip.Prefix) error { - if err := r.createIPSet(setName); err != nil { - return fmt.Errorf("create set %s: %w", setName, err) - } - - for _, prefix := range sources { - if err := r.addPrefixToIPSet(setName, prefix); err != nil { - return fmt.Errorf("add element to set %s: %w", setName, err) - } - } - - return nil -} - -func (r *router) deleteIpSet(setName string) error { - if err := r.destroyIPSet(setName); err != nil { - return fmt.Errorf("destroy set %s: %w", setName, err) - } - - log.Debugf("Deleted unused ipset %s", setName) - return nil -} - -// AddNatRule inserts an iptables rule pair into the nat chain -func (r *router) AddNatRule(pair firewall.RouterPair) error { - if r.legacyManagement { - log.Warnf("This peer is connected to a NetBird Management service with an older version. Allowing all traffic for %s", pair.Destination) - if err := r.addLegacyRouteRule(pair); err != nil { - return fmt.Errorf("add legacy routing rule: %w", err) - } - } - - if !pair.Masquerade { - return nil - } - - if err := r.addNatRule(pair); err != nil { - return fmt.Errorf("add nat rule: %w", err) - } - - if err := r.addNatRule(firewall.GetInversePair(pair)); err != nil { - return fmt.Errorf("add inverse nat rule: %w", err) - } - - r.updateState() - - return nil -} - -// RemoveNatRule removes an iptables rule pair from forwarding and nat chains -func (r *router) RemoveNatRule(pair firewall.RouterPair) error { - if pair.Masquerade { - if err := r.removeNatRule(pair); err != nil { - return fmt.Errorf("remove nat rule: %w", err) - } - - if err := r.removeNatRule(firewall.GetInversePair(pair)); err != nil { - return fmt.Errorf("remove inverse nat rule: %w", err) - } - } - - if err := r.removeLegacyRouteRule(pair); err != nil { - return fmt.Errorf("remove legacy routing rule: %w", err) - } - - r.updateState() - - return nil -} - -// addLegacyRouteRule adds a legacy routing rule for mgmt servers pre route acls -func (r *router) addLegacyRouteRule(pair firewall.RouterPair) error { - ruleKey := firewall.GenKey(firewall.ForwardingFormat, pair) - - if err := r.removeLegacyRouteRule(pair); err != nil { - return err - } - - rule := []string{"-s", pair.Source.String(), "-d", pair.Destination.String(), "-j", routingFinalForwardJump} - if err := r.iptablesClient.Append(tableFilter, chainRTFWDIN, rule...); err != nil { - return fmt.Errorf("add legacy forwarding rule %s -> %s: %v", pair.Source, pair.Destination, err) - } - - r.rules[ruleKey] = rule - - return nil -} - -func (r *router) removeLegacyRouteRule(pair firewall.RouterPair) error { - ruleKey := firewall.GenKey(firewall.ForwardingFormat, pair) - - if rule, exists := r.rules[ruleKey]; exists { - if err := r.iptablesClient.DeleteIfExists(tableFilter, chainRTFWDIN, rule...); err != nil { - return fmt.Errorf("remove legacy forwarding rule %s -> %s: %v", pair.Source, pair.Destination, err) - } - delete(r.rules, ruleKey) - - if err := r.decrementSetCounter(rule); err != nil { - return fmt.Errorf("decrement ipset counter: %w", err) - } - } - - return nil -} - -// GetLegacyManagement returns the current legacy management mode -func (r *router) GetLegacyManagement() bool { - return r.legacyManagement -} - -// SetLegacyManagement sets the route manager to use legacy management mode -func (r *router) SetLegacyManagement(isLegacy bool) { - r.legacyManagement = isLegacy -} - -// RemoveAllLegacyRouteRules removes all legacy routing rules for mgmt servers pre route acls -func (r *router) RemoveAllLegacyRouteRules() error { - var merr *multierror.Error - for k, rule := range r.rules { - if !strings.HasPrefix(k, firewall.ForwardingFormatPrefix) { - continue - } - if err := r.iptablesClient.DeleteIfExists(tableFilter, chainRTFWDIN, rule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove legacy forwarding rule: %v", err)) - } else { - delete(r.rules, k) - } - } - - r.updateState() - - return nberrors.FormatErrorOrNil(merr) -} - -func (r *router) Reset() error { - var merr *multierror.Error - if err := r.cleanUpDefaultForwardRules(); err != nil { - merr = multierror.Append(merr, err) - } - - if err := r.ipsetCounter.Flush(); err != nil { - merr = multierror.Append(merr, err) - } - - if err := r.cleanupDataPlaneMark(); err != nil { - merr = multierror.Append(merr, err) - } - - r.rules = make(map[string][]string) - r.updateState() - - return nberrors.FormatErrorOrNil(merr) -} - -func (r *router) cleanUpDefaultForwardRules() error { - if err := r.cleanJumpRules(); err != nil { - return fmt.Errorf("clean jump rules: %w", err) - } - - log.Debug("flushing routing related tables") - - // Remove jump rules from built-in chains before deleting custom chains, - // otherwise the chain deletion fails with "device or resource busy". - jumpRule := []string{"-j", chainNATOutput} - if err := r.iptablesClient.Delete(tableNat, "OUTPUT", jumpRule...); err != nil { - log.Debugf("clean OUTPUT jump rule: %v", err) - } - - for _, chainInfo := range []struct { - chain string - table string - }{ - {chainRTFWDIN, tableFilter}, - {chainRTFWDOUT, tableFilter}, - {chainRTPRE, tableMangle}, - {chainRTNAT, tableNat}, - {chainRTRDR, tableNat}, - {chainNATOutput, tableNat}, - {chainRTMSSCLAMP, tableMangle}, - } { - ok, err := r.iptablesClient.ChainExists(chainInfo.table, chainInfo.chain) - if err != nil { - return fmt.Errorf("check chain %s in table %s: %w", chainInfo.chain, chainInfo.table, err) - } else if ok { - if err = r.iptablesClient.ClearAndDeleteChain(chainInfo.table, chainInfo.chain); err != nil { - return fmt.Errorf("clear and delete chain %s in table %s: %w", chainInfo.chain, chainInfo.table, err) - } - } - } - - return nil -} - -func (r *router) createContainers() error { - for _, chainInfo := range []struct { - chain string - table string - }{ - {chainRTFWDIN, tableFilter}, - {chainRTFWDOUT, tableFilter}, - {chainRTPRE, tableMangle}, - {chainRTNAT, tableNat}, - {chainRTRDR, tableNat}, - {chainRTMSSCLAMP, tableMangle}, - } { - if err := r.iptablesClient.NewChain(chainInfo.table, chainInfo.chain); err != nil { - return fmt.Errorf("create chain %s in table %s: %w", chainInfo.chain, chainInfo.table, err) - } - } - - if err := r.insertEstablishedRule(chainRTFWDIN); err != nil { - return fmt.Errorf("insert established rule: %w", err) - } - - if err := r.insertEstablishedRule(chainRTFWDOUT); err != nil { - return fmt.Errorf("insert established rule: %w", err) - } - - if err := r.addPostroutingRules(); err != nil { - return fmt.Errorf("add static nat rules: %w", err) - } - - if err := r.addJumpRules(); err != nil { - return fmt.Errorf("add jump rules: %w", err) - } - - if err := r.addMSSClampingRules(); err != nil { - log.Errorf("failed to add MSS clamping rules: %s", err) - } - - return nil -} - -// setupDataPlaneMark configures the fwmark for the data plane -func (r *router) setupDataPlaneMark() error { - var merr *multierror.Error - preRule := []string{ - "-i", r.wgIface.Name(), - "-m", "conntrack", "--ctstate", "NEW", - "-j", "CONNMARK", "--set-mark", fmt.Sprintf("%#x", nbnet.DataPlaneMarkIn), - } - - if err := r.iptablesClient.AppendUnique(tableMangle, chainPREROUTING, preRule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("add mangle prerouting rule: %w", err)) - } else { - r.rules[markManglePre] = preRule - } - - postRule := []string{ - "-o", r.wgIface.Name(), - "-m", "conntrack", "--ctstate", "NEW", - "-j", "CONNMARK", "--set-mark", fmt.Sprintf("%#x", nbnet.DataPlaneMarkOut), - } - - if err := r.iptablesClient.AppendUnique(tableMangle, chainPOSTROUTING, postRule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("add mangle postrouting rule: %w", err)) - } else { - r.rules[markManglePost] = postRule - } - - return nberrors.FormatErrorOrNil(merr) -} - -func (r *router) cleanupDataPlaneMark() error { - var merr *multierror.Error - if preRule, exists := r.rules[markManglePre]; exists { - if err := r.iptablesClient.DeleteIfExists(tableMangle, chainPREROUTING, preRule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove mangle prerouting rule: %w", err)) - } else { - delete(r.rules, markManglePre) - } - } - - if postRule, exists := r.rules[markManglePost]; exists { - if err := r.iptablesClient.DeleteIfExists(tableMangle, chainPOSTROUTING, postRule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove mangle postrouting rule: %w", err)) - } else { - delete(r.rules, markManglePost) - } - } - - return nberrors.FormatErrorOrNil(merr) -} - -func (r *router) addPostroutingRules() error { - // First rule for outbound masquerade - rule1 := []string{ - "-m", "mark", "--mark", fmt.Sprintf("%#x", nbnet.PreroutingFwmarkMasquerade), - "!", "-o", "lo", - "-j", routingFinalNatJump, - } - if err := r.iptablesClient.Append(tableNat, chainRTNAT, rule1...); err != nil { - return fmt.Errorf("add outbound masquerade rule: %v", err) - } - r.rules["static-nat-outbound"] = rule1 - - // Second rule for return traffic masquerade - rule2 := []string{ - "-m", "mark", "--mark", fmt.Sprintf("%#x", nbnet.PreroutingFwmarkMasqueradeReturn), - "-o", r.wgIface.Name(), - "-j", routingFinalNatJump, - } - if err := r.iptablesClient.Append(tableNat, chainRTNAT, rule2...); err != nil { - return fmt.Errorf("add return masquerade rule: %v", err) - } - r.rules["static-nat-return"] = rule2 - - return nil -} - -// addMSSClampingRules adds MSS clamping rules to prevent fragmentation for forwarded traffic. -// TODO: Add IPv6 support -func (r *router) addMSSClampingRules() error { - mss := r.mtu - ipTCPHeaderMinSize - - // Add jump rule from FORWARD chain in mangle table to our custom chain - jumpRule := []string{ - "-j", chainRTMSSCLAMP, - } - if err := r.iptablesClient.Insert(tableMangle, chainFORWARD, 1, jumpRule...); err != nil { - return fmt.Errorf("add jump to MSS clamp chain: %w", err) - } - r.rules[jumpMSSClamp] = jumpRule - - ruleOut := []string{ - "-o", r.wgIface.Name(), - "-p", "tcp", - "--tcp-flags", "SYN,RST", "SYN", - "-j", "TCPMSS", - "--set-mss", fmt.Sprintf("%d", mss), - } - if err := r.iptablesClient.Append(tableMangle, chainRTMSSCLAMP, ruleOut...); err != nil { - return fmt.Errorf("add outbound MSS clamp rule: %w", err) - } - r.rules["mss-clamp-out"] = ruleOut - - return nil -} - -func (r *router) insertEstablishedRule(chain string) error { - establishedRule := getConntrackEstablished() - - err := r.iptablesClient.Insert(tableFilter, chain, 1, establishedRule...) - if err != nil { - return fmt.Errorf("failed to insert established rule: %v", err) - } - - ruleKey := "established-" + chain - r.rules[ruleKey] = establishedRule - - return nil -} - -func (r *router) addJumpRules() error { - // Jump to nat chain - natRule := []string{"-j", chainRTNAT} - if err := r.iptablesClient.Insert(tableNat, chainPOSTROUTING, 1, natRule...); err != nil { - return fmt.Errorf("add nat postrouting jump rule: %v", err) - } - r.rules[jumpNatPost] = natRule - - // Jump to mangle prerouting chain - preRule := []string{"-j", chainRTPRE} - if err := r.iptablesClient.Insert(tableMangle, chainPREROUTING, 1, preRule...); err != nil { - return fmt.Errorf("add mangle prerouting jump rule: %v", err) - } - r.rules[jumpManglePre] = preRule - - // Jump to nat prerouting chain - rdrRule := []string{"-j", chainRTRDR} - if err := r.iptablesClient.Insert(tableNat, chainPREROUTING, 1, rdrRule...); err != nil { - return fmt.Errorf("add nat prerouting jump rule: %v", err) - } - r.rules[jumpNatPre] = rdrRule - - return nil -} - -func (r *router) cleanJumpRules() error { - for _, ruleKey := range []string{jumpNatPost, jumpManglePre, jumpNatPre, jumpMSSClamp} { - if rule, exists := r.rules[ruleKey]; exists { - var table, chain string - switch ruleKey { - case jumpNatPost: - table = tableNat - chain = chainPOSTROUTING - case jumpManglePre: - table = tableMangle - chain = chainPREROUTING - case jumpNatPre: - table = tableNat - chain = chainPREROUTING - case jumpMSSClamp: - table = tableMangle - chain = chainFORWARD - default: - return fmt.Errorf("unknown jump rule: %s", ruleKey) - } - - if err := r.iptablesClient.DeleteIfExists(table, chain, rule...); err != nil { - return fmt.Errorf("delete rule from chain %s in table %s, err: %v", chain, table, err) - } - delete(r.rules, ruleKey) - } - } - return nil -} - -func (r *router) addNatRule(pair firewall.RouterPair) error { - ruleKey := firewall.GenKey(firewall.NatFormat, pair) - - if rule, exists := r.rules[ruleKey]; exists { - if err := r.iptablesClient.DeleteIfExists(tableMangle, chainRTPRE, rule...); err != nil { - return fmt.Errorf("error while removing existing marking rule for %s: %v", pair.Destination, err) - } - delete(r.rules, ruleKey) - } - - markValue := nbnet.PreroutingFwmarkMasquerade - if pair.Inverse { - markValue = nbnet.PreroutingFwmarkMasqueradeReturn - } - - rule := []string{"-i", r.wgIface.Name()} - if pair.Inverse { - rule = []string{"!", "-i", r.wgIface.Name()} - } - - rule = append(rule, - "-m", "conntrack", - "--ctstate", "NEW", - ) - sourceExp, err := r.applyNetwork("-s", pair.Source, nil) - if err != nil { - return fmt.Errorf("apply network -s: %w", err) - } - destExp, err := r.applyNetwork("-d", pair.Destination, nil) - if err != nil { - return fmt.Errorf("apply network -d: %w", err) - } - - rule = append(rule, sourceExp...) - rule = append(rule, destExp...) - rule = append(rule, - "-j", "MARK", "--set-mark", fmt.Sprintf("%#x", markValue), - ) - - // Ensure nat rules come first, so the mark can be overwritten. - // Currently overwritten by the dst-type LOCAL rules for redirected traffic. - if err := r.iptablesClient.Insert(tableMangle, chainRTPRE, 1, rule...); err != nil { - // TODO: rollback ipset counter - return fmt.Errorf("error while adding marking rule for %s: %v", pair.Destination, err) - } - - r.rules[ruleKey] = rule - - r.updateState() - return nil -} - -func (r *router) removeNatRule(pair firewall.RouterPair) error { - ruleKey := firewall.GenKey(firewall.NatFormat, pair) - - if rule, exists := r.rules[ruleKey]; exists { - if err := r.iptablesClient.DeleteIfExists(tableMangle, chainRTPRE, rule...); err != nil { - return fmt.Errorf("error while removing marking rule for %s: %v", pair.Destination, err) - } - delete(r.rules, ruleKey) - - if err := r.decrementSetCounter(rule); err != nil { - return fmt.Errorf("decrement ipset counter: %w", err) - } - } else { - log.Debugf("marking rule %s not found", ruleKey) - } - - r.updateState() - return nil -} - -func (r *router) updateState() { - if r.stateManager == nil { - return - } - - var currentState *ShutdownState - if existing := r.stateManager.GetState(currentState); existing != nil { - if existingState, ok := existing.(*ShutdownState); ok { - currentState = existingState - } - } - if currentState == nil { - currentState = &ShutdownState{} - } - - currentState.Lock() - defer currentState.Unlock() - - currentState.RouteRules = r.rules - currentState.RouteIPsetCounter = r.ipsetCounter - - if err := r.stateManager.UpdateState(currentState); err != nil { - log.Errorf("failed to update state: %v", err) - } -} - -func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) { - if err := r.ipFwdState.RequestForwarding(); err != nil { - return nil, err - } - - ruleKey := rule.ID() - if _, exists := r.rules[ruleKey+dnatSuffix]; exists { - return rule, nil - } - - toDestination := rule.TranslatedAddress.String() - switch { - case len(rule.TranslatedPort.Values) == 0: - // no translated port, use original port - case len(rule.TranslatedPort.Values) == 1: - toDestination += fmt.Sprintf(":%d", rule.TranslatedPort.Values[0]) - case rule.TranslatedPort.IsRange && len(rule.TranslatedPort.Values) == 2: - // need the "/originalport" suffix to avoid dnat port randomization - toDestination += fmt.Sprintf(":%d-%d/%d", rule.TranslatedPort.Values[0], rule.TranslatedPort.Values[1], rule.DestinationPort.Values[0]) - default: - return nil, fmt.Errorf("invalid translated port: %v", rule.TranslatedPort) - } - - proto := strings.ToLower(string(rule.Protocol)) - - rules := make(map[string]ruleInfo, 3) - - // DNAT rule - dnatRule := []string{ - "!", "-i", r.wgIface.Name(), - "-p", proto, - "-j", "DNAT", - "--to-destination", toDestination, - } - dnatRule = append(dnatRule, applyPort("--dport", &rule.DestinationPort)...) - rules[ruleKey+dnatSuffix] = ruleInfo{ - table: tableNat, - chain: chainRTRDR, - rule: dnatRule, - } - - // SNAT rule - snatRule := []string{ - "-o", r.wgIface.Name(), - "-p", proto, - "-d", rule.TranslatedAddress.String(), - "-j", "MASQUERADE", - } - snatRule = append(snatRule, applyPort("--dport", &rule.TranslatedPort)...) - rules[ruleKey+snatSuffix] = ruleInfo{ - table: tableNat, - chain: chainRTNAT, - rule: snatRule, - } - - // Forward filtering rule, if fwd policy is DROP - forwardRule := []string{ - "-o", r.wgIface.Name(), - "-p", proto, - "-d", rule.TranslatedAddress.String(), - "-j", "ACCEPT", - } - forwardRule = append(forwardRule, applyPort("--dport", &rule.TranslatedPort)...) - rules[ruleKey+fwdSuffix] = ruleInfo{ - table: tableFilter, - chain: chainRTFWDOUT, - rule: forwardRule, - } - - for key, ruleInfo := range rules { - if err := r.iptablesClient.Append(ruleInfo.table, ruleInfo.chain, ruleInfo.rule...); err != nil { - if rollbackErr := r.rollbackRules(rules); rollbackErr != nil { - log.Errorf("rollback failed: %v", rollbackErr) - } - return nil, fmt.Errorf("add rule %s: %w", key, err) - } - r.rules[key] = ruleInfo.rule - } - - r.updateState() - return rule, nil -} - -func (r *router) rollbackRules(rules map[string]ruleInfo) error { - var merr *multierror.Error - for key, ruleInfo := range rules { - if err := r.iptablesClient.DeleteIfExists(ruleInfo.table, ruleInfo.chain, ruleInfo.rule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("rollback rule %s: %w", key, err)) - // On rollback error, add to rules map for next cleanup - r.rules[key] = ruleInfo.rule - } - } - if merr != nil { - r.updateState() - } - return nberrors.FormatErrorOrNil(merr) -} - -func (r *router) DeleteDNATRule(rule firewall.Rule) error { - if err := r.ipFwdState.ReleaseForwarding(); err != nil { - log.Errorf("%v", err) - } - - ruleKey := rule.ID() - - var merr *multierror.Error - if dnatRule, exists := r.rules[ruleKey+dnatSuffix]; exists { - if err := r.iptablesClient.Delete(tableNat, chainRTRDR, dnatRule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("delete DNAT rule: %w", err)) - } - delete(r.rules, ruleKey+dnatSuffix) - } - - if snatRule, exists := r.rules[ruleKey+snatSuffix]; exists { - if err := r.iptablesClient.Delete(tableNat, chainRTNAT, snatRule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("delete SNAT rule: %w", err)) - } - delete(r.rules, ruleKey+snatSuffix) - } - - if fwdRule, exists := r.rules[ruleKey+fwdSuffix]; exists { - if err := r.iptablesClient.Delete(tableFilter, chainRTFWDIN, fwdRule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("delete forward rule: %w", err)) - } - delete(r.rules, ruleKey+fwdSuffix) - } - - r.updateState() - return nberrors.FormatErrorOrNil(merr) -} - -func (r *router) genRouteRuleSpec(params routeFilteringRuleParams, sources []netip.Prefix) ([]string, error) { - var rule []string - - sourceExp, err := r.applyNetwork("-s", params.Source, sources) - if err != nil { - return nil, fmt.Errorf("apply network -s: %w", err) - - } - destExp, err := r.applyNetwork("-d", params.Destination, nil) - if err != nil { - return nil, fmt.Errorf("apply network -d: %w", err) - } - - rule = append(rule, sourceExp...) - rule = append(rule, destExp...) - - if params.Proto != firewall.ProtocolALL { - rule = append(rule, "-p", strings.ToLower(string(params.Proto))) - rule = append(rule, applyPort("--sport", params.SPort)...) - rule = append(rule, applyPort("--dport", params.DPort)...) - } - - rule = append(rule, "-j", actionToStr(params.Action)) - - return rule, nil -} - -func (r *router) applyNetwork(flag string, network firewall.Network, prefixes []netip.Prefix) ([]string, error) { - direction := "src" - if flag == "-d" { - direction = "dst" - } - - if network.IsSet() { - if _, err := r.ipsetCounter.Increment(network.Set.HashedName(), prefixes); err != nil { - return nil, fmt.Errorf("create or get ipset: %w", err) - } - - return []string{"-m", "set", matchSet, network.Set.HashedName(), direction}, nil - } - if network.IsPrefix() { - return []string{flag, network.Prefix.String()}, nil - } - - // nolint:nilnil - return nil, nil -} - -func (r *router) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error { - var merr *multierror.Error - for _, prefix := range prefixes { - // TODO: Implement IPv6 support - if prefix.Addr().Is6() { - log.Tracef("skipping IPv6 prefix %s: IPv6 support not yet implemented", prefix) - continue - } - if err := r.addPrefixToIPSet(set.HashedName(), prefix); err != nil { - merr = multierror.Append(merr, fmt.Errorf("add prefix to ipset: %w", err)) - } - } - if merr == nil { - log.Debugf("updated set %s with prefixes %v", set.HashedName(), prefixes) - } - - return nberrors.FormatErrorOrNil(merr) -} - -// AddInboundDNAT adds an inbound DNAT rule redirecting traffic from NetBird peers to local services. -func (r *router) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { - ruleID := fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, sourcePort, targetPort) - - if _, exists := r.rules[ruleID]; exists { - return nil - } - - dnatRule := []string{ - "-i", r.wgIface.Name(), - "-p", strings.ToLower(string(protocol)), - "--dport", strconv.Itoa(int(sourcePort)), - "-d", localAddr.String(), - "-m", "addrtype", "--dst-type", "LOCAL", - "-j", "DNAT", - "--to-destination", ":" + strconv.Itoa(int(targetPort)), - } - - ruleInfo := ruleInfo{ - table: tableNat, - chain: chainRTRDR, - rule: dnatRule, - } - - if err := r.iptablesClient.Append(ruleInfo.table, ruleInfo.chain, ruleInfo.rule...); err != nil { - return fmt.Errorf("add inbound DNAT rule: %w", err) - } - r.rules[ruleID] = ruleInfo.rule - - r.updateState() - return nil -} - -// RemoveInboundDNAT removes an inbound DNAT rule. -func (r *router) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { - ruleID := fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, sourcePort, targetPort) - - if dnatRule, exists := r.rules[ruleID]; exists { - if err := r.iptablesClient.Delete(tableNat, chainRTRDR, dnatRule...); err != nil { - return fmt.Errorf("delete inbound DNAT rule: %w", err) - } - delete(r.rules, ruleID) - } - - r.updateState() - return nil -} - -// ensureNATOutputChain lazily creates the OUTPUT NAT chain and jump rule on first use. -func (r *router) ensureNATOutputChain() error { - if _, exists := r.rules[jumpNatOutput]; exists { - return nil - } - - chainExists, err := r.iptablesClient.ChainExists(tableNat, chainNATOutput) - if err != nil { - return fmt.Errorf("check chain %s: %w", chainNATOutput, err) - } - if !chainExists { - if err := r.iptablesClient.NewChain(tableNat, chainNATOutput); err != nil { - return fmt.Errorf("create chain %s: %w", chainNATOutput, err) - } - } - - jumpRule := []string{"-j", chainNATOutput} - if err := r.iptablesClient.Insert(tableNat, "OUTPUT", 1, jumpRule...); err != nil { - if !chainExists { - if delErr := r.iptablesClient.ClearAndDeleteChain(tableNat, chainNATOutput); delErr != nil { - log.Warnf("failed to rollback chain %s: %v", chainNATOutput, delErr) - } - } - return fmt.Errorf("add OUTPUT jump rule: %w", err) - } - r.rules[jumpNatOutput] = jumpRule - - r.updateState() - return nil -} - -// AddOutputDNAT adds an OUTPUT chain DNAT rule for locally-generated traffic. -func (r *router) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { - ruleID := fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, sourcePort, targetPort) - - if _, exists := r.rules[ruleID]; exists { - return nil - } - - if err := r.ensureNATOutputChain(); err != nil { - return err - } - - dnatRule := []string{ - "-p", strings.ToLower(string(protocol)), - "--dport", strconv.Itoa(int(sourcePort)), - "-d", localAddr.String(), - "-j", "DNAT", - "--to-destination", ":" + strconv.Itoa(int(targetPort)), - } - - if err := r.iptablesClient.Append(tableNat, chainNATOutput, dnatRule...); err != nil { - return fmt.Errorf("add output DNAT rule: %w", err) - } - r.rules[ruleID] = dnatRule - - r.updateState() - return nil -} - -// RemoveOutputDNAT removes an OUTPUT chain DNAT rule. -func (r *router) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { - ruleID := fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, sourcePort, targetPort) - - if dnatRule, exists := r.rules[ruleID]; exists { - if err := r.iptablesClient.Delete(tableNat, chainNATOutput, dnatRule...); err != nil { - return fmt.Errorf("delete output DNAT rule: %w", err) - } - delete(r.rules, ruleID) - } - - r.updateState() - return nil -} - -func applyPort(flag string, port *firewall.Port) []string { - if port == nil { - return nil - } - - if port.IsRange && len(port.Values) == 2 { - return []string{flag, fmt.Sprintf("%d:%d", port.Values[0], port.Values[1])} - } - - if len(port.Values) > 1 { - portList := make([]string, len(port.Values)) - for i, p := range port.Values { - portList[i] = strconv.Itoa(int(p)) - } - return []string{"-m", "multiport", flag, strings.Join(portList, ",")} - } - - return []string{flag, strconv.Itoa(int(port.Values[0]))} -} - -func (r *router) createIPSet(name string) error { - opts := ipset.CreateOptions{ - Replace: true, - } - - if err := ipset.Create(name, ipset.TypeHashNet, opts); err != nil { - return fmt.Errorf("create ipset %s: %w", name, err) - } - - log.Debugf("created ipset %s with type hash:net", name) - return nil -} - -func (r *router) addPrefixToIPSet(name string, prefix netip.Prefix) error { - addr := prefix.Addr() - ip := addr.AsSlice() - - entry := &ipset.Entry{ - IP: ip, - CIDR: uint8(prefix.Bits()), - Replace: true, - } - - if err := ipset.Add(name, entry); err != nil { - return fmt.Errorf("add prefix to ipset %s: %w", name, err) - } - - return nil -} - -func (r *router) destroyIPSet(name string) error { - return ipset.Destroy(name) -} diff --git a/client/firewall/iptables/router_linux_test.go b/client/firewall/iptables/router_linux_test.go index 6707573be..6c4ae9425 100644 --- a/client/firewall/iptables/router_linux_test.go +++ b/client/firewall/iptables/router_linux_test.go @@ -1,4 +1,4 @@ -//go:build !android +//go:build !android && privileged package iptables @@ -31,7 +31,7 @@ func TestIptablesManager_RestoreOrCreateContainers(t *testing.T) { iptablesClient, err := iptables.NewWithProtocol(iptables.ProtocolIPv4) require.NoError(t, err, "failed to init iptables client") - manager, err := newRouter(iptablesClient, ifaceMock, iface.DefaultMTU) + manager, err := newFamily(iptablesClient, ifaceMock, iface.DefaultMTU) require.NoError(t, err, "should return a valid iptables manager") require.NoError(t, manager.init(nil)) @@ -52,12 +52,12 @@ func TestIptablesManager_RestoreOrCreateContainers(t *testing.T) { // 11. MSS clamping rule for outbound traffic require.Len(t, manager.rules, 11, "should have created rules map") - exists, err := manager.iptablesClient.Exists(tableNat, chainPOSTROUTING, "-j", chainRTNAT) - require.NoError(t, err, "should be able to query the iptables %s table and %s chain", tableNat, chainPOSTROUTING) + exists, err := manager.iptablesClient.Exists(tableNat, chainPostrouting, "-j", chainRTNAT) + require.NoError(t, err, "should be able to query the iptables %s table and %s chain", tableNat, chainPostrouting) require.True(t, exists, "postrouting jump rule should exist") - exists, err = manager.iptablesClient.Exists(tableMangle, chainPREROUTING, "-j", chainRTPRE) - require.NoError(t, err, "should be able to query the iptables %s table and %s chain", tableMangle, chainPREROUTING) + exists, err = manager.iptablesClient.Exists(tableMangle, chainPrerouting, "-j", chainRTPre) + require.NoError(t, err, "should be able to query the iptables %s table and %s chain", tableMangle, chainPrerouting) require.True(t, exists, "prerouting jump rule should exist") pair := firewall.RouterPair{ @@ -84,7 +84,7 @@ func TestIptablesManager_AddNatRule(t *testing.T) { iptablesClient, err := iptables.NewWithProtocol(iptables.ProtocolIPv4) require.NoError(t, err, "failed to init iptables client") - manager, err := newRouter(iptablesClient, ifaceMock, iface.DefaultMTU) + manager, err := newFamily(iptablesClient, ifaceMock, iface.DefaultMTU) require.NoError(t, err, "shouldn't return error") require.NoError(t, manager.init(nil)) @@ -95,7 +95,7 @@ func TestIptablesManager_AddNatRule(t *testing.T) { err = manager.AddNatRule(testCase.InputPair) require.NoError(t, err, "marking rule should be inserted") - natRuleKey := firewall.GenKey(firewall.NatFormat, testCase.InputPair) + natRuleKey := testCase.InputPair.GenKey(firewall.NatFormat) markingRule := []string{ "-i", ifaceMock.Name(), "-m", "conntrack", @@ -106,8 +106,8 @@ func TestIptablesManager_AddNatRule(t *testing.T) { fmt.Sprintf("%#x", nbnet.PreroutingFwmarkMasquerade), } - exists, err := iptablesClient.Exists(tableMangle, chainRTPRE, markingRule...) - require.NoError(t, err, "should be able to query the iptables %s table and %s chain", tableMangle, chainRTPRE) + exists, err := iptablesClient.Exists(tableMangle, chainRTPre, markingRule...) + require.NoError(t, err, "should be able to query the iptables %s table and %s chain", tableMangle, chainRTPre) if testCase.InputPair.Masquerade { require.True(t, exists, "marking rule should be created") foundRule, found := manager.rules[natRuleKey] @@ -121,7 +121,7 @@ func TestIptablesManager_AddNatRule(t *testing.T) { // Check inverse rule inversePair := firewall.GetInversePair(testCase.InputPair) - inverseRuleKey := firewall.GenKey(firewall.NatFormat, inversePair) + inverseRuleKey := inversePair.GenKey(firewall.NatFormat) inverseMarkingRule := []string{ "!", "-i", ifaceMock.Name(), "-m", "conntrack", @@ -132,8 +132,8 @@ func TestIptablesManager_AddNatRule(t *testing.T) { fmt.Sprintf("%#x", nbnet.PreroutingFwmarkMasqueradeReturn), } - exists, err = iptablesClient.Exists(tableMangle, chainRTPRE, inverseMarkingRule...) - require.NoError(t, err, "should be able to query the iptables %s table and %s chain", tableMangle, chainRTPRE) + exists, err = iptablesClient.Exists(tableMangle, chainRTPre, inverseMarkingRule...) + require.NoError(t, err, "should be able to query the iptables %s table and %s chain", tableMangle, chainRTPre) if testCase.InputPair.Masquerade { require.True(t, exists, "inverse marking rule should be created") foundRule, found := manager.rules[inverseRuleKey] @@ -157,7 +157,7 @@ func TestIptablesManager_RemoveNatRule(t *testing.T) { t.Run(testCase.Name, func(t *testing.T) { iptablesClient, _ := iptables.NewWithProtocol(iptables.ProtocolIPv4) - manager, err := newRouter(iptablesClient, ifaceMock, iface.DefaultMTU) + manager, err := newFamily(iptablesClient, ifaceMock, iface.DefaultMTU) require.NoError(t, err, "shouldn't return error") require.NoError(t, manager.init(nil)) defer func() { @@ -170,7 +170,7 @@ func TestIptablesManager_RemoveNatRule(t *testing.T) { err = manager.RemoveNatRule(testCase.InputPair) require.NoError(t, err, "shouldn't return error") - natRuleKey := firewall.GenKey(firewall.NatFormat, testCase.InputPair) + natRuleKey := testCase.InputPair.GenKey(firewall.NatFormat) markingRule := []string{ "-i", ifaceMock.Name(), "-m", "conntrack", @@ -181,8 +181,8 @@ func TestIptablesManager_RemoveNatRule(t *testing.T) { fmt.Sprintf("%#x", nbnet.PreroutingFwmarkMasquerade), } - exists, err := iptablesClient.Exists(tableMangle, chainRTPRE, markingRule...) - require.NoError(t, err, "should be able to query the iptables %s table and %s chain", tableMangle, chainRTPRE) + exists, err := iptablesClient.Exists(tableMangle, chainRTPre, markingRule...) + require.NoError(t, err, "should be able to query the iptables %s table and %s chain", tableMangle, chainRTPre) require.False(t, exists, "marking rule should not exist") _, found := manager.rules[natRuleKey] @@ -190,7 +190,7 @@ func TestIptablesManager_RemoveNatRule(t *testing.T) { // Check inverse rule removal inversePair := firewall.GetInversePair(testCase.InputPair) - inverseRuleKey := firewall.GenKey(firewall.NatFormat, inversePair) + inverseRuleKey := inversePair.GenKey(firewall.NatFormat) inverseMarkingRule := []string{ "!", "-i", ifaceMock.Name(), "-m", "conntrack", @@ -201,8 +201,8 @@ func TestIptablesManager_RemoveNatRule(t *testing.T) { fmt.Sprintf("%#x", nbnet.PreroutingFwmarkMasqueradeReturn), } - exists, err = iptablesClient.Exists(tableMangle, chainRTPRE, inverseMarkingRule...) - require.NoError(t, err, "should be able to query the iptables %s table and %s chain", tableMangle, chainRTPRE) + exists, err = iptablesClient.Exists(tableMangle, chainRTPre, inverseMarkingRule...) + require.NoError(t, err, "should be able to query the iptables %s table and %s chain", tableMangle, chainRTPre) require.False(t, exists, "inverse marking rule should not exist") _, found = manager.rules[inverseRuleKey] @@ -219,13 +219,13 @@ func TestRouter_AddRouteFiltering(t *testing.T) { iptablesClient, err := iptables.NewWithProtocol(iptables.ProtocolIPv4) require.NoError(t, err, "Failed to create iptables client") - r, err := newRouter(iptablesClient, ifaceMock, iface.DefaultMTU) - require.NoError(t, err, "Failed to create router manager") + r, err := newFamily(iptablesClient, ifaceMock, iface.DefaultMTU) + require.NoError(t, err, "Failed to create family manager") require.NoError(t, r.init(nil)) defer func() { err := r.Reset() - require.NoError(t, err, "Failed to reset router") + require.NoError(t, err, "Failed to reset family") }() tests := []struct { @@ -334,62 +334,30 @@ func TestRouter_AddRouteFiltering(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ruleKey, err := r.AddRouteFiltering(nil, tt.sources, firewall.Network{Prefix: tt.destination}, tt.proto, tt.sPort, tt.dPort, tt.action) - require.NoError(t, err, "AddRouteFiltering failed") + ruleKey, err := r.AddFilterRule(nil, tt.sources, firewall.Network{Prefix: tt.destination}, tt.proto, tt.sPort, tt.dPort, tt.action) + require.NoError(t, err, "AddFilterRule failed") - // Check if the rule is in the internal map - rule, ok := r.rules[ruleKey.ID()] - assert.True(t, ok, "Rule not found in internal map") + stored, ok := r.filters[ruleKey.ID()] + require.True(t, ok, "rule not stored in filters") + t.Logf("Internal rule: %v", stored.specs) - // Log the internal rule - t.Logf("Internal rule: %v", rule) - - // Check if the rule exists in iptables - exists, err := iptablesClient.Exists(tableFilter, chainRTFWDIN, rule...) + exists, err := iptablesClient.Exists(tableFilter, chainRTFwdIn, stored.specs...) assert.NoError(t, err, "Failed to check rule existence") assert.True(t, exists, "Rule not found in iptables") - var source firewall.Network - if len(tt.sources) > 1 { - source.Set = firewall.NewPrefixSet(tt.sources) - } else if len(tt.sources) > 0 { - source.Prefix = tt.sources[0] - } - // Verify rule content - params := routeFilteringRuleParams{ - Source: source, - Destination: firewall.Network{Prefix: tt.destination}, - Proto: tt.proto, - SPort: tt.sPort, - DPort: tt.dPort, - Action: tt.action, - } - - expectedRule, err := r.genRouteRuleSpec(params, nil) - require.NoError(t, err, "Failed to generate expected rule spec") - if tt.expectSet { setName := firewall.NewPrefixSet(tt.sources).HashedName() - expectedRule, err = r.genRouteRuleSpec(params, nil) - require.NoError(t, err, "Failed to generate expected rule spec with set") - - // Check if the set was created _, exists := r.ipsetCounter.Get(setName) assert.True(t, exists, "IPSet not created") + assert.NotEmpty(t, findSets(stored.specs), "Rule should reference an ipset") } - assert.Equal(t, expectedRule, rule, "Rule content mismatch") - - // Clean up - err = r.DeleteRouteRule(ruleKey) - require.NoError(t, err, "Failed to delete rule") + require.NoError(t, r.DeleteFilterRule(ruleKey), "Failed to delete rule") }) } } func TestFindSetNameInRule(t *testing.T) { - r := &router{} - testCases := []struct { name string rule []string @@ -430,7 +398,7 @@ func TestFindSetNameInRule(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - result := r.findSets(tc.rule) + result := findSets(tc.rule) if len(result) != len(tc.expected) { t.Errorf("Expected %d sets, got %d. Sets found: %v", len(tc.expected), len(result), result) diff --git a/client/firewall/iptables/routing_linux.go b/client/firewall/iptables/routing_linux.go new file mode 100644 index 000000000..c63be4e43 --- /dev/null +++ b/client/firewall/iptables/routing_linux.go @@ -0,0 +1,273 @@ +//go:build !android + +package iptables + +import ( + "fmt" + "strings" + + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + + nberrors "github.com/netbirdio/netbird/client/errors" + firewall "github.com/netbirdio/netbird/client/firewall/manager" + nbnet "github.com/netbirdio/netbird/client/net" +) + +func (r *family) AddNatRule(pair firewall.RouterPair) error { + if r.legacyManagement { + log.Warnf("This peer is connected to a NetBird Management service with an older version. Allowing all traffic for %s", pair.Destination) + if err := r.addLegacyRouteRule(pair); err != nil { + return fmt.Errorf("add legacy routing rule: %w", err) + } + } + + if pair.Masquerade { + if err := r.addNatRule(pair); err != nil { + return fmt.Errorf("add nat rule: %w", err) + } + + if err := r.addNatRule(firewall.GetInversePair(pair)); err != nil { + return fmt.Errorf("add inverse nat rule: %w", err) + } + } + + r.updateState() + + return nil +} + +// RemoveNatRule removes an iptables rule pair from forwarding and nat chains +func (r *family) RemoveNatRule(pair firewall.RouterPair) error { + if pair.Masquerade { + if err := r.removeNatRule(pair); err != nil { + return fmt.Errorf("remove nat rule: %w", err) + } + + if err := r.removeNatRule(firewall.GetInversePair(pair)); err != nil { + return fmt.Errorf("remove inverse nat rule: %w", err) + } + } + + if err := r.removeLegacyRouteRule(pair); err != nil { + return fmt.Errorf("remove legacy routing rule: %w", err) + } + + r.updateState() + + return nil +} + +// addLegacyRouteRule adds a legacy routing rule for mgmt servers pre route acls +func (r *family) addLegacyRouteRule(pair firewall.RouterPair) error { + ruleID := pair.GenKey(firewall.ForwardingFormat) + + if err := r.removeLegacyRouteRule(pair); err != nil { + return err + } + + rule := []string{"-s", pair.Source.String(), "-d", pair.Destination.String(), "-j", "ACCEPT"} + if err := r.iptablesClient.Append(tableFilter, chainRTFwdIn, rule...); err != nil { + return fmt.Errorf("add legacy forwarding rule %s -> %s: %w", pair.Source, pair.Destination, err) + } + + r.rules[ruleID] = rule + + return nil +} + +func (r *family) removeLegacyRouteRule(pair firewall.RouterPair) error { + ruleID := pair.GenKey(firewall.ForwardingFormat) + + if rule, exists := r.rules[ruleID]; exists { + if err := r.iptablesClient.DeleteIfExists(tableFilter, chainRTFwdIn, rule...); err != nil { + return fmt.Errorf("remove legacy forwarding rule %s -> %s: %w", pair.Source, pair.Destination, err) + } + delete(r.rules, ruleID) + + if err := r.decrementSetCounter(rule); err != nil { + return fmt.Errorf("decrement ipset counter: %w", err) + } + } + + return nil +} + +// GetLegacyManagement returns the current legacy management mode +func (r *family) GetLegacyManagement() bool { + return r.legacyManagement +} + +// SetLegacyManagement sets the route manager to use legacy management mode +func (r *family) SetLegacyManagement(isLegacy bool) { + r.legacyManagement = isLegacy +} + +// RemoveAllLegacyRouteRules removes all legacy routing rules for mgmt servers pre route acls +func (r *family) RemoveAllLegacyRouteRules() error { + var merr *multierror.Error + for k, rule := range r.rules { + if !strings.HasPrefix(string(k), firewall.ForwardingFormatPrefix) { + continue + } + if err := r.iptablesClient.DeleteIfExists(tableFilter, chainRTFwdIn, rule...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove legacy forwarding rule: %w", err)) + } else { + delete(r.rules, k) + } + } + + r.updateState() + + return nberrors.FormatErrorOrNil(merr) +} + +func (r *family) addPostroutingRules() error { + // First rule for outbound masquerade + rule1 := []string{ + "-m", "mark", "--mark", fmt.Sprintf("%#x", nbnet.PreroutingFwmarkMasquerade), + "!", "-o", "lo", + "-j", "MASQUERADE", + } + if err := r.iptablesClient.Append(tableNat, chainRTNAT, rule1...); err != nil { + return fmt.Errorf("add outbound masquerade rule: %w", err) + } + r.rules["static-nat-outbound"] = rule1 + + // Second rule for return traffic masquerade + rule2 := []string{ + "-m", "mark", "--mark", fmt.Sprintf("%#x", nbnet.PreroutingFwmarkMasqueradeReturn), + "-o", r.wgIface.Name(), + "-j", "MASQUERADE", + } + if err := r.iptablesClient.Append(tableNat, chainRTNAT, rule2...); err != nil { + return fmt.Errorf("add return masquerade rule: %w", err) + } + r.rules["static-nat-return"] = rule2 + + return nil +} + +// addMSSClampingRules adds MSS clamping rules to prevent fragmentation for forwarded traffic. +func (r *family) addMSSClampingRules() error { + overhead := uint16(ipv4TCPHeaderSize) + if r.v6 { + overhead = ipv6TCPHeaderSize + } + mss := r.mtu - overhead + + // Add jump rule from FORWARD chain in mangle table to our custom chain + jumpRule := jumpRuleSpec(chainRTMSSClamp) + if err := r.iptablesClient.Insert(tableMangle, chainForward, 1, jumpRule...); err != nil { + return fmt.Errorf("add jump to MSS clamp chain: %w", err) + } + r.rules[jumpMSSClamp] = jumpRule + + ruleOut := []string{ + "-o", r.wgIface.Name(), + "-p", "tcp", + "--tcp-flags", "SYN,RST", "SYN", + "-j", "TCPMSS", + "--set-mss", fmt.Sprintf("%d", mss), + } + if err := r.iptablesClient.Append(tableMangle, chainRTMSSClamp, ruleOut...); err != nil { + return fmt.Errorf("add outbound MSS clamp rule: %w", err) + } + r.rules["mss-clamp-out"] = ruleOut + + return nil +} + +func (r *family) insertEstablishedRule(chain string) error { + establishedRule := getConntrackEstablished() + + err := r.iptablesClient.Insert(tableFilter, chain, 1, establishedRule...) + if err != nil { + return fmt.Errorf("insert established rule: %w", err) + } + + ruleID := firewall.RuleID("established-" + chain) + r.rules[ruleID] = establishedRule + + return nil +} + +func (r *family) addNatRule(pair firewall.RouterPair) (err error) { + ruleID := pair.GenKey(firewall.NatFormat) + + if rule, exists := r.rules[ruleID]; exists { + if derr := r.iptablesClient.DeleteIfExists(tableMangle, chainRTPre, rule...); derr != nil { + return fmt.Errorf("remove existing marking rule for %s: %w", pair.Destination, derr) + } + delete(r.rules, ruleID) + + // Drop the replaced spec's set references only once the new spec has + // taken its own, so a set both specs share is not destroyed and + // recreated, which would lose the prefixes UpdateSet put in it. + defer func() { + if derr := r.decrementSetCounter(rule); derr != nil && err == nil { + err = fmt.Errorf("decrement ipset counter: %w", derr) + } + }() + } + + markValue := nbnet.PreroutingFwmarkMasquerade + if pair.Inverse { + markValue = nbnet.PreroutingFwmarkMasqueradeReturn + } + + rule := []string{"-i", r.wgIface.Name()} + if pair.Inverse { + rule = []string{"!", "-i", r.wgIface.Name()} + } + + rule = append(rule, + "-m", "conntrack", + "--ctstate", "NEW", + ) + sourceExp, err := r.applyNetwork("-s", pair.Source, nil) + if err != nil { + return fmt.Errorf("apply network -s: %w", err) + } + destExp, err := r.applyNetwork("-d", pair.Destination, nil) + if err != nil { + r.dropSourceMatch(sourceExp) + return fmt.Errorf("apply network -d: %w", err) + } + + rule = append(rule, sourceExp...) + rule = append(rule, destExp...) + rule = append(rule, + "-j", "MARK", "--set-mark", fmt.Sprintf("%#x", markValue), + ) + + // Ensure nat rules come first, so the mark can be overwritten. + // Currently overwritten by the dst-type LOCAL rules for redirected traffic. + if err := r.iptablesClient.Insert(tableMangle, chainRTPre, 1, rule...); err != nil { + r.dropSourceMatch(rule) + return fmt.Errorf("add marking rule for %s: %w", pair.Destination, err) + } + + r.rules[ruleID] = rule + + return nil +} + +func (r *family) removeNatRule(pair firewall.RouterPair) error { + ruleID := pair.GenKey(firewall.NatFormat) + + if rule, exists := r.rules[ruleID]; exists { + if err := r.iptablesClient.DeleteIfExists(tableMangle, chainRTPre, rule...); err != nil { + return fmt.Errorf("remove marking rule for %s: %w", pair.Destination, err) + } + delete(r.rules, ruleID) + + if err := r.decrementSetCounter(rule); err != nil { + return fmt.Errorf("decrement ipset counter: %w", err) + } + } else { + log.Debugf("marking rule %s not found", ruleID) + } + + return nil +} diff --git a/client/firewall/iptables/rule.go b/client/firewall/iptables/rule.go index aa4d2d079..33bcbd1d2 100644 --- a/client/firewall/iptables/rule.go +++ b/client/firewall/iptables/rule.go @@ -1,17 +1,37 @@ package iptables -// Rule to handle management of rules -type Rule struct { - ruleID string - ipsetName string +import "github.com/netbirdio/netbird/client/firewall/manager" +// Rule to handle management of rules. Source set membership (when the +// rule was built against a shared hash:net ipset) is encoded in specs; +// DeleteFilterRule recovers it via findSets so the refcounter can drop +// the right reference. +type Rule struct { + id manager.RuleID specs []string mangleSpecs []string - ip string - chain string + // extraRules holds the rules beyond the first when the ipset + // fallback expands a multi-source rule into one rule per prefix. + extraRules []filterSpecs + chain string + v6 bool } -// GetRuleID returns the rule id -func (r *Rule) ID() string { - return r.ruleID +// filterSpecs is one installed iptables rule: its filter-table spec and +// the paired mangle redirect-mark spec (nil for route rules or when the +// mangle rule could not be added). +type filterSpecs struct { + specs []string + mangleSpecs []string +} + +// allSpecs returns the spec pairs of every iptables rule backing this +// Rule, the primary one first. +func (r *Rule) allSpecs() []filterSpecs { + return append([]filterSpecs{{specs: r.specs, mangleSpecs: r.mangleSpecs}}, r.extraRules...) +} + +// ID returns the rule id +func (r *Rule) ID() manager.RuleID { + return r.id } diff --git a/client/firewall/iptables/rulestore_linux.go b/client/firewall/iptables/rulestore_linux.go deleted file mode 100644 index 004c512a4..000000000 --- a/client/firewall/iptables/rulestore_linux.go +++ /dev/null @@ -1,103 +0,0 @@ -package iptables - -import "encoding/json" - -type ipList struct { - ips map[string]struct{} -} - -func newIpList(ip string) *ipList { - ips := make(map[string]struct{}) - ips[ip] = struct{}{} - - return &ipList{ - ips: ips, - } -} - -func (s *ipList) addIP(ip string) { - s.ips[ip] = struct{}{} -} - -// MarshalJSON implements json.Marshaler -func (s *ipList) MarshalJSON() ([]byte, error) { - return json.Marshal(struct { - IPs map[string]struct{} `json:"ips"` - }{ - IPs: s.ips, - }) -} - -// UnmarshalJSON implements json.Unmarshaler -func (s *ipList) UnmarshalJSON(data []byte) error { - temp := struct { - IPs map[string]struct{} `json:"ips"` - }{} - if err := json.Unmarshal(data, &temp); err != nil { - return err - } - s.ips = temp.IPs - - if temp.IPs == nil { - temp.IPs = make(map[string]struct{}) - } - - return nil -} - -type ipsetStore struct { - ipsets map[string]*ipList -} - -func newIpsetStore() *ipsetStore { - return &ipsetStore{ - ipsets: make(map[string]*ipList), - } -} - -func (s *ipsetStore) ipset(ipsetName string) (*ipList, bool) { - r, ok := s.ipsets[ipsetName] - return r, ok -} - -func (s *ipsetStore) addIpList(ipsetName string, list *ipList) { - s.ipsets[ipsetName] = list -} - -func (s *ipsetStore) deleteIpset(ipsetName string) { - delete(s.ipsets, ipsetName) -} - -func (s *ipsetStore) ipsetNames() []string { - names := make([]string, 0, len(s.ipsets)) - for name := range s.ipsets { - names = append(names, name) - } - return names -} - -// MarshalJSON implements json.Marshaler -func (s *ipsetStore) MarshalJSON() ([]byte, error) { - return json.Marshal(struct { - IPSets map[string]*ipList `json:"ipsets"` - }{ - IPSets: s.ipsets, - }) -} - -// UnmarshalJSON implements json.Unmarshaler -func (s *ipsetStore) UnmarshalJSON(data []byte) error { - temp := struct { - IPSets map[string]*ipList `json:"ipsets"` - }{} - if err := json.Unmarshal(data, &temp); err != nil { - return err - } - s.ipsets = temp.IPSets - - if temp.IPSets == nil { - temp.IPSets = make(map[string]*ipList) - } - - return nil -} diff --git a/client/firewall/iptables/state_linux.go b/client/firewall/iptables/state_linux.go index 121c755e9..00bf1cebd 100644 --- a/client/firewall/iptables/state_linux.go +++ b/client/firewall/iptables/state_linux.go @@ -4,6 +4,8 @@ import ( "fmt" "sync" + log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/iface/wgaddr" ) @@ -27,11 +29,13 @@ type ShutdownState struct { InterfaceState *InterfaceState `json:"interface_state,omitempty"` - RouteRules routeRules `json:"route_rules,omitempty"` - RouteIPsetCounter *ipsetCounter `json:"route_ipset_counter,omitempty"` + RouteRules routeRules `json:"route_rules,omitempty"` + RouteRules6 routeRules `json:"route_rules_v6,omitempty"` + RouteIPsetCounter *ipsetCounter `json:"route_ipset_counter,omitempty"` + RouteIPsetCounter6 *ipsetCounter `json:"route_ipset_counter_v6,omitempty"` - ACLEntries aclEntries `json:"acl_entries,omitempty"` - ACLIPsetStore *ipsetStore `json:"acl_ipset_store,omitempty"` + ACLEntries aclEntries `json:"acl_entries,omitempty"` + ACLEntries6 aclEntries `json:"acl_entries_v6,omitempty"` } func (s *ShutdownState) Name() string { @@ -49,17 +53,33 @@ func (s *ShutdownState) Cleanup() error { } if s.RouteRules != nil { - ipt.router.rules = s.RouteRules + ipt.family4.rules = s.RouteRules } if s.RouteIPsetCounter != nil { - ipt.router.ipsetCounter.LoadData(s.RouteIPsetCounter) + ipt.family4.ipsetCounter.LoadData(s.RouteIPsetCounter) } if s.ACLEntries != nil { - ipt.aclMgr.entries = s.ACLEntries + ipt.family4.entries = s.ACLEntries } - if s.ACLIPsetStore != nil { - ipt.aclMgr.ipsetStore = s.ACLIPsetStore + + // Clean up v6 state even if the current run has no IPv6. + // The previous run may have left ip6tables rules behind. + if !ipt.hasIPv6() { + if err := ipt.createIPv6Components(s.InterfaceState, mtu); err != nil { + log.Warnf("failed to create v6 components for cleanup: %v", err) + } + } + if ipt.hasIPv6() { + if s.RouteRules6 != nil { + ipt.family6.rules = s.RouteRules6 + } + if s.RouteIPsetCounter6 != nil { + ipt.family6.ipsetCounter.LoadData(s.RouteIPsetCounter6) + } + if s.ACLEntries6 != nil { + ipt.family6.entries = s.ACLEntries6 + } } if err := ipt.Close(nil); err != nil { diff --git a/client/firewall/iptables/testhelpers_linux_test.go b/client/firewall/iptables/testhelpers_linux_test.go new file mode 100644 index 000000000..fe44f7cc3 --- /dev/null +++ b/client/firewall/iptables/testhelpers_linux_test.go @@ -0,0 +1,27 @@ +//go:build privileged + +package iptables + +import ( + "fmt" + "net" + "net/netip" +) + +func pfx(ip net.IP) []netip.Prefix { + if ip == nil { + return []netip.Prefix{netip.PrefixFrom(netip.IPv4Unspecified(), 0)} + } + if ip.IsUnspecified() { + if ip.To4() != nil { + return []netip.Prefix{netip.PrefixFrom(netip.IPv4Unspecified(), 0)} + } + return []netip.Prefix{netip.PrefixFrom(netip.IPv6Unspecified(), 0)} + } + a, ok := netip.AddrFromSlice(ip) + if !ok { + panic(fmt.Sprintf("invalid IP length: %d", len(ip))) + } + a = a.Unmap() + return []netip.Prefix{netip.PrefixFrom(a, a.BitLen())} +} diff --git a/client/firewall/manager/firewall.go b/client/firewall/manager/firewall.go index d65d717b3..97a94d0f5 100644 --- a/client/firewall/manager/firewall.go +++ b/client/firewall/manager/firewall.go @@ -1,8 +1,8 @@ package manager import ( + "errors" "fmt" - "net" "net/netip" "sort" @@ -11,6 +11,16 @@ import ( "github.com/netbirdio/netbird/client/internal/statemanager" ) +// ErrIPv6NotInitialized is returned when an IPv6 address is passed to a firewall +// method but the IPv6 firewall components were not initialized. +var ErrIPv6NotInitialized = errors.New("IPv6 firewall not initialized") + +// ErrNoSources is returned when AddFilterRule is called with an empty +// source list. "Match any source" must be expressed explicitly with a +// /0 prefix; an empty list is a caller error and is rejected rather +// than silently widening the rule to every source. +var ErrNoSources = errors.New("rule has no sources") + const ( ForwardingFormatPrefix = "netbird-fwd-" ForwardingFormat = "netbird-fwd-%s-%t" @@ -18,13 +28,18 @@ const ( NatFormat = "netbird-nat-%s-%t" ) +// RuleID identifies a firewall rule. It is a typed string so the +// compiler catches accidental mixing with arbitrary string keys. It is +// only an identifier and does not implement Rule. +type RuleID string + // Rule abstraction should be implemented by each firewall manager // // Each firewall type for different OS can use different type // of the properties to hold data of the created rule type Rule interface { // ID returns the rule id - ID() string + ID() RuleID } // RuleDirection is the traffic direction which a rule is applied @@ -86,6 +101,13 @@ func (d Network) IsPrefix() bool { return d.Prefix.IsValid() } +// IsZero returns true if the network designates no destination, i.e. it +// is the zero value. A zero Network is the peer-rule sentinel; a non-zero +// one carries a prefix or set destination. +func (d Network) IsZero() bool { + return !d.IsPrefix() && !d.IsSet() +} + // Manager is the high level abstraction of a firewall manager // // It declares methods which handle actions required by the @@ -93,46 +115,42 @@ func (d Network) IsPrefix() bool { type Manager interface { Init(stateManager *statemanager.Manager) error - // AllowNetbird allows netbird interface traffic - AllowNetbird() error - - // AddPeerFiltering adds a rule to the firewall + // AddFilterRule adds a packet-filtering rule to the firewall. // - // If comment argument is empty firewall manager should set - // rule ID as comment for the rule + // If destination is the zero Network, the rule applies to traffic + // inbound to this node, i.e. peer ACL semantics, installed in + // the kernel's input chain. If destination is set (prefix or + // set), the rule applies to forwarded traffic with that + // destination, route ACL semantics, installed in the forward + // chain. // - // Note: Callers should call Flush() after adding rules to ensure - // they are applied to the kernel and rule handles are refreshed. - AddPeerFiltering( + // sources must be a single address family; the caller splits mixed + // families and calls once per family. "Match any source" must be + // expressed with an explicit /0 prefix; an empty sources list is + // rejected with ErrNoSources so a zeroed list can never widen a + // rule to every source. + // + // Note: callers should call Flush() after adding rules. + AddFilterRule( id []byte, - ip net.IP, + sources []netip.Prefix, + destination Network, proto Protocol, sPort *Port, dPort *Port, action Action, - ipsetName string, - ) ([]Rule, error) + ) (Rule, error) - // DeletePeerRule from the firewall by rule definition - DeletePeerRule(rule Rule) error + // DeleteFilterRule removes a filtering rule previously added via + // AddFilterRule. The rule's own type identifies whether it lives + // in the peer (input) or route (forward) path. + DeleteFilterRule(rule Rule) error // IsServerRouteSupported returns true if the firewall supports server side routing operations IsServerRouteSupported() bool IsStateful() bool - AddRouteFiltering( - id []byte, - sources []netip.Prefix, - destination Network, - proto Protocol, - sPort, dPort *Port, - action Action, - ) (Rule, error) - - // DeleteRouteRule deletes a routing rule - DeleteRouteRule(rule Rule) error - // AddNatRule inserts a routing NAT rule AddNatRule(pair RouterPair) error @@ -164,26 +182,25 @@ type Manager interface { UpdateSet(hash Set, prefixes []netip.Prefix) error // AddInboundDNAT adds an inbound DNAT rule redirecting traffic from NetBird peers to local services - AddInboundDNAT(localAddr netip.Addr, protocol Protocol, sourcePort, targetPort uint16) error + AddInboundDNAT(localAddr netip.Addr, protocol Protocol, originalPort, translatedPort uint16) error // RemoveInboundDNAT removes inbound DNAT rule - RemoveInboundDNAT(localAddr netip.Addr, protocol Protocol, sourcePort, targetPort uint16) error + RemoveInboundDNAT(localAddr netip.Addr, protocol Protocol, originalPort, translatedPort uint16) error // AddOutputDNAT adds an OUTPUT chain DNAT rule for locally-generated traffic. - // localAddr must be IPv4; the underlying iptables/nftables backends are IPv4-only. - AddOutputDNAT(localAddr netip.Addr, protocol Protocol, sourcePort, targetPort uint16) error + AddOutputDNAT(localAddr netip.Addr, protocol Protocol, originalPort, translatedPort uint16) error // RemoveOutputDNAT removes an OUTPUT chain DNAT rule. - // localAddr must be IPv4; the underlying iptables/nftables backends are IPv4-only. - RemoveOutputDNAT(localAddr netip.Addr, protocol Protocol, sourcePort, targetPort uint16) error + RemoveOutputDNAT(localAddr netip.Addr, protocol Protocol, originalPort, translatedPort uint16) error // SetupEBPFProxyNoTrack creates static notrack rules for eBPF proxy loopback traffic. // This prevents conntrack from interfering with WireGuard proxy communication. SetupEBPFProxyNoTrack(proxyPort, wgPort uint16) error } -func GenKey(format string, pair RouterPair) string { - return fmt.Sprintf(format, pair.ID, pair.Inverse) +// GenKey builds the rule id for this pair from the given format. +func (p RouterPair) GenKey(format string) RuleID { + return RuleID(fmt.Sprintf(format, p.ID, p.Inverse)) } // LegacyManager defines the interface for legacy management operations @@ -239,6 +256,20 @@ func MergeIPRanges(prefixes []netip.Prefix) []netip.Prefix { return merged } +// UnmapPrefix normalizes a v4-mapped v6 prefix (::ffff:a.b.c.d) to its +// plain v4 form, shifting the prefix length out of the 96-bit mapped +// range. Other prefixes are returned unchanged. Keeping prefixes +// unmapped ensures v4 rules match consistently and the match builders +// read the correct address length. +func UnmapPrefix(p netip.Prefix) netip.Prefix { + addr := p.Addr() + if !addr.Is4In6() { + return p + } + bits := max(p.Bits()-96, 0) + return netip.PrefixFrom(addr.Unmap(), bits) +} + // SortPrefixes sorts the given slice of netip.Prefix in place. // It sorts first by IP address, then by prefix length (most specific to least specific). func SortPrefixes(prefixes []netip.Prefix) { diff --git a/client/firewall/manager/forward_rule.go b/client/firewall/manager/forward_rule.go index 21a43520e..c2e9e5c60 100644 --- a/client/firewall/manager/forward_rule.go +++ b/client/firewall/manager/forward_rule.go @@ -13,13 +13,13 @@ type ForwardRule struct { TranslatedPort Port } -func (r ForwardRule) ID() string { +func (r ForwardRule) ID() RuleID { id := fmt.Sprintf("%s;%s;%s;%s", r.Protocol, r.DestinationPort.String(), r.TranslatedAddress.String(), r.TranslatedPort.String()) - return id + return RuleID(id) } func (r ForwardRule) String() string { diff --git a/client/firewall/manager/routerpair.go b/client/firewall/manager/routerpair.go index 079c051d9..096f8b9bb 100644 --- a/client/firewall/manager/routerpair.go +++ b/client/firewall/manager/routerpair.go @@ -1,6 +1,8 @@ package manager import ( + "net/netip" + "github.com/netbirdio/netbird/route" ) @@ -10,6 +12,10 @@ type RouterPair struct { Destination Network Masquerade bool Inverse bool + // Dynamic indicates the route is domain-based. NAT rules for dynamic + // routes are duplicated to the v6 table so that resolved AAAA records + // are masqueraded correctly. + Dynamic bool } func GetInversePair(pair RouterPair) RouterPair { @@ -20,5 +26,17 @@ func GetInversePair(pair RouterPair) RouterPair { Destination: pair.Source, Masquerade: pair.Masquerade, Inverse: true, + Dynamic: pair.Dynamic, } } + +// ToV6NatPair creates a v6 counterpart of a v4 NAT pair with `::/0` source +// and, for prefix destinations, `::/0` destination. +func ToV6NatPair(pair RouterPair) RouterPair { + v6 := pair + v6.Source = Network{Prefix: netip.PrefixFrom(netip.IPv6Unspecified(), 0)} + if v6.Destination.IsPrefix() { + v6.Destination = Network{Prefix: netip.PrefixFrom(netip.IPv6Unspecified(), 0)} + } + return v6 +} diff --git a/client/firewall/manager/set.go b/client/firewall/manager/set.go index dda93bf47..fa55471ea 100644 --- a/client/firewall/manager/set.go +++ b/client/firewall/manager/set.go @@ -40,7 +40,7 @@ func (h Set) Comment() string { // NewPrefixSet generates a unique name for an ipset based on the given prefixes. func NewPrefixSet(prefixes []netip.Prefix) Set { - // sort for consistent naming + prefixes = slices.Clone(prefixes) SortPrefixes(prefixes) hash := sha256.New() diff --git a/client/firewall/nftables/acl_linux.go b/client/firewall/nftables/acl_linux.go deleted file mode 100644 index a9d066e2f..000000000 --- a/client/firewall/nftables/acl_linux.go +++ /dev/null @@ -1,721 +0,0 @@ -package nftables - -import ( - "bytes" - "fmt" - "net" - "slices" - "strconv" - "strings" - "time" - - "github.com/google/nftables" - "github.com/google/nftables/binaryutil" - "github.com/google/nftables/expr" - log "github.com/sirupsen/logrus" - "golang.org/x/sys/unix" - - firewall "github.com/netbirdio/netbird/client/firewall/manager" - nbnet "github.com/netbirdio/netbird/client/net" -) - -const ( - - // rules chains contains the effective ACL rules - chainNameInputRules = "netbird-acl-input-rules" - - // filter chains contains the rules that jump to the rules chains - chainNameInputFilter = "netbird-acl-input-filter" - chainNameForwardFilter = "netbird-acl-forward-filter" - chainNameManglePrerouting = "netbird-mangle-prerouting" - chainNameManglePostrouting = "netbird-mangle-postrouting" -) - -const flushError = "flush: %w" - -var ( - anyIP = []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} -) - -type AclManager struct { - rConn *nftables.Conn - sConn *nftables.Conn - wgIface iFaceMapper - routingFwChainName string - - workTable *nftables.Table - chainInputRules *nftables.Chain - chainPrerouting *nftables.Chain - - ipsetStore *ipsetStore - rules map[string]*Rule -} - -func newAclManager(table *nftables.Table, wgIface iFaceMapper, routingFwChainName string) (*AclManager, error) { - // sConn is used for creating sets and adding/removing elements from them - // it's differ then rConn (which does create new conn for each flush operation) - // and is permanent. Using same connection for both type of operations - // overloads netlink with high amount of rules ( > 10000) - sConn, err := nftables.New(nftables.AsLasting()) - if err != nil { - return nil, fmt.Errorf("create nf conn: %w", err) - } - - return &AclManager{ - rConn: &nftables.Conn{}, - sConn: sConn, - wgIface: wgIface, - workTable: table, - routingFwChainName: routingFwChainName, - - ipsetStore: newIpsetStore(), - rules: make(map[string]*Rule), - }, nil -} - -func (m *AclManager) init(workTable *nftables.Table) error { - m.workTable = workTable - return m.createDefaultChains() -} - -// AddPeerFiltering rule to the firewall -// -// If comment argument is empty firewall manager should set -// rule ID as comment for the rule -func (m *AclManager) AddPeerFiltering( - id []byte, - ip net.IP, - proto firewall.Protocol, - sPort *firewall.Port, - dPort *firewall.Port, - action firewall.Action, - ipsetName string, -) ([]firewall.Rule, error) { - var ipset *nftables.Set - if ipsetName != "" { - var err error - ipset, err = m.addIpToSet(ipsetName, ip) - if err != nil { - return nil, err - } - } - - newRules := make([]firewall.Rule, 0, 2) - ioRule, err := m.addIOFiltering(ip, proto, sPort, dPort, action, ipset) - if err != nil { - return nil, err - } - - newRules = append(newRules, ioRule) - return newRules, nil -} - -// DeletePeerRule from the firewall by rule definition -func (m *AclManager) DeletePeerRule(rule firewall.Rule) error { - r, ok := rule.(*Rule) - if !ok { - return fmt.Errorf("invalid rule type") - } - - if r.nftSet == nil { - if err := m.rConn.DelRule(r.nftRule); err != nil { - log.Errorf("failed to delete rule: %v", err) - } - if r.mangleRule != nil { - if err := m.rConn.DelRule(r.mangleRule); err != nil { - log.Errorf("failed to delete mangle rule: %v", err) - } - } - delete(m.rules, r.ID()) - return m.rConn.Flush() - } - - ips, ok := m.ipsetStore.ips(r.nftSet.Name) - if !ok { - if err := m.rConn.DelRule(r.nftRule); err != nil { - log.Errorf("failed to delete rule: %v", err) - } - if r.mangleRule != nil { - if err := m.rConn.DelRule(r.mangleRule); err != nil { - log.Errorf("failed to delete mangle rule: %v", err) - } - } - delete(m.rules, r.ID()) - return m.rConn.Flush() - } - - if _, ok := ips[r.ip.String()]; ok { - err := m.sConn.SetDeleteElements(r.nftSet, []nftables.SetElement{{Key: r.ip.To4()}}) - if err != nil { - log.Errorf("delete elements for set %q: %v", r.nftSet.Name, err) - } - if err := m.sConn.Flush(); err != nil { - log.Debugf("flush error of set delete element, %s", r.nftSet.Name) - return err - } - m.ipsetStore.DeleteIpFromSet(r.nftSet.Name, r.ip) - } - - // if after delete, set still contains other IPs, - // no need to delete firewall rule and we should exit here - if len(ips) > 0 { - return nil - } - - if err := m.rConn.DelRule(r.nftRule); err != nil { - log.Errorf("failed to delete rule: %v", err) - } - if r.mangleRule != nil { - if err := m.rConn.DelRule(r.mangleRule); err != nil { - log.Errorf("failed to delete mangle rule: %v", err) - } - } - - if err := m.rConn.Flush(); err != nil { - return err - } - - delete(m.rules, r.ID()) - m.ipsetStore.DeleteReferenceFromIpSet(r.nftSet.Name) - - if m.ipsetStore.HasReferenceToSet(r.nftSet.Name) { - return nil - } - - // we delete last IP from the set, that means we need to delete - // set itself and associated firewall rule too - m.rConn.FlushSet(r.nftSet) - m.rConn.DelSet(r.nftSet) - m.ipsetStore.deleteIpset(r.nftSet.Name) - return nil -} - -// createDefaultAllowRules creates default allow rules for the input and output chains -func (m *AclManager) createDefaultAllowRules() error { - expIn := []expr.Any{ - &expr.Verdict{ - Kind: expr.VerdictAccept, - }, - } - - _ = m.rConn.InsertRule(&nftables.Rule{ - Table: m.workTable, - Chain: m.chainInputRules, - Position: 0, - Exprs: expIn, - }) - - if err := m.rConn.Flush(); err != nil { - return fmt.Errorf(flushError, err) - } - return nil -} - -// Flush rule/chain/set operations from the buffer -// -// Method also get all rules after flush and refreshes handle values in the rulesets -func (m *AclManager) Flush() error { - if err := m.flushWithBackoff(); err != nil { - return err - } - - if err := m.refreshRuleHandles(m.chainInputRules, false); err != nil { - log.Errorf("failed to refresh rule handles ipv4 input chain: %v", err) - } - if err := m.refreshRuleHandles(m.chainPrerouting, true); err != nil { - log.Errorf("failed to refresh rule handles prerouting chain: %v", err) - } - - return nil -} - -func (m *AclManager) addIOFiltering( - ip net.IP, - proto firewall.Protocol, - sPort *firewall.Port, - dPort *firewall.Port, - action firewall.Action, - ipset *nftables.Set, -) (*Rule, error) { - ruleId := generatePeerRuleId(ip, proto, sPort, dPort, action, ipset) - if r, ok := m.rules[ruleId]; ok { - return &Rule{ - nftRule: r.nftRule, - mangleRule: r.mangleRule, - nftSet: r.nftSet, - ruleID: r.ruleID, - ip: ip, - }, nil - } - - var expressions []expr.Any - - if proto != firewall.ProtocolALL { - expressions = append(expressions, &expr.Payload{ - DestRegister: 1, - Base: expr.PayloadBaseNetworkHeader, - Offset: uint32(9), - Len: uint32(1), - }) - - protoData, err := protoToInt(proto) - if err != nil { - return nil, fmt.Errorf("convert protocol to number: %v", err) - } - - expressions = append(expressions, &expr.Cmp{ - Register: 1, - Op: expr.CmpOpEq, - Data: []byte{protoData}, - }) - } - - rawIP := ip.To4() - // check if rawIP contains zeroed IPv4 0.0.0.0 value - // in that case not add IP match expression into the rule definition - if !bytes.HasPrefix(anyIP, rawIP) { - // source address position - addrOffset := uint32(12) - - expressions = append(expressions, - &expr.Payload{ - DestRegister: 1, - Base: expr.PayloadBaseNetworkHeader, - Offset: addrOffset, - Len: 4, - }, - ) - // add individual IP for match if no ipset defined - if ipset == nil { - expressions = append(expressions, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: rawIP, - }, - ) - } else { - expressions = append(expressions, - &expr.Lookup{ - SourceRegister: 1, - SetName: ipset.Name, - SetID: ipset.ID, - }, - ) - } - } - - expressions = append(expressions, applyPort(sPort, true)...) - expressions = append(expressions, applyPort(dPort, false)...) - - mainExpressions := slices.Clone(expressions) - - switch action { - case firewall.ActionAccept: - mainExpressions = append(mainExpressions, &expr.Verdict{Kind: expr.VerdictAccept}) - case firewall.ActionDrop: - mainExpressions = append(mainExpressions, &expr.Verdict{Kind: expr.VerdictDrop}) - } - - userData := []byte(ruleId) - - chain := m.chainInputRules - rule := &nftables.Rule{ - Table: m.workTable, - Chain: chain, - Exprs: mainExpressions, - UserData: userData, - } - - // Insert DROP rules at the beginning, append ACCEPT rules at the end - var nftRule *nftables.Rule - if action == firewall.ActionDrop { - nftRule = m.rConn.InsertRule(rule) - } else { - nftRule = m.rConn.AddRule(rule) - } - - if err := m.rConn.Flush(); err != nil { - return nil, fmt.Errorf("flush input rule %s: %v", ruleId, err) - } - - ruleStruct := &Rule{ - nftRule: nftRule, - // best effort mangle rule - mangleRule: m.createPreroutingRule(expressions, userData), - nftSet: ipset, - ruleID: ruleId, - ip: ip, - } - m.rules[ruleId] = ruleStruct - if ipset != nil { - m.ipsetStore.AddReferenceToIpset(ipset.Name) - } - - return ruleStruct, nil -} - -func (m *AclManager) createPreroutingRule(expressions []expr.Any, userData []byte) *nftables.Rule { - if m.chainPrerouting == nil { - log.Warn("prerouting chain is not created") - return nil - } - - preroutingExprs := slices.Clone(expressions) - - // interface - preroutingExprs = append([]expr.Any{ - &expr.Meta{ - Key: expr.MetaKeyIIFNAME, - Register: 1, - }, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: ifname(m.wgIface.Name()), - }, - }, preroutingExprs...) - - // local destination and mark - preroutingExprs = append(preroutingExprs, - &expr.Fib{ - Register: 1, - ResultADDRTYPE: true, - FlagDADDR: true, - }, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: binaryutil.NativeEndian.PutUint32(unix.RTN_LOCAL), - }, - - &expr.Immediate{ - Register: 1, - Data: binaryutil.NativeEndian.PutUint32(nbnet.PreroutingFwmarkRedirected), - }, - &expr.Meta{ - Key: expr.MetaKeyMARK, - Register: 1, - SourceRegister: true, - }, - ) - - nfRule := m.rConn.AddRule(&nftables.Rule{ - Table: m.workTable, - Chain: m.chainPrerouting, - Exprs: preroutingExprs, - UserData: userData, - }) - - if err := m.rConn.Flush(); err != nil { - log.Errorf("failed to flush mangle rule %s: %v", string(userData), err) - return nil - } - - return nfRule -} - -func (m *AclManager) createDefaultChains() (err error) { - // chainNameInputRules - chain := m.createChain(chainNameInputRules) - err = m.rConn.Flush() - if err != nil { - log.Debugf("failed to create chain (%s): %s", chain.Name, err) - return fmt.Errorf(flushError, err) - } - m.chainInputRules = chain - - // netbird-acl-input-filter - // type filter hook input priority filter; policy accept; - chain = m.createFilterChainWithHook(chainNameInputFilter, nftables.ChainHookInput) - m.addJumpRule(chain, m.chainInputRules.Name, expr.MetaKeyIIFNAME) // to netbird-acl-input-rules - m.addDropExpressions(chain, expr.MetaKeyIIFNAME) - err = m.rConn.Flush() - if err != nil { - log.Debugf("failed to create chain (%s): %s", chain.Name, err) - return err - } - - // netbird-acl-forward-filter - chainFwFilter := m.createFilterChainWithHook(chainNameForwardFilter, nftables.ChainHookForward) - m.addJumpRulesToRtForward(chainFwFilter) // to netbird-rt-fwd - m.addDropExpressions(chainFwFilter, expr.MetaKeyIIFNAME) - - err = m.rConn.Flush() - if err != nil { - log.Debugf("failed to create chain (%s): %s", chainNameForwardFilter, err) - return fmt.Errorf(flushError, err) - } - - if err := m.allowRedirectedTraffic(chainFwFilter); err != nil { - log.Errorf("failed to allow redirected traffic: %s", err) - } - - return nil -} - -// Makes redirected traffic originally destined for the host itself (now subject to the forward filter) -// go through the input filter as well. This will enable e.g. Docker services to keep working by accessing the -// netbird peer IP. -func (m *AclManager) allowRedirectedTraffic(chainFwFilter *nftables.Chain) error { - // Chain is created by route manager - // TODO: move creation to a common place - m.chainPrerouting = &nftables.Chain{ - Name: chainNameManglePrerouting, - Table: m.workTable, - Type: nftables.ChainTypeFilter, - Hooknum: nftables.ChainHookPrerouting, - Priority: nftables.ChainPriorityMangle, - } - - m.addFwmarkToForward(chainFwFilter) - - if err := m.rConn.Flush(); err != nil { - return fmt.Errorf(flushError, err) - } - - return nil -} - -func (m *AclManager) addFwmarkToForward(chainFwFilter *nftables.Chain) { - m.rConn.InsertRule(&nftables.Rule{ - Table: m.workTable, - Chain: chainFwFilter, - Exprs: []expr.Any{ - &expr.Meta{ - Key: expr.MetaKeyMARK, - Register: 1, - }, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: binaryutil.NativeEndian.PutUint32(nbnet.PreroutingFwmarkRedirected), - }, - &expr.Verdict{ - Kind: expr.VerdictAccept, - }, - }, - }) -} - -func (m *AclManager) addJumpRulesToRtForward(chainFwFilter *nftables.Chain) { - expressions := []expr.Any{ - &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: ifname(m.wgIface.Name()), - }, - &expr.Verdict{ - Kind: expr.VerdictJump, - Chain: m.routingFwChainName, - }, - } - - _ = m.rConn.AddRule(&nftables.Rule{ - Table: m.workTable, - Chain: chainFwFilter, - Exprs: expressions, - }) -} - -func (m *AclManager) createChain(name string) *nftables.Chain { - chain := &nftables.Chain{ - Name: name, - Table: m.workTable, - } - - chain = m.rConn.AddChain(chain) - - insertReturnTrafficRule(m.rConn, m.workTable, chain) - - return chain -} - -func (m *AclManager) createFilterChainWithHook(name string, hookNum *nftables.ChainHook) *nftables.Chain { - polAccept := nftables.ChainPolicyAccept - chain := &nftables.Chain{ - Name: name, - Table: m.workTable, - Hooknum: hookNum, - Priority: nftables.ChainPriorityFilter, - Type: nftables.ChainTypeFilter, - Policy: &polAccept, - } - - return m.rConn.AddChain(chain) -} - -func (m *AclManager) addDropExpressions(chain *nftables.Chain, ifaceKey expr.MetaKey) []expr.Any { - expressions := []expr.Any{ - &expr.Meta{Key: ifaceKey, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: ifname(m.wgIface.Name()), - }, - &expr.Verdict{Kind: expr.VerdictDrop}, - } - _ = m.rConn.AddRule(&nftables.Rule{ - Table: m.workTable, - Chain: chain, - Exprs: expressions, - }) - return nil -} - -func (m *AclManager) addJumpRule(chain *nftables.Chain, to string, ifaceKey expr.MetaKey) { - expressions := []expr.Any{ - &expr.Meta{Key: ifaceKey, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: ifname(m.wgIface.Name()), - }, - &expr.Verdict{ - Kind: expr.VerdictJump, - Chain: to, - }, - } - - _ = m.rConn.AddRule(&nftables.Rule{ - Table: chain.Table, - Chain: chain, - Exprs: expressions, - }) -} - -func (m *AclManager) addIpToSet(ipsetName string, ip net.IP) (*nftables.Set, error) { - ipset, err := m.rConn.GetSetByName(m.workTable, ipsetName) - rawIP := ip.To4() - if err != nil { - if ipset, err = m.createSet(m.workTable, ipsetName); err != nil { - return nil, fmt.Errorf("get set name: %v", err) - } - - m.ipsetStore.newIpset(ipset.Name) - } - - if m.ipsetStore.IsIpInSet(ipset.Name, ip) { - return ipset, nil - } - - if err := m.sConn.SetAddElements(ipset, []nftables.SetElement{{Key: rawIP}}); err != nil { - return nil, fmt.Errorf("add set element for the first time: %v", err) - } - - m.ipsetStore.AddIpToSet(ipset.Name, ip) - - if err := m.sConn.Flush(); err != nil { - return nil, fmt.Errorf("flush add elements: %v", err) - } - - return ipset, nil -} - -// createSet in given table by name -func (m *AclManager) createSet(table *nftables.Table, name string) (*nftables.Set, error) { - ipset := &nftables.Set{ - Name: name, - Table: table, - Dynamic: true, - KeyType: nftables.TypeIPAddr, - } - - if err := m.rConn.AddSet(ipset, nil); err != nil { - return nil, fmt.Errorf("create set: %v", err) - } - - if err := m.rConn.Flush(); err != nil { - return nil, fmt.Errorf("flush created set: %v", err) - } - - return ipset, nil -} - -func (m *AclManager) flushWithBackoff() (err error) { - backoff := 4 - backoffTime := 1000 * time.Millisecond - for i := 0; ; i++ { - err = m.rConn.Flush() - if err != nil { - log.Debugf("failed to flush nftables: %v", err) - if !strings.Contains(err.Error(), "busy") { - return - } - log.Error("failed to flush nftables, retrying...") - if i == backoff-1 { - return err - } - time.Sleep(backoffTime) - backoffTime *= 2 - continue - } - break - } - return -} - -func (m *AclManager) refreshRuleHandles(chain *nftables.Chain, mangle bool) error { - if m.workTable == nil || chain == nil { - return nil - } - - list, err := m.rConn.GetRules(m.workTable, chain) - if err != nil { - return err - } - - for _, rule := range list { - if len(rule.UserData) == 0 { - continue - } - split := bytes.Split(rule.UserData, []byte(" ")) - r, ok := m.rules[string(split[0])] - if ok { - if mangle { - *r.mangleRule = *rule - } else { - *r.nftRule = *rule - } - } - } - - return nil -} - -func generatePeerRuleId(ip net.IP, proto firewall.Protocol, sPort *firewall.Port, dPort *firewall.Port, action firewall.Action, ipset *nftables.Set) string { - rulesetID := ":" + string(proto) + ":" - if sPort != nil { - rulesetID += sPort.String() - } - rulesetID += ":" - if dPort != nil { - rulesetID += dPort.String() - } - rulesetID += ":" - rulesetID += strconv.Itoa(int(action)) - if ipset == nil { - return "ip:" + ip.String() + rulesetID - } - return "set:" + ipset.Name + rulesetID -} - -func ifname(n string) []byte { - b := make([]byte, 16) - copy(b, n+"\x00") - return b -} - -func protoToInt(protocol firewall.Protocol) (uint8, error) { - switch protocol { - case firewall.ProtocolTCP: - return unix.IPPROTO_TCP, nil - case firewall.ProtocolUDP: - return unix.IPPROTO_UDP, nil - case firewall.ProtocolICMP: - return unix.IPPROTO_ICMP, nil - } - - return 0, fmt.Errorf("unsupported protocol: %s", protocol) -} diff --git a/client/firewall/nftables/addr_family_linux.go b/client/firewall/nftables/addr_family_linux.go new file mode 100644 index 000000000..0c90d704a --- /dev/null +++ b/client/firewall/nftables/addr_family_linux.go @@ -0,0 +1,81 @@ +package nftables + +import ( + "fmt" + "net" + + "github.com/google/nftables" + "golang.org/x/sys/unix" + + firewall "github.com/netbirdio/netbird/client/firewall/manager" +) + +var ( + // afIPv4 defines IPv4 header layout and nftables types. + afIPv4 = addrFamily{ + protoOffset: 9, + srcAddrOffset: 12, + dstAddrOffset: 16, + addrLen: net.IPv4len, + totalBits: 8 * net.IPv4len, + setKeyType: nftables.TypeIPAddr, + tableFamily: nftables.TableFamilyIPv4, + icmpProto: unix.IPPROTO_ICMP, + } + // afIPv6 defines IPv6 header layout and nftables types. + afIPv6 = addrFamily{ + protoOffset: 6, + srcAddrOffset: 8, + dstAddrOffset: 24, + addrLen: net.IPv6len, + totalBits: 8 * net.IPv6len, + setKeyType: nftables.TypeIP6Addr, + tableFamily: nftables.TableFamilyIPv6, + icmpProto: unix.IPPROTO_ICMPV6, + } +) + +// addrFamily holds protocol-specific constants for nftables expression building. +type addrFamily struct { + // protoOffset is the IP header offset for the protocol/next-header field (9 for v4, 6 for v6) + protoOffset uint32 + // srcAddrOffset is the IP header offset for the source address (12 for v4, 8 for v6) + srcAddrOffset uint32 + // dstAddrOffset is the IP header offset for the destination address (16 for v4, 24 for v6) + dstAddrOffset uint32 + // addrLen is the byte length of addresses (4 for v4, 16 for v6) + addrLen uint32 + // totalBits is the address size in bits (32 for v4, 128 for v6) + totalBits int + // setKeyType is the nftables set data type for addresses + setKeyType nftables.SetDatatype + // tableFamily is the nftables table family + tableFamily nftables.TableFamily + // icmpProto is the ICMP protocol number for this family (1 for v4, 58 for v6) + icmpProto uint8 +} + +// familyForAddr returns the address family for the given IP. +func familyForAddr(is4 bool) addrFamily { + if is4 { + return afIPv4 + } + return afIPv6 +} + +// protoNum converts a firewall protocol to the IP protocol number, +// using the correct ICMP variant for the address family. +func (af addrFamily) protoNum(protocol firewall.Protocol) (uint8, error) { + switch protocol { + case firewall.ProtocolTCP: + return unix.IPPROTO_TCP, nil + case firewall.ProtocolUDP: + return unix.IPPROTO_UDP, nil + case firewall.ProtocolICMP: + return af.icmpProto, nil + case firewall.ProtocolALL: + return 0, nil + default: + return 0, fmt.Errorf("unsupported protocol: %s", protocol) + } +} diff --git a/client/firewall/nftables/chains_linux.go b/client/firewall/nftables/chains_linux.go new file mode 100644 index 000000000..71f0c60f2 --- /dev/null +++ b/client/firewall/nftables/chains_linux.go @@ -0,0 +1,880 @@ +//go:build !android + +package nftables + +import ( + "bytes" + "errors" + "fmt" + "slices" + "strings" + "time" + + "github.com/coreos/go-iptables/iptables" + "github.com/google/nftables" + "github.com/google/nftables/binaryutil" + "github.com/google/nftables/expr" + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + "golang.org/x/sys/unix" + + nberrors "github.com/netbirdio/netbird/client/errors" + "github.com/netbirdio/netbird/client/firewall/firewalld" + firewall "github.com/netbirdio/netbird/client/firewall/manager" + nbnet "github.com/netbirdio/netbird/client/net" +) + +func (r *family) createContainers() error { + r.chains[chainNameRoutingFw] = r.conn.AddChain(&nftables.Chain{ + Name: chainNameRoutingFw, + Table: r.workTable, + }) + + prio := *nftables.ChainPriorityNATSource - 1 + r.chains[chainNameRoutingNat] = r.conn.AddChain(&nftables.Chain{ + Name: chainNameRoutingNat, + Table: r.workTable, + Hooknum: nftables.ChainHookPostrouting, + Priority: &prio, + Type: nftables.ChainTypeNAT, + }) + + r.chains[chainNameRoutingRdr] = r.conn.AddChain(&nftables.Chain{ + Name: chainNameRoutingRdr, + Table: r.workTable, + Hooknum: nftables.ChainHookPrerouting, + Priority: nftables.ChainPriorityNATDest, + Type: nftables.ChainTypeNAT, + }) + + r.chains[chainNameManglePostrouting] = r.conn.AddChain(&nftables.Chain{ + Name: chainNameManglePostrouting, + Table: r.workTable, + Hooknum: nftables.ChainHookPostrouting, + Priority: nftables.ChainPriorityMangle, + Type: nftables.ChainTypeFilter, + }) + + r.chains[chainNameManglePrerouting] = r.conn.AddChain(&nftables.Chain{ + Name: chainNameManglePrerouting, + Table: r.workTable, + Hooknum: nftables.ChainHookPrerouting, + Priority: nftables.ChainPriorityMangle, + Type: nftables.ChainTypeFilter, + }) + + r.chains[chainNameMangleForward] = r.conn.AddChain(&nftables.Chain{ + Name: chainNameMangleForward, + Table: r.workTable, + Hooknum: nftables.ChainHookForward, + Priority: nftables.ChainPriorityMangle, + Type: nftables.ChainTypeFilter, + }) + + insertReturnTrafficRule(r.conn, r.workTable, r.chains[chainNameRoutingFw]) + + r.addPostroutingRules() + + if err := r.conn.Flush(); err != nil { + return fmt.Errorf("initialize tables: %v", err) + } + + if err := r.addMSSClampingRules(); err != nil { + log.Errorf("failed to add MSS clamping rules: %s", err) + } + + // Kernel routing opens both INPUT and FORWARD. + if err := r.openInterface(true); err != nil { + log.Errorf("failed to open interface in foreign chains: %s", err) + } + + if err := firewalld.TrustInterface(r.wgIface.Name()); err != nil { + log.Warnf("failed to trust interface in firewalld: %v", err) + } + + if err := r.refreshRulesMap(); err != nil { + log.Errorf("failed to refresh rules: %s", err) + } + + return nil +} + +// setupDataPlaneMark configures the fwmark for the data plane +func (r *family) setupDataPlaneMark() error { + if r.chains[chainNameManglePrerouting] == nil || r.chains[chainNameManglePostrouting] == nil { + return errors.New("no mangle chains found") + } + + ctNew := getCtNewExprs() + preExprs := []expr.Any{ + &expr.Meta{ + Key: expr.MetaKeyIIFNAME, + Register: 1, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: ifname(r.wgIface.Name()), + }, + } + preExprs = append(preExprs, ctNew...) + preExprs = append(preExprs, + &expr.Immediate{ + Register: 1, + Data: binaryutil.NativeEndian.PutUint32(nbnet.DataPlaneMarkIn), + }, + &expr.Ct{ + Key: expr.CtKeyMARK, + Register: 1, + SourceRegister: true, + }, + ) + + preNftRule := &nftables.Rule{ + Table: r.workTable, + Chain: r.chains[chainNameManglePrerouting], + Exprs: preExprs, + } + r.conn.AddRule(preNftRule) + + postExprs := []expr.Any{ + &expr.Meta{ + Key: expr.MetaKeyOIFNAME, + Register: 1, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: ifname(r.wgIface.Name()), + }, + } + postExprs = append(postExprs, ctNew...) + postExprs = append(postExprs, + &expr.Immediate{ + Register: 1, + Data: binaryutil.NativeEndian.PutUint32(nbnet.DataPlaneMarkOut), + }, + &expr.Ct{ + Key: expr.CtKeyMARK, + Register: 1, + SourceRegister: true, + }, + ) + + postNftRule := &nftables.Rule{ + Table: r.workTable, + Chain: r.chains[chainNameManglePostrouting], + Exprs: postExprs, + } + r.conn.AddRule(postNftRule) + + if err := r.conn.Flush(); err != nil { + return fmt.Errorf("flush: %w", err) + } + + return nil +} + +// openInterface adds passthrough accept rules for the NetBird interface to the +// kernel's filter table and external chains so they don't drop our traffic. +// includeForward also opens the FORWARD chains (kernel routing); when false only +// INPUT is opened, which is all the userspace router needs since it never +// forwards in the kernel. +func (r *family) openInterface(includeForward bool) error { + var merr *multierror.Error + + if err := r.acceptFilterTableRules(includeForward); err != nil { + merr = multierror.Append(merr, err) + } + + if err := r.acceptExternalChainsRules(includeForward); err != nil { + merr = multierror.Append(merr, fmt.Errorf("add accept rules to external chains: %w", err)) + } + + return nberrors.FormatErrorOrNil(merr) +} + +func (r *family) acceptFilterTableRules(includeForward bool) error { + if r.filterTable == nil { + return nil + } + + fw := "iptables" + + defer func() { + log.Debugf("Used %s to add accept input/forward rules", fw) + }() + + // Try iptables first and fallback to nftables if iptables is not available. + // Use the correct protocol (iptables vs ip6tables) for the address family. + ipt, err := iptables.NewWithProtocol(r.iptablesProto()) + if err != nil { + log.Warnf("Will use nftables to manipulate the filter table because iptables is not available: %v", err) + + fw = "nftables" + return r.acceptFilterRulesNftables(r.filterTable, includeForward) + } + + if err := r.acceptFilterRulesIptables(ipt, includeForward); err != nil { + log.Warnf("iptables failed (table may be incompatible), falling back to nftables: %v", err) + fw = "nftables" + return r.acceptFilterRulesNftables(r.filterTable, includeForward) + } + return nil +} + +func (r *family) acceptFilterRulesIptables(ipt *iptables.IPTables, includeForward bool) error { + var merr *multierror.Error + + if includeForward { + for _, rule := range r.getAcceptForwardRules() { + if err := ipt.Insert("filter", chainNameForward, 1, rule...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("add iptables forward rule: %v", err)) + } else { + log.Debugf("added iptables forward rule: %v", rule) + } + } + } + + inputRule := r.getAcceptInputRule() + if err := ipt.Insert("filter", chainNameInput, 1, inputRule...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("add iptables input rule: %v", err)) + } else { + log.Debugf("added iptables input rule: %v", inputRule) + } + + return nberrors.FormatErrorOrNil(merr) +} + +func (r *family) getAcceptForwardRules() [][]string { + intf := r.wgIface.Name() + return [][]string{ + {"-i", intf, "-j", "ACCEPT"}, + {"-o", intf, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "ACCEPT"}, + } +} + +func (r *family) getAcceptInputRule() []string { + return []string{"-i", r.wgIface.Name(), "-j", "ACCEPT"} +} + +// acceptFilterRulesNftables adds accept rules to the ip filter table using nftables. +// This is used when iptables is not available. +func (r *family) acceptFilterRulesNftables(table *nftables.Table, includeForward bool) error { + intf := ifname(r.wgIface.Name()) + + if includeForward { + forwardChain := &nftables.Chain{ + Name: chainNameForward, + Table: table, + Type: nftables.ChainTypeFilter, + Hooknum: nftables.ChainHookForward, + Priority: nftables.ChainPriorityFilter, + } + r.insertForwardAcceptRules(forwardChain, intf) + } + + inputChain := &nftables.Chain{ + Name: chainNameInput, + Table: table, + Type: nftables.ChainTypeFilter, + Hooknum: nftables.ChainHookInput, + Priority: nftables.ChainPriorityFilter, + } + r.insertInputAcceptRule(inputChain, intf) + + return r.conn.Flush() +} + +// acceptExternalChainsRules adds accept rules to external chains (non-netbird, non-iptables tables). +// It dynamically finds chains at call time to handle chains that may have been created after startup. +func (r *family) acceptExternalChainsRules(includeForward bool) error { + chains := r.findExternalChains() + if len(chains) == 0 { + return nil + } + + intf := ifname(r.wgIface.Name()) + for _, chain := range chains { + r.applyExternalChainAccept(chain, intf, includeForward) + } + + if err := r.conn.Flush(); err != nil { + return fmt.Errorf("flush external chain rules: %w", err) + } + return nil +} + +func (r *family) applyExternalChainAccept(chain *nftables.Chain, intf []byte, includeForward bool) { + if chain.Hooknum == nil { + log.Debugf("skipping external chain %s/%s: hooknum is nil", chain.Table.Name, chain.Name) + return + } + + log.Debugf("adding accept rules to external %s chain: %s %s/%s", + hookName(chain.Hooknum), familyName(chain.Table.Family), chain.Table.Name, chain.Name) + + switch *chain.Hooknum { + case *nftables.ChainHookForward: + if includeForward { + r.insertForwardAcceptRules(chain, intf) + } + case *nftables.ChainHookInput: + r.insertInputAcceptRule(chain, intf) + } +} + +func (r *family) insertForwardAcceptRules(chain *nftables.Chain, intf []byte) { + existing, err := r.existingNetbirdRulesInChain(chain) + if err != nil { + log.Warnf("skip forward accept rules in %s/%s: %v", chain.Table.Name, chain.Name, err) + return + } + r.insertForwardIifRule(chain, intf, existing) + r.insertForwardOifEstablishedRule(chain, intf, existing) +} + +func (r *family) insertForwardIifRule(chain *nftables.Chain, intf []byte, existing map[string]bool) { + if existing[userDataAcceptForwardRuleIif] { + return + } + r.conn.InsertRule(&nftables.Rule{ + Table: chain.Table, + Chain: chain, + Exprs: []expr.Any{ + &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: intf}, + &expr.Counter{}, + &expr.Verdict{Kind: expr.VerdictAccept}, + }, + UserData: []byte(userDataAcceptForwardRuleIif), + }) +} + +func (r *family) insertForwardOifEstablishedRule(chain *nftables.Chain, intf []byte, existing map[string]bool) { + if existing[userDataAcceptForwardRuleOif] { + return + } + exprs := []expr.Any{ + &expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: intf}, + } + r.conn.InsertRule(&nftables.Rule{ + Table: chain.Table, + Chain: chain, + Exprs: append(exprs, getEstablishedExprs(2)...), + UserData: []byte(userDataAcceptForwardRuleOif), + }) +} + +func (r *family) insertInputAcceptRule(chain *nftables.Chain, intf []byte) { + existing, err := r.existingNetbirdRulesInChain(chain) + if err != nil { + log.Warnf("skip input accept rule in %s/%s: %v", chain.Table.Name, chain.Name, err) + return + } + if existing[userDataAcceptInputRule] { + return + } + r.conn.InsertRule(&nftables.Rule{ + Table: chain.Table, + Chain: chain, + Exprs: []expr.Any{ + &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: intf}, + &expr.Counter{}, + &expr.Verdict{Kind: expr.VerdictAccept}, + }, + UserData: []byte(userDataAcceptInputRule), + }) +} + +// existingNetbirdRulesInChain returns the set of netbird-owned UserData tags present in a chain; callers must bail on error since InsertRule is additive. +func (r *family) existingNetbirdRulesInChain(chain *nftables.Chain) (map[string]bool, error) { + rules, err := r.conn.GetRules(chain.Table, chain) + if err != nil { + return nil, fmt.Errorf("list rules: %w", err) + } + present := map[string]bool{} + for _, rule := range rules { + if !isNetbirdAcceptRuleTag(rule.UserData) { + continue + } + present[string(rule.UserData)] = true + } + return present, nil +} + +func isNetbirdAcceptRuleTag(userData []byte) bool { + switch string(userData) { + case userDataAcceptForwardRuleIif, + userDataAcceptForwardRuleOif, + userDataAcceptInputRule: + return true + } + return false +} + +func (r *family) removeAcceptFilterRules() error { + var merr *multierror.Error + + if err := r.removeFilterTableRules(); err != nil { + merr = multierror.Append(merr, err) + } + + if err := r.removeExternalChainsRules(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove external chain rules: %w", err)) + } + + return nberrors.FormatErrorOrNil(merr) +} + +func (r *family) removeFilterTableRules() error { + if r.filterTable == nil { + return nil + } + + ipt, err := iptables.NewWithProtocol(r.iptablesProto()) + if err != nil { + log.Debugf("iptables not available, using nftables to remove filter rules: %v", err) + return r.removeAcceptRulesFromTable(r.filterTable) + } + + if err := r.removeAcceptFilterRulesIptables(ipt); err != nil { + log.Debugf("iptables removal failed (table may be incompatible), falling back to nftables: %v", err) + return r.removeAcceptRulesFromTable(r.filterTable) + } + return nil +} + +func (r *family) removeAcceptRulesFromTable(table *nftables.Table) error { + chains, err := r.conn.ListChainsOfTableFamily(table.Family) + if err != nil { + return fmt.Errorf("list chains: %v", err) + } + + for _, chain := range chains { + if chain.Table.Name != table.Name { + continue + } + + if chain.Name != chainNameForward && chain.Name != chainNameInput { + continue + } + + if err := r.removeAcceptRulesFromChain(table, chain); err != nil { + return err + } + } + + return r.conn.Flush() +} + +func (r *family) removeAcceptRulesFromChain(table *nftables.Table, chain *nftables.Chain) error { + rules, err := r.conn.GetRules(table, chain) + if err != nil { + return fmt.Errorf("get rules from %s/%s: %v", table.Name, chain.Name, err) + } + + for _, rule := range rules { + if bytes.Equal(rule.UserData, []byte(userDataAcceptForwardRuleIif)) || + bytes.Equal(rule.UserData, []byte(userDataAcceptForwardRuleOif)) || + bytes.Equal(rule.UserData, []byte(userDataAcceptInputRule)) { + if err := r.conn.DelRule(rule); err != nil { + return fmt.Errorf("delete rule from %s/%s: %v", table.Name, chain.Name, err) + } + } + } + return nil +} + +// removeExternalChainsRules removes our accept rules from all external chains. +// This is deterministic - it scans for chains at removal time rather than relying on saved state, +// ensuring cleanup works even after a crash or if chains changed. +func (r *family) removeExternalChainsRules() error { + chains := r.findExternalChains() + if len(chains) == 0 { + return nil + } + + var merr *multierror.Error + for _, chain := range chains { + if err := r.removeAcceptRulesFromChain(chain.Table, chain); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove rules from external chain %s/%s: %w", chain.Table.Name, chain.Name, err)) + continue + } + if err := r.conn.Flush(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("flush external chain %s/%s: %w", chain.Table.Name, chain.Name, err)) + } + } + + return nberrors.FormatErrorOrNil(merr) +} + +// findExternalChains scans for chains from non-netbird tables that have FORWARD or INPUT hooks. +// This is used both at startup (to know where to add rules) and at cleanup (to ensure deterministic removal). +func (r *family) findExternalChains() []*nftables.Chain { + var chains []*nftables.Chain + + families := []nftables.TableFamily{r.af.tableFamily, nftables.TableFamilyINet} + + for _, family := range families { + allChains, err := r.conn.ListChainsOfTableFamily(family) + if err != nil { + log.Debugf("list chains for family %d: %v", family, err) + continue + } + + for _, chain := range allChains { + if r.isExternalChain(chain) { + chains = append(chains, chain) + } + } + } + + return chains +} + +func (r *family) isExternalChain(chain *nftables.Chain) bool { + if r.workTable != nil && chain.Table.Name == r.workTable.Name { + return false + } + + // Skip firewalld-owned chains. Firewalld creates its chains with the + // NFT_CHAIN_OWNER flag, so inserting rules into them returns EPERM. + // We delegate acceptance to firewalld by trusting the interface instead. + if chain.Table.Name == firewalldTableName { + return false + } + + // Skip iptables/ip6tables-managed tables (adding nft-native rules breaks iptables-save compat) + if (chain.Table.Family == nftables.TableFamilyIPv4 || chain.Table.Family == nftables.TableFamilyIPv6) && isIptablesTable(chain.Table.Name) { + return false + } + + if chain.Type != nftables.ChainTypeFilter { + return false + } + + if chain.Hooknum == nil { + return false + } + + return *chain.Hooknum == *nftables.ChainHookForward || *chain.Hooknum == *nftables.ChainHookInput +} + +func isIptablesTable(name string) bool { + switch name { + case tableNameFilter, tableNat, tableMangle, tableRaw, tableSecurity: + return true + } + return false +} + +func (r *family) removeAcceptFilterRulesIptables(ipt *iptables.IPTables) error { + var merr *multierror.Error + + for _, rule := range r.getAcceptForwardRules() { + if err := ipt.DeleteIfExists("filter", chainNameForward, rule...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove iptables forward rule: %v", err)) + } + } + + inputRule := r.getAcceptInputRule() + if err := ipt.DeleteIfExists("filter", chainNameInput, inputRule...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove iptables input rule: %v", err)) + } + + return nberrors.FormatErrorOrNil(merr) +} + +// Flush rule/chain/set operations from the buffer +// +// Method also get all rules after flush and refreshes handle values in the rulesets +func (r *family) Flush() error { + if err := r.flushWithBackoff(); err != nil { + return err + } + + if err := r.refreshRuleHandles(r.chainInputRules, false); err != nil { + log.Errorf("failed to refresh rule handles ipv4 input chain: %v", err) + } + if err := r.refreshRuleHandles(r.chainPrerouting, true); err != nil { + log.Errorf("failed to refresh rule handles prerouting chain: %v", err) + } + + return nil +} + +// queuePreroutingRule builds the prerouting mangle rule that marks +// redirected traffic and queues it on the connection without flushing, +// so the caller can commit it in the same transaction as the rule it +// pairs with. Returns nil when the prerouting chain is absent, in which +// case nothing is queued. +func (r *family) queuePreroutingRule(expressions []expr.Any, userData []byte) *nftables.Rule { + if r.chainPrerouting == nil { + log.Warn("prerouting chain is not created") + return nil + } + + preroutingExprs := slices.Clone(expressions) + + // interface + preroutingExprs = append([]expr.Any{ + &expr.Meta{ + Key: expr.MetaKeyIIFNAME, + Register: 1, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: ifname(r.wgIface.Name()), + }, + }, preroutingExprs...) + + // local destination and mark + preroutingExprs = append(preroutingExprs, + &expr.Fib{ + Register: 1, + ResultADDRTYPE: true, + FlagDADDR: true, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: binaryutil.NativeEndian.PutUint32(unix.RTN_LOCAL), + }, + + &expr.Immediate{ + Register: 1, + Data: binaryutil.NativeEndian.PutUint32(nbnet.PreroutingFwmarkRedirected), + }, + &expr.Meta{ + Key: expr.MetaKeyMARK, + Register: 1, + SourceRegister: true, + }, + ) + + return r.conn.AddRule(&nftables.Rule{ + Table: r.workTable, + Chain: r.chainPrerouting, + Exprs: preroutingExprs, + UserData: userData, + }) +} + +func (r *family) createDefaultChains() (err error) { + // chainNameInputRules + chain := r.createChain(chainNameInputRules) + err = r.conn.Flush() + if err != nil { + log.Debugf("failed to create chain (%s): %s", chain.Name, err) + return fmt.Errorf(flushError, err) + } + r.chainInputRules = chain + + // netbird-acl-input-filter + // type filter hook input priority filter; policy accept; + chain = r.createFilterChainWithHook(chainNameInputFilter, nftables.ChainHookInput) + r.addJumpRule(chain, r.chainInputRules.Name, expr.MetaKeyIIFNAME) // to netbird-acl-input-rules + r.addDropExpressions(chain, expr.MetaKeyIIFNAME) + err = r.conn.Flush() + if err != nil { + log.Debugf("failed to create chain (%s): %s", chain.Name, err) + return err + } + + // netbird-acl-forward-filter + chainFwFilter := r.createFilterChainWithHook(chainNameForwardFilter, nftables.ChainHookForward) + r.addJumpRulesToRtForward(chainFwFilter) // to netbird-rt-fwd + r.addDropExpressions(chainFwFilter, expr.MetaKeyIIFNAME) + + err = r.conn.Flush() + if err != nil { + log.Debugf("failed to create chain (%s): %s", chainNameForwardFilter, err) + return fmt.Errorf(flushError, err) + } + + if err := r.allowRedirectedTraffic(chainFwFilter); err != nil { + log.Errorf("failed to allow redirected traffic: %s", err) + } + + return nil +} + +// Makes redirected traffic originally destined for the host itself (now subject to the forward filter) +// go through the input filter as well. This will enable e.g. Docker services to keep working by accessing the +// netbird peer IP. +func (r *family) allowRedirectedTraffic(chainFwFilter *nftables.Chain) error { + r.chainPrerouting = r.chains[chainNameManglePrerouting] + + r.addFwmarkToForward(chainFwFilter) + + if err := r.conn.Flush(); err != nil { + return fmt.Errorf(flushError, err) + } + + return nil +} + +func (r *family) addFwmarkToForward(chainFwFilter *nftables.Chain) { + r.conn.InsertRule(&nftables.Rule{ + Table: r.workTable, + Chain: chainFwFilter, + Exprs: []expr.Any{ + &expr.Meta{ + Key: expr.MetaKeyMARK, + Register: 1, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: binaryutil.NativeEndian.PutUint32(nbnet.PreroutingFwmarkRedirected), + }, + &expr.Verdict{ + Kind: expr.VerdictAccept, + }, + }, + }) +} + +func (r *family) addJumpRulesToRtForward(chainFwFilter *nftables.Chain) { + expressions := []expr.Any{ + &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: ifname(r.wgIface.Name()), + }, + &expr.Verdict{ + Kind: expr.VerdictJump, + Chain: r.routingFwChainName, + }, + } + + _ = r.conn.AddRule(&nftables.Rule{ + Table: r.workTable, + Chain: chainFwFilter, + Exprs: expressions, + }) +} + +func (r *family) createChain(name string) *nftables.Chain { + chain := &nftables.Chain{ + Name: name, + Table: r.workTable, + } + + chain = r.conn.AddChain(chain) + + insertReturnTrafficRule(r.conn, r.workTable, chain) + + return chain +} + +func (r *family) createFilterChainWithHook(name string, hookNum *nftables.ChainHook) *nftables.Chain { + polAccept := nftables.ChainPolicyAccept + chain := &nftables.Chain{ + Name: name, + Table: r.workTable, + Hooknum: hookNum, + Priority: nftables.ChainPriorityFilter, + Type: nftables.ChainTypeFilter, + Policy: &polAccept, + } + + return r.conn.AddChain(chain) +} + +func (r *family) addDropExpressions(chain *nftables.Chain, ifaceKey expr.MetaKey) []expr.Any { + expressions := []expr.Any{ + &expr.Meta{Key: ifaceKey, Register: 1}, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: ifname(r.wgIface.Name()), + }, + &expr.Verdict{Kind: expr.VerdictDrop}, + } + _ = r.conn.AddRule(&nftables.Rule{ + Table: r.workTable, + Chain: chain, + Exprs: expressions, + }) + return nil +} + +func (r *family) addJumpRule(chain *nftables.Chain, to string, ifaceKey expr.MetaKey) { + expressions := []expr.Any{ + &expr.Meta{Key: ifaceKey, Register: 1}, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: ifname(r.wgIface.Name()), + }, + &expr.Verdict{ + Kind: expr.VerdictJump, + Chain: to, + }, + } + + _ = r.conn.AddRule(&nftables.Rule{ + Table: chain.Table, + Chain: chain, + Exprs: expressions, + }) +} + +func (r *family) flushWithBackoff() (err error) { + backoff := 4 + backoffTime := 1000 * time.Millisecond + for i := 0; ; i++ { + err = r.conn.Flush() + if err != nil { + log.Debugf("failed to flush nftables: %v", err) + if !strings.Contains(err.Error(), "busy") { + return + } + log.Error("failed to flush nftables, retrying...") + if i == backoff-1 { + return err + } + time.Sleep(backoffTime) + backoffTime *= 2 + continue + } + break + } + return +} + +func (r *family) refreshRuleHandles(chain *nftables.Chain, mangle bool) error { + if r.workTable == nil || chain == nil { + return nil + } + + list, err := r.conn.GetRules(r.workTable, chain) + if err != nil { + return err + } + + for _, rule := range list { + if len(rule.UserData) == 0 { + continue + } + pr, ok := r.filters[firewall.RuleID(rule.UserData)] + if !ok { + continue + } + if mangle { + if pr.mangleRule != nil { + *pr.mangleRule = *rule + } + } else { + *pr.nftRule = *rule + } + } + + return nil +} diff --git a/client/firewall/nftables/dnat_linux.go b/client/firewall/nftables/dnat_linux.go new file mode 100644 index 000000000..8eae694a2 --- /dev/null +++ b/client/firewall/nftables/dnat_linux.go @@ -0,0 +1,573 @@ +//go:build !android + +package nftables + +import ( + "fmt" + "net/netip" + + "github.com/google/nftables" + "github.com/google/nftables/binaryutil" + "github.com/google/nftables/expr" + "github.com/google/nftables/xt" + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + + nberrors "github.com/netbirdio/netbird/client/errors" + firewall "github.com/netbirdio/netbird/client/firewall/manager" +) + +func (r *family) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) { + ruleID := rule.ID() + if _, exists := r.rules[ruleID+dnatSuffix]; exists { + return rule, nil + } + + protoNum, err := r.af.protoNum(rule.Protocol) + if err != nil { + return nil, fmt.Errorf("convert protocol to number: %w", err) + } + + // Request forwarding before queueing rules: addDnatRedirect/addDnatMasq + // buffer netlink messages on r.conn that the next caller's Flush would + // commit if we returned without flushing them ourselves. + if err := r.ipFwdState.RequestForwarding(r.isV6()); err != nil { + return nil, fmt.Errorf("enable forwarding: %w", err) + } + + if err := r.addDnatRedirect(rule, protoNum, ruleID); err != nil { + r.releaseForwarding() + return nil, err + } + + if err := r.addDnatMasq(rule, protoNum, ruleID); err != nil { + r.releaseForwarding() + delete(r.rules, ruleID+dnatSuffix) + return nil, err + } + + // Unlike iptables, there's no point in adding "out" rules in the forward chain here as our policy is ACCEPT. + // To overcome DROP policies in other chains, we'd have to add rules to the chains there. + // We also cannot just add "oif accept" there and filter in our own table as we don't know what is supposed to be allowed. + // TODO: find chains with drop policies and add rules there + + if err := r.conn.Flush(); err != nil { + r.releaseForwarding() + delete(r.rules, ruleID+dnatSuffix) + delete(r.rules, ruleID+snatSuffix) + return nil, fmt.Errorf("flush rules: %w", err) + } + + return &rule, nil +} + +func (r *family) addDnatRedirect(rule firewall.ForwardRule, protoNum uint8, ruleID firewall.RuleID) error { + dnatExprs := []expr.Any{ + &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, + &expr.Cmp{ + Op: expr.CmpOpNeq, + Register: 1, + Data: ifname(r.wgIface.Name()), + }, + &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: []byte{protoNum}, + }, + &expr.Payload{ + DestRegister: 1, + Base: expr.PayloadBaseTransportHeader, + Offset: 2, + Len: 2, + }, + } + portExprs, err := r.applyPort(&rule.DestinationPort, false) + if err != nil { + return fmt.Errorf("apply destination port: %w", err) + } + dnatExprs = append(dnatExprs, portExprs...) + + // shifted translated port is not supported in nftables, so we hand this over to xtables + if rule.TranslatedPort.IsRange && len(rule.TranslatedPort.Values) == 2 { + if rule.TranslatedPort.Values[0] != rule.DestinationPort.Values[0] || + rule.TranslatedPort.Values[1] != rule.DestinationPort.Values[1] { + return r.addXTablesRedirect(dnatExprs, ruleID, rule) + } + } + + additionalExprs, regProtoMin, regProtoMax, err := r.handleTranslatedPort(rule) + if err != nil { + return err + } + dnatExprs = append(dnatExprs, additionalExprs...) + + dnatExprs = append(dnatExprs, + &expr.NAT{ + Type: expr.NATTypeDestNAT, + Family: uint32(r.af.tableFamily), + RegAddrMin: 1, + RegProtoMin: regProtoMin, + RegProtoMax: regProtoMax, + }, + ) + + dnatRule := &nftables.Rule{ + Table: r.workTable, + Chain: r.chains[chainNameRoutingRdr], + Exprs: dnatExprs, + UserData: []byte(ruleID + dnatSuffix), + } + r.conn.AddRule(dnatRule) + r.rules[ruleID+dnatSuffix] = dnatRule + + return nil +} + +func (r *family) handleTranslatedPort(rule firewall.ForwardRule) ([]expr.Any, uint32, uint32, error) { + switch { + case rule.TranslatedPort.IsRange && len(rule.TranslatedPort.Values) == 2: + return r.handlePortRange(rule) + case len(rule.TranslatedPort.Values) == 0: + return r.handleAddressOnly(rule) + case len(rule.TranslatedPort.Values) == 1: + return r.handleSinglePort(rule) + default: + return nil, 0, 0, fmt.Errorf("invalid translated port: %v", rule.TranslatedPort) + } +} + +func (r *family) handlePortRange(rule firewall.ForwardRule) ([]expr.Any, uint32, uint32, error) { + exprs := []expr.Any{ + &expr.Immediate{ + Register: 1, + Data: rule.TranslatedAddress.AsSlice(), + }, + &expr.Immediate{ + Register: 2, + Data: binaryutil.BigEndian.PutUint16(rule.TranslatedPort.Values[0]), + }, + &expr.Immediate{ + Register: 3, + Data: binaryutil.BigEndian.PutUint16(rule.TranslatedPort.Values[1]), + }, + } + return exprs, 2, 3, nil +} + +func (r *family) handleAddressOnly(rule firewall.ForwardRule) ([]expr.Any, uint32, uint32, error) { + exprs := []expr.Any{ + &expr.Immediate{ + Register: 1, + Data: rule.TranslatedAddress.AsSlice(), + }, + } + return exprs, 0, 0, nil +} + +func (r *family) handleSinglePort(rule firewall.ForwardRule) ([]expr.Any, uint32, uint32, error) { + exprs := []expr.Any{ + &expr.Immediate{ + Register: 1, + Data: rule.TranslatedAddress.AsSlice(), + }, + &expr.Immediate{ + Register: 2, + Data: binaryutil.BigEndian.PutUint16(rule.TranslatedPort.Values[0]), + }, + } + return exprs, 2, 0, nil +} + +func (r *family) addXTablesRedirect(dnatExprs []expr.Any, ruleID firewall.RuleID, rule firewall.ForwardRule) error { + dnatExprs = append(dnatExprs, + &expr.Counter{}, + &expr.Target{ + Name: "DNAT", + Rev: 2, + Info: &xt.NatRange2{ + NatRange: xt.NatRange{ + Flags: uint(xt.NatRangeMapIPs | xt.NatRangeProtoSpecified | xt.NatRangeProtoOffset), + MinIP: rule.TranslatedAddress.AsSlice(), + MaxIP: rule.TranslatedAddress.AsSlice(), + MinPort: rule.TranslatedPort.Values[0], + MaxPort: rule.TranslatedPort.Values[1], + }, + BasePort: rule.DestinationPort.Values[0], + }, + }, + ) + + natTable := &nftables.Table{ + Name: tableNat, + Family: r.af.tableFamily, + } + dnatRule := &nftables.Rule{ + Table: natTable, + Chain: &nftables.Chain{ + Name: chainNameNatPrerouting, + Table: natTable, + Type: nftables.ChainTypeNAT, + Hooknum: nftables.ChainHookPrerouting, + Priority: nftables.ChainPriorityNATDest, + }, + Exprs: dnatExprs, + UserData: []byte(ruleID + dnatSuffix), + } + r.conn.AddRule(dnatRule) + r.rules[ruleID+dnatSuffix] = dnatRule + + return nil +} + +func (r *family) addDnatMasq(rule firewall.ForwardRule, protoNum uint8, ruleID firewall.RuleID) error { + portExprs, err := r.applyPort(&rule.TranslatedPort, false) + if err != nil { + return fmt.Errorf("apply translated port: %w", err) + } + + masqExprs := []expr.Any{ + &expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1}, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: ifname(r.wgIface.Name()), + }, + &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: []byte{protoNum}, + }, + &expr.Payload{ + DestRegister: 1, + Base: expr.PayloadBaseNetworkHeader, + Offset: r.af.dstAddrOffset, + Len: r.af.addrLen, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: rule.TranslatedAddress.AsSlice(), + }, + } + + masqExprs = append(masqExprs, portExprs...) + masqExprs = append(masqExprs, &expr.Masq{}) + + masqRule := &nftables.Rule{ + Table: r.workTable, + Chain: r.chains[chainNameRoutingNat], + Exprs: masqExprs, + UserData: []byte(ruleID + snatSuffix), + } + r.conn.AddRule(masqRule) + r.rules[ruleID+snatSuffix] = masqRule + + return nil +} + +func (r *family) DeleteDNATRule(rule firewall.Rule) error { + ruleID := rule.ID() + + if err := r.refreshRulesMap(); err != nil { + return fmt.Errorf(refreshRulesMapError, err) + } + + var merr *multierror.Error + var needsFlush bool + var found bool + + if dnatRule, exists := r.rules[ruleID+dnatSuffix]; exists { + found = true + if dnatRule.Handle == 0 { + log.Warnf("dnat rule %s has no handle, removing stale entry", ruleID+dnatSuffix) + delete(r.rules, ruleID+dnatSuffix) + } else if err := r.conn.DelRule(dnatRule); err != nil { + merr = multierror.Append(merr, fmt.Errorf("delete dnat rule: %w", err)) + } else { + needsFlush = true + } + } + + if masqRule, exists := r.rules[ruleID+snatSuffix]; exists { + found = true + if masqRule.Handle == 0 { + log.Warnf("snat rule %s has no handle, removing stale entry", ruleID+snatSuffix) + delete(r.rules, ruleID+snatSuffix) + } else if err := r.conn.DelRule(masqRule); err != nil { + merr = multierror.Append(merr, fmt.Errorf("delete snat rule: %w", err)) + } else { + needsFlush = true + } + } + + if needsFlush { + if err := r.conn.Flush(); err != nil { + merr = multierror.Append(merr, fmt.Errorf(flushError, err)) + } + } + + if merr != nil { + return nberrors.FormatErrorOrNil(merr) + } + + delete(r.rules, ruleID+dnatSuffix) + delete(r.rules, ruleID+snatSuffix) + + // Release once, only if the rule was present and removed. + if found { + r.releaseForwarding() + } + + return nil +} + +// releaseForwarding drops one IP forwarding reference, logging any error. +func (r *family) releaseForwarding() { + if err := r.ipFwdState.ReleaseForwarding(r.isV6()); err != nil { + log.Errorf("release IP forwarding: %v", err) + } +} + +// isV6 reports whether this family handles the IPv6 table. +func (r *family) isV6() bool { + return r.af.tableFamily == nftables.TableFamilyIPv6 +} + +func (r *family) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { + ruleID := firewall.RuleID(fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort)) + + if _, exists := r.rules[ruleID]; exists { + return nil + } + + protoNum, err := r.af.protoNum(protocol) + if err != nil { + return fmt.Errorf("convert protocol to number: %w", err) + } + + exprs := []expr.Any{ + &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: ifname(r.wgIface.Name()), + }, + &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 2}, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 2, + Data: []byte{protoNum}, + }, + &expr.Payload{ + DestRegister: 3, + Base: expr.PayloadBaseTransportHeader, + Offset: 2, + Len: 2, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 3, + Data: binaryutil.BigEndian.PutUint16(originalPort), + }, + } + + bits := 32 + if localAddr.Is6() { + bits = 128 + } + exprs = append(exprs, prefixMatchExprs(r.af, netip.PrefixFrom(localAddr, bits), false)...) + + exprs = append(exprs, + &expr.Immediate{ + Register: 1, + Data: localAddr.AsSlice(), + }, + &expr.Immediate{ + Register: 2, + Data: binaryutil.BigEndian.PutUint16(translatedPort), + }, + &expr.NAT{ + Type: expr.NATTypeDestNAT, + Family: uint32(r.af.tableFamily), + RegAddrMin: 1, + RegProtoMin: 2, + RegProtoMax: 0, + }, + ) + + dnatRule := &nftables.Rule{ + Table: r.workTable, + Chain: r.chains[chainNameRoutingRdr], + Exprs: exprs, + UserData: []byte(ruleID), + } + r.conn.AddRule(dnatRule) + + if err := r.conn.Flush(); err != nil { + return fmt.Errorf("add inbound DNAT rule: %w", err) + } + + r.rules[ruleID] = dnatRule + + return nil +} + +// RemoveInboundDNAT removes an inbound DNAT rule. +func (r *family) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { + if err := r.refreshRulesMap(); err != nil { + return fmt.Errorf(refreshRulesMapError, err) + } + + ruleID := firewall.RuleID(fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort)) + + rule, exists := r.rules[ruleID] + if !exists { + return nil + } + + if rule.Handle == 0 { + log.Warnf("inbound DNAT rule %s has no handle, removing stale entry", ruleID) + delete(r.rules, ruleID) + return nil + } + + if err := r.conn.DelRule(rule); err != nil { + return fmt.Errorf("delete inbound DNAT rule %s: %w", ruleID, err) + } + if err := r.conn.Flush(); err != nil { + return fmt.Errorf("flush delete inbound DNAT rule: %w", err) + } + delete(r.rules, ruleID) + + return nil +} + +// ensureNATOutputChain lazily creates the OUTPUT NAT chain on first use. +func (r *family) ensureNATOutputChain() error { + if _, exists := r.chains[chainNameNATOutput]; exists { + return nil + } + + r.chains[chainNameNATOutput] = r.conn.AddChain(&nftables.Chain{ + Name: chainNameNATOutput, + Table: r.workTable, + Hooknum: nftables.ChainHookOutput, + Priority: nftables.ChainPriorityNATDest, + Type: nftables.ChainTypeNAT, + }) + + if err := r.conn.Flush(); err != nil { + delete(r.chains, chainNameNATOutput) + return fmt.Errorf("create NAT output chain: %w", err) + } + return nil +} + +// AddOutputDNAT adds an OUTPUT chain DNAT rule for locally-generated traffic. +func (r *family) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { + ruleID := firewall.RuleID(fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort)) + + if _, exists := r.rules[ruleID]; exists { + return nil + } + + if err := r.ensureNATOutputChain(); err != nil { + return err + } + + protoNum, err := r.af.protoNum(protocol) + if err != nil { + return fmt.Errorf("convert protocol to number: %w", err) + } + + exprs := []expr.Any{ + &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: []byte{protoNum}, + }, + &expr.Payload{ + DestRegister: 2, + Base: expr.PayloadBaseTransportHeader, + Offset: 2, + Len: 2, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 2, + Data: binaryutil.BigEndian.PutUint16(originalPort), + }, + } + + bits := 32 + if localAddr.Is6() { + bits = 128 + } + exprs = append(exprs, prefixMatchExprs(r.af, netip.PrefixFrom(localAddr, bits), false)...) + + exprs = append(exprs, + &expr.Immediate{ + Register: 1, + Data: localAddr.AsSlice(), + }, + &expr.Immediate{ + Register: 2, + Data: binaryutil.BigEndian.PutUint16(translatedPort), + }, + &expr.NAT{ + Type: expr.NATTypeDestNAT, + Family: uint32(r.af.tableFamily), + RegAddrMin: 1, + RegProtoMin: 2, + }, + ) + + dnatRule := &nftables.Rule{ + Table: r.workTable, + Chain: r.chains[chainNameNATOutput], + Exprs: exprs, + UserData: []byte(ruleID), + } + r.conn.AddRule(dnatRule) + + if err := r.conn.Flush(); err != nil { + return fmt.Errorf("add output DNAT rule: %w", err) + } + + r.rules[ruleID] = dnatRule + + return nil +} + +// RemoveOutputDNAT removes an OUTPUT chain DNAT rule. +func (r *family) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { + if err := r.refreshRulesMap(); err != nil { + return fmt.Errorf(refreshRulesMapError, err) + } + + ruleID := firewall.RuleID(fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort)) + + rule, exists := r.rules[ruleID] + if !exists { + return nil + } + + if rule.Handle == 0 { + log.Warnf("output DNAT rule %s has no handle, removing stale entry", ruleID) + delete(r.rules, ruleID) + return nil + } + + if err := r.conn.DelRule(rule); err != nil { + return fmt.Errorf("delete output DNAT rule %s: %w", ruleID, err) + } + if err := r.conn.Flush(); err != nil { + return fmt.Errorf("flush delete output DNAT rule: %w", err) + } + delete(r.rules, ruleID) + + return nil +} diff --git a/client/firewall/nftables/dnat_refcount_linux_test.go b/client/firewall/nftables/dnat_refcount_linux_test.go new file mode 100644 index 000000000..cdc24e77f --- /dev/null +++ b/client/firewall/nftables/dnat_refcount_linux_test.go @@ -0,0 +1,249 @@ +//go:build privileged + +package nftables + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + fw "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/wgaddr" +) + +func nftRefcountIfaceV4() *iFaceMock { + return &iFaceMock{ + NameFunc: func() string { return "wt-refcount" }, + AddressFunc: func() wgaddr.Address { + return wgaddr.Address{ + IP: netip.MustParseAddr("100.96.0.1"), + Network: netip.MustParsePrefix("100.96.0.0/16"), + } + }, + } +} + +func nftRefcountIfaceDual() *iFaceMock { + return &iFaceMock{ + NameFunc: func() string { return "wt-refcount" }, + AddressFunc: func() wgaddr.Address { + return wgaddr.Address{ + IP: netip.MustParseAddr("100.96.0.1"), + Network: netip.MustParsePrefix("100.96.0.0/16"), + IPv6: netip.MustParseAddr("fd00::1"), + IPv6Net: netip.MustParsePrefix("fd00::/64"), + } + }, + } +} + +func newNftRefcountManager(t *testing.T, dual bool) *Manager { + t.Helper() + if check() != NFTABLES { + t.Skip("nftables not supported on this system") + } + var ifMock *iFaceMock + if dual { + ifMock = nftRefcountIfaceDual() + } else { + ifMock = nftRefcountIfaceV4() + } + m, err := Create(ifMock, iface.DefaultMTU) + require.NoError(t, err, "create manager") + require.NoError(t, m.Init(nil), "init manager") + t.Cleanup(func() { + require.NoError(t, m.Close(nil), "close manager") + }) + return m +} + +func dnatV4(port uint16) fw.ForwardRule { + return fw.ForwardRule{ + Protocol: fw.ProtocolTCP, + DestinationPort: fw.Port{Values: []uint16{port}}, + TranslatedAddress: netip.MustParseAddr("100.96.0.2"), + TranslatedPort: fw.Port{Values: []uint16{80}}, + } +} + +func dnatV6(port uint16) fw.ForwardRule { + return fw.ForwardRule{ + Protocol: fw.ProtocolTCP, + DestinationPort: fw.Port{Values: []uint16{port}}, + TranslatedAddress: netip.MustParseAddr("fd00::2"), + TranslatedPort: fw.Port{Values: []uint16{80}}, + } +} + +// TestNftablesDNAT_RefcountBalancedV4 verifies that Add/Delete pairs leave the +// v4 refcount at zero. +func TestNftablesDNAT_RefcountBalancedV4(t *testing.T) { + m := newNftRefcountManager(t, false) + state := m.family4.ipFwdState + + r1, err := m.AddDNATRule(dnatV4(8081)) + require.NoError(t, err, "add v4 dnat 1") + v4, v6 := state.Counts() + assert.Equal(t, 1, v4, "v4 refcount after first add") + assert.Equal(t, 0, v6, "v6 refcount unchanged") + + r2, err := m.AddDNATRule(dnatV4(8082)) + require.NoError(t, err, "add v4 dnat 2") + v4, v6 = state.Counts() + assert.Equal(t, 2, v4, "v4 refcount after second add") + assert.Equal(t, 0, v6, "v6 refcount unchanged") + + require.NoError(t, m.DeleteDNATRule(r1), "delete v4 dnat 1") + v4, v6 = state.Counts() + assert.Equal(t, 1, v4, "v4 refcount after first delete") + assert.Equal(t, 0, v6, "v6 refcount unchanged") + + require.NoError(t, m.DeleteDNATRule(r2), "delete v4 dnat 2") + v4, v6 = state.Counts() + assert.Equal(t, 0, v4, "v4 refcount after second delete") + assert.Equal(t, 0, v6, "v6 refcount unchanged") +} + +// TestNftablesDNAT_RefcountBalancedV6 verifies the v6 path increments v6 only +// and decrements back to zero on Delete. +func TestNftablesDNAT_RefcountBalancedV6(t *testing.T) { + m := newNftRefcountManager(t, true) + require.NotNil(t, m.family6, "v6 family") + require.Same(t, m.family4.ipFwdState, m.family6.ipFwdState, "shared state") + state := m.family4.ipFwdState + + r1, err := m.AddDNATRule(dnatV6(9091)) + require.NoError(t, err, "add v6 dnat 1") + v4, v6 := state.Counts() + assert.Equal(t, 0, v4, "v4 refcount unchanged") + assert.Equal(t, 1, v6, "v6 refcount after first add") + + r2, err := m.AddDNATRule(dnatV6(9092)) + require.NoError(t, err, "add v6 dnat 2") + v4, v6 = state.Counts() + assert.Equal(t, 0, v4) + assert.Equal(t, 2, v6, "v6 refcount after second add") + + require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat 1") + v4, v6 = state.Counts() + assert.Equal(t, 0, v4, "v4 refcount unchanged") + assert.Equal(t, 1, v6, "v6 refcount after first delete") + + require.NoError(t, m.DeleteDNATRule(r2), "delete v6 dnat 2") + v4, v6 = state.Counts() + assert.Equal(t, 0, v4) + assert.Equal(t, 0, v6, "v6 refcount after second delete") +} + +// TestNftablesDNAT_DuplicateAddNoLeak verifies that a duplicate Add (same +// ForwardRule) does not double-increment the refcount. +func TestNftablesDNAT_DuplicateAddNoLeak(t *testing.T) { + m := newNftRefcountManager(t, true) + state := m.family4.ipFwdState + + rule := dnatV4(8083) + r1, err := m.AddDNATRule(rule) + require.NoError(t, err, "add v4 dnat") + v4, _ := state.Counts() + assert.Equal(t, 1, v4) + + // duplicate add: same rule ID, must be a no-op for the refcount. + _, err = m.AddDNATRule(rule) + require.NoError(t, err, "duplicate add") + v4, _ = state.Counts() + assert.Equal(t, 1, v4, "duplicate add must not increment") + + require.NoError(t, m.DeleteDNATRule(r1), "delete v4 dnat") + v4, _ = state.Counts() + assert.Equal(t, 0, v4, "single delete must drop to zero") +} + +// TestNftablesDNAT_DeleteMissingNoUnderflow verifies deleting a rule that was +// never added does not underflow the refcount. +func TestNftablesDNAT_DeleteMissingNoUnderflow(t *testing.T) { + m := newNftRefcountManager(t, true) + state := m.family4.ipFwdState + + // Construct a Rule reference for something never added. The router stores + // rules by ID(), and DeleteDNATRule looks them up in r.rules; a missing + // entry must be a no-op rather than calling Release. + phantom := dnatV4(8099) + require.NoError(t, m.DeleteDNATRule(&phantom), "delete missing v4 dnat") + v4, v6 := state.Counts() + assert.Equal(t, 0, v4, "v4 refcount unaffected by missing delete") + assert.Equal(t, 0, v6, "v6 refcount unaffected") + + phantom6 := dnatV6(9099) + require.NoError(t, m.DeleteDNATRule(&phantom6), "delete missing v6 dnat") + v4, v6 = state.Counts() + assert.Equal(t, 0, v4) + assert.Equal(t, 0, v6, "v6 refcount unaffected by missing delete") + + // And after a phantom delete, a real add still results in count=1. + r1, err := m.AddDNATRule(dnatV4(8100)) + require.NoError(t, err, "add v4 dnat after phantom delete") + v4, _ = state.Counts() + assert.Equal(t, 1, v4, "real add still increments after phantom delete") + require.NoError(t, m.DeleteDNATRule(r1)) +} + +// TestNftablesRouting_RepeatedEnableSingleReference verifies that EnableRouting +// (called on every network-map update) holds at most one reference per family +// and a single DisableRouting drops both back to zero. +func TestNftablesRouting_RepeatedEnableSingleReference(t *testing.T) { + m := newNftRefcountManager(t, true) + state := m.family4.ipFwdState + + require.NoError(t, m.EnableRouting(), "first enable") + require.NoError(t, m.EnableRouting(), "second enable") + require.NoError(t, m.EnableRouting(), "third enable") + v4, v6 := state.Counts() + assert.Equal(t, 1, v4, "repeated enable holds a single v4 reference") + assert.Equal(t, 1, v6, "repeated enable holds a single v6 reference") + + require.NoError(t, m.DisableRouting(), "disable") + v4, v6 = state.Counts() + assert.Equal(t, 0, v4, "single disable releases the v4 reference") + assert.Equal(t, 0, v6, "single disable releases the v6 reference") +} + +// TestNftablesRouting_DisableKeepsDNATReference verifies that an unpaired +// DisableRouting does not release references held by active DNAT rules. +func TestNftablesRouting_DisableKeepsDNATReference(t *testing.T) { + m := newNftRefcountManager(t, true) + state := m.family4.ipFwdState + + r1, err := m.AddDNATRule(dnatV6(9095)) + require.NoError(t, err, "add v6 dnat") + + require.NoError(t, m.DisableRouting(), "unpaired disable") + _, v6 := state.Counts() + assert.Equal(t, 1, v6, "DNAT-held reference survives unpaired DisableRouting") + + require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat") + _, v6 = state.Counts() + assert.Equal(t, 0, v6, "delete releases the DNAT reference") +} + +// TestNftablesDNAT_DoubleDeleteNoUnderflow verifies that deleting the same rule +// twice does not underflow the refcount (the second delete is a no-op). +func TestNftablesDNAT_DoubleDeleteNoUnderflow(t *testing.T) { + m := newNftRefcountManager(t, true) + state := m.family4.ipFwdState + + r1, err := m.AddDNATRule(dnatV6(9093)) + require.NoError(t, err) + _, v6 := state.Counts() + assert.Equal(t, 1, v6) + + require.NoError(t, m.DeleteDNATRule(r1), "first delete") + _, v6 = state.Counts() + assert.Equal(t, 0, v6) + + require.NoError(t, m.DeleteDNATRule(r1), "second delete must be no-op") + _, v6 = state.Counts() + assert.Equal(t, 0, v6, "double delete must not underflow") +} diff --git a/client/firewall/nftables/external_chain_monitor_integration_linux_test.go b/client/firewall/nftables/external_chain_monitor_integration_linux_test.go new file mode 100644 index 000000000..3c4e3f44d --- /dev/null +++ b/client/firewall/nftables/external_chain_monitor_integration_linux_test.go @@ -0,0 +1,76 @@ +//go:build linux + +package nftables + +import ( + "os" + "sync/atomic" + "testing" + "time" + + "github.com/google/nftables" + "github.com/stretchr/testify/require" +) + +// TestExternalChainMonitorRootIntegration verifies that adding a new chain +// in an external (non-netbird) filter table triggers the reconciler. +// Requires CAP_NET_ADMIN; skip otherwise. +func TestExternalChainMonitorRootIntegration(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("root required") + } + + calls := make(chan struct{}, 8) + var count atomic.Int32 + rec := &countingReconciler{calls: calls, count: &count} + + m := newExternalChainMonitor(rec) + m.start() + t.Cleanup(m.stop) + + // Give the netlink subscription a moment to register. + time.Sleep(200 * time.Millisecond) + + conn := &nftables.Conn{} + table := conn.AddTable(&nftables.Table{ + Name: "nbmon_integration_test", + Family: nftables.TableFamilyINet, + }) + t.Cleanup(func() { + cleanup := &nftables.Conn{} + cleanup.DelTable(table) + _ = cleanup.Flush() + }) + + chain := conn.AddChain(&nftables.Chain{ + Name: "filter_INPUT", + Table: table, + Hooknum: nftables.ChainHookInput, + Priority: nftables.ChainPriorityFilter, + Type: nftables.ChainTypeFilter, + }) + _ = chain + require.NoError(t, conn.Flush(), "create external test chain") + + select { + case <-calls: + // success + case <-time.After(3 * time.Second): + t.Fatalf("reconcile was not invoked after creating an external chain") + } + require.GreaterOrEqual(t, count.Load(), int32(1)) +} + +type countingReconciler struct { + calls chan struct{} + count *atomic.Int32 +} + +func (c *countingReconciler) reconcileExternalChains() error { + c.count.Add(1) + select { + case c.calls <- struct{}{}: + default: + } + return nil +} diff --git a/client/firewall/nftables/external_chain_monitor_linux.go b/client/firewall/nftables/external_chain_monitor_linux.go new file mode 100644 index 000000000..9c91c95cf --- /dev/null +++ b/client/firewall/nftables/external_chain_monitor_linux.go @@ -0,0 +1,200 @@ +package nftables + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/google/nftables" + log "github.com/sirupsen/logrus" +) + +const ( + externalMonitorReconcileDelay = 500 * time.Millisecond + externalMonitorInitInterval = 5 * time.Second + externalMonitorMaxInterval = 5 * time.Minute + externalMonitorRandomization = 0.5 +) + +// externalChainReconciler re-applies passthrough accept rules to external +// nftables chains. Implementations must be safe to call from the monitor +// goroutine; the Manager locks its mutex internally. +type externalChainReconciler interface { + reconcileExternalChains() error +} + +// externalChainMonitor watches nftables netlink events and triggers a +// reconcile when a new table or chain appears (e.g. after +// `firewall-cmd --reload`). Netlink errors trigger exponential-backoff +// reconnect. +type externalChainMonitor struct { + reconciler externalChainReconciler + + mu sync.Mutex + cancel context.CancelFunc + done chan struct{} +} + +func newExternalChainMonitor(r externalChainReconciler) *externalChainMonitor { + return &externalChainMonitor{reconciler: r} +} + +func (m *externalChainMonitor) start() { + m.mu.Lock() + defer m.mu.Unlock() + + if m.cancel != nil { + return + } + + ctx, cancel := context.WithCancel(context.Background()) + m.cancel = cancel + done := make(chan struct{}) + m.done = done + + go m.run(ctx, done) +} + +func (m *externalChainMonitor) stop() { + m.mu.Lock() + cancel := m.cancel + done := m.done + m.cancel = nil + m.done = nil + m.mu.Unlock() + + if cancel == nil { + return + } + cancel() + <-done +} + +func (m *externalChainMonitor) run(ctx context.Context, done chan struct{}) { + defer close(done) + + bo := &backoff.ExponentialBackOff{ + InitialInterval: externalMonitorInitInterval, + RandomizationFactor: externalMonitorRandomization, + Multiplier: backoff.DefaultMultiplier, + MaxInterval: externalMonitorMaxInterval, + MaxElapsedTime: 0, + Clock: backoff.SystemClock, + } + bo.Reset() + + for ctx.Err() == nil { + err := m.watch(ctx) + if ctx.Err() != nil { + return + } + + delay := bo.NextBackOff() + log.Warnf("external chain monitor: %v, reconnecting in %s", err, delay) + select { + case <-ctx.Done(): + return + case <-time.After(delay): + } + } +} + +func (m *externalChainMonitor) watch(ctx context.Context) error { + events, closeMon, err := m.subscribe() + if err != nil { + return err + } + defer closeMon() + + debounce := time.NewTimer(time.Hour) + if !debounce.Stop() { + <-debounce.C + } + defer debounce.Stop() + + pending := false + for { + select { + case <-ctx.Done(): + return nil + case <-debounce.C: + pending = false + m.reconcile() + case ev, ok := <-events: + if !ok { + return errors.New("monitor channel closed") + } + if ev.Error != nil { + return fmt.Errorf("monitor event: %w", ev.Error) + } + if !isRelevantMonitorEvent(ev) { + continue + } + resetDebounce(debounce, pending) + pending = true + } + } +} + +func (m *externalChainMonitor) subscribe() (chan *nftables.MonitorEvent, func(), error) { + conn := &nftables.Conn{} + mon := nftables.NewMonitor( + nftables.WithMonitorAction(nftables.MonitorActionNew), + nftables.WithMonitorObject(nftables.MonitorObjectChains|nftables.MonitorObjectTables), + ) + events, err := conn.AddMonitor(mon) + if err != nil { + return nil, nil, fmt.Errorf("add netlink monitor: %w", err) + } + return events, func() { _ = mon.Close() }, nil +} + +// resetDebounce reschedules a pending debounce timer without leaking a stale +// fire on its channel. pending must reflect whether the timer is armed. +func resetDebounce(t *time.Timer, pending bool) { + if pending && !t.Stop() { + select { + case <-t.C: + default: + } + } + t.Reset(externalMonitorReconcileDelay) +} + +func (m *externalChainMonitor) reconcile() { + if err := m.reconciler.reconcileExternalChains(); err != nil { + log.Warnf("reconcile external chain rules: %v", err) + } +} + +// isRelevantMonitorEvent returns true for table/chain creation events on +// families we care about. The reconciler filters to actual external filter +// chains. +func isRelevantMonitorEvent(ev *nftables.MonitorEvent) bool { + switch ev.Type { + case nftables.MonitorEventTypeNewChain: + chain, ok := ev.Data.(*nftables.Chain) + if !ok || chain == nil || chain.Table == nil { + return false + } + return isMonitoredFamily(chain.Table.Family) + case nftables.MonitorEventTypeNewTable: + table, ok := ev.Data.(*nftables.Table) + if !ok || table == nil { + return false + } + return isMonitoredFamily(table.Family) + } + return false +} + +func isMonitoredFamily(family nftables.TableFamily) bool { + switch family { + case nftables.TableFamilyIPv4, nftables.TableFamilyIPv6, nftables.TableFamilyINet: + return true + } + return false +} diff --git a/client/firewall/nftables/external_chain_monitor_linux_test.go b/client/firewall/nftables/external_chain_monitor_linux_test.go new file mode 100644 index 000000000..1a37faca2 --- /dev/null +++ b/client/firewall/nftables/external_chain_monitor_linux_test.go @@ -0,0 +1,137 @@ +package nftables + +import ( + "testing" + + "github.com/google/nftables" + "github.com/stretchr/testify/assert" +) + +func TestIsMonitoredFamily(t *testing.T) { + tests := []struct { + family nftables.TableFamily + want bool + }{ + {nftables.TableFamilyIPv4, true}, + {nftables.TableFamilyIPv6, true}, + {nftables.TableFamilyINet, true}, + {nftables.TableFamilyARP, false}, + {nftables.TableFamilyBridge, false}, + {nftables.TableFamilyNetdev, false}, + {nftables.TableFamilyUnspecified, false}, + } + for _, tc := range tests { + assert.Equal(t, tc.want, isMonitoredFamily(tc.family), "family=%d", tc.family) + } +} + +func TestIsRelevantMonitorEvent(t *testing.T) { + inetTable := &nftables.Table{Name: "firewalld", Family: nftables.TableFamilyINet} + ipTable := &nftables.Table{Name: "filter", Family: nftables.TableFamilyIPv4} + arpTable := &nftables.Table{Name: "arp", Family: nftables.TableFamilyARP} + + tests := []struct { + name string + ev *nftables.MonitorEvent + want bool + }{ + { + name: "new chain in inet firewalld", + ev: &nftables.MonitorEvent{ + Type: nftables.MonitorEventTypeNewChain, + Data: &nftables.Chain{Name: "filter_INPUT", Table: inetTable}, + }, + want: true, + }, + { + name: "new chain in ip filter", + ev: &nftables.MonitorEvent{ + Type: nftables.MonitorEventTypeNewChain, + Data: &nftables.Chain{Name: "INPUT", Table: ipTable}, + }, + want: true, + }, + { + name: "new chain in unwatched arp family", + ev: &nftables.MonitorEvent{ + Type: nftables.MonitorEventTypeNewChain, + Data: &nftables.Chain{Name: "x", Table: arpTable}, + }, + want: false, + }, + { + name: "new table inet", + ev: &nftables.MonitorEvent{ + Type: nftables.MonitorEventTypeNewTable, + Data: inetTable, + }, + want: true, + }, + { + name: "del chain (we only act on new)", + ev: &nftables.MonitorEvent{ + Type: nftables.MonitorEventTypeDelChain, + Data: &nftables.Chain{Name: "filter_INPUT", Table: inetTable}, + }, + want: false, + }, + { + name: "chain with nil table", + ev: &nftables.MonitorEvent{ + Type: nftables.MonitorEventTypeNewChain, + Data: &nftables.Chain{Name: "x"}, + }, + want: false, + }, + { + name: "nil data", + ev: &nftables.MonitorEvent{ + Type: nftables.MonitorEventTypeNewChain, + Data: (*nftables.Chain)(nil), + }, + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, isRelevantMonitorEvent(tc.ev)) + }) + } +} + +// fakeReconciler records reconcile invocations for debounce tests. +type fakeReconciler struct { + calls chan struct{} +} + +func (f *fakeReconciler) reconcileExternalChains() error { + f.calls <- struct{}{} + return nil +} + +func TestExternalChainMonitorStopWithoutStart(t *testing.T) { + m := newExternalChainMonitor(&fakeReconciler{calls: make(chan struct{}, 1)}) + // Must not panic or block. + m.stop() +} + +func TestExternalChainMonitorDoubleStart(t *testing.T) { + // start() twice should be a no-op; stop() cleans up once. + // We avoid exercising the netlink watch loop here because it needs root. + m := newExternalChainMonitor(&fakeReconciler{calls: make(chan struct{}, 1)}) + + // Replace run with a stub that just waits for cancel, so start() stays + // deterministic without opening a netlink socket. + origDone := make(chan struct{}) + m.done = origDone + m.cancel = func() { close(origDone) } + + // Second start should be a no-op (cancel already set). + m.start() + assert.NotNil(t, m.cancel) + + m.stop() + assert.Nil(t, m.cancel) + assert.Nil(t, m.done) +} diff --git a/client/firewall/nftables/family_linux.go b/client/firewall/nftables/family_linux.go new file mode 100644 index 000000000..7a5df3ed7 --- /dev/null +++ b/client/firewall/nftables/family_linux.go @@ -0,0 +1,249 @@ +//go:build !android + +package nftables + +import ( + "fmt" + "net/netip" + + "github.com/coreos/go-iptables/iptables" + "github.com/google/nftables" + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + + nberrors "github.com/netbirdio/netbird/client/errors" + "github.com/netbirdio/netbird/client/firewall/firewalld" + firewall "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/internal/routemanager/ipfwdstate" + "github.com/netbirdio/netbird/client/internal/routemanager/refcounter" +) + +const ( + tableNat = "nat" + tableMangle = "mangle" + tableRaw = "raw" + tableSecurity = "security" + + chainNameNatPrerouting = "PREROUTING" + chainNameRoutingFw = "netbird-rt-fwd" + chainNameRoutingNat = "netbird-rt-postrouting" + chainNameRoutingRdr = "netbird-rt-redirect" + chainNameNATOutput = "netbird-nat-output" + chainNameForward = "FORWARD" + chainNameMangleForward = "netbird-mangle-forward" + + // Peer ACL chain names. + chainNameInputRules = "netbird-acl-input-rules" + chainNameInputFilter = "netbird-acl-input-filter" + chainNameForwardFilter = "netbird-acl-forward-filter" + chainNameManglePrerouting = "netbird-mangle-prerouting" + chainNameManglePostrouting = "netbird-mangle-postrouting" + + flushError = "flush: %w" + + firewalldTableName = "firewalld" + + userDataAcceptForwardRuleIif = "frwacceptiif" + userDataAcceptForwardRuleOif = "frwacceptoif" + userDataAcceptInputRule = "inputaccept" + + dnatSuffix firewall.RuleID = "_dnat" + snatSuffix firewall.RuleID = "_snat" + + // ipv4TCPHeaderSize is the minimum IPv4 (20) + TCP (20) header size for MSS calculation. + ipv4TCPHeaderSize = 40 + // ipv6TCPHeaderSize is the minimum IPv6 (40) + TCP (20) header size for MSS calculation. + ipv6TCPHeaderSize = 60 + + // maxPrefixesSet 1638 prefixes start to fail, taking some margin + maxPrefixesSet = 1500 + refreshRulesMapError = "refresh rules map: %w" +) + +var ( + errFilterTableNotFound = fmt.Errorf("'filter' table not found") +) + +type setInput struct { + set firewall.Set + prefixes []netip.Prefix +} + +// family holds the per-address-family nftables state. One instance +// handles route ACLs, peer ACLs, NAT, DNAT, and MSS clamping for a +// single family; the top-level Manager owns one for v4 and another +// for v6. The name predates the peer-ACL absorption; it's effectively +// the per-family backend now. +type family struct { + conn *nftables.Conn + workTable *nftables.Table + filterTable *nftables.Table + chains map[string]*nftables.Chain + + // filters holds peer + route filter rules keyed by content hash. + // AddFilterRule writes here; DeleteFilterRule looks up by id. + filters map[firewall.RuleID]*Rule + + // rules holds NAT, DNAT, and external accept rules (auxiliary + // plumbing that isn't a filter rule). + rules map[firewall.RuleID]*nftables.Rule + + // Peer ACL chain handles. + chainInputRules *nftables.Chain + chainPrerouting *nftables.Chain + routingFwChainName string + + ipsetCounter *refcounter.Counter[string, setInput, *nftables.Set] + + af addrFamily + wgIface iFaceMapper + ipFwdState *ipfwdstate.IPForwardingState + legacyManagement bool + mtu uint16 +} + +func newFamily(workTable *nftables.Table, wgIface iFaceMapper, mtu uint16) *family { + r := &family{ + conn: &nftables.Conn{}, + workTable: workTable, + chains: make(map[string]*nftables.Chain), + filters: make(map[firewall.RuleID]*Rule), + rules: make(map[firewall.RuleID]*nftables.Rule), + routingFwChainName: chainNameRoutingFw, + af: familyForAddr(workTable.Family == nftables.TableFamilyIPv4), + wgIface: wgIface, + ipFwdState: ipfwdstate.NewIPForwardingState(wgIface.Name()), + mtu: mtu, + } + + r.ipsetCounter = refcounter.New( + r.createIpSet, + r.deleteIpSet, + ) + + var err error + r.filterTable, err = r.loadFilterTable() + if err != nil { + log.Debugf("ip filter table not found: %v", err) + } + + return r +} + +func (r *family) init(workTable *nftables.Table) error { + r.workTable = workTable + + if err := r.removeAcceptFilterRules(); err != nil { + log.Errorf("failed to clean up rules from filter table: %s", err) + } + + if err := r.createContainers(); err != nil { + return fmt.Errorf("create containers: %w", err) + } + + if err := r.setupDataPlaneMark(); err != nil { + log.Errorf("failed to set up data plane mark: %v", err) + } + + if err := r.createDefaultChains(); err != nil { + return fmt.Errorf("create default acl chains: %w", err) + } + + return nil +} + +// Reset cleans existing nftables filter table rules from the system +func (r *family) Reset() error { + // clear without deleting the ipsets, the nf table will be deleted by the caller + r.ipsetCounter.Clear() + + var merr *multierror.Error + + if err := r.removeAcceptFilterRules(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove accept filter rules: %w", err)) + } + + if err := firewalld.UntrustInterface(r.wgIface.Name()); err != nil { + merr = multierror.Append(merr, err) + } + + if err := r.removeNatPreroutingRules(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove filter prerouting rules: %w", err)) + } + + return nberrors.FormatErrorOrNil(merr) +} + +func (r *family) loadFilterTable() (*nftables.Table, error) { + tables, err := r.conn.ListTablesOfFamily(r.af.tableFamily) + if err != nil { + return nil, fmt.Errorf("list tables: %w", err) + } + + for _, table := range tables { + if table.Name == "filter" { + return table, nil + } + } + + return nil, errFilterTableNotFound +} + +func hookName(hook *nftables.ChainHook) string { + if hook == nil { + return "unknown" + } + switch *hook { + case *nftables.ChainHookForward: + return chainNameForward + case *nftables.ChainHookInput: + return chainNameInput + default: + return fmt.Sprintf("hook(%d)", *hook) + } +} + +func familyName(family nftables.TableFamily) string { + switch family { + case nftables.TableFamilyIPv4: + return "ip" + case nftables.TableFamilyIPv6: + return "ip6" + case nftables.TableFamilyINet: + return "inet" + default: + return fmt.Sprintf("family(%d)", family) + } +} + +func (r *family) iptablesProto() iptables.Protocol { + if r.af.tableFamily == nftables.TableFamilyIPv6 { + return iptables.ProtocolIPv6 + } + return iptables.ProtocolIPv4 +} + +func (r *family) refreshRulesMap() error { + var merr *multierror.Error + newRules := make(map[firewall.RuleID]*nftables.Rule) + for _, chain := range r.chains { + rules, err := r.conn.GetRules(chain.Table, chain) + if err != nil { + merr = multierror.Append(merr, fmt.Errorf("list rules for chain %s: %w", chain.Name, err)) + // preserve existing entries for this chain since we can't verify their state + for k, v := range r.rules { + if v.Chain != nil && v.Chain.Name == chain.Name { + newRules[k] = v + } + } + continue + } + for _, rule := range rules { + if len(rule.UserData) > 0 { + newRules[firewall.RuleID(rule.UserData)] = rule + } + } + } + r.rules = newRules + return nberrors.FormatErrorOrNil(merr) +} diff --git a/client/firewall/nftables/filter_linux.go b/client/firewall/nftables/filter_linux.go new file mode 100644 index 000000000..ebd238063 --- /dev/null +++ b/client/firewall/nftables/filter_linux.go @@ -0,0 +1,540 @@ +//go:build !android + +package nftables + +import ( + "fmt" + "net" + "net/netip" + "slices" + + "github.com/google/nftables" + "github.com/google/nftables/binaryutil" + "github.com/google/nftables/expr" + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + + nberrors "github.com/netbirdio/netbird/client/errors" + firewall "github.com/netbirdio/netbird/client/firewall/manager" + nbid "github.com/netbirdio/netbird/client/internal/acl/id" +) + +// AddFilterRule installs one nftables packet-filter rule. With +// destination empty the rule goes to the peer ACL input chain plus a +// paired prerouting mangle rule for the redirect mark. With +// destination set (prefix or named set) it goes to the route ACL +// forward chain. Multi-source rules collapse to one nftables rule +// backed by the shared refcounted hash:net set. +func (r *family) AddFilterRule( + id []byte, + sources []netip.Prefix, + destination firewall.Network, + proto firewall.Protocol, + sPort *firewall.Port, + dPort *firewall.Port, + action firewall.Action, +) (firewall.Rule, error) { + isRoute := !destination.IsZero() + + ruleID := nbid.GenerateRuleID(sources, destination, proto, sPort, dPort, action) + if existing, ok := r.filters[ruleID]; ok { + return existing, nil + } + + srcExprs, err := r.applyNetwork(sourceNetwork(sources), sources, true) + if err != nil { + return nil, fmt.Errorf("apply source: %w", err) + } + + var exprs []expr.Any + if isRoute { + exprs, err = r.buildRouteFilterExprs(srcExprs, destination, proto, sPort, dPort) + } else { + exprs, err = r.buildPeerFilterExprs(srcExprs, proto, sPort, dPort) + } + if err != nil { + r.dropNetworkMatch(srcExprs) + return nil, err + } + + mainExprs := slices.Clone(exprs) + verdict := expr.VerdictAccept + if action == firewall.ActionDrop { + verdict = expr.VerdictDrop + } + mainExprs = append(mainExprs, &expr.Verdict{Kind: verdict}) + + chain := r.chainInputRules + if isRoute { + chain = r.chains[chainNameRoutingFw] + } + + userData := []byte(ruleID) + + // Build the paired prerouting mangle rule before flushing so both + // rules commit in one transaction. An anonymous port set binds to + // exactly one rule, so the mangle rule needs its own expression list + // with fresh sets, not a clone of the main rule's. Guard on the + // prerouting chain first: building the expressions queues the port + // set, so skipping the build when there is no chain to bind it to + // keeps an unbound set out of the connection batch. + var mangleRule *nftables.Rule + if !isRoute && r.chainPrerouting != nil { + mangleExprs, err := r.buildPeerFilterExprs(srcExprs, proto, sPort, dPort) + if err != nil { + r.dropNetworkMatch(exprs) + return nil, fmt.Errorf("build mangle rule: %w", err) + } + mangleRule = r.queuePreroutingRule(mangleExprs, userData) + } + + nftRule := &nftables.Rule{ + Table: r.workTable, + Chain: chain, + Exprs: mainExprs, + UserData: userData, + } + if action == firewall.ActionDrop { + nftRule = r.conn.InsertRule(nftRule) + } else { + nftRule = r.conn.AddRule(nftRule) + } + if err := r.conn.Flush(); err != nil { + r.dropNetworkMatch(exprs) + return nil, fmt.Errorf(flushError, err) + } + + rule := &Rule{ + nftRule: nftRule, + mangleRule: mangleRule, + sources: sources, + id: ruleID, + } + r.filters[ruleID] = rule + + log.Debugf("added filter rule: sources=%v, destination=%v, proto=%v, sPort=%v, dPort=%v, action=%v", + sources, destination, proto, sPort, dPort, action) + return rule, nil +} + +// buildPeerFilterExprs assembles the input-chain (peer ACL) match: the +// IP-header protocol byte read via Payload, then source, then ports +// (no counter), matching the historical peer shape so per-rule kernel +// state is identical to pre-unification. +func (r *family) buildPeerFilterExprs( + srcExprs []expr.Any, + proto firewall.Protocol, + sPort, dPort *firewall.Port, +) ([]expr.Any, error) { + var exprs []expr.Any + + if proto != firewall.ProtocolALL { + protoNum, err := r.af.protoNum(proto) + if err != nil { + return nil, fmt.Errorf("convert protocol to number: %w", err) + } + exprs = append(exprs, + &expr.Payload{ + DestRegister: 1, + Base: expr.PayloadBaseNetworkHeader, + Offset: r.af.protoOffset, + Len: 1, + }, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{protoNum}}, + ) + } + exprs = append(exprs, srcExprs...) + + portExprs, err := r.applyPorts(sPort, dPort) + if err != nil { + return nil, err + } + exprs = append(exprs, portExprs...) + return exprs, nil +} + +// buildRouteFilterExprs assembles the forward-chain (route ACL) match: +// source, then destination, then optional proto/ports, then a counter. +func (r *family) buildRouteFilterExprs( + srcExprs []expr.Any, + destination firewall.Network, + proto firewall.Protocol, + sPort, dPort *firewall.Port, +) ([]expr.Any, error) { + exprs := append([]expr.Any{}, srcExprs...) + + destExprs, err := r.applyNetwork(destination, nil, false) + if err != nil { + return nil, fmt.Errorf("apply destination: %w", err) + } + exprs = append(exprs, destExprs...) + + if proto != firewall.ProtocolALL { + protoNum, err := r.af.protoNum(proto) + if err != nil { + r.dropNetworkMatch(destExprs) + return nil, fmt.Errorf("convert protocol to number: %w", err) + } + exprs = append(exprs, + &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{protoNum}}, + ) + + portExprs, err := r.applyPorts(sPort, dPort) + if err != nil { + r.dropNetworkMatch(destExprs) + return nil, err + } + exprs = append(exprs, portExprs...) + } + + exprs = append(exprs, &expr.Counter{}) + return exprs, nil +} + +func (r *family) hasRule(id firewall.RuleID) bool { + _, ok := r.filters[id] + return ok +} + +func (r *family) hasDNATRule(id firewall.RuleID) bool { + _, ok := r.rules[id+dnatSuffix] + return ok +} + +// DeleteFilterRule removes a previously installed filter rule. Source +// set references are recovered from the stored rule's expressions via +// findSets and dropped from the shared refcounter. +func (r *family) DeleteFilterRule(rule firewall.Rule) error { + ruleID := rule.ID() + pr, ok := r.filters[ruleID] + if !ok { + log.Debugf("filter rule %s not found", ruleID) + return nil + } + + // A freshly added rule carries no handle until it is read back from + // the kernel, and Flush only refreshes the peer chains. Pull live + // handles for this rule's chain before deciding it is stale so route + // rules (which Flush never refreshes) can actually be deleted. A + // refresh failure aborts the delete without touching tracking state, + // so the caller can retry while the rule may still exist in the kernel. + if pr.nftRule.Handle == 0 { + if err := r.refreshRuleHandles(pr.nftRule.Chain, false); err != nil { + return fmt.Errorf("refresh handles for chain %s: %w", pr.nftRule.Chain.Name, err) + } + } + // Refresh the mangle handle independently: the main rule's handle can + // be populated while the prerouting refresh during Flush failed, and + // gating the mangle refresh on the main handle would leak the mangle + // rule on delete. + if pr.mangleRule != nil && pr.mangleRule.Handle == 0 { + if err := r.refreshRuleHandles(r.chainPrerouting, true); err != nil { + return fmt.Errorf("refresh mangle handles: %w", err) + } + } + + if pr.nftRule.Handle == 0 { + log.Warnf("filter rule %s has no handle, removing stale entry", ruleID) + // The paired mangle rule can still be in the kernel with a live + // handle. Dropping the tracking entry without removing it would + // leave a prerouting rule that nothing can find again. + if err := r.deleteMangleRule(pr, ruleID); err != nil { + return err + } + r.dropNetworkMatch(pr.nftRule.Exprs) + delete(r.filters, ruleID) + return nil + } + + if err := r.conn.DelRule(pr.nftRule); err != nil { + log.Errorf("queue rule delete: %v", err) + } + r.queueMangleDelete(pr) + if err := r.conn.Flush(); err != nil { + return fmt.Errorf("flush delete %s: %w", ruleID, err) + } + + r.dropNetworkMatch(pr.nftRule.Exprs) + delete(r.filters, ruleID) + return nil +} + +// deleteMangleRule removes the prerouting rule paired with a filter rule on +// its own, for the paths that drop the filter rule's tracking without queueing +// a delete for it. +func (r *family) deleteMangleRule(pr *Rule, ruleID firewall.RuleID) error { + if pr.mangleRule == nil || pr.mangleRule.Handle == 0 { + return nil + } + + r.queueMangleDelete(pr) + if err := r.conn.Flush(); err != nil { + return fmt.Errorf("flush mangle delete %s: %w", ruleID, err) + } + return nil +} + +// queueMangleDelete queues the delete of the rule's prerouting counterpart, if +// it has one. The caller commits it. +func (r *family) queueMangleDelete(pr *Rule) { + if pr.mangleRule == nil { + return + } + if err := r.conn.DelRule(pr.mangleRule); err != nil { + log.Errorf("queue mangle rule delete: %v", err) + } +} + +func (r *family) decrementSetCounter(rule *nftables.Rule) error { + if r.ipsetCounter == nil { + return nil + } + sets := findSets(rule) + + var merr *multierror.Error + for _, setName := range sets { + if _, err := r.ipsetCounter.Decrement(setName); err != nil { + merr = multierror.Append(merr, fmt.Errorf("decrement set counter: %w", err)) + } + } + + return nberrors.FormatErrorOrNil(merr) +} + +// dropNetworkMatch undoes whatever the source/destination match +// reserved. Safe to call when the spec is empty or holds only inline +// matchers. +func (r *family) dropNetworkMatch(exprs []expr.Any) { + if r.ipsetCounter == nil { + return + } + for _, e := range exprs { + lookup, ok := e.(*expr.Lookup) + if !ok { + continue + } + if _, err := r.ipsetCounter.Decrement(lookup.SetName); err != nil { + log.Errorf("rollback ipset decrement %s: %v", lookup.SetName, err) + } + } +} + +func (r *family) applyNetwork( + network firewall.Network, + setPrefixes []netip.Prefix, + isSource bool, +) ([]expr.Any, error) { + if network.IsSet() { + exprs, err := r.getIpSet(network.Set, setPrefixes, isSource) + if err != nil { + side := "destination" + if isSource { + side = "source" + } + return nil, fmt.Errorf("%s set: %w", side, err) + } + return exprs, nil + } + + if network.IsPrefix() { + return prefixMatchExprs(r.af, network.Prefix, isSource), nil + } + + return nil, nil +} + +// applyPort builds the transport-header port match. A single value +// compares directly, a range uses a range expression, and multiple +// values go through an anonymous constant set: consecutive cmp +// expressions AND together, so chained equality comparisons could +// never match more than one port. The set is queued on the +// connection and committed by the caller's flush together with the +// rule that binds it. +func (r *family) applyPort(port *firewall.Port, isSource bool) ([]expr.Any, error) { + if port == nil || len(port.Values) == 0 { + return nil, nil + } + + // dst port + offset := uint32(2) + if isSource { + // src port + offset = 0 + } + + exprs := []expr.Any{ + &expr.Payload{ + DestRegister: 1, + Base: expr.PayloadBaseTransportHeader, + Offset: offset, + Len: 2, + }, + } + + switch { + case port.IsRange && len(port.Values) == 2: + exprs = append(exprs, &expr.Range{ + Op: expr.CmpOpEq, + Register: 1, + FromData: binaryutil.BigEndian.PutUint16(port.Values[0]), + ToData: binaryutil.BigEndian.PutUint16(port.Values[1]), + }) + case len(port.Values) == 1: + exprs = append(exprs, &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: binaryutil.BigEndian.PutUint16(port.Values[0]), + }) + default: + lookup, err := r.anonymousPortSet(port.Values) + if err != nil { + return nil, err + } + exprs = append(exprs, lookup) + } + + return exprs, nil +} + +// anonymousPortSet queues an anonymous constant set holding the given +// ports on the connection and returns a lookup against it. The set is +// committed by the caller's flush together with the rule that binds it. +func (r *family) anonymousPortSet(values []uint16) (*expr.Lookup, error) { + set := &nftables.Set{ + Anonymous: true, + Constant: true, + Table: r.workTable, + KeyType: nftables.TypeInetService, + } + elements := make([]nftables.SetElement, 0, len(values)) + for _, p := range values { + elements = append(elements, nftables.SetElement{Key: binaryutil.BigEndian.PutUint16(p)}) + } + if err := r.conn.AddSet(set, elements); err != nil { + return nil, fmt.Errorf("add anonymous port set: %w", err) + } + return &expr.Lookup{ + SourceRegister: 1, + SetID: set.ID, + SetName: set.Name, + }, nil +} + +// applyPorts builds the source then destination port matches. +func (r *family) applyPorts(sPort, dPort *firewall.Port) ([]expr.Any, error) { + sPortExprs, err := r.applyPort(sPort, true) + if err != nil { + return nil, fmt.Errorf("apply source port: %w", err) + } + + dPortExprs, err := r.applyPort(dPort, false) + if err != nil { + return nil, fmt.Errorf("apply destination port: %w", err) + } + + return append(sPortExprs, dPortExprs...), nil +} + +// prefixMatchExprs is the family-aware match sequence for a CIDR +// prefix. /0 returns nil; a host prefix (full bit length for the +// family) skips the bitwise step since the mask is all-ones. Shared +// between family and aclManager so both treat single prefixes +// identically. +func prefixMatchExprs(af addrFamily, prefix netip.Prefix, isSource bool) []expr.Any { + offset := af.dstAddrOffset + if isSource { + offset = af.srcAddrOffset + } + + ones := prefix.Bits() + if ones == 0 { + return nil + } + + payload := &expr.Payload{ + DestRegister: 1, + Base: expr.PayloadBaseNetworkHeader, + Offset: offset, + Len: af.addrLen, + } + cmp := &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: prefix.Masked().Addr().AsSlice(), + } + + if ones == af.totalBits { + return []expr.Any{payload, cmp} + } + + mask := net.CIDRMask(ones, af.totalBits) + xor := make([]byte, af.addrLen) + return []expr.Any{ + payload, + &expr.Bitwise{ + DestRegister: 1, + SourceRegister: 1, + Len: af.addrLen, + Mask: mask, + Xor: xor, + }, + cmp, + } +} + +func getCtNewExprs() []expr.Any { + return []expr.Any{ + &expr.Ct{ + Key: expr.CtKeySTATE, + Register: 1, + }, + &expr.Bitwise{ + SourceRegister: 1, + DestRegister: 1, + Len: 4, + Mask: binaryutil.NativeEndian.PutUint32(expr.CtStateBitNEW), + Xor: binaryutil.NativeEndian.PutUint32(0), + }, + &expr.Cmp{ + Op: expr.CmpOpNeq, + Register: 1, + Data: []byte{0, 0, 0, 0}, + }, + } +} + +// sourceNetwork classifies a source-prefix list into the firewall.Network +// shape the rest of the spec-builder consumes: empty for match-any, a +// single prefix inline, or an ipset for multiple sources. +func sourceNetwork(sources []netip.Prefix) firewall.Network { + switch { + case len(sources) == 0: + return firewall.Network{} + case len(sources) == 1 && sources[0].Bits() == 0: + return firewall.Network{} + case len(sources) == 1: + return firewall.Network{Prefix: sources[0]} + default: + return firewall.Network{Set: firewall.NewPrefixSet(sources)} + } +} + +func ifname(n string) []byte { + b := make([]byte, 16) + copy(b, n+"\x00") + return b +} + +// findSets scans an nftables rule's expressions for expr.Lookup and +// returns the named sets in occurrence order. Used at delete time to +// drop ipsetCounter references; peer and route ACLs go through it. +func findSets(rule *nftables.Rule) []string { + var sets []string + for _, e := range rule.Exprs { + if lookup, ok := e.(*expr.Lookup); ok { + sets = append(sets, lookup.SetName) + } + } + return sets +} diff --git a/client/firewall/nftables/interface_allower_integration_linux_test.go b/client/firewall/nftables/interface_allower_integration_linux_test.go new file mode 100644 index 000000000..4d4bc6187 --- /dev/null +++ b/client/firewall/nftables/interface_allower_integration_linux_test.go @@ -0,0 +1,90 @@ +//go:build privileged + +package nftables + +import ( + "bytes" + "os" + "testing" + + "github.com/google/nftables" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/iface" +) + +// TestInterfaceAllowerInputOnly verifies the userspace-mode allower opens the +// interface on the INPUT hook of foreign chains only (not FORWARD, since the +// userspace router never forwards in the kernel), creates no netbird work +// table, and removes its rules on Close. +func TestInterfaceAllowerInputOnly(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("root required") + } + + require.False(t, ipTableExists(t, getTableName()), "precondition: no stale netbird table") + + conn := &nftables.Conn{} + extTable := conn.AddTable(&nftables.Table{Name: "nbtest_extchains", Family: nftables.TableFamilyINet}) + inputChain := conn.AddChain(&nftables.Chain{ + Name: "ext_input", Table: extTable, + Hooknum: nftables.ChainHookInput, Priority: nftables.ChainPriorityFilter, Type: nftables.ChainTypeFilter, + }) + forwardChain := conn.AddChain(&nftables.Chain{ + Name: "ext_forward", Table: extTable, + Hooknum: nftables.ChainHookForward, Priority: nftables.ChainPriorityFilter, Type: nftables.ChainTypeFilter, + }) + require.NoError(t, conn.Flush(), "create external table and chains") + t.Cleanup(func() { + c := &nftables.Conn{} + c.DelTable(extTable) + _ = c.Flush() + }) + + allower, err := NewInterfaceAllower(ifaceMock, iface.DefaultMTU) + require.NoError(t, err, "create allower") + require.NoError(t, allower.Apply(), "apply") + + require.True(t, chainHasUserData(t, extTable, inputChain, userDataAcceptInputRule), + "external INPUT chain should get the accept rule") + require.Len(t, listRules(t, extTable, forwardChain), 0, + "external FORWARD chain must not be opened in userspace mode") + require.False(t, ipTableExists(t, getTableName()), + "allower must not create a netbird work table") + + require.NoError(t, allower.Close(), "close") + require.False(t, chainHasUserData(t, extTable, inputChain, userDataAcceptInputRule), + "accept rule should be removed on close") +} + +func listRules(t *testing.T, table *nftables.Table, chain *nftables.Chain) []*nftables.Rule { + t.Helper() + c := &nftables.Conn{} + rules, err := c.GetRules(table, chain) + require.NoError(t, err) + return rules +} + +func chainHasUserData(t *testing.T, table *nftables.Table, chain *nftables.Chain, ud string) bool { + for _, r := range listRules(t, table, chain) { + if bytes.Equal(r.UserData, []byte(ud)) { + return true + } + } + return false +} + +func ipTableExists(t *testing.T, name string) bool { + t.Helper() + c := &nftables.Conn{} + for _, fam := range []nftables.TableFamily{nftables.TableFamilyIPv4, nftables.TableFamilyIPv6} { + tbls, err := c.ListTablesOfFamily(fam) + require.NoError(t, err) + for _, tb := range tbls { + if tb.Name == name { + return true + } + } + } + return false +} diff --git a/client/firewall/nftables/interface_allower_linux.go b/client/firewall/nftables/interface_allower_linux.go new file mode 100644 index 000000000..e7232e2bf --- /dev/null +++ b/client/firewall/nftables/interface_allower_linux.go @@ -0,0 +1,107 @@ +package nftables + +import ( + "fmt" + + "github.com/google/nftables" + "github.com/hashicorp/go-multierror" + + nberrors "github.com/netbirdio/netbird/client/errors" +) + +// InterfaceAllower opens the NetBird interface in the kernel's filter table and +// external chains and keeps them reconciled via a netlink monitor, so the host +// firewall doesn't drop traffic the NetBird firewall handles. It is used by the +// userspace firewall, where routing happens in the forwarder, so only INPUT is +// opened (the userspace router never forwards in the kernel). +// +// It owns its own families/connection and never creates a netbird work table. +// firewalld trust is handled by the caller, not here. Its operations are serial +// (Apply before the monitor starts; reconciles run on the single monitor +// goroutine; Close stops the monitor before removing), so it needs no locking. +// +// TODO: this opens nftables and the iptables-nft filter table (detected via +// nft), but not a legacy-iptables ruleset running in parallel with nftables. +// Such a host would keep its legacy filter chains closed for the interface. +type InterfaceAllower struct { + family4 *family + family6 *family + extMonitor *externalChainMonitor +} + +// NewInterfaceAllower builds an allower for the given interface. It returns an +// error when nftables is unavailable (e.g. an iptables-legacy host), so the +// caller can fall back to firewalld trust. +func NewInterfaceAllower(wgIface iFaceMapper, mtu uint16) (*InterfaceAllower, error) { + tableName := getTableName() + + family4 := newFamily(&nftables.Table{Name: tableName, Family: nftables.TableFamilyIPv4}, wgIface, mtu) + + // Probe nftables availability before committing to this backend. + if _, err := family4.conn.ListChainsOfTableFamily(nftables.TableFamilyINet); err != nil { + return nil, fmt.Errorf("nftables not available: %w", err) + } + + a := &InterfaceAllower{family4: family4} + + if wgIface.Address().HasIPv6() { + a.family6 = newFamily(&nftables.Table{Name: tableName, Family: nftables.TableFamilyIPv6}, wgIface, mtu) + } + + a.extMonitor = newExternalChainMonitor(a) + return a, nil +} + +// Apply opens the interface (INPUT only) in the foreign filter chains and starts +// reconciling them on nftables changes. +func (a *InterfaceAllower) Apply() error { + var merr *multierror.Error + for _, f := range a.families() { + // Remove any stale accepts first so a prior unclean exit (e.g. SIGKILL, + // where Close never ran) is recovered deterministically rather than + // accumulating duplicate rules on the iptables filter table. + if err := f.removeAcceptFilterRules(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("clean stale accept rules: %w", err)) + } + if err := f.openInterface(false); err != nil { + merr = multierror.Append(merr, err) + } + } + + a.extMonitor.start() + return nberrors.FormatErrorOrNil(merr) +} + +// families returns the configured address families (v4, and v6 when present). +func (a *InterfaceAllower) families() []*family { + families := []*family{a.family4} + if a.family6 != nil { + families = append(families, a.family6) + } + return families +} + +// reconcileExternalChains re-applies the INPUT accepts to external chains. It +// implements externalChainReconciler for the monitor. +func (a *InterfaceAllower) reconcileExternalChains() error { + var merr *multierror.Error + for _, f := range a.families() { + if err := f.acceptExternalChainsRules(false); err != nil { + merr = multierror.Append(merr, err) + } + } + return nberrors.FormatErrorOrNil(merr) +} + +// Close stops the monitor and removes the accept rules. +func (a *InterfaceAllower) Close() error { + a.extMonitor.stop() + + var merr *multierror.Error + for _, f := range a.families() { + if err := f.removeAcceptFilterRules(); err != nil { + merr = multierror.Append(merr, err) + } + } + return nberrors.FormatErrorOrNil(merr) +} diff --git a/client/firewall/nftables/ipset_linux.go b/client/firewall/nftables/ipset_linux.go new file mode 100644 index 000000000..34783bbeb --- /dev/null +++ b/client/firewall/nftables/ipset_linux.go @@ -0,0 +1,210 @@ +//go:build !android + +package nftables + +import ( + "encoding/binary" + "fmt" + "net/netip" + + "github.com/google/nftables" + "github.com/google/nftables/expr" + log "github.com/sirupsen/logrus" + + firewall "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/internal/routemanager/refcounter" +) + +func (r *family) getIpSet(set firewall.Set, prefixes []netip.Prefix, isSource bool) ([]expr.Any, error) { + ref, err := r.ipsetCounter.Increment(set.HashedName(), setInput{ + set: set, + prefixes: prefixes, + }) + if err != nil { + return nil, fmt.Errorf("create or get ipset: %w", err) + } + + return r.getIpSetExprs(ref, isSource) +} + +func (r *family) createIpSet(setName string, input setInput) (*nftables.Set, error) { + // overlapping prefixes will result in an error, so we need to merge them + prefixes := firewall.MergeIPRanges(input.prefixes) + + nfset := &nftables.Set{ + Name: setName, + Comment: input.set.Comment(), + Table: r.workTable, + // required for prefixes + Interval: true, + KeyType: r.af.setKeyType, + } + + elements := r.convertPrefixesToSet(prefixes) + nElements := len(elements) + + maxElements := maxPrefixesSet * 2 + initialElements := elements[:min(maxElements, nElements)] + + if err := r.conn.AddSet(nfset, initialElements); err != nil { + return nil, fmt.Errorf("error adding set %s: %w", setName, err) + } + if err := r.conn.Flush(); err != nil { + return nil, fmt.Errorf("flush error: %w", err) + } + log.Debugf("Created new ipset: %s with %d initial prefixes (total prefixes %d)", setName, len(initialElements)/2, len(prefixes)) + + // The set is committed now. If a later batch fails, destroy it: the + // refcounter records nothing on a create-callback error, so it would + // otherwise leak, and a partial source set fails-open for deny rules. + if err := r.addRemainingElements(nfset, elements, maxElements); err != nil { + if derr := r.deleteIpSet(setName, nfset); derr != nil { + log.Warnf("rollback ipset %s after add failure: %v", setName, derr) + } + return nil, err + } + + log.Infof("Created new ipset: %s with %d prefixes", setName, len(prefixes)) + return nfset, nil +} + +// addRemainingElements adds element batches beyond the initial one in +// maxElements-sized chunks, flushing each. Called after the set has been +// created with its first batch. +func (r *family) addRemainingElements(nfset *nftables.Set, elements []nftables.SetElement, maxElements int) error { + nElements := len(elements) + for subStart := maxElements; subStart < nElements; subStart += maxElements { + subEnd := min(subStart+maxElements, nElements) + subElement := elements[subStart:subEnd] + nSubPrefixes := len(subElement) / 2 + log.Tracef("Adding new prefixes (%d) in ipset: %s", nSubPrefixes, nfset.Name) + if err := r.conn.SetAddElements(nfset, subElement); err != nil { + return fmt.Errorf("error adding prefixes (%d) to set %s: %w", nSubPrefixes, nfset.Name, err) + } + if err := r.conn.Flush(); err != nil { + return fmt.Errorf("flush error: %w", err) + } + log.Debugf("Added new prefixes (%d) in ipset: %s", nSubPrefixes, nfset.Name) + } + return nil +} + +func (r *family) convertPrefixesToSet(prefixes []netip.Prefix) []nftables.SetElement { + var elements []nftables.SetElement + for _, prefix := range prefixes { + // nftables needs half-open intervals [firstIP, lastIP) for prefixes + // e.g. 10.0.0.0/24 becomes [10.0.0.0, 10.0.1.0), 10.1.1.1/32 becomes [10.1.1.1, 10.1.1.2) etc + firstIP := prefix.Addr() + + // For a /0 the last address is the broadcast and its Next() overflows + // to an invalid Addr with an empty key, so wrap to the zero address, + // which nftables reads as the open end of a full-range interval. + var lastKey []byte + if prefix.Bits() == 0 { + lastKey = make([]byte, r.af.addrLen) + } else { + lastKey = calculateLastIP(prefix).Next().AsSlice() + } + + // the nft tool also adds a zero-address IntervalEnd element, see https://github.com/google/nftables/issues/247 + // nftables.SetElement{Key: make([]byte, r.af.addrLen), IntervalEnd: true}, + elements = append(elements, + nftables.SetElement{Key: firstIP.AsSlice()}, + nftables.SetElement{Key: lastKey, IntervalEnd: true}, + ) + } + return elements +} + +// calculateLastIP determines the last IP in a given prefix. +func calculateLastIP(prefix netip.Prefix) netip.Addr { + masked := prefix.Masked() + if masked.Addr().Is4() { + hostMask := ^uint32(0) >> masked.Bits() + lastIP := uint32FromNetipAddr(masked.Addr()) | hostMask + return netip.AddrFrom4(uint32ToBytes(lastIP)) + } + + // IPv6: set host bits to all 1s + b := masked.Addr().As16() + bits := masked.Bits() + for i := bits; i < 128; i++ { + b[i/8] |= 1 << (7 - i%8) + } + return netip.AddrFrom16(b) +} + +// Utility function to convert netip.Addr to uint32. +func uint32FromNetipAddr(addr netip.Addr) uint32 { + b := addr.As4() + return binary.BigEndian.Uint32(b[:]) +} + +// Utility function to convert uint32 to a netip-compatible byte slice. +func uint32ToBytes(ip uint32) [4]byte { + var b [4]byte + binary.BigEndian.PutUint32(b[:], ip) + return b +} + +func (r *family) deleteIpSet(setName string, nfset *nftables.Set) error { + r.conn.DelSet(nfset) + if err := r.conn.Flush(); err != nil { + return fmt.Errorf(flushError, err) + } + + log.Debugf("Deleted unused ipset %s", setName) + return nil +} + +func (r *family) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error { + nfset, err := r.conn.GetSetByName(r.workTable, set.HashedName()) + if err != nil { + return fmt.Errorf("get set %s: %w", set.HashedName(), err) + } + + // Overlapping prefixes (e.g. duplicate resolved addresses) make the + // interval set reject the batch, so merge them as createIpSet does. + prefixes = firewall.MergeIPRanges(prefixes) + elements := r.convertPrefixesToSet(prefixes) + + // Add in batches sized like createIpSet so a large update does not + // exceed the netlink message size limit. + maxElements := maxPrefixesSet * 2 + for start := 0; start < len(elements); start += maxElements { + end := min(start+maxElements, len(elements)) + if err := r.conn.SetAddElements(nfset, elements[start:end]); err != nil { + return fmt.Errorf("add elements to set %s: %w", set.HashedName(), err) + } + if err := r.conn.Flush(); err != nil { + return fmt.Errorf(flushError, err) + } + } + + log.Debugf("updated set %s with %d prefixes", set.HashedName(), len(prefixes)) + + return nil +} + +func (r *family) getIpSetExprs(ref refcounter.Ref[*nftables.Set], isSource bool) ([]expr.Any, error) { + // dst offset by default + offset := r.af.dstAddrOffset + if isSource { + // src offset + offset = r.af.srcAddrOffset + } + + return []expr.Any{ + &expr.Payload{ + DestRegister: 1, + Base: expr.PayloadBaseNetworkHeader, + Offset: offset, + Len: r.af.addrLen, + }, + &expr.Lookup{ + SourceRegister: 1, + SetName: ref.Out.Name, + SetID: ref.Out.ID, + }, + }, nil +} diff --git a/client/firewall/nftables/ipset_linux_test.go b/client/firewall/nftables/ipset_linux_test.go new file mode 100644 index 000000000..7ab2f6c3f --- /dev/null +++ b/client/firewall/nftables/ipset_linux_test.go @@ -0,0 +1,36 @@ +package nftables + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestConvertPrefixesToSetWildcard verifies that a /0 prefix produces a +// usable interval. The last address of a /0 is the broadcast, whose Next() +// overflows to an invalid Addr with an empty key; the IntervalEnd must wrap +// to the zero address instead so nftables sees a full-range interval. +func TestConvertPrefixesToSetWildcard(t *testing.T) { + tests := []struct { + name string + af addrFamily + prefix string + }{ + {"IPv4 /0", afIPv4, "0.0.0.0/0"}, + {"IPv6 /0", afIPv6, "::/0"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &family{af: tt.af} + elements := r.convertPrefixesToSet([]netip.Prefix{netip.MustParsePrefix(tt.prefix)}) + + require.Len(t, elements, 2, "expected start and interval-end element") + assert.False(t, elements[0].IntervalEnd, "first element is the interval start") + assert.True(t, elements[1].IntervalEnd, "second element is the interval end") + assert.Len(t, elements[1].Key, int(tt.af.addrLen), "interval-end key must be a zero address, not empty") + }) + } +} diff --git a/client/firewall/nftables/ipsetstore_linux.go b/client/firewall/nftables/ipsetstore_linux.go deleted file mode 100644 index a6c2e9496..000000000 --- a/client/firewall/nftables/ipsetstore_linux.go +++ /dev/null @@ -1,85 +0,0 @@ -package nftables - -import ( - "net" -) - -type ipsetStore struct { - ipsetReference map[string]int - ipsets map[string]map[string]struct{} // ipsetName -> list of ips -} - -func newIpsetStore() *ipsetStore { - return &ipsetStore{ - ipsetReference: make(map[string]int), - ipsets: make(map[string]map[string]struct{}), - } -} - -func (s *ipsetStore) ips(ipsetName string) (map[string]struct{}, bool) { - r, ok := s.ipsets[ipsetName] - return r, ok -} - -func (s *ipsetStore) newIpset(ipsetName string) map[string]struct{} { - s.ipsetReference[ipsetName] = 0 - ipList := make(map[string]struct{}) - s.ipsets[ipsetName] = ipList - return ipList -} - -func (s *ipsetStore) deleteIpset(ipsetName string) { - delete(s.ipsetReference, ipsetName) - delete(s.ipsets, ipsetName) -} - -func (s *ipsetStore) DeleteIpFromSet(ipsetName string, ip net.IP) { - ipList, ok := s.ipsets[ipsetName] - if !ok { - return - } - delete(ipList, ip.String()) -} - -func (s *ipsetStore) AddIpToSet(ipsetName string, ip net.IP) { - ipList, ok := s.ipsets[ipsetName] - if !ok { - return - } - ipList[ip.String()] = struct{}{} -} - -func (s *ipsetStore) IsIpInSet(ipsetName string, ip net.IP) bool { - ipList, ok := s.ipsets[ipsetName] - if !ok { - return false - } - _, ok = ipList[ip.String()] - return ok -} - -func (s *ipsetStore) AddReferenceToIpset(ipsetName string) { - s.ipsetReference[ipsetName]++ -} - -func (s *ipsetStore) DeleteReferenceFromIpSet(ipsetName string) { - r, ok := s.ipsetReference[ipsetName] - if !ok { - return - } - if r == 0 { - return - } - s.ipsetReference[ipsetName]-- -} - -func (s *ipsetStore) HasReferenceToSet(ipsetName string) bool { - if _, ok := s.ipsetReference[ipsetName]; !ok { - return false - } - if s.ipsetReference[ipsetName] == 0 { - return false - } - - return true -} diff --git a/client/firewall/nftables/legacy_rule_linux_test.go b/client/firewall/nftables/legacy_rule_linux_test.go new file mode 100644 index 000000000..dc2f1c7a0 --- /dev/null +++ b/client/firewall/nftables/legacy_rule_linux_test.go @@ -0,0 +1,60 @@ +package nftables + +import ( + "testing" + + "github.com/google/nftables/expr" + "github.com/stretchr/testify/require" +) + +func TestBuildLegacyRouteRuleExpressions(t *testing.T) { + sourcePayload := &expr.Payload{} + sourceCmp := &expr.Cmp{} + destinationPayload := &expr.Payload{} + destinationCmp := &expr.Cmp{} + nilSourceDestination := &expr.Payload{} + nilDestinationSource := &expr.Cmp{} + + tests := []struct { + name string + source []expr.Any + destination []expr.Any + matches []expr.Any + }{ + { + name: "both non-empty", + source: []expr.Any{sourcePayload, sourceCmp}, + destination: []expr.Any{destinationPayload, destinationCmp}, + matches: []expr.Any{sourcePayload, sourceCmp, destinationPayload, destinationCmp}, + }, + { + name: "nil source", + destination: []expr.Any{nilSourceDestination}, + matches: []expr.Any{nilSourceDestination}, + }, + { + name: "nil destination", + source: []expr.Any{nilDestinationSource}, + matches: []expr.Any{nilDestinationSource}, + }, + { + name: "both nil", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildLegacyRouteRuleExpressions(tt.source, tt.destination) + + require.Len(t, got, len(tt.matches)+2) + for i, match := range tt.matches { + require.Same(t, match, got[i]) + } + + require.IsType(t, &expr.Counter{}, got[len(tt.matches)]) + verdict, ok := got[len(tt.matches)+1].(*expr.Verdict) + require.True(t, ok) + require.Equal(t, expr.VerdictAccept, verdict.Kind) + }) + } +} diff --git a/client/firewall/nftables/manager_linux.go b/client/firewall/nftables/manager_linux.go index 8cd5cc6b3..dbd5e4fa2 100644 --- a/client/firewall/nftables/manager_linux.go +++ b/client/firewall/nftables/manager_linux.go @@ -3,7 +3,6 @@ package nftables import ( "context" "fmt" - "net" "net/netip" "os" "sync" @@ -11,10 +10,11 @@ import ( "github.com/google/nftables" "github.com/google/nftables/binaryutil" "github.com/google/nftables/expr" + "github.com/hashicorp/go-multierror" log "github.com/sirupsen/logrus" "golang.org/x/sys/unix" - "github.com/netbirdio/netbird/client/firewall/firewalld" + nberrors "github.com/netbirdio/netbird/client/errors" firewall "github.com/netbirdio/netbird/client/firewall/manager" "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/internal/statemanager" @@ -43,16 +43,22 @@ type iFaceMapper interface { Address() wgaddr.Address } -// Manager of iptables firewall +// Manager of nftables firewall. Per-family state (peer ACLs, route +// ACLs, NAT, DNAT, MSS clamping) lives on family; Manager dispatches +// by family and provides the public firewall.Manager surface. type Manager struct { mutex sync.Mutex rConn *nftables.Conn wgIface iFaceMapper - router *router - aclManager *AclManager + family4 *family + // IPv6 counterpart, nil when no v6 overlay. + family6 *family + notrackOutputChain *nftables.Chain notrackPreroutingChain *nftables.Chain + + extMonitor *externalChainMonitor } // Create nftables firewall manager @@ -62,124 +68,233 @@ func Create(wgIface iFaceMapper, mtu uint16) (*Manager, error) { wgIface: wgIface, } - workTable := &nftables.Table{Name: getTableName(), Family: nftables.TableFamilyIPv4} + tableName := getTableName() + workTable := &nftables.Table{Name: tableName, Family: nftables.TableFamilyIPv4} - var err error - m.router, err = newRouter(workTable, wgIface, mtu) - if err != nil { - return nil, fmt.Errorf("create router: %w", err) + m.family4 = newFamily(workTable, wgIface, mtu) + + if wgIface.Address().HasIPv6() { + m.createIPv6Components(tableName, wgIface, mtu) } - m.aclManager, err = newAclManager(workTable, wgIface, chainNameRoutingFw) - if err != nil { - return nil, fmt.Errorf("create acl manager: %w", err) - } + m.extMonitor = newExternalChainMonitor(m) return m, nil } +func (m *Manager) createIPv6Components(tableName string, wgIface iFaceMapper, mtu uint16) { + workTable6 := &nftables.Table{Name: tableName, Family: nftables.TableFamilyIPv6} + + m.family6 = newFamily(workTable6, wgIface, mtu) + + // Share the per-family forwarding refcounter with the v4 family so a v4 + // rule and a v6 rule against the same state machine cooperate cleanly. + m.family6.ipFwdState = m.family4.ipFwdState +} + +// hasIPv6 reports whether the manager has IPv6 components initialized. +func (m *Manager) hasIPv6() bool { + return m.family6 != nil +} + +func (m *Manager) initIPv6() error { + workTable6, err := m.createWorkTableFamily(nftables.TableFamilyIPv6) + if err != nil { + return fmt.Errorf("create v6 work table: %w", err) + } + + if err := m.family6.init(workTable6); err != nil { + return fmt.Errorf("v6 family init: %w", err) + } + + return nil +} + // Init nftables firewall manager func (m *Manager) Init(stateManager *statemanager.Manager) error { + if err := m.initFirewall(); err != nil { + return err + } + + m.persistState(stateManager) + + // Start after initFirewall has installed the baseline external-chain + // accept rules. start() is idempotent across Init/Close/Init cycles. + m.extMonitor.start() + + return nil +} + +// reconcileExternalChains re-applies passthrough accept rules to external +// filter chains for both IPv4 and IPv6 routers. Called by the monitor when +// tables or chains appear (e.g. after firewalld reloads). Kernel routing opens +// both INPUT and FORWARD. +func (m *Manager) reconcileExternalChains() error { + m.mutex.Lock() + defer m.mutex.Unlock() + + var merr *multierror.Error + if m.family4 != nil { + if err := m.family4.acceptExternalChainsRules(true); err != nil { + merr = multierror.Append(merr, fmt.Errorf("v4: %w", err)) + } + } + if m.hasIPv6() { + if err := m.family6.acceptExternalChainsRules(true); err != nil { + merr = multierror.Append(merr, fmt.Errorf("v6: %w", err)) + } + } + return nberrors.FormatErrorOrNil(merr) +} + +func (m *Manager) initFirewall() (err error) { workTable, err := m.createWorkTable() if err != nil { return fmt.Errorf("create work table: %w", err) } - if err := m.router.init(workTable); err != nil { - return fmt.Errorf("router init: %w", err) + defer func() { + if err != nil { + m.rollbackInit() + } + }() + + if err := m.family4.init(workTable); err != nil { + return fmt.Errorf("family init: %w", err) } - if err := m.aclManager.init(workTable); err != nil { - // TODO: cleanup router - return fmt.Errorf("acl manager init: %w", err) + if m.hasIPv6() { + if err := m.initIPv6(); err != nil { + // Peer has a v6 address: v6 firewall MUST work or we risk fail-open. + return fmt.Errorf("init IPv6 firewall (required because peer has IPv6 address): %w", err) + } } if err := m.initNoTrackChains(workTable); err != nil { log.Warnf("raw priority chains not available, notrack rules will be disabled: %v", err) } + return nil +} + +// persistState saves the current interface state for potential recreation on restart. +// Unlike iptables, which requires tracking individual rules, nftables maintains +// a known state (our netbird table plus a few static rules). This allows for easy +// cleanup using Close() without needing to store specific rules. +func (m *Manager) persistState(stateManager *statemanager.Manager) { stateManager.RegisterState(&ShutdownState{}) - // We only need to record minimal interface state for potential recreation. - // Unlike iptables, which requires tracking individual rules, nftables maintains - // a known state (our netbird table plus a few static rules). This allows for easy - // cleanup using Close() without needing to store specific rules. if err := stateManager.UpdateState(&ShutdownState{ InterfaceState: &InterfaceState{ NameStr: m.wgIface.Name(), WGAddress: m.wgIface.Address(), - MTU: m.router.mtu, + MTU: m.family4.mtu, }, }); err != nil { log.Errorf("failed to update state: %v", err) } - // persist early go func() { if err := stateManager.PersistState(context.Background()); err != nil { log.Errorf("failed to persist state: %v", err) } }() - - return nil } -// AddPeerFiltering rule to the firewall -// -// If comment argument is empty firewall manager should set -// rule ID as comment for the rule -func (m *Manager) AddPeerFiltering( - id []byte, - ip net.IP, - proto firewall.Protocol, - sPort *firewall.Port, - dPort *firewall.Port, - action firewall.Action, - ipsetName string, -) ([]firewall.Rule, error) { - m.mutex.Lock() - defer m.mutex.Unlock() - - rawIP := ip.To4() - if rawIP == nil { - return nil, fmt.Errorf("unsupported IP version: %s", ip.String()) +// rollbackInit performs best-effort cleanup of already-initialized state when Init fails partway through. +func (m *Manager) rollbackInit() { + if err := m.family4.Reset(); err != nil { + log.Warnf("rollback family: %v", err) + } + if m.hasIPv6() { + if err := m.family6.Reset(); err != nil { + log.Warnf("rollback v6 family: %v", err) + } + } + if err := m.cleanupNetbirdTables(); err != nil { + log.Warnf("cleanup tables: %v", err) + } + if err := m.rConn.Flush(); err != nil { + log.Warnf("flush: %v", err) } - - return m.aclManager.AddPeerFiltering(id, ip, proto, sPort, dPort, action, ipsetName) } -func (m *Manager) AddRouteFiltering( +// AddFilterRule installs a packet-filtering rule. +// +// Destination semantics: zero Network → input chain (peer ACL); +// set Network → forward chain (route ACL). +// +// Sources are a single address family; the rule is dispatched to the +// matching per-family backend. +func (m *Manager) AddFilterRule( id []byte, sources []netip.Prefix, destination firewall.Network, proto firewall.Protocol, - sPort, dPort *firewall.Port, + sPort *firewall.Port, + dPort *firewall.Port, action firewall.Action, ) (firewall.Rule, error) { - m.mutex.Lock() - defer m.mutex.Unlock() - - if destination.IsPrefix() && !destination.Prefix.Addr().Is4() { - return nil, fmt.Errorf("unsupported IP version: %s", destination.Prefix.Addr().String()) + if len(sources) == 0 { + return nil, firewall.ErrNoSources } - return m.router.AddRouteFiltering(id, sources, destination, proto, sPort, dPort, action) -} - -// DeletePeerRule from the firewall by rule definition -func (m *Manager) DeletePeerRule(rule firewall.Rule) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.aclManager.DeletePeerRule(rule) + fam := m.family4 + if isIPv6Rule(sources, destination) { + if !m.hasIPv6() { + return nil, fmt.Errorf("add filtering: %w", firewall.ErrIPv6NotInitialized) + } + fam = m.family6 + } + return fam.AddFilterRule(id, sources, destination, proto, sPort, dPort, action) } -// DeleteRouteRule deletes a routing rule -func (m *Manager) DeleteRouteRule(rule firewall.Rule) error { +// DeleteFilterRule removes a filtering rule. The owning family is found +// by id in the in-memory filter maps, which are the only tracking for +// filter rules. family.DeleteFilterRule is idempotent when the id is +// absent. +func (m *Manager) DeleteFilterRule(rule firewall.Rule) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.DeleteRouteRule(rule) + fam, err := m.familyForRuleID(rule.ID(), (*family).hasRule, false) + if err != nil { + return err + } + return fam.DeleteFilterRule(rule) +} + +// familyForRuleID picks the family holding the rule with the given id, using +// the supplied lookup. With refresh set, a miss in both cached maps reloads +// the NAT/DNAT rule maps from the kernel once and re-checks before falling +// back to the v4 family. Filter rules are tracked only in memory and have no +// kernel-backed reload, so their callers pass refresh as false. +func (m *Manager) familyForRuleID(id firewall.RuleID, has func(*family, firewall.RuleID) bool, refresh bool) (*family, error) { + if has(m.family4, id) { + return m.family4, nil + } + if !m.hasIPv6() { + return m.family4, nil + } + if has(m.family6, id) { + return m.family6, nil + } + if !refresh { + return m.family4, nil + } + if err := m.family4.refreshRulesMap(); err != nil { + return nil, fmt.Errorf("refresh v4 rules: %w", err) + } + if err := m.family6.refreshRulesMap(); err != nil { + return nil, fmt.Errorf("refresh v6 rules: %w", err) + } + if has(m.family6, id) && !has(m.family4, id) { + return m.family6, nil + } + return m.family4, nil } func (m *Manager) IsServerRouteSupported() bool { @@ -194,64 +309,103 @@ func (m *Manager) AddNatRule(pair firewall.RouterPair) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.AddNatRule(pair) + if pair.Destination.IsPrefix() && pair.Destination.Prefix.Addr().Is6() { + if !m.hasIPv6() { + return fmt.Errorf("add NAT rule: %w", firewall.ErrIPv6NotInitialized) + } + return m.family6.AddNatRule(pair) + } + + if err := m.family4.AddNatRule(pair); err != nil { + return err + } + + // Dynamic routes need NAT in both tables since resolved IPs can be + // either v4 or v6. This covers both DomainSet (modern) and the legacy + // wildcard 0.0.0.0/0 destination where the client resolves DNS. + // On v6 failure we keep the v4 NAT rule rather than rolling back: half + // connectivity is better than none, and RemoveNatRule is content-keyed + // so the eventual cleanup still works. + if m.hasIPv6() && pair.Dynamic { + v6Pair := firewall.ToV6NatPair(pair) + if err := m.family6.AddNatRule(v6Pair); err != nil { + return fmt.Errorf("add v6 NAT rule: %w", err) + } + } + + return nil } func (m *Manager) RemoveNatRule(pair firewall.RouterPair) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.RemoveNatRule(pair) -} - -// AllowNetbird allows netbird interface traffic. -// This is called when USPFilter wraps the native firewall, adding blanket accept -// rules so that packet filtering is handled in userspace instead of by netfilter. -func (m *Manager) AllowNetbird() error { - m.mutex.Lock() - defer m.mutex.Unlock() - - if err := m.aclManager.createDefaultAllowRules(); err != nil { - return fmt.Errorf("create default allow rules: %w", err) - } - if err := m.rConn.Flush(); err != nil { - return fmt.Errorf("flush allow input netbird rules: %w", err) + if pair.Destination.IsPrefix() && pair.Destination.Prefix.Addr().Is6() { + if !m.hasIPv6() { + return nil + } + return m.family6.RemoveNatRule(pair) } - if err := firewalld.TrustInterface(m.wgIface.Name()); err != nil { - log.Warnf("failed to trust interface in firewalld: %v", err) + var merr *multierror.Error + + if err := m.family4.RemoveNatRule(pair); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove v4 NAT rule: %w", err)) } - return nil + if m.hasIPv6() && pair.Dynamic { + v6Pair := firewall.ToV6NatPair(pair) + if err := m.family6.RemoveNatRule(v6Pair); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove v6 NAT rule: %w", err)) + } + } + + return nberrors.FormatErrorOrNil(merr) } // SetLegacyManagement sets the route manager to use legacy management func (m *Manager) SetLegacyManagement(isLegacy bool) error { - return firewall.SetLegacyManagement(m.router, isLegacy) + if err := firewall.SetLegacyManagement(m.family4, isLegacy); err != nil { + return err + } + if m.hasIPv6() { + return firewall.SetLegacyManagement(m.family6, isLegacy) + } + return nil } // Close closes the firewall manager func (m *Manager) Close(stateManager *statemanager.Manager) error { + m.extMonitor.stop() + m.mutex.Lock() defer m.mutex.Unlock() - if err := m.router.Reset(); err != nil { - return fmt.Errorf("reset router: %v", err) + var merr *multierror.Error + + if err := m.family4.Reset(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("reset family: %w", err)) + } + + if m.hasIPv6() { + if err := m.family6.Reset(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("reset v6 family: %w", err)) + } } if err := m.cleanupNetbirdTables(); err != nil { - return fmt.Errorf("cleanup netbird tables: %v", err) + merr = multierror.Append(merr, fmt.Errorf("cleanup netbird tables: %v", err)) } if err := m.rConn.Flush(); err != nil { - return fmt.Errorf(flushError, err) + merr = multierror.Append(merr, fmt.Errorf(flushError, err)) } if err := stateManager.DeleteState(&ShutdownState{}); err != nil { - return fmt.Errorf("delete state: %v", err) + merr = multierror.Append(merr, fmt.Errorf("delete state: %v", err)) } - return nil + return nberrors.FormatErrorOrNil(merr) } func (m *Manager) cleanupNetbirdTables() error { @@ -275,17 +429,12 @@ func (m *Manager) SetLogLevel(log.Level) { } func (m *Manager) EnableRouting() error { - if err := m.router.ipFwdState.RequestForwarding(); err != nil { - return fmt.Errorf("enable IP forwarding: %w", err) - } - return nil + // v6 only when the overlay actually has v6. + return m.family4.ipFwdState.RequestRouting(m.hasIPv6()) } func (m *Manager) DisableRouting() error { - if err := m.router.ipFwdState.ReleaseForwarding(); err != nil { - return fmt.Errorf("disable IP forwarding: %w", err) - } - return nil + return m.family4.ipFwdState.ReleaseRouting() } // Flush rule/chain/set operations from the buffer @@ -296,10 +445,16 @@ func (m *Manager) Flush() error { m.mutex.Lock() defer m.mutex.Unlock() - if err := m.aclManager.Flush(); err != nil { + if err := m.family4.Flush(); err != nil { return err } + if m.hasIPv6() { + if err := m.family6.Flush(); err != nil { + return fmt.Errorf("flush v6 family: %w", err) + } + } + if err := m.refreshNoTrackChains(); err != nil { log.Errorf("failed to refresh notrack chains: %v", err) } @@ -312,7 +467,13 @@ func (m *Manager) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) m.mutex.Lock() defer m.mutex.Unlock() - return m.router.AddDNATRule(rule) + if rule.TranslatedAddress.Is6() { + if !m.hasIPv6() { + return nil, fmt.Errorf("add DNAT rule: %w", firewall.ErrIPv6NotInitialized) + } + return m.family6.AddDNATRule(rule) + } + return m.family4.AddDNATRule(rule) } // DeleteDNATRule deletes a DNAT rule @@ -320,7 +481,11 @@ func (m *Manager) DeleteDNATRule(rule firewall.Rule) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.DeleteDNATRule(rule) + r, err := m.familyForRuleID(rule.ID(), (*family).hasDNATRule, true) + if err != nil { + return err + } + return r.DeleteDNATRule(rule) } // UpdateSet updates the set with the given prefixes @@ -328,39 +493,82 @@ func (m *Manager) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.UpdateSet(set, prefixes) + var v4Prefixes, v6Prefixes []netip.Prefix + for _, p := range prefixes { + if p.Addr().Is6() { + v6Prefixes = append(v6Prefixes, p) + } else { + v4Prefixes = append(v4Prefixes, p) + } + } + + if err := m.family4.UpdateSet(set, v4Prefixes); err != nil { + return err + } + + if m.hasIPv6() && len(v6Prefixes) > 0 { + if err := m.family6.UpdateSet(set, v6Prefixes); err != nil { + return fmt.Errorf("update v6 set: %w", err) + } + } + + return nil } // AddInboundDNAT adds an inbound DNAT rule redirecting traffic from NetBird peers to local services. -func (m *Manager) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { +func (m *Manager) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.AddInboundDNAT(localAddr, protocol, sourcePort, targetPort) + if localAddr.Is6() { + if !m.hasIPv6() { + return fmt.Errorf("add inbound DNAT: %w", firewall.ErrIPv6NotInitialized) + } + return m.family6.AddInboundDNAT(localAddr, protocol, originalPort, translatedPort) + } + return m.family4.AddInboundDNAT(localAddr, protocol, originalPort, translatedPort) } // RemoveInboundDNAT removes an inbound DNAT rule. -func (m *Manager) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { +func (m *Manager) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.RemoveInboundDNAT(localAddr, protocol, sourcePort, targetPort) + if localAddr.Is6() { + if !m.hasIPv6() { + return fmt.Errorf("remove inbound DNAT: %w", firewall.ErrIPv6NotInitialized) + } + return m.family6.RemoveInboundDNAT(localAddr, protocol, originalPort, translatedPort) + } + return m.family4.RemoveInboundDNAT(localAddr, protocol, originalPort, translatedPort) } // AddOutputDNAT adds an OUTPUT chain DNAT rule for locally-generated traffic. -func (m *Manager) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { +func (m *Manager) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.AddOutputDNAT(localAddr, protocol, sourcePort, targetPort) + if localAddr.Is6() { + if !m.hasIPv6() { + return fmt.Errorf("add output DNAT: %w", firewall.ErrIPv6NotInitialized) + } + return m.family6.AddOutputDNAT(localAddr, protocol, originalPort, translatedPort) + } + return m.family4.AddOutputDNAT(localAddr, protocol, originalPort, translatedPort) } // RemoveOutputDNAT removes an OUTPUT chain DNAT rule. -func (m *Manager) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { +func (m *Manager) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.RemoveOutputDNAT(localAddr, protocol, sourcePort, targetPort) + if localAddr.Is6() { + if !m.hasIPv6() { + return fmt.Errorf("remove output DNAT: %w", firewall.ErrIPv6NotInitialized) + } + return m.family6.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort) + } + return m.family4.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort) } const ( @@ -534,7 +742,11 @@ func (m *Manager) refreshNoTrackChains() error { } func (m *Manager) createWorkTable() (*nftables.Table, error) { - tables, err := m.rConn.ListTablesOfFamily(nftables.TableFamilyIPv4) + return m.createWorkTableFamily(nftables.TableFamilyIPv4) +} + +func (m *Manager) createWorkTableFamily(family nftables.TableFamily) (*nftables.Table, error) { + tables, err := m.rConn.ListTablesOfFamily(family) if err != nil { return nil, fmt.Errorf("list of tables: %w", err) } @@ -546,7 +758,7 @@ func (m *Manager) createWorkTable() (*nftables.Table, error) { } } - table := m.rConn.AddTable(&nftables.Table{Name: getTableName(), Family: nftables.TableFamilyIPv4}) + table := m.rConn.AddTable(&nftables.Table{Name: tableName, Family: family}) err = m.rConn.Flush() return table, err } @@ -585,3 +797,14 @@ func getEstablishedExprs(register uint32) []expr.Any { }, } } + +// isIPv6Rule reports whether the rule belongs to the v6 table. For a +// prefix destination the destination family decides; otherwise the +// (single-family) sources do, since management duplicates rules per +// family. +func isIPv6Rule(sources []netip.Prefix, destination firewall.Network) bool { + if destination.IsPrefix() { + return destination.Prefix.Addr().Is6() + } + return len(sources) > 0 && sources[0].Addr().Is6() +} diff --git a/client/firewall/nftables/manager_linux_test.go b/client/firewall/nftables/manager_linux_test.go index d48e4ba88..0ca56409e 100644 --- a/client/firewall/nftables/manager_linux_test.go +++ b/client/firewall/nftables/manager_linux_test.go @@ -1,3 +1,5 @@ +//go:build privileged + package nftables import ( @@ -70,13 +72,13 @@ func TestNftablesManager(t *testing.T) { testClient := &nftables.Conn{} - rule, err := manager.AddPeerFiltering(nil, ip.AsSlice(), fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{53}}, fw.ActionDrop, "") + rule, err := manager.AddFilterRule(nil, pfx(ip.AsSlice()), fw.Network{}, fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{53}}, fw.ActionDrop) require.NoError(t, err, "failed to add rule") err = manager.Flush() require.NoError(t, err, "failed to flush") - rules, err := testClient.GetRules(manager.aclManager.workTable, manager.aclManager.chainInputRules) + rules, err := testClient.GetRules(manager.family4.workTable, manager.family4.chainInputRules) require.NoError(t, err, "failed to get rules") require.Len(t, rules, 2, "expected 2 rules") @@ -147,15 +149,12 @@ func TestNftablesManager(t *testing.T) { // Compare connection tracking rule at position 1 (pushed down by DROP rule insertion) compareExprsIgnoringCounters(t, rules[1].Exprs, expectedExprs1) - for _, r := range rule { - err = manager.DeletePeerRule(r) - require.NoError(t, err, "failed to delete rule") - } + require.NoError(t, manager.DeleteFilterRule(rule), "failed to delete rule") err = manager.Flush() require.NoError(t, err, "failed to flush") - rules, err = testClient.GetRules(manager.aclManager.workTable, manager.aclManager.chainInputRules) + rules, err = testClient.GetRules(manager.family4.workTable, manager.family4.chainInputRules) require.NoError(t, err, "failed to get rules") // established rule remains require.Len(t, rules, 1, "expected 1 rules after deletion") @@ -180,47 +179,39 @@ func TestNftablesManagerRuleOrder(t *testing.T) { testClient := &nftables.Conn{} // Add accept rule first - _, err = manager.AddPeerFiltering(nil, ip.AsSlice(), fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionAccept, "accept-http") + _, err = manager.AddFilterRule(nil, pfx(ip.AsSlice()), fw.Network{}, fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionAccept) require.NoError(t, err, "failed to add accept rule") // Add deny rule second for the same traffic - _, err = manager.AddPeerFiltering(nil, ip.AsSlice(), fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionDrop, "deny-http") + _, err = manager.AddFilterRule(nil, pfx(ip.AsSlice()), fw.Network{}, fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionDrop) require.NoError(t, err, "failed to add deny rule") err = manager.Flush() require.NoError(t, err, "failed to flush") - rules, err := testClient.GetRules(manager.aclManager.workTable, manager.aclManager.chainInputRules) + rules, err := testClient.GetRules(manager.family4.workTable, manager.family4.chainInputRules) require.NoError(t, err, "failed to get rules") t.Logf("Found %d rules in nftables chain", len(rules)) - // Find the accept and deny rules and verify deny comes before accept + // Single-source rules emit a direct payload+cmp on the source IP + // (no set lookup). Match by source-IP + port + verdict instead of + // the legacy per-(action,port) set names ("deny-http"/"accept-http") + // that this test predates. + wantSrc := ip.AsSlice() var acceptRuleIndex, denyRuleIndex = -1, -1 for i, rule := range rules { - hasAcceptHTTPSet := false - hasDenyHTTPSet := false - hasPort80 := false + var hasSrc, hasPort80 bool var action string - for _, e := range rule.Exprs { - // Check for set lookup - if lookup, ok := e.(*expr.Lookup); ok { - switch lookup.SetName { - case "accept-http": - hasAcceptHTTPSet = true - case "deny-http": - hasDenyHTTPSet = true + if cmp, ok := e.(*expr.Cmp); ok && cmp.Op == expr.CmpOpEq { + if bytes.Equal(cmp.Data, wantSrc) { + hasSrc = true } - - } - // Check for port 80 - if cmp, ok := e.(*expr.Cmp); ok { - if cmp.Op == expr.CmpOpEq && len(cmp.Data) == 2 && binary.BigEndian.Uint16(cmp.Data) == 80 { + if len(cmp.Data) == 2 && binary.BigEndian.Uint16(cmp.Data) == 80 { hasPort80 = true } } - // Check for verdict if verdict, ok := e.(*expr.Verdict); ok { switch verdict.Kind { case expr.VerdictAccept: @@ -231,11 +222,15 @@ func TestNftablesManagerRuleOrder(t *testing.T) { } } - if hasAcceptHTTPSet && hasPort80 && action == "ACCEPT" { - t.Logf("Rule [%d]: accept-http set + Port 80 + ACCEPT", i) + if !hasSrc || !hasPort80 { + continue + } + switch action { + case "ACCEPT": + t.Logf("Rule [%d]: src=%s port=80 ACCEPT", i, ip) acceptRuleIndex = i - } else if hasDenyHTTPSet && hasPort80 && action == "DROP" { - t.Logf("Rule [%d]: deny-http set + Port 80 + DROP", i) + case "DROP": + t.Logf("Rule [%d]: src=%s port=80 DROP", i, ip) denyRuleIndex = i } } @@ -279,7 +274,7 @@ func TestNFtablesCreatePerformance(t *testing.T) { start := time.Now() for i := 0; i < testMax; i++ { port := &fw.Port{Values: []uint16{uint16(1000 + i)}} - _, err = manager.AddPeerFiltering(nil, ip.AsSlice(), "tcp", nil, port, fw.ActionAccept, "") + _, err = manager.AddFilterRule(nil, pfx(ip.AsSlice()), fw.Network{}, "tcp", nil, port, fw.ActionAccept) require.NoError(t, err, "failed to add rule") if i%100 == 0 { @@ -361,10 +356,10 @@ func TestNftablesManagerCompatibilityWithIptables(t *testing.T) { }) ip := netip.MustParseAddr("100.96.0.1") - _, err = manager.AddPeerFiltering(nil, ip.AsSlice(), fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionAccept, "") + _, err = manager.AddFilterRule(nil, pfx(ip.AsSlice()), fw.Network{}, fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionAccept) require.NoError(t, err, "failed to add peer filtering rule") - _, err = manager.AddRouteFiltering( + _, err = manager.AddFilterRule( nil, []netip.Prefix{netip.MustParsePrefix("192.168.2.0/24")}, fw.Network{Prefix: netip.MustParsePrefix("10.1.0.0/24")}, @@ -383,10 +378,138 @@ func TestNftablesManagerCompatibilityWithIptables(t *testing.T) { err = manager.AddNatRule(pair) require.NoError(t, err, "failed to add NAT rule") + dnatRule, err := manager.AddDNATRule(fw.ForwardRule{ + Protocol: fw.ProtocolTCP, + DestinationPort: fw.Port{Values: []uint16{8080}}, + TranslatedAddress: netip.MustParseAddr("100.96.0.2"), + TranslatedPort: fw.Port{Values: []uint16{80}}, + }) + require.NoError(t, err, "failed to add DNAT rule") + + t.Cleanup(func() { + require.NoError(t, manager.DeleteDNATRule(dnatRule), "failed to delete DNAT rule") + }) + stdout, stderr = runIptablesSave(t) verifyIptablesOutput(t, stdout, stderr) } +func TestNftablesManagerIPv6CompatibilityWithIp6tables(t *testing.T) { + if check() != NFTABLES { + t.Skip("nftables not supported on this system") + } + + for _, bin := range []string{"ip6tables", "ip6tables-save", "iptables-save"} { + if _, err := exec.LookPath(bin); err != nil { + t.Skipf("%s not available on this system: %v", bin, err) + } + } + + // Seed ip6 tables in the nft backend. Docker may not create them. + seedIp6tables(t) + + ifaceMockV6 := &iFaceMock{ + NameFunc: func() string { return "wt-test" }, + AddressFunc: func() wgaddr.Address { + return wgaddr.Address{ + IP: netip.MustParseAddr("100.96.0.1"), + Network: netip.MustParsePrefix("100.96.0.0/16"), + IPv6: netip.MustParseAddr("fd00::1"), + IPv6Net: netip.MustParsePrefix("fd00::/64"), + } + }, + } + + manager, err := Create(ifaceMockV6, iface.DefaultMTU) + require.NoError(t, err, "create manager") + require.NoError(t, manager.Init(nil)) + + t.Cleanup(func() { + require.NoError(t, manager.Close(nil), "close manager") + + stdout, stderr := runIp6tablesSave(t) + verifyIp6tablesOutput(t, stdout, stderr) + }) + + ip := netip.MustParseAddr("fd00::2") + _, err = manager.AddFilterRule(nil, pfx(ip.AsSlice()), fw.Network{}, fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionAccept) + require.NoError(t, err, "add v6 peer filtering rule") + + _, err = manager.AddFilterRule( + nil, + []netip.Prefix{netip.MustParsePrefix("fd00:1::/64")}, + fw.Network{Prefix: netip.MustParsePrefix("2001:db8::/48")}, + fw.ProtocolTCP, + nil, + &fw.Port{Values: []uint16{443}}, + fw.ActionAccept, + ) + require.NoError(t, err, "add v6 route filtering rule") + + err = manager.AddNatRule(fw.RouterPair{ + Source: fw.Network{Prefix: netip.MustParsePrefix("fd00::/64")}, + Destination: fw.Network{Prefix: netip.MustParsePrefix("2001:db8::/48")}, + Masquerade: true, + }) + require.NoError(t, err, "add v6 NAT rule") + + dnatRule, err := manager.AddDNATRule(fw.ForwardRule{ + Protocol: fw.ProtocolTCP, + DestinationPort: fw.Port{Values: []uint16{8080}}, + TranslatedAddress: netip.MustParseAddr("fd00::2"), + TranslatedPort: fw.Port{Values: []uint16{80}}, + }) + require.NoError(t, err, "add v6 DNAT rule") + + t.Cleanup(func() { + require.NoError(t, manager.DeleteDNATRule(dnatRule), "delete v6 DNAT rule") + }) + + stdout, stderr := runIptablesSave(t) + verifyIptablesOutput(t, stdout, stderr) + + stdout, stderr = runIp6tablesSave(t) + verifyIp6tablesOutput(t, stdout, stderr) +} + +func seedIp6tables(t *testing.T) { + t.Helper() + for _, tc := range []struct{ table, chain string }{ + {"filter", "FORWARD"}, + {"nat", "POSTROUTING"}, + {"mangle", "FORWARD"}, + } { + add := exec.Command("ip6tables", "-t", tc.table, "-A", tc.chain, "-j", "ACCEPT") + require.NoError(t, add.Run(), "seed ip6tables -t %s", tc.table) + del := exec.Command("ip6tables", "-t", tc.table, "-D", tc.chain, "-j", "ACCEPT") + require.NoError(t, del.Run(), "unseed ip6tables -t %s", tc.table) + } +} + +func runIp6tablesSave(t *testing.T) (string, string) { + t.Helper() + var stdout, stderr bytes.Buffer + cmd := exec.Command("ip6tables-save") + cmd.Stdout = &stdout + cmd.Stderr = &stderr + require.NoError(t, cmd.Run(), "ip6tables-save failed") + return stdout.String(), stderr.String() +} + +func verifyIp6tablesOutput(t *testing.T, stdout, stderr string) { + t.Helper() + for _, msg := range []string{ + "Table `nat' is incompatible", + "Table `mangle' is incompatible", + "Table `filter' is incompatible", + } { + require.NotContains(t, stdout, msg, + "ip6tables-save stdout reports incompatibility: %s", stdout) + require.NotContains(t, stderr, msg, + "ip6tables-save stderr reports incompatibility: %s", stderr) + } +} + func TestNftablesManagerCompatibilityWithIptablesFor6kPrefixes(t *testing.T) { if check() != NFTABLES { t.Skip("nftables not supported on this system") @@ -422,7 +545,7 @@ func TestNftablesManagerCompatibilityWithIptablesFor6kPrefixes(t *testing.T) { prefixes = append(prefixes, netip.PrefixFrom(addr, 24)) } } - _, err = manager.AddRouteFiltering( + _, err = manager.AddFilterRule( nil, prefixes, fw.Network{Prefix: netip.MustParsePrefix("10.2.0.0/24")}, @@ -437,7 +560,7 @@ func TestNftablesManagerCompatibilityWithIptablesFor6kPrefixes(t *testing.T) { verifyIptablesOutput(t, stdout, stderr) } -func TestNftablesManagerCompatibilityWithIptablesForEmptyPrefixes(t *testing.T) { +func TestNftablesManagerCompatibilityWithIptablesForWildcardSource(t *testing.T) { if check() != NFTABLES { t.Skip("nftables not supported on this system") } @@ -463,9 +586,9 @@ func TestNftablesManagerCompatibilityWithIptablesForEmptyPrefixes(t *testing.T) verifyIptablesOutput(t, stdout, stderr) }) - _, err = manager.AddRouteFiltering( + _, err = manager.AddFilterRule( nil, - []netip.Prefix{}, + []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")}, fw.Network{Prefix: netip.MustParsePrefix("10.2.0.0/24")}, fw.ProtocolTCP, nil, @@ -478,6 +601,73 @@ func TestNftablesManagerCompatibilityWithIptablesForEmptyPrefixes(t *testing.T) verifyIptablesOutput(t, stdout, stderr) } +func TestNftablesManagerMultiPortFilter(t *testing.T) { + if check() != NFTABLES { + t.Skip("nftables not supported on this system") + } + + manager, err := Create(ifaceMock, iface.DefaultMTU) + require.NoError(t, err) + require.NoError(t, manager.Init(nil)) + + t.Cleanup(func() { + require.NoError(t, manager.Close(nil), "failed to reset manager state") + }) + + ip := netip.MustParseAddr("100.96.0.1") + + rule, err := manager.AddFilterRule(nil, pfx(ip.AsSlice()), fw.Network{}, fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80, 443}}, fw.ActionAccept) + require.NoError(t, err, "failed to add multi-port rule") + + testClient := &nftables.Conn{} + rules, err := testClient.GetRules(manager.family4.workTable, manager.family4.chainInputRules) + require.NoError(t, err, "failed to get rules") + + var lookup *expr.Lookup + for _, kernelRule := range rules { + if string(kernelRule.UserData) != string(rule.ID()) { + continue + } + for _, e := range kernelRule.Exprs { + if l, ok := e.(*expr.Lookup); ok { + lookup = l + } + } + } + require.NotNil(t, lookup, "multi-port rule must match ports via a set lookup") + + sets, err := testClient.GetSets(manager.family4.workTable) + require.NoError(t, err, "failed to get sets") + + var portSet *nftables.Set + for _, s := range sets { + if s.Name == lookup.SetName { + portSet = s + } + } + require.NotNil(t, portSet, "anonymous port set not found in kernel") + + portSet.Table = manager.family4.workTable + elements, err := testClient.GetSetElements(portSet) + require.NoError(t, err, "failed to get set elements") + + ports := make(map[uint16]bool) + for _, e := range elements { + require.Len(t, e.Key, 2, "port set element key should be 2 bytes") + ports[binary.BigEndian.Uint16(e.Key)] = true + } + require.True(t, ports[80], "port set should contain port 80") + require.True(t, ports[443], "port set should contain port 443") + + require.NoError(t, manager.DeleteFilterRule(rule), "failed to delete rule") + + rules, err = testClient.GetRules(manager.family4.workTable, manager.family4.chainInputRules) + require.NoError(t, err, "failed to get rules after delete") + for _, kernelRule := range rules { + require.NotEqual(t, string(rule.ID()), string(kernelRule.UserData), "rule should be removed from kernel") + } +} + func compareExprsIgnoringCounters(t *testing.T, got, want []expr.Any) { t.Helper() require.Equal(t, len(got), len(want), "expression count mismatch") diff --git a/client/firewall/nftables/router_linux.go b/client/firewall/nftables/router_linux.go deleted file mode 100644 index 8cc0d2792..000000000 --- a/client/firewall/nftables/router_linux.go +++ /dev/null @@ -1,2157 +0,0 @@ -package nftables - -import ( - "bytes" - "encoding/binary" - "errors" - "fmt" - "net" - "net/netip" - "strings" - - "github.com/coreos/go-iptables/iptables" - "github.com/google/nftables" - "github.com/google/nftables/binaryutil" - "github.com/google/nftables/expr" - "github.com/google/nftables/xt" - "github.com/hashicorp/go-multierror" - log "github.com/sirupsen/logrus" - "golang.org/x/sys/unix" - - nberrors "github.com/netbirdio/netbird/client/errors" - "github.com/netbirdio/netbird/client/firewall/firewalld" - firewall "github.com/netbirdio/netbird/client/firewall/manager" - nbid "github.com/netbirdio/netbird/client/internal/acl/id" - "github.com/netbirdio/netbird/client/internal/routemanager/ipfwdstate" - "github.com/netbirdio/netbird/client/internal/routemanager/refcounter" - nbnet "github.com/netbirdio/netbird/client/net" -) - -const ( - tableNat = "nat" - tableMangle = "mangle" - tableRaw = "raw" - tableSecurity = "security" - - chainNameNatPrerouting = "PREROUTING" - chainNameRoutingFw = "netbird-rt-fwd" - chainNameRoutingNat = "netbird-rt-postrouting" - chainNameRoutingRdr = "netbird-rt-redirect" - chainNameNATOutput = "netbird-nat-output" - chainNameForward = "FORWARD" - chainNameMangleForward = "netbird-mangle-forward" - - firewalldTableName = "firewalld" - - userDataAcceptForwardRuleIif = "frwacceptiif" - userDataAcceptForwardRuleOif = "frwacceptoif" - userDataAcceptInputRule = "inputaccept" - - dnatSuffix = "_dnat" - snatSuffix = "_snat" - - // ipTCPHeaderMinSize represents minimum IP (20) + TCP (20) header size for MSS calculation - ipTCPHeaderMinSize = 40 - - // maxPrefixesSet 1638 prefixes start to fail, taking some margin - maxPrefixesSet = 1500 - refreshRulesMapError = "refresh rules map: %w" -) - -var ( - errFilterTableNotFound = fmt.Errorf("'filter' table not found") -) - -type setInput struct { - set firewall.Set - prefixes []netip.Prefix -} - -type router struct { - conn *nftables.Conn - workTable *nftables.Table - filterTable *nftables.Table - chains map[string]*nftables.Chain - // rules is useful to avoid duplicates and to get missing attributes that we don't have when adding new rules - rules map[string]*nftables.Rule - ipsetCounter *refcounter.Counter[string, setInput, *nftables.Set] - - wgIface iFaceMapper - ipFwdState *ipfwdstate.IPForwardingState - legacyManagement bool - mtu uint16 -} - -func newRouter(workTable *nftables.Table, wgIface iFaceMapper, mtu uint16) (*router, error) { - r := &router{ - conn: &nftables.Conn{}, - workTable: workTable, - chains: make(map[string]*nftables.Chain), - rules: make(map[string]*nftables.Rule), - wgIface: wgIface, - ipFwdState: ipfwdstate.NewIPForwardingState(), - mtu: mtu, - } - - r.ipsetCounter = refcounter.New( - r.createIpSet, - r.deleteIpSet, - ) - - var err error - r.filterTable, err = r.loadFilterTable() - if err != nil { - log.Debugf("ip filter table not found: %v", err) - } - - return r, nil -} - -func (r *router) init(workTable *nftables.Table) error { - r.workTable = workTable - - if err := r.removeAcceptFilterRules(); err != nil { - log.Errorf("failed to clean up rules from filter table: %s", err) - } - - if err := r.createContainers(); err != nil { - return fmt.Errorf("create containers: %w", err) - } - - if err := r.setupDataPlaneMark(); err != nil { - log.Errorf("failed to set up data plane mark: %v", err) - } - - return nil -} - -// Reset cleans existing nftables filter table rules from the system -func (r *router) Reset() error { - // clear without deleting the ipsets, the nf table will be deleted by the caller - r.ipsetCounter.Clear() - - var merr *multierror.Error - - if err := r.removeAcceptFilterRules(); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove accept filter rules: %w", err)) - } - - if err := firewalld.UntrustInterface(r.wgIface.Name()); err != nil { - merr = multierror.Append(merr, err) - } - - if err := r.removeNatPreroutingRules(); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove filter prerouting rules: %w", err)) - } - - return nberrors.FormatErrorOrNil(merr) -} - -func (r *router) removeNatPreroutingRules() error { - table := &nftables.Table{ - Name: tableNat, - Family: nftables.TableFamilyIPv4, - } - chain := &nftables.Chain{ - Name: chainNameNatPrerouting, - Table: table, - Hooknum: nftables.ChainHookPrerouting, - Priority: nftables.ChainPriorityNATDest, - Type: nftables.ChainTypeNAT, - } - rules, err := r.conn.GetRules(table, chain) - if err != nil { - return fmt.Errorf("get rules from nat table: %w", err) - } - - var merr *multierror.Error - - // Delete rules that have our UserData suffix - for _, rule := range rules { - if len(rule.UserData) == 0 || !strings.HasSuffix(string(rule.UserData), dnatSuffix) { - continue - } - if err := r.conn.DelRule(rule); err != nil { - merr = multierror.Append(merr, fmt.Errorf("delete rule %s: %w", rule.UserData, err)) - } - } - - if err := r.conn.Flush(); err != nil { - merr = multierror.Append(merr, fmt.Errorf(flushError, err)) - } - return nberrors.FormatErrorOrNil(merr) -} - -func (r *router) loadFilterTable() (*nftables.Table, error) { - tables, err := r.conn.ListTablesOfFamily(nftables.TableFamilyIPv4) - if err != nil { - return nil, fmt.Errorf("list tables: %w", err) - } - - for _, table := range tables { - if table.Name == "filter" { - return table, nil - } - } - - return nil, errFilterTableNotFound -} - -func hookName(hook *nftables.ChainHook) string { - if hook == nil { - return "unknown" - } - switch *hook { - case *nftables.ChainHookForward: - return chainNameForward - case *nftables.ChainHookInput: - return chainNameInput - default: - return fmt.Sprintf("hook(%d)", *hook) - } -} - -func familyName(family nftables.TableFamily) string { - switch family { - case nftables.TableFamilyIPv4: - return "ip" - case nftables.TableFamilyIPv6: - return "ip6" - case nftables.TableFamilyINet: - return "inet" - default: - return fmt.Sprintf("family(%d)", family) - } -} - -func (r *router) createContainers() error { - r.chains[chainNameRoutingFw] = r.conn.AddChain(&nftables.Chain{ - Name: chainNameRoutingFw, - Table: r.workTable, - }) - - prio := *nftables.ChainPriorityNATSource - 1 - r.chains[chainNameRoutingNat] = r.conn.AddChain(&nftables.Chain{ - Name: chainNameRoutingNat, - Table: r.workTable, - Hooknum: nftables.ChainHookPostrouting, - Priority: &prio, - Type: nftables.ChainTypeNAT, - }) - - r.chains[chainNameRoutingRdr] = r.conn.AddChain(&nftables.Chain{ - Name: chainNameRoutingRdr, - Table: r.workTable, - Hooknum: nftables.ChainHookPrerouting, - Priority: nftables.ChainPriorityNATDest, - Type: nftables.ChainTypeNAT, - }) - - r.chains[chainNameManglePostrouting] = r.conn.AddChain(&nftables.Chain{ - Name: chainNameManglePostrouting, - Table: r.workTable, - Hooknum: nftables.ChainHookPostrouting, - Priority: nftables.ChainPriorityMangle, - Type: nftables.ChainTypeFilter, - }) - - r.chains[chainNameManglePrerouting] = r.conn.AddChain(&nftables.Chain{ - Name: chainNameManglePrerouting, - Table: r.workTable, - Hooknum: nftables.ChainHookPrerouting, - Priority: nftables.ChainPriorityMangle, - Type: nftables.ChainTypeFilter, - }) - - r.chains[chainNameMangleForward] = r.conn.AddChain(&nftables.Chain{ - Name: chainNameMangleForward, - Table: r.workTable, - Hooknum: nftables.ChainHookForward, - Priority: nftables.ChainPriorityMangle, - Type: nftables.ChainTypeFilter, - }) - - insertReturnTrafficRule(r.conn, r.workTable, r.chains[chainNameRoutingFw]) - - r.addPostroutingRules() - - if err := r.conn.Flush(); err != nil { - return fmt.Errorf("initialize tables: %v", err) - } - - if err := r.addMSSClampingRules(); err != nil { - log.Errorf("failed to add MSS clamping rules: %s", err) - } - - if err := r.acceptForwardRules(); err != nil { - log.Errorf("failed to add accept rules for the forward chain: %s", err) - } - - if err := firewalld.TrustInterface(r.wgIface.Name()); err != nil { - log.Warnf("failed to trust interface in firewalld: %v", err) - } - - if err := r.refreshRulesMap(); err != nil { - log.Errorf("failed to refresh rules: %s", err) - } - - return nil -} - -// setupDataPlaneMark configures the fwmark for the data plane -func (r *router) setupDataPlaneMark() error { - if r.chains[chainNameManglePrerouting] == nil || r.chains[chainNameManglePostrouting] == nil { - return errors.New("no mangle chains found") - } - - ctNew := getCtNewExprs() - preExprs := []expr.Any{ - &expr.Meta{ - Key: expr.MetaKeyIIFNAME, - Register: 1, - }, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: ifname(r.wgIface.Name()), - }, - } - preExprs = append(preExprs, ctNew...) - preExprs = append(preExprs, - &expr.Immediate{ - Register: 1, - Data: binaryutil.NativeEndian.PutUint32(nbnet.DataPlaneMarkIn), - }, - &expr.Ct{ - Key: expr.CtKeyMARK, - Register: 1, - SourceRegister: true, - }, - ) - - preNftRule := &nftables.Rule{ - Table: r.workTable, - Chain: r.chains[chainNameManglePrerouting], - Exprs: preExprs, - } - r.conn.AddRule(preNftRule) - - postExprs := []expr.Any{ - &expr.Meta{ - Key: expr.MetaKeyOIFNAME, - Register: 1, - }, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: ifname(r.wgIface.Name()), - }, - } - postExprs = append(postExprs, ctNew...) - postExprs = append(postExprs, - &expr.Immediate{ - Register: 1, - Data: binaryutil.NativeEndian.PutUint32(nbnet.DataPlaneMarkOut), - }, - &expr.Ct{ - Key: expr.CtKeyMARK, - Register: 1, - SourceRegister: true, - }, - ) - - postNftRule := &nftables.Rule{ - Table: r.workTable, - Chain: r.chains[chainNameManglePostrouting], - Exprs: postExprs, - } - r.conn.AddRule(postNftRule) - - if err := r.conn.Flush(); err != nil { - return fmt.Errorf("flush: %w", err) - } - - return nil -} - -// AddRouteFiltering appends a nftables rule to the routing chain -func (r *router) AddRouteFiltering( - id []byte, - sources []netip.Prefix, - destination firewall.Network, - proto firewall.Protocol, - sPort *firewall.Port, - dPort *firewall.Port, - action firewall.Action, -) (firewall.Rule, error) { - - ruleKey := nbid.GenerateRouteRuleKey(sources, destination, proto, sPort, dPort, action) - if _, ok := r.rules[string(ruleKey)]; ok { - return ruleKey, nil - } - - chain := r.chains[chainNameRoutingFw] - var exprs []expr.Any - - var source firewall.Network - switch { - case len(sources) == 1 && sources[0].Bits() == 0: - // If it's 0.0.0.0/0, we don't need to add any source matching - case len(sources) == 1: - // If there's only one source, we can use it directly - source.Prefix = sources[0] - default: - // If there are multiple sources, use a set - source.Set = firewall.NewPrefixSet(sources) - } - - sourceExp, err := r.applyNetwork(source, sources, true) - if err != nil { - return nil, fmt.Errorf("apply source: %w", err) - } - exprs = append(exprs, sourceExp...) - - destExp, err := r.applyNetwork(destination, nil, false) - if err != nil { - return nil, fmt.Errorf("apply destination: %w", err) - } - exprs = append(exprs, destExp...) - - // Handle protocol - if proto != firewall.ProtocolALL { - protoNum, err := protoToInt(proto) - if err != nil { - return nil, fmt.Errorf("convert protocol to number: %w", err) - } - exprs = append(exprs, &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}) - exprs = append(exprs, &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: []byte{protoNum}, - }) - - exprs = append(exprs, applyPort(sPort, true)...) - exprs = append(exprs, applyPort(dPort, false)...) - } - - exprs = append(exprs, &expr.Counter{}) - - var verdict expr.VerdictKind - if action == firewall.ActionAccept { - verdict = expr.VerdictAccept - } else { - verdict = expr.VerdictDrop - } - exprs = append(exprs, &expr.Verdict{Kind: verdict}) - - rule := &nftables.Rule{ - Table: r.workTable, - Chain: chain, - Exprs: exprs, - UserData: []byte(ruleKey), - } - - // Insert DROP rules at the beginning, append ACCEPT rules at the end - if action == firewall.ActionDrop { - // TODO: Insert after the established rule - rule = r.conn.InsertRule(rule) - } else { - rule = r.conn.AddRule(rule) - } - - if err := r.conn.Flush(); err != nil { - return nil, fmt.Errorf(flushError, err) - } - - r.rules[string(ruleKey)] = rule - - log.Debugf("added route rule: sources=%v, destination=%v, proto=%v, sPort=%v, dPort=%v, action=%v", sources, destination, proto, sPort, dPort, action) - - return ruleKey, nil -} - -func (r *router) getIpSet(set firewall.Set, prefixes []netip.Prefix, isSource bool) ([]expr.Any, error) { - ref, err := r.ipsetCounter.Increment(set.HashedName(), setInput{ - set: set, - prefixes: prefixes, - }) - if err != nil { - return nil, fmt.Errorf("create or get ipset: %w", err) - } - - return getIpSetExprs(ref, isSource) -} - -func (r *router) DeleteRouteRule(rule firewall.Rule) error { - if err := r.refreshRulesMap(); err != nil { - return fmt.Errorf(refreshRulesMapError, err) - } - - ruleKey := rule.ID() - nftRule, exists := r.rules[ruleKey] - if !exists { - log.Debugf("route rule %s not found", ruleKey) - return nil - } - - if nftRule.Handle == 0 { - log.Warnf("route rule %s has no handle, removing stale entry", ruleKey) - if err := r.decrementSetCounter(nftRule); err != nil { - log.Warnf("decrement set counter for stale rule %s: %v", ruleKey, err) - } - delete(r.rules, ruleKey) - return nil - } - - if err := r.deleteNftRule(nftRule, ruleKey); err != nil { - return fmt.Errorf("delete: %w", err) - } - - if err := r.conn.Flush(); err != nil { - return fmt.Errorf(flushError, err) - } - - if err := r.decrementSetCounter(nftRule); err != nil { - return fmt.Errorf("decrement set counter: %w", err) - } - - return nil -} - -func (r *router) createIpSet(setName string, input setInput) (*nftables.Set, error) { - // overlapping prefixes will result in an error, so we need to merge them - prefixes := firewall.MergeIPRanges(input.prefixes) - - nfset := &nftables.Set{ - Name: setName, - Comment: input.set.Comment(), - Table: r.workTable, - // required for prefixes - Interval: true, - KeyType: nftables.TypeIPAddr, - } - - elements := convertPrefixesToSet(prefixes) - nElements := len(elements) - - maxElements := maxPrefixesSet * 2 - initialElements := elements[:min(maxElements, nElements)] - - if err := r.conn.AddSet(nfset, initialElements); err != nil { - return nil, fmt.Errorf("error adding set %s: %w", setName, err) - } - if err := r.conn.Flush(); err != nil { - return nil, fmt.Errorf("flush error: %w", err) - } - log.Debugf("Created new ipset: %s with %d initial prefixes (total prefixes %d)", setName, len(initialElements)/2, len(prefixes)) - - var subEnd int - for subStart := maxElements; subStart < nElements; subStart += maxElements { - subEnd = min(subStart+maxElements, nElements) - subElement := elements[subStart:subEnd] - nSubPrefixes := len(subElement) / 2 - log.Tracef("Adding new prefixes (%d) in ipset: %s", nSubPrefixes, setName) - if err := r.conn.SetAddElements(nfset, subElement); err != nil { - return nil, fmt.Errorf("error adding prefixes (%d) to set %s: %w", nSubPrefixes, setName, err) - } - if err := r.conn.Flush(); err != nil { - return nil, fmt.Errorf("flush error: %w", err) - } - log.Debugf("Added new prefixes (%d) in ipset: %s", nSubPrefixes, setName) - } - - log.Infof("Created new ipset: %s with %d prefixes", setName, len(prefixes)) - return nfset, nil -} - -func convertPrefixesToSet(prefixes []netip.Prefix) []nftables.SetElement { - var elements []nftables.SetElement - for _, prefix := range prefixes { - // TODO: Implement IPv6 support - if prefix.Addr().Is6() { - log.Tracef("skipping IPv6 prefix %s: IPv6 support not yet implemented", prefix) - continue - } - - // nftables needs half-open intervals [firstIP, lastIP) for prefixes - // e.g. 10.0.0.0/24 becomes [10.0.0.0, 10.0.1.0), 10.1.1.1/32 becomes [10.1.1.1, 10.1.1.2) etc - firstIP := prefix.Addr() - lastIP := calculateLastIP(prefix).Next() - - elements = append(elements, - // the nft tool also adds a line like this, see https://github.com/google/nftables/issues/247 - // nftables.SetElement{Key: []byte{0, 0, 0, 0}, IntervalEnd: true}, - nftables.SetElement{Key: firstIP.AsSlice()}, - nftables.SetElement{Key: lastIP.AsSlice(), IntervalEnd: true}, - ) - } - return elements -} - -// calculateLastIP determines the last IP in a given prefix. -func calculateLastIP(prefix netip.Prefix) netip.Addr { - hostMask := ^uint32(0) >> prefix.Masked().Bits() - lastIP := uint32FromNetipAddr(prefix.Addr()) | hostMask - - return netip.AddrFrom4(uint32ToBytes(lastIP)) -} - -// Utility function to convert netip.Addr to uint32. -func uint32FromNetipAddr(addr netip.Addr) uint32 { - b := addr.As4() - return binary.BigEndian.Uint32(b[:]) -} - -// Utility function to convert uint32 to a netip-compatible byte slice. -func uint32ToBytes(ip uint32) [4]byte { - var b [4]byte - binary.BigEndian.PutUint32(b[:], ip) - return b -} - -func (r *router) deleteIpSet(setName string, nfset *nftables.Set) error { - r.conn.DelSet(nfset) - if err := r.conn.Flush(); err != nil { - return fmt.Errorf(flushError, err) - } - - log.Debugf("Deleted unused ipset %s", setName) - return nil -} - -func (r *router) decrementSetCounter(rule *nftables.Rule) error { - sets := r.findSets(rule) - - var merr *multierror.Error - for _, setName := range sets { - if _, err := r.ipsetCounter.Decrement(setName); err != nil { - merr = multierror.Append(merr, fmt.Errorf("decrement set counter: %w", err)) - } - } - - return nberrors.FormatErrorOrNil(merr) -} - -func (r *router) findSets(rule *nftables.Rule) []string { - var sets []string - for _, e := range rule.Exprs { - if lookup, ok := e.(*expr.Lookup); ok { - sets = append(sets, lookup.SetName) - } - } - return sets -} - -func (r *router) deleteNftRule(rule *nftables.Rule, ruleKey string) error { - if err := r.conn.DelRule(rule); err != nil { - return fmt.Errorf("delete rule %s: %w", ruleKey, err) - } - delete(r.rules, ruleKey) - - log.Debugf("removed route rule %s", ruleKey) - - return nil -} - -// AddNatRule appends a nftables rule pair to the nat chain -func (r *router) AddNatRule(pair firewall.RouterPair) error { - if err := r.refreshRulesMap(); err != nil { - return fmt.Errorf(refreshRulesMapError, err) - } - - if r.legacyManagement { - log.Warnf("This peer is connected to a NetBird Management service with an older version. Allowing all traffic for %s", pair.Destination) - if err := r.addLegacyRouteRule(pair); err != nil { - return fmt.Errorf("add legacy routing rule: %w", err) - } - } - - if pair.Masquerade { - if err := r.addNatRule(pair); err != nil { - return fmt.Errorf("add nat rule: %w", err) - } - - if err := r.addNatRule(firewall.GetInversePair(pair)); err != nil { - return fmt.Errorf("add inverse nat rule: %w", err) - } - } - - if err := r.conn.Flush(); err != nil { - r.rollbackRules(pair) - return fmt.Errorf("insert rules for %s: %w", pair.Destination, err) - } - - return nil -} - -// rollbackRules cleans up unflushed rules and their set counters after a flush failure. -func (r *router) rollbackRules(pair firewall.RouterPair) { - keys := []string{ - firewall.GenKey(firewall.ForwardingFormat, pair), - firewall.GenKey(firewall.PreroutingFormat, pair), - firewall.GenKey(firewall.PreroutingFormat, firewall.GetInversePair(pair)), - } - for _, key := range keys { - rule, ok := r.rules[key] - if !ok { - continue - } - if err := r.decrementSetCounter(rule); err != nil { - log.Warnf("rollback set counter for %s: %v", key, err) - } - delete(r.rules, key) - } -} - -// addNatRule inserts a nftables rule to the conn client flush queue -func (r *router) addNatRule(pair firewall.RouterPair) error { - sourceExp, err := r.applyNetwork(pair.Source, nil, true) - if err != nil { - return fmt.Errorf("apply source: %w", err) - } - - destExp, err := r.applyNetwork(pair.Destination, nil, false) - if err != nil { - return fmt.Errorf("apply destination: %w", err) - } - - op := expr.CmpOpEq - if pair.Inverse { - op = expr.CmpOpNeq - } - - exprs := []expr.Any{ - &expr.Meta{ - Key: expr.MetaKeyIIFNAME, - Register: 1, - }, - &expr.Cmp{ - Op: op, - Register: 1, - Data: ifname(r.wgIface.Name()), - }, - } - // We only care about NEW connections to mark them and later identify them in the postrouting chain for masquerading. - // Masquerading will take care of the conntrack state, which means we won't need to mark established connections. - exprs = append(exprs, getCtNewExprs()...) - - exprs = append(exprs, sourceExp...) - exprs = append(exprs, destExp...) - - var markValue uint32 = nbnet.PreroutingFwmarkMasquerade - if pair.Inverse { - markValue = nbnet.PreroutingFwmarkMasqueradeReturn - } - - exprs = append(exprs, - &expr.Immediate{ - Register: 1, - Data: binaryutil.NativeEndian.PutUint32(markValue), - }, - &expr.Meta{ - Key: expr.MetaKeyMARK, - SourceRegister: true, - Register: 1, - }, - ) - - ruleKey := firewall.GenKey(firewall.PreroutingFormat, pair) - - if _, exists := r.rules[ruleKey]; exists { - if err := r.removeNatRule(pair); err != nil { - return fmt.Errorf("remove prerouting rule: %w", err) - } - } - - // Ensure nat rules come first, so the mark can be overwritten. - // Currently overwritten by the dst-type LOCAL rules for redirected traffic. - r.rules[ruleKey] = r.conn.InsertRule(&nftables.Rule{ - Table: r.workTable, - Chain: r.chains[chainNameManglePrerouting], - Exprs: exprs, - UserData: []byte(ruleKey), - }) - - return nil -} - -// addPostroutingRules adds the masquerade rules -func (r *router) addPostroutingRules() { - // First masquerade rule for traffic coming in from WireGuard interface - exprs := []expr.Any{ - // Match on the first fwmark - &expr.Meta{ - Key: expr.MetaKeyMARK, - Register: 1, - }, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: binaryutil.NativeEndian.PutUint32(nbnet.PreroutingFwmarkMasquerade), - }, - - // We need to exclude the loopback interface as this changes the ebpf proxy port - &expr.Meta{ - Key: expr.MetaKeyOIFNAME, - Register: 1, - }, - &expr.Cmp{ - Op: expr.CmpOpNeq, - Register: 1, - Data: ifname("lo"), - }, - &expr.Counter{}, - &expr.Masq{}, - } - - r.conn.AddRule(&nftables.Rule{ - Table: r.workTable, - Chain: r.chains[chainNameRoutingNat], - Exprs: exprs, - }) - - // Second masquerade rule for traffic going out through WireGuard interface - exprs2 := []expr.Any{ - // Match on the second fwmark - &expr.Meta{ - Key: expr.MetaKeyMARK, - Register: 1, - }, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: binaryutil.NativeEndian.PutUint32(nbnet.PreroutingFwmarkMasqueradeReturn), - }, - - // Match WireGuard interface - &expr.Meta{ - Key: expr.MetaKeyOIFNAME, - Register: 1, - }, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: ifname(r.wgIface.Name()), - }, - &expr.Counter{}, - &expr.Masq{}, - } - - r.conn.AddRule(&nftables.Rule{ - Table: r.workTable, - Chain: r.chains[chainNameRoutingNat], - Exprs: exprs2, - }) -} - -// addMSSClampingRules adds MSS clamping rules to prevent fragmentation for forwarded traffic. -// TODO: Add IPv6 support -func (r *router) addMSSClampingRules() error { - mss := r.mtu - ipTCPHeaderMinSize - - exprsOut := []expr.Any{ - &expr.Meta{ - Key: expr.MetaKeyOIFNAME, - Register: 1, - }, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: ifname(r.wgIface.Name()), - }, - &expr.Meta{ - Key: expr.MetaKeyL4PROTO, - Register: 1, - }, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: []byte{unix.IPPROTO_TCP}, - }, - &expr.Payload{ - DestRegister: 1, - Base: expr.PayloadBaseTransportHeader, - Offset: 13, - Len: 1, - }, - &expr.Bitwise{ - DestRegister: 1, - SourceRegister: 1, - Len: 1, - Mask: []byte{0x02}, - Xor: []byte{0x00}, - }, - &expr.Cmp{ - Op: expr.CmpOpNeq, - Register: 1, - Data: []byte{0x00}, - }, - &expr.Counter{}, - &expr.Exthdr{ - DestRegister: 1, - Type: 2, - Offset: 2, - Len: 2, - Op: expr.ExthdrOpTcpopt, - }, - &expr.Cmp{ - Op: expr.CmpOpGt, - Register: 1, - Data: binaryutil.BigEndian.PutUint16(uint16(mss)), - }, - &expr.Immediate{ - Register: 1, - Data: binaryutil.BigEndian.PutUint16(uint16(mss)), - }, - &expr.Exthdr{ - SourceRegister: 1, - Type: 2, - Offset: 2, - Len: 2, - Op: expr.ExthdrOpTcpopt, - }, - } - - r.conn.AddRule(&nftables.Rule{ - Table: r.workTable, - Chain: r.chains[chainNameMangleForward], - Exprs: exprsOut, - }) - - return r.conn.Flush() -} - -// addLegacyRouteRule adds a legacy routing rule for mgmt servers pre route acls -func (r *router) addLegacyRouteRule(pair firewall.RouterPair) error { - sourceExp, err := r.applyNetwork(pair.Source, nil, true) - if err != nil { - return fmt.Errorf("apply source: %w", err) - } - - destExp, err := r.applyNetwork(pair.Destination, nil, false) - if err != nil { - return fmt.Errorf("apply destination: %w", err) - } - - exprs := []expr.Any{ - &expr.Counter{}, - &expr.Verdict{ - Kind: expr.VerdictAccept, - }, - } - - exprs = append(exprs, sourceExp...) - exprs = append(exprs, destExp...) - - ruleKey := firewall.GenKey(firewall.ForwardingFormat, pair) - - if _, exists := r.rules[ruleKey]; exists { - if err := r.removeLegacyRouteRule(pair); err != nil { - return fmt.Errorf("remove legacy routing rule: %w", err) - } - } - - r.rules[ruleKey] = r.conn.AddRule(&nftables.Rule{ - Table: r.workTable, - Chain: r.chains[chainNameRoutingFw], - Exprs: exprs, - UserData: []byte(ruleKey), - }) - return nil -} - -// removeLegacyRouteRule removes a legacy routing rule for mgmt servers pre route acls -func (r *router) removeLegacyRouteRule(pair firewall.RouterPair) error { - ruleKey := firewall.GenKey(firewall.ForwardingFormat, pair) - - rule, exists := r.rules[ruleKey] - if !exists { - return nil - } - - if rule.Handle == 0 { - log.Warnf("legacy forwarding rule %s has no handle, removing stale entry", ruleKey) - if err := r.decrementSetCounter(rule); err != nil { - log.Warnf("decrement set counter for stale rule %s: %v", ruleKey, err) - } - delete(r.rules, ruleKey) - return nil - } - - if err := r.conn.DelRule(rule); err != nil { - return fmt.Errorf("remove legacy forwarding rule %s -> %s: %w", pair.Source, pair.Destination, err) - } - - log.Debugf("removed legacy forwarding rule %s -> %s", pair.Source, pair.Destination) - - delete(r.rules, ruleKey) - - if err := r.decrementSetCounter(rule); err != nil { - return fmt.Errorf("decrement set counter: %w", err) - } - - return nil -} - -// GetLegacyManagement returns the route manager's legacy management mode -func (r *router) GetLegacyManagement() bool { - return r.legacyManagement -} - -// SetLegacyManagement sets the route manager to use legacy management mode -func (r *router) SetLegacyManagement(isLegacy bool) { - r.legacyManagement = isLegacy -} - -// RemoveAllLegacyRouteRules removes all legacy routing rules for mgmt servers pre route acls -func (r *router) RemoveAllLegacyRouteRules() error { - if err := r.refreshRulesMap(); err != nil { - return fmt.Errorf(refreshRulesMapError, err) - } - - var merr *multierror.Error - for k, rule := range r.rules { - if !strings.HasPrefix(k, firewall.ForwardingFormatPrefix) { - continue - } - if err := r.conn.DelRule(rule); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove legacy forwarding rule: %v", err)) - } else { - delete(r.rules, k) - } - - } - return nberrors.FormatErrorOrNil(merr) -} - -// acceptForwardRules adds iif/oif rules in the filter table/forward chain to make sure -// that our traffic is not dropped by existing rules there. -// The existing FORWARD rules/policies decide outbound traffic towards our interface. -// In case the FORWARD policy is set to "drop", we add an established/related rule to allow return traffic for the inbound rule. -// This method also adds INPUT chain rules to allow traffic to the local interface. -func (r *router) acceptForwardRules() error { - var merr *multierror.Error - - if err := r.acceptFilterTableRules(); err != nil { - merr = multierror.Append(merr, err) - } - - if err := r.acceptExternalChainsRules(); err != nil { - merr = multierror.Append(merr, fmt.Errorf("add accept rules to external chains: %w", err)) - } - - return nberrors.FormatErrorOrNil(merr) -} - -func (r *router) acceptFilterTableRules() error { - if r.filterTable == nil { - return nil - } - - fw := "iptables" - - defer func() { - log.Debugf("Used %s to add accept forward and input rules", fw) - }() - - // Try iptables first and fallback to nftables if iptables is not available - ipt, err := iptables.New() - if err != nil { - // iptables is not available but the filter table exists - log.Warnf("Will use nftables to manipulate the filter table because iptables is not available: %v", err) - - fw = "nftables" - return r.acceptFilterRulesNftables(r.filterTable) - } - - return r.acceptFilterRulesIptables(ipt) -} - -func (r *router) acceptFilterRulesIptables(ipt *iptables.IPTables) error { - var merr *multierror.Error - - for _, rule := range r.getAcceptForwardRules() { - if err := ipt.Insert("filter", chainNameForward, 1, rule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("add iptables forward rule: %v", err)) - } else { - log.Debugf("added iptables forward rule: %v", rule) - } - } - - inputRule := r.getAcceptInputRule() - if err := ipt.Insert("filter", chainNameInput, 1, inputRule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("add iptables input rule: %v", err)) - } else { - log.Debugf("added iptables input rule: %v", inputRule) - } - - return nberrors.FormatErrorOrNil(merr) -} - -func (r *router) getAcceptForwardRules() [][]string { - intf := r.wgIface.Name() - return [][]string{ - {"-i", intf, "-j", "ACCEPT"}, - {"-o", intf, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "ACCEPT"}, - } -} - -func (r *router) getAcceptInputRule() []string { - return []string{"-i", r.wgIface.Name(), "-j", "ACCEPT"} -} - -// acceptFilterRulesNftables adds accept rules to the ip filter table using nftables. -// This is used when iptables is not available. -func (r *router) acceptFilterRulesNftables(table *nftables.Table) error { - intf := ifname(r.wgIface.Name()) - - forwardChain := &nftables.Chain{ - Name: chainNameForward, - Table: table, - Type: nftables.ChainTypeFilter, - Hooknum: nftables.ChainHookForward, - Priority: nftables.ChainPriorityFilter, - } - r.insertForwardAcceptRules(forwardChain, intf) - - inputChain := &nftables.Chain{ - Name: chainNameInput, - Table: table, - Type: nftables.ChainTypeFilter, - Hooknum: nftables.ChainHookInput, - Priority: nftables.ChainPriorityFilter, - } - r.insertInputAcceptRule(inputChain, intf) - - return r.conn.Flush() -} - -// acceptExternalChainsRules adds accept rules to external chains (non-netbird, non-iptables tables). -// It dynamically finds chains at call time to handle chains that may have been created after startup. -func (r *router) acceptExternalChainsRules() error { - chains := r.findExternalChains() - if len(chains) == 0 { - return nil - } - - intf := ifname(r.wgIface.Name()) - - for _, chain := range chains { - if chain.Hooknum == nil { - log.Debugf("skipping external chain %s/%s: hooknum is nil", chain.Table.Name, chain.Name) - continue - } - - log.Debugf("adding accept rules to external %s chain: %s %s/%s", - hookName(chain.Hooknum), familyName(chain.Table.Family), chain.Table.Name, chain.Name) - - switch *chain.Hooknum { - case *nftables.ChainHookForward: - r.insertForwardAcceptRules(chain, intf) - case *nftables.ChainHookInput: - r.insertInputAcceptRule(chain, intf) - } - } - - if err := r.conn.Flush(); err != nil { - return fmt.Errorf("flush external chain rules: %w", err) - } - - return nil -} - -func (r *router) insertForwardAcceptRules(chain *nftables.Chain, intf []byte) { - iifRule := &nftables.Rule{ - Table: chain.Table, - Chain: chain, - Exprs: []expr.Any{ - &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: intf, - }, - &expr.Counter{}, - &expr.Verdict{Kind: expr.VerdictAccept}, - }, - UserData: []byte(userDataAcceptForwardRuleIif), - } - r.conn.InsertRule(iifRule) - - oifExprs := []expr.Any{ - &expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: intf, - }, - } - oifRule := &nftables.Rule{ - Table: chain.Table, - Chain: chain, - Exprs: append(oifExprs, getEstablishedExprs(2)...), - UserData: []byte(userDataAcceptForwardRuleOif), - } - r.conn.InsertRule(oifRule) -} - -func (r *router) insertInputAcceptRule(chain *nftables.Chain, intf []byte) { - inputRule := &nftables.Rule{ - Table: chain.Table, - Chain: chain, - Exprs: []expr.Any{ - &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: intf, - }, - &expr.Counter{}, - &expr.Verdict{Kind: expr.VerdictAccept}, - }, - UserData: []byte(userDataAcceptInputRule), - } - r.conn.InsertRule(inputRule) -} - -func (r *router) removeAcceptFilterRules() error { - var merr *multierror.Error - - if err := r.removeFilterTableRules(); err != nil { - merr = multierror.Append(merr, err) - } - - if err := r.removeExternalChainsRules(); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove external chain rules: %w", err)) - } - - return nberrors.FormatErrorOrNil(merr) -} - -func (r *router) removeFilterTableRules() error { - if r.filterTable == nil { - return nil - } - - ipt, err := iptables.New() - if err != nil { - log.Debugf("iptables not available, using nftables to remove filter rules: %v", err) - return r.removeAcceptRulesFromTable(r.filterTable) - } - - return r.removeAcceptFilterRulesIptables(ipt) -} - -func (r *router) removeAcceptRulesFromTable(table *nftables.Table) error { - chains, err := r.conn.ListChainsOfTableFamily(table.Family) - if err != nil { - return fmt.Errorf("list chains: %v", err) - } - - for _, chain := range chains { - if chain.Table.Name != table.Name { - continue - } - - if chain.Name != chainNameForward && chain.Name != chainNameInput { - continue - } - - if err := r.removeAcceptRulesFromChain(table, chain); err != nil { - return err - } - } - - return r.conn.Flush() -} - -func (r *router) removeAcceptRulesFromChain(table *nftables.Table, chain *nftables.Chain) error { - rules, err := r.conn.GetRules(table, chain) - if err != nil { - return fmt.Errorf("get rules from %s/%s: %v", table.Name, chain.Name, err) - } - - for _, rule := range rules { - if bytes.Equal(rule.UserData, []byte(userDataAcceptForwardRuleIif)) || - bytes.Equal(rule.UserData, []byte(userDataAcceptForwardRuleOif)) || - bytes.Equal(rule.UserData, []byte(userDataAcceptInputRule)) { - if err := r.conn.DelRule(rule); err != nil { - return fmt.Errorf("delete rule from %s/%s: %v", table.Name, chain.Name, err) - } - } - } - return nil -} - -// removeExternalChainsRules removes our accept rules from all external chains. -// This is deterministic - it scans for chains at removal time rather than relying on saved state, -// ensuring cleanup works even after a crash or if chains changed. -func (r *router) removeExternalChainsRules() error { - chains := r.findExternalChains() - if len(chains) == 0 { - return nil - } - - for _, chain := range chains { - if err := r.removeAcceptRulesFromChain(chain.Table, chain); err != nil { - log.Warnf("remove rules from external chain %s/%s: %v", chain.Table.Name, chain.Name, err) - } - } - - return r.conn.Flush() -} - -// findExternalChains scans for chains from non-netbird tables that have FORWARD or INPUT hooks. -// This is used both at startup (to know where to add rules) and at cleanup (to ensure deterministic removal). -func (r *router) findExternalChains() []*nftables.Chain { - var chains []*nftables.Chain - - families := []nftables.TableFamily{nftables.TableFamilyIPv4, nftables.TableFamilyINet} - - for _, family := range families { - allChains, err := r.conn.ListChainsOfTableFamily(family) - if err != nil { - log.Debugf("list chains for family %d: %v", family, err) - continue - } - - for _, chain := range allChains { - if r.isExternalChain(chain) { - chains = append(chains, chain) - } - } - } - - return chains -} - -func (r *router) isExternalChain(chain *nftables.Chain) bool { - if r.workTable != nil && chain.Table.Name == r.workTable.Name { - return false - } - - // Skip firewalld-owned chains. Firewalld creates its chains with the - // NFT_CHAIN_OWNER flag, so inserting rules into them returns EPERM. - // We delegate acceptance to firewalld by trusting the interface instead. - if chain.Table.Name == firewalldTableName { - return false - } - - // Skip all iptables-managed tables in the ip family - if chain.Table.Family == nftables.TableFamilyIPv4 && isIptablesTable(chain.Table.Name) { - return false - } - - if chain.Type != nftables.ChainTypeFilter { - return false - } - - if chain.Hooknum == nil { - return false - } - - return *chain.Hooknum == *nftables.ChainHookForward || *chain.Hooknum == *nftables.ChainHookInput -} - -func isIptablesTable(name string) bool { - switch name { - case tableNameFilter, tableNat, tableMangle, tableRaw, tableSecurity: - return true - } - return false -} - -func (r *router) removeAcceptFilterRulesIptables(ipt *iptables.IPTables) error { - var merr *multierror.Error - - for _, rule := range r.getAcceptForwardRules() { - if err := ipt.DeleteIfExists("filter", chainNameForward, rule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove iptables forward rule: %v", err)) - } - } - - inputRule := r.getAcceptInputRule() - if err := ipt.DeleteIfExists("filter", chainNameInput, inputRule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove iptables input rule: %v", err)) - } - - return nberrors.FormatErrorOrNil(merr) -} - -// RemoveNatRule removes the prerouting mark rule -func (r *router) RemoveNatRule(pair firewall.RouterPair) error { - if err := r.refreshRulesMap(); err != nil { - return fmt.Errorf(refreshRulesMapError, err) - } - - var merr *multierror.Error - - if pair.Masquerade { - if err := r.removeNatRule(pair); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove prerouting rule: %w", err)) - } - - if err := r.removeNatRule(firewall.GetInversePair(pair)); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove inverse prerouting rule: %w", err)) - } - } - - if err := r.removeLegacyRouteRule(pair); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove legacy routing rule: %w", err)) - } - - // Set counters are decremented in the sub-methods above before flush. If flush fails, - // counters will be off until the next successful removal or refresh cycle. - if err := r.conn.Flush(); err != nil { - merr = multierror.Append(merr, fmt.Errorf("flush remove nat rules %s: %w", pair.Destination, err)) - } - - return nberrors.FormatErrorOrNil(merr) -} - -func (r *router) removeNatRule(pair firewall.RouterPair) error { - ruleKey := firewall.GenKey(firewall.PreroutingFormat, pair) - - rule, exists := r.rules[ruleKey] - if !exists { - log.Debugf("prerouting rule %s not found", ruleKey) - return nil - } - - if rule.Handle == 0 { - log.Warnf("prerouting rule %s has no handle, removing stale entry", ruleKey) - if err := r.decrementSetCounter(rule); err != nil { - log.Warnf("decrement set counter for stale rule %s: %v", ruleKey, err) - } - delete(r.rules, ruleKey) - return nil - } - - if err := r.conn.DelRule(rule); err != nil { - return fmt.Errorf("remove prerouting rule %s -> %s: %w", pair.Source, pair.Destination, err) - } - - log.Debugf("removed prerouting rule %s -> %s", pair.Source, pair.Destination) - - delete(r.rules, ruleKey) - - if err := r.decrementSetCounter(rule); err != nil { - return fmt.Errorf("decrement set counter: %w", err) - } - - return nil -} - -// refreshRulesMap rebuilds the rule map from the kernel. This removes stale entries -// (e.g. from failed flushes) and updates handles for all existing rules. -func (r *router) refreshRulesMap() error { - var merr *multierror.Error - newRules := make(map[string]*nftables.Rule) - for _, chain := range r.chains { - rules, err := r.conn.GetRules(chain.Table, chain) - if err != nil { - merr = multierror.Append(merr, fmt.Errorf("list rules for chain %s: %w", chain.Name, err)) - // preserve existing entries for this chain since we can't verify their state - for k, v := range r.rules { - if v.Chain != nil && v.Chain.Name == chain.Name { - newRules[k] = v - } - } - continue - } - for _, rule := range rules { - if len(rule.UserData) > 0 { - newRules[string(rule.UserData)] = rule - } - } - } - r.rules = newRules - return nberrors.FormatErrorOrNil(merr) -} - -func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) { - if err := r.ipFwdState.RequestForwarding(); err != nil { - return nil, err - } - - ruleKey := rule.ID() - if _, exists := r.rules[ruleKey+dnatSuffix]; exists { - return rule, nil - } - - protoNum, err := protoToInt(rule.Protocol) - if err != nil { - return nil, fmt.Errorf("convert protocol to number: %w", err) - } - - if err := r.addDnatRedirect(rule, protoNum, ruleKey); err != nil { - return nil, err - } - - r.addDnatMasq(rule, protoNum, ruleKey) - - // Unlike iptables, there's no point in adding "out" rules in the forward chain here as our policy is ACCEPT. - // To overcome DROP policies in other chains, we'd have to add rules to the chains there. - // We also cannot just add "oif accept" there and filter in our own table as we don't know what is supposed to be allowed. - // TODO: find chains with drop policies and add rules there - - if err := r.conn.Flush(); err != nil { - return nil, fmt.Errorf("flush rules: %w", err) - } - - return &rule, nil -} - -func (r *router) addDnatRedirect(rule firewall.ForwardRule, protoNum uint8, ruleKey string) error { - dnatExprs := []expr.Any{ - &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpNeq, - Register: 1, - Data: ifname(r.wgIface.Name()), - }, - &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: []byte{protoNum}, - }, - &expr.Payload{ - DestRegister: 1, - Base: expr.PayloadBaseTransportHeader, - Offset: 2, - Len: 2, - }, - } - dnatExprs = append(dnatExprs, applyPort(&rule.DestinationPort, false)...) - - // shifted translated port is not supported in nftables, so we hand this over to xtables - if rule.TranslatedPort.IsRange && len(rule.TranslatedPort.Values) == 2 { - if rule.TranslatedPort.Values[0] != rule.DestinationPort.Values[0] || - rule.TranslatedPort.Values[1] != rule.DestinationPort.Values[1] { - return r.addXTablesRedirect(dnatExprs, ruleKey, rule) - } - } - - additionalExprs, regProtoMin, regProtoMax, err := r.handleTranslatedPort(rule) - if err != nil { - return err - } - dnatExprs = append(dnatExprs, additionalExprs...) - - dnatExprs = append(dnatExprs, - &expr.NAT{ - Type: expr.NATTypeDestNAT, - Family: uint32(nftables.TableFamilyIPv4), - RegAddrMin: 1, - RegProtoMin: regProtoMin, - RegProtoMax: regProtoMax, - }, - ) - - dnatRule := &nftables.Rule{ - Table: r.workTable, - Chain: r.chains[chainNameRoutingRdr], - Exprs: dnatExprs, - UserData: []byte(ruleKey + dnatSuffix), - } - r.conn.AddRule(dnatRule) - r.rules[ruleKey+dnatSuffix] = dnatRule - - return nil -} - -func (r *router) handleTranslatedPort(rule firewall.ForwardRule) ([]expr.Any, uint32, uint32, error) { - switch { - case rule.TranslatedPort.IsRange && len(rule.TranslatedPort.Values) == 2: - return r.handlePortRange(rule) - case len(rule.TranslatedPort.Values) == 0: - return r.handleAddressOnly(rule) - case len(rule.TranslatedPort.Values) == 1: - return r.handleSinglePort(rule) - default: - return nil, 0, 0, fmt.Errorf("invalid translated port: %v", rule.TranslatedPort) - } -} - -func (r *router) handlePortRange(rule firewall.ForwardRule) ([]expr.Any, uint32, uint32, error) { - exprs := []expr.Any{ - &expr.Immediate{ - Register: 1, - Data: rule.TranslatedAddress.AsSlice(), - }, - &expr.Immediate{ - Register: 2, - Data: binaryutil.BigEndian.PutUint16(rule.TranslatedPort.Values[0]), - }, - &expr.Immediate{ - Register: 3, - Data: binaryutil.BigEndian.PutUint16(rule.TranslatedPort.Values[1]), - }, - } - return exprs, 2, 3, nil -} - -func (r *router) handleAddressOnly(rule firewall.ForwardRule) ([]expr.Any, uint32, uint32, error) { - exprs := []expr.Any{ - &expr.Immediate{ - Register: 1, - Data: rule.TranslatedAddress.AsSlice(), - }, - } - return exprs, 0, 0, nil -} - -func (r *router) handleSinglePort(rule firewall.ForwardRule) ([]expr.Any, uint32, uint32, error) { - exprs := []expr.Any{ - &expr.Immediate{ - Register: 1, - Data: rule.TranslatedAddress.AsSlice(), - }, - &expr.Immediate{ - Register: 2, - Data: binaryutil.BigEndian.PutUint16(rule.TranslatedPort.Values[0]), - }, - } - return exprs, 2, 0, nil -} - -func (r *router) addXTablesRedirect(dnatExprs []expr.Any, ruleKey string, rule firewall.ForwardRule) error { - dnatExprs = append(dnatExprs, - &expr.Counter{}, - &expr.Target{ - Name: "DNAT", - Rev: 2, - Info: &xt.NatRange2{ - NatRange: xt.NatRange{ - Flags: uint(xt.NatRangeMapIPs | xt.NatRangeProtoSpecified | xt.NatRangeProtoOffset), - MinIP: rule.TranslatedAddress.AsSlice(), - MaxIP: rule.TranslatedAddress.AsSlice(), - MinPort: rule.TranslatedPort.Values[0], - MaxPort: rule.TranslatedPort.Values[1], - }, - BasePort: rule.DestinationPort.Values[0], - }, - }, - ) - - dnatRule := &nftables.Rule{ - Table: &nftables.Table{ - Name: tableNat, - Family: nftables.TableFamilyIPv4, - }, - Chain: &nftables.Chain{ - Name: chainNameNatPrerouting, - Table: r.filterTable, - Type: nftables.ChainTypeNAT, - Hooknum: nftables.ChainHookPrerouting, - Priority: nftables.ChainPriorityNATDest, - }, - Exprs: dnatExprs, - UserData: []byte(ruleKey + dnatSuffix), - } - r.conn.AddRule(dnatRule) - r.rules[ruleKey+dnatSuffix] = dnatRule - - return nil -} - -func (r *router) addDnatMasq(rule firewall.ForwardRule, protoNum uint8, ruleKey string) { - masqExprs := []expr.Any{ - &expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: ifname(r.wgIface.Name()), - }, - &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: []byte{protoNum}, - }, - &expr.Payload{ - DestRegister: 1, - Base: expr.PayloadBaseNetworkHeader, - Offset: 16, - Len: 4, - }, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: rule.TranslatedAddress.AsSlice(), - }, - } - - masqExprs = append(masqExprs, applyPort(&rule.TranslatedPort, false)...) - masqExprs = append(masqExprs, &expr.Masq{}) - - masqRule := &nftables.Rule{ - Table: r.workTable, - Chain: r.chains[chainNameRoutingNat], - Exprs: masqExprs, - UserData: []byte(ruleKey + snatSuffix), - } - r.conn.AddRule(masqRule) - r.rules[ruleKey+snatSuffix] = masqRule -} - -func (r *router) DeleteDNATRule(rule firewall.Rule) error { - if err := r.ipFwdState.ReleaseForwarding(); err != nil { - log.Errorf("%v", err) - } - - ruleKey := rule.ID() - - if err := r.refreshRulesMap(); err != nil { - return fmt.Errorf(refreshRulesMapError, err) - } - - var merr *multierror.Error - var needsFlush bool - - if dnatRule, exists := r.rules[ruleKey+dnatSuffix]; exists { - if dnatRule.Handle == 0 { - log.Warnf("dnat rule %s has no handle, removing stale entry", ruleKey+dnatSuffix) - delete(r.rules, ruleKey+dnatSuffix) - } else if err := r.conn.DelRule(dnatRule); err != nil { - merr = multierror.Append(merr, fmt.Errorf("delete dnat rule: %w", err)) - } else { - needsFlush = true - } - } - - if masqRule, exists := r.rules[ruleKey+snatSuffix]; exists { - if masqRule.Handle == 0 { - log.Warnf("snat rule %s has no handle, removing stale entry", ruleKey+snatSuffix) - delete(r.rules, ruleKey+snatSuffix) - } else if err := r.conn.DelRule(masqRule); err != nil { - merr = multierror.Append(merr, fmt.Errorf("delete snat rule: %w", err)) - } else { - needsFlush = true - } - } - - if needsFlush { - if err := r.conn.Flush(); err != nil { - merr = multierror.Append(merr, fmt.Errorf(flushError, err)) - } - } - - if merr == nil { - delete(r.rules, ruleKey+dnatSuffix) - delete(r.rules, ruleKey+snatSuffix) - } - - return nberrors.FormatErrorOrNil(merr) -} - -func (r *router) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error { - nfset, err := r.conn.GetSetByName(r.workTable, set.HashedName()) - if err != nil { - return fmt.Errorf("get set %s: %w", set.HashedName(), err) - } - - elements := convertPrefixesToSet(prefixes) - if err := r.conn.SetAddElements(nfset, elements); err != nil { - return fmt.Errorf("add elements to set %s: %w", set.HashedName(), err) - } - - if err := r.conn.Flush(); err != nil { - return fmt.Errorf(flushError, err) - } - - log.Debugf("updated set %s with prefixes %v", set.HashedName(), prefixes) - - return nil -} - -// AddInboundDNAT adds an inbound DNAT rule redirecting traffic from NetBird peers to local services. -func (r *router) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { - ruleID := fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, sourcePort, targetPort) - - if _, exists := r.rules[ruleID]; exists { - return nil - } - - protoNum, err := protoToInt(protocol) - if err != nil { - return fmt.Errorf("convert protocol to number: %w", err) - } - - exprs := []expr.Any{ - &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: ifname(r.wgIface.Name()), - }, - &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 2}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 2, - Data: []byte{protoNum}, - }, - &expr.Payload{ - DestRegister: 3, - Base: expr.PayloadBaseTransportHeader, - Offset: 2, - Len: 2, - }, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 3, - Data: binaryutil.BigEndian.PutUint16(sourcePort), - }, - } - - exprs = append(exprs, applyPrefix(netip.PrefixFrom(localAddr, 32), false)...) - - exprs = append(exprs, - &expr.Immediate{ - Register: 1, - Data: localAddr.AsSlice(), - }, - &expr.Immediate{ - Register: 2, - Data: binaryutil.BigEndian.PutUint16(targetPort), - }, - &expr.NAT{ - Type: expr.NATTypeDestNAT, - Family: uint32(nftables.TableFamilyIPv4), - RegAddrMin: 1, - RegProtoMin: 2, - RegProtoMax: 0, - }, - ) - - dnatRule := &nftables.Rule{ - Table: r.workTable, - Chain: r.chains[chainNameRoutingRdr], - Exprs: exprs, - UserData: []byte(ruleID), - } - r.conn.AddRule(dnatRule) - - if err := r.conn.Flush(); err != nil { - return fmt.Errorf("add inbound DNAT rule: %w", err) - } - - r.rules[ruleID] = dnatRule - - return nil -} - -// RemoveInboundDNAT removes an inbound DNAT rule. -func (r *router) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { - if err := r.refreshRulesMap(); err != nil { - return fmt.Errorf(refreshRulesMapError, err) - } - - ruleID := fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, sourcePort, targetPort) - - rule, exists := r.rules[ruleID] - if !exists { - return nil - } - - if rule.Handle == 0 { - log.Warnf("inbound DNAT rule %s has no handle, removing stale entry", ruleID) - delete(r.rules, ruleID) - return nil - } - - if err := r.conn.DelRule(rule); err != nil { - return fmt.Errorf("delete inbound DNAT rule %s: %w", ruleID, err) - } - if err := r.conn.Flush(); err != nil { - return fmt.Errorf("flush delete inbound DNAT rule: %w", err) - } - delete(r.rules, ruleID) - - return nil -} - -// ensureNATOutputChain lazily creates the OUTPUT NAT chain on first use. -func (r *router) ensureNATOutputChain() error { - if _, exists := r.chains[chainNameNATOutput]; exists { - return nil - } - - r.chains[chainNameNATOutput] = r.conn.AddChain(&nftables.Chain{ - Name: chainNameNATOutput, - Table: r.workTable, - Hooknum: nftables.ChainHookOutput, - Priority: nftables.ChainPriorityNATDest, - Type: nftables.ChainTypeNAT, - }) - - if err := r.conn.Flush(); err != nil { - delete(r.chains, chainNameNATOutput) - return fmt.Errorf("create NAT output chain: %w", err) - } - return nil -} - -// AddOutputDNAT adds an OUTPUT chain DNAT rule for locally-generated traffic. -func (r *router) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { - ruleID := fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, sourcePort, targetPort) - - if _, exists := r.rules[ruleID]; exists { - return nil - } - - if err := r.ensureNATOutputChain(); err != nil { - return err - } - - protoNum, err := protoToInt(protocol) - if err != nil { - return fmt.Errorf("convert protocol to number: %w", err) - } - - exprs := []expr.Any{ - &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: []byte{protoNum}, - }, - &expr.Payload{ - DestRegister: 2, - Base: expr.PayloadBaseTransportHeader, - Offset: 2, - Len: 2, - }, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 2, - Data: binaryutil.BigEndian.PutUint16(sourcePort), - }, - } - - exprs = append(exprs, applyPrefix(netip.PrefixFrom(localAddr, 32), false)...) - - exprs = append(exprs, - &expr.Immediate{ - Register: 1, - Data: localAddr.AsSlice(), - }, - &expr.Immediate{ - Register: 2, - Data: binaryutil.BigEndian.PutUint16(targetPort), - }, - &expr.NAT{ - Type: expr.NATTypeDestNAT, - Family: uint32(nftables.TableFamilyIPv4), - RegAddrMin: 1, - RegProtoMin: 2, - }, - ) - - dnatRule := &nftables.Rule{ - Table: r.workTable, - Chain: r.chains[chainNameNATOutput], - Exprs: exprs, - UserData: []byte(ruleID), - } - r.conn.AddRule(dnatRule) - - if err := r.conn.Flush(); err != nil { - return fmt.Errorf("add output DNAT rule: %w", err) - } - - r.rules[ruleID] = dnatRule - - return nil -} - -// RemoveOutputDNAT removes an OUTPUT chain DNAT rule. -func (r *router) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { - if err := r.refreshRulesMap(); err != nil { - return fmt.Errorf(refreshRulesMapError, err) - } - - ruleID := fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, sourcePort, targetPort) - - rule, exists := r.rules[ruleID] - if !exists { - return nil - } - - if rule.Handle == 0 { - log.Warnf("output DNAT rule %s has no handle, removing stale entry", ruleID) - delete(r.rules, ruleID) - return nil - } - - if err := r.conn.DelRule(rule); err != nil { - return fmt.Errorf("delete output DNAT rule %s: %w", ruleID, err) - } - if err := r.conn.Flush(); err != nil { - return fmt.Errorf("flush delete output DNAT rule: %w", err) - } - delete(r.rules, ruleID) - - return nil -} - -// applyNetwork generates nftables expressions for networks (CIDR) or sets -func (r *router) applyNetwork( - network firewall.Network, - setPrefixes []netip.Prefix, - isSource bool, -) ([]expr.Any, error) { - if network.IsSet() { - exprs, err := r.getIpSet(network.Set, setPrefixes, isSource) - if err != nil { - return nil, fmt.Errorf("source: %w", err) - } - return exprs, nil - } - - if network.IsPrefix() { - return applyPrefix(network.Prefix, isSource), nil - } - - return nil, nil -} - -// applyPrefix generates nftables expressions for a CIDR prefix -func applyPrefix(prefix netip.Prefix, isSource bool) []expr.Any { - // dst offset - offset := uint32(16) - if isSource { - // src offset - offset = 12 - } - - ones := prefix.Bits() - // 0.0.0.0/0 doesn't need extra expressions - if ones == 0 { - return nil - } - - mask := net.CIDRMask(ones, 32) - - return []expr.Any{ - &expr.Payload{ - DestRegister: 1, - Base: expr.PayloadBaseNetworkHeader, - Offset: offset, - Len: 4, - }, - // netmask - &expr.Bitwise{ - DestRegister: 1, - SourceRegister: 1, - Len: 4, - Mask: mask, - Xor: []byte{0, 0, 0, 0}, - }, - // net address - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: prefix.Masked().Addr().AsSlice(), - }, - } -} - -func applyPort(port *firewall.Port, isSource bool) []expr.Any { - if port == nil { - return nil - } - - var exprs []expr.Any - - offset := uint32(2) // Default offset for destination port - if isSource { - offset = 0 // Offset for source port - } - - exprs = append(exprs, &expr.Payload{ - DestRegister: 1, - Base: expr.PayloadBaseTransportHeader, - Offset: offset, - Len: 2, - }) - - if port.IsRange && len(port.Values) == 2 { - // Handle port range - exprs = append(exprs, - &expr.Range{ - Op: expr.CmpOpEq, - Register: 1, - FromData: binaryutil.BigEndian.PutUint16(port.Values[0]), - ToData: binaryutil.BigEndian.PutUint16(port.Values[1]), - }, - ) - } else { - // Handle single port or multiple ports - for i, p := range port.Values { - if i > 0 { - // Add a bitwise OR operation between port checks - exprs = append(exprs, &expr.Bitwise{ - SourceRegister: 1, - DestRegister: 1, - Len: 4, - Mask: []byte{0x00, 0x00, 0xff, 0xff}, - Xor: []byte{0x00, 0x00, 0x00, 0x00}, - }) - } - exprs = append(exprs, &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: binaryutil.BigEndian.PutUint16(p), - }) - } - } - - return exprs -} - -func getCtNewExprs() []expr.Any { - return []expr.Any{ - &expr.Ct{ - Key: expr.CtKeySTATE, - Register: 1, - }, - &expr.Bitwise{ - SourceRegister: 1, - DestRegister: 1, - Len: 4, - Mask: binaryutil.NativeEndian.PutUint32(expr.CtStateBitNEW), - Xor: binaryutil.NativeEndian.PutUint32(0), - }, - &expr.Cmp{ - Op: expr.CmpOpNeq, - Register: 1, - Data: []byte{0, 0, 0, 0}, - }, - } -} - -func getIpSetExprs(ref refcounter.Ref[*nftables.Set], isSource bool) ([]expr.Any, error) { - - // dst offset - offset := uint32(16) - if isSource { - // src offset - offset = 12 - } - - return []expr.Any{ - &expr.Payload{ - DestRegister: 1, - Base: expr.PayloadBaseNetworkHeader, - Offset: offset, - Len: 4, - }, - &expr.Lookup{ - SourceRegister: 1, - SetName: ref.Out.Name, - SetID: ref.Out.ID, - }, - }, nil -} diff --git a/client/firewall/nftables/router_linux_test.go b/client/firewall/nftables/router_linux_test.go index f0e34d211..49dbfc8f2 100644 --- a/client/firewall/nftables/router_linux_test.go +++ b/client/firewall/nftables/router_linux_test.go @@ -1,4 +1,4 @@ -//go:build !android +//go:build !android && privileged package nftables @@ -37,7 +37,7 @@ func TestNftablesManager_AddNatRule(t *testing.T) { for _, testCase := range test.InsertRuleTestCases { t.Run(testCase.Name, func(t *testing.T) { - // need fw manager to init both acl mgr and router for all chains to be present + // need fw manager to init both acl mgr and family for all chains to be present manager, err := Create(ifaceMock, iface.DefaultMTU) t.Cleanup(func() { require.NoError(t, manager.Close(nil)) @@ -47,7 +47,7 @@ func TestNftablesManager_AddNatRule(t *testing.T) { nftablesTestingClient := &nftables.Conn{} - rtr := manager.router + rtr := manager.family4 err = rtr.AddNatRule(testCase.InputPair) require.NoError(t, err, "pair should be inserted") @@ -90,8 +90,9 @@ func TestNftablesManager_AddNatRule(t *testing.T) { } // Build CIDR matching expressions - sourceExp := applyPrefix(testCase.InputPair.Source.Prefix, true) - destExp := applyPrefix(testCase.InputPair.Destination.Prefix, false) + testRouter := &family{af: afIPv4} + sourceExp := prefixMatchExprs(testRouter.af, testCase.InputPair.Source.Prefix, true) + destExp := prefixMatchExprs(testRouter.af, testCase.InputPair.Destination.Prefix, false) // Combine all expressions in the correct order // nolint:gocritic @@ -99,14 +100,14 @@ func TestNftablesManager_AddNatRule(t *testing.T) { testingExpression = append(testingExpression, sourceExp...) testingExpression = append(testingExpression, destExp...) - natRuleKey := firewall.GenKey(firewall.PreroutingFormat, testCase.InputPair) + natRuleKey := testCase.InputPair.GenKey(firewall.PreroutingFormat) found := 0 for _, chain := range rtr.chains { if chain.Name == chainNameManglePrerouting { rules, err := nftablesTestingClient.GetRules(chain.Table, chain) require.NoError(t, err, "should list rules for %s table and %s chain", chain.Table.Name, chain.Name) for _, rule := range rules { - if len(rule.UserData) > 0 && string(rule.UserData) == natRuleKey { + if len(rule.UserData) > 0 && firewall.RuleID(rule.UserData) == natRuleKey { // Compare expressions up to the mark setting expressions require.ElementsMatchf(t, rule.Exprs[:len(testingExpression)], testingExpression, "prerouting nat rule elements should match") found = 1 @@ -134,19 +135,19 @@ func TestNftablesManager_RemoveNatRule(t *testing.T) { require.NoError(t, err) require.NoError(t, manager.Init(nil)) - rtr := manager.router + rtr := manager.family4 - // First add the NAT rule using the router's method + // First add the NAT rule using the family's method err = rtr.AddNatRule(testCase.InputPair) require.NoError(t, err, "should add NAT rule") // Verify the rule was added - natRuleKey := firewall.GenKey(firewall.PreroutingFormat, testCase.InputPair) + natRuleKey := testCase.InputPair.GenKey(firewall.PreroutingFormat) found := false rules, err := rtr.conn.GetRules(rtr.workTable, rtr.chains[chainNameManglePrerouting]) require.NoError(t, err, "should list rules") for _, rule := range rules { - if len(rule.UserData) > 0 && string(rule.UserData) == natRuleKey { + if len(rule.UserData) > 0 && firewall.RuleID(rule.UserData) == natRuleKey { found = true break } @@ -162,7 +163,7 @@ func TestNftablesManager_RemoveNatRule(t *testing.T) { rules, err = rtr.conn.GetRules(rtr.workTable, rtr.chains[chainNameManglePrerouting]) require.NoError(t, err, "should list rules after removal") for _, rule := range rules { - if len(rule.UserData) > 0 && string(rule.UserData) == natRuleKey { + if len(rule.UserData) > 0 && firewall.RuleID(rule.UserData) == natRuleKey { found = true break } @@ -199,11 +200,10 @@ func TestRouter_AddRouteFiltering(t *testing.T) { defer deleteWorkTable() - r, err := newRouter(workTable, ifaceMock, iface.DefaultMTU) - require.NoError(t, err, "Failed to create router") + r := newFamily(workTable, ifaceMock, iface.DefaultMTU) require.NoError(t, r.init(workTable)) - defer func(r *router) { + defer func(r *family) { require.NoError(t, r.Reset(), "Failed to reset rules") }(r) @@ -313,16 +313,16 @@ func TestRouter_AddRouteFiltering(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ruleKey, err := r.AddRouteFiltering(nil, tt.sources, firewall.Network{Prefix: tt.destination}, tt.proto, tt.sPort, tt.dPort, tt.action) - require.NoError(t, err, "AddRouteFiltering failed") + ruleKey, err := r.AddFilterRule(nil, tt.sources, firewall.Network{Prefix: tt.destination}, tt.proto, tt.sPort, tt.dPort, tt.action) + require.NoError(t, err, "AddFilterRule failed") t.Cleanup(func() { - require.NoError(t, r.DeleteRouteRule(ruleKey), "Failed to delete rule") + require.NoError(t, r.DeleteFilterRule(ruleKey), "Failed to delete rule") }) - // Check if the rule is in the internal map - rule, ok := r.rules[ruleKey.ID()] - assert.True(t, ok, "Rule not found in internal map") + stored, ok := r.filters[id.RuleID(ruleKey.ID())] + require.True(t, ok, "Rule not found in filters map") + rule := stored.nftRule t.Log("Internal rule expressions:") for i, expr := range rule.Exprs { @@ -338,7 +338,7 @@ func TestRouter_AddRouteFiltering(t *testing.T) { var nftRule *nftables.Rule for _, rule := range rules { - if string(rule.UserData) == ruleKey.ID() { + if firewall.RuleID(rule.UserData) == ruleKey.ID() { nftRule = rule break } @@ -366,12 +366,11 @@ func TestNftablesCreateIpSet(t *testing.T) { defer deleteWorkTable() - r, err := newRouter(workTable, ifaceMock, iface.DefaultMTU) - require.NoError(t, err, "Failed to create router") + r := newFamily(workTable, ifaceMock, iface.DefaultMTU) require.NoError(t, r.init(workTable)) defer func() { - require.NoError(t, r.Reset(), "Failed to reset router") + require.NoError(t, r.Reset(), "Failed to reset family") }() tests := []struct { @@ -508,6 +507,187 @@ func TestNftablesCreateIpSet(t *testing.T) { } } +// TestNftablesUpdateSetMergesOverlapping verifies that UpdateSet merges +// overlapping prefixes before adding them. An interval set rejects +// overlapping elements, so without the merge a batch holding a /32 already +// covered by a /24, or a duplicate address as DNS resolution can produce, +// would fail. +func TestNftablesUpdateSetMergesOverlapping(t *testing.T) { + if check() != NFTABLES { + t.Skip("nftables not supported on this system") + } + + workTable, err := createWorkTable() + require.NoError(t, err, "create work table") + defer deleteWorkTable() + + r := newFamily(workTable, ifaceMock, iface.DefaultMTU) + require.NoError(t, r.init(workTable)) + defer func() { + require.NoError(t, r.Reset(), "reset family") + }() + + initial := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")} + set := firewall.NewPrefixSet(initial) + + created, err := r.createIpSet(set.HashedName(), setInput{prefixes: initial}) + require.NoError(t, err, "create ip set") + require.NotNil(t, created) + + overlapping := []netip.Prefix{ + netip.MustParsePrefix("192.168.1.0/24"), + netip.MustParsePrefix("192.168.1.1/32"), + netip.MustParsePrefix("192.168.1.1/32"), + } + require.NoError(t, r.UpdateSet(set, overlapping), "UpdateSet must merge overlapping prefixes") + + fetchedSet, err := r.conn.GetSetByName(r.workTable, set.HashedName()) + require.NoError(t, err, "fetch updated set") + elements, err := r.conn.GetSetElements(fetchedSet) + require.NoError(t, err, "get set elements") + + starts := make(map[string]bool) + for _, elem := range elements { + if elem.IntervalEnd { + continue + } + starts[netip.AddrFrom4(*(*[4]byte)(elem.Key)).String()] = true + } + // The /32s are covered by the /24, so the update adds one interval and + // leaves the one created earlier in place. + assert.Equal(t, map[string]bool{"10.0.0.0": true, "192.168.1.0": true}, starts, + "merged set must hold the original and the merged interval") +} + +func TestNftablesCreateIpSet_IPv6(t *testing.T) { + if check() != NFTABLES { + t.Skip("nftables not supported on this system") + } + + workTable, err := createWorkTableIPv6() + require.NoError(t, err, "Failed to create v6 work table") + defer deleteWorkTableIPv6() + + r := newFamily(workTable, ifaceMock, iface.DefaultMTU) + require.NoError(t, r.init(workTable)) + defer func() { + require.NoError(t, r.Reset(), "Failed to reset family") + }() + + tests := []struct { + name string + sources []netip.Prefix + expected []netip.Prefix + }{ + { + name: "Single IPv6", + sources: []netip.Prefix{netip.MustParsePrefix("2001:db8::1/128")}, + }, + { + name: "Multiple IPv6 Subnets", + sources: []netip.Prefix{ + netip.MustParsePrefix("fd00::/64"), + netip.MustParsePrefix("2001:db8::/48"), + netip.MustParsePrefix("fe80::/10"), + }, + }, + { + name: "Overlapping IPv6", + sources: []netip.Prefix{ + netip.MustParsePrefix("fd00::/48"), + netip.MustParsePrefix("fd00::/64"), + netip.MustParsePrefix("fd00::1/128"), + }, + expected: []netip.Prefix{ + netip.MustParsePrefix("fd00::/48"), + }, + }, + { + name: "Mixed prefix lengths", + sources: []netip.Prefix{ + netip.MustParsePrefix("2001:db8:1::/48"), + netip.MustParsePrefix("2001:db8:2::1/128"), + netip.MustParsePrefix("fd00:abcd::/32"), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setName := firewall.NewPrefixSet(tt.sources).HashedName() + set, err := r.createIpSet(setName, setInput{prefixes: tt.sources}) + require.NoError(t, err, "Failed to create IPv6 set") + require.NotNil(t, set) + + assert.Equal(t, setName, set.Name) + assert.True(t, set.Interval) + assert.Equal(t, nftables.TypeIP6Addr, set.KeyType) + + fetchedSet, err := r.conn.GetSetByName(r.workTable, setName) + require.NoError(t, err, "Failed to fetch created set") + + elements, err := r.conn.GetSetElements(fetchedSet) + require.NoError(t, err, "Failed to get set elements") + + uniquePrefixes := make(map[string]bool) + for _, elem := range elements { + if !elem.IntervalEnd && len(elem.Key) == 16 { + ip := netip.AddrFrom16([16]byte(elem.Key)) + uniquePrefixes[ip.String()] = true + } + } + + expectedCount := len(tt.expected) + if expectedCount == 0 { + expectedCount = len(tt.sources) + } + assert.Equal(t, expectedCount, len(uniquePrefixes), "unique prefix count mismatch") + + r.conn.DelSet(set) + require.NoError(t, r.conn.Flush()) + }) + } +} + +func createWorkTableIPv6() (*nftables.Table, error) { + sConn, err := nftables.New(nftables.AsLasting()) + if err != nil { + return nil, err + } + + tables, err := sConn.ListTablesOfFamily(nftables.TableFamilyIPv6) + if err != nil { + return nil, err + } + for _, t := range tables { + if t.Name == tableNameNetbird { + sConn.DelTable(t) + } + } + + table := sConn.AddTable(&nftables.Table{Name: tableNameNetbird, Family: nftables.TableFamilyIPv6}) + err = sConn.Flush() + return table, err +} + +func deleteWorkTableIPv6() { + sConn, err := nftables.New(nftables.AsLasting()) + if err != nil { + return + } + + tables, err := sConn.ListTablesOfFamily(nftables.TableFamilyIPv6) + if err != nil { + return + } + for _, t := range tables { + if t.Name == tableNameNetbird { + sConn.DelTable(t) + _ = sConn.Flush() + } + } +} + func verifyRule(t *testing.T, rule *nftables.Rule, sources []netip.Prefix, destination netip.Prefix, proto firewall.Protocol, sPort, dPort *firewall.Port, direction firewall.RuleDirection, action firewall.Action, expectSet bool) { t.Helper() @@ -617,6 +797,14 @@ func containsPort(exprs []expr.Any, port *firewall.Port, isSource bool) bool { } } } + case *expr.Lookup: + // Multiple discrete ports compile to an anonymous set lookup + // rather than a chain of comparisons. The set's id and name are + // assigned dynamically, so matching the lookup is enough here; + // the set elements are verified separately. + if !port.IsRange && len(port.Values) > 1 { + portMatchFound = true + } } if payloadFound && portMatchFound { return true @@ -627,7 +815,7 @@ func containsPort(exprs []expr.Any, port *firewall.Port, isSource bool) bool { func containsProtocol(exprs []expr.Any, proto firewall.Protocol) bool { var metaFound, cmpFound bool - expectedProto, _ := protoToInt(proto) + expectedProto, _ := afIPv4.protoNum(proto) for _, e := range exprs { switch ex := e.(type) { case *expr.Meta: @@ -730,13 +918,12 @@ func TestRouter_RefreshRulesMap_RemovesStaleEntries(t *testing.T) { require.NoError(t, err) defer deleteWorkTable() - r, err := newRouter(workTable, ifaceMock, iface.DefaultMTU) - require.NoError(t, err) + r := newFamily(workTable, ifaceMock, iface.DefaultMTU) require.NoError(t, r.init(workTable)) defer func() { require.NoError(t, r.Reset()) }() // Add a real rule to the kernel - ruleKey, err := r.AddRouteFiltering( + ruleKey, err := r.AddFilterRule( nil, []netip.Prefix{netip.MustParsePrefix("192.168.1.0/24")}, firewall.Network{Prefix: netip.MustParsePrefix("10.0.0.0/24")}, @@ -747,11 +934,11 @@ func TestRouter_RefreshRulesMap_RemovesStaleEntries(t *testing.T) { ) require.NoError(t, err) t.Cleanup(func() { - require.NoError(t, r.DeleteRouteRule(ruleKey)) + require.NoError(t, r.DeleteFilterRule(ruleKey)) }) // Inject a stale entry with Handle=0 (simulates store-before-flush failure) - staleKey := "stale-rule-that-does-not-exist" + staleKey := firewall.RuleID("stale-rule-that-does-not-exist") r.rules[staleKey] = &nftables.Rule{ Table: r.workTable, Chain: r.chains[chainNameRoutingFw], @@ -771,6 +958,54 @@ func TestRouter_RefreshRulesMap_RemovesStaleEntries(t *testing.T) { assert.NotZero(t, realRule.Handle, "real rule should have a valid handle") } +// TestRouter_DeleteRouteRule_RemovesKernelRule verifies a route filter +// rule is actually removed from the kernel on delete. The route chain is +// not refreshed by Flush, so the stored rule carries a zero handle; +// DeleteFilterRule must pull live handles itself before issuing the +// delete or the kernel rule leaks. Regression test for that path. +func TestRouter_DeleteRouteRule_RemovesKernelRule(t *testing.T) { + if check() != NFTABLES { + t.Skip("nftables not supported on this system") + } + + workTable, err := createWorkTable() + require.NoError(t, err) + defer deleteWorkTable() + + r := newFamily(workTable, ifaceMock, iface.DefaultMTU) + require.NoError(t, r.init(workTable)) + defer func() { require.NoError(t, r.Reset()) }() + + ruleKey, err := r.AddFilterRule( + nil, + []netip.Prefix{netip.MustParsePrefix("192.168.1.0/24")}, + firewall.Network{Prefix: netip.MustParsePrefix("10.0.0.0/24")}, + firewall.ProtocolTCP, + nil, + &firewall.Port{Values: []uint16{80}}, + firewall.ActionAccept, + ) + require.NoError(t, err) + + countKernelRules := func() int { + list, err := r.conn.GetRules(r.workTable, r.chains[chainNameRoutingFw]) + require.NoError(t, err) + n := 0 + for _, rule := range list { + if string(rule.UserData) == string(ruleKey.ID()) { + n++ + } + } + return n + } + + require.Equal(t, 1, countKernelRules(), "rule should be present in the kernel after add") + + require.NoError(t, r.DeleteFilterRule(ruleKey)) + assert.Equal(t, 0, countKernelRules(), "rule must be removed from the kernel after delete") + assert.NotContains(t, r.filters, ruleKey.ID(), "filters map entry should be cleared") +} + func TestRouter_DeleteRouteRule_StaleHandle(t *testing.T) { if check() != NFTABLES { t.Skip("nftables not supported on this system") @@ -780,24 +1015,27 @@ func TestRouter_DeleteRouteRule_StaleHandle(t *testing.T) { require.NoError(t, err) defer deleteWorkTable() - r, err := newRouter(workTable, ifaceMock, iface.DefaultMTU) - require.NoError(t, err) + r := newFamily(workTable, ifaceMock, iface.DefaultMTU) require.NoError(t, r.init(workTable)) defer func() { require.NoError(t, r.Reset()) }() // Inject a stale entry with Handle=0 - staleKey := "stale-route-rule" - r.rules[staleKey] = &nftables.Rule{ - Table: r.workTable, - Chain: r.chains[chainNameRoutingFw], - Handle: 0, - UserData: []byte(staleKey), + staleKey := id.RuleID("stale-route-rule") + staleRule := &Rule{ + nftRule: &nftables.Rule{ + Table: r.workTable, + Chain: r.chains[chainNameRoutingFw], + Handle: 0, + UserData: []byte(staleKey), + }, + id: staleKey, } + r.filters[staleKey] = staleRule - // DeleteRouteRule should not return an error for stale handles - err = r.DeleteRouteRule(id.RuleID(staleKey)) + // DeleteFilterRule should not return an error for stale handles + err = r.DeleteFilterRule(staleRule) assert.NoError(t, err, "deleting a stale rule should not error") - assert.NotContains(t, r.rules, staleKey, "stale entry should be cleaned up") + assert.NotContains(t, r.filters, staleKey, "stale entry should be cleaned up") } func TestRouter_AddNatRule_WithStaleEntry(t *testing.T) { @@ -819,7 +1057,7 @@ func TestRouter_AddNatRule_WithStaleEntry(t *testing.T) { Masquerade: true, } - rtr := manager.router + rtr := manager.family4 // First add succeeds err = rtr.AddNatRule(pair) @@ -829,11 +1067,11 @@ func TestRouter_AddNatRule_WithStaleEntry(t *testing.T) { }) // Corrupt the handle to simulate stale state - natRuleKey := firewall.GenKey(firewall.PreroutingFormat, pair) + natRuleKey := pair.GenKey(firewall.PreroutingFormat) if rule, exists := rtr.rules[natRuleKey]; exists { rule.Handle = 0 } - inverseKey := firewall.GenKey(firewall.PreroutingFormat, firewall.GetInversePair(pair)) + inverseKey := firewall.GetInversePair(pair).GenKey(firewall.PreroutingFormat) if rule, exists := rtr.rules[inverseKey]; exists { rule.Handle = 0 } @@ -848,9 +1086,61 @@ func TestRouter_AddNatRule_WithStaleEntry(t *testing.T) { found := 0 for _, rule := range rules { - if len(rule.UserData) > 0 && string(rule.UserData) == natRuleKey { + if len(rule.UserData) > 0 && firewall.RuleID(rule.UserData) == natRuleKey { found++ } } assert.Equal(t, 1, found, "NAT rule should exist in kernel") } + +func TestCalculateLastIP(t *testing.T) { + tests := []struct { + prefix string + want string + }{ + {"10.0.0.0/24", "10.0.0.255"}, + {"10.0.0.0/32", "10.0.0.0"}, + {"0.0.0.0/0", "255.255.255.255"}, + {"192.168.1.0/28", "192.168.1.15"}, + {"fd00::/64", "fd00::ffff:ffff:ffff:ffff"}, + {"fd00::/128", "fd00::"}, + {"2001:db8::/48", "2001:db8:0:ffff:ffff:ffff:ffff:ffff"}, + {"::/0", "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"}, + } + for _, tt := range tests { + t.Run(tt.prefix, func(t *testing.T) { + prefix := netip.MustParsePrefix(tt.prefix) + got := calculateLastIP(prefix) + assert.Equal(t, tt.want, got.String()) + }) + } +} + +func TestConvertPrefixesToSet_IPv6(t *testing.T) { + r := &family{af: afIPv6} + prefixes := []netip.Prefix{ + netip.MustParsePrefix("fd00::/64"), + netip.MustParsePrefix("2001:db8::1/128"), + } + + elements := r.convertPrefixesToSet(prefixes) + + // Each prefix produces 2 elements (start + end) + require.Len(t, elements, 4) + + // fd00::/64 start + assert.Equal(t, netip.MustParseAddr("fd00::").As16(), [16]byte(elements[0].Key)) + assert.False(t, elements[0].IntervalEnd) + + // fd00::/64 end (fd00:0:0:1::, one past the last) + assert.Equal(t, netip.MustParseAddr("fd00:0:0:1::").As16(), [16]byte(elements[1].Key)) + assert.True(t, elements[1].IntervalEnd) + + // 2001:db8::1/128 start + assert.Equal(t, netip.MustParseAddr("2001:db8::1").As16(), [16]byte(elements[2].Key)) + assert.False(t, elements[2].IntervalEnd) + + // 2001:db8::1/128 end (2001:db8::2) + assert.Equal(t, netip.MustParseAddr("2001:db8::2").As16(), [16]byte(elements[3].Key)) + assert.True(t, elements[3].IntervalEnd) +} diff --git a/client/firewall/nftables/routing_linux.go b/client/firewall/nftables/routing_linux.go new file mode 100644 index 000000000..4115c94bd --- /dev/null +++ b/client/firewall/nftables/routing_linux.go @@ -0,0 +1,558 @@ +//go:build !android + +package nftables + +import ( + "fmt" + "strings" + + "github.com/google/nftables" + "github.com/google/nftables/binaryutil" + "github.com/google/nftables/expr" + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + "golang.org/x/sys/unix" + + nberrors "github.com/netbirdio/netbird/client/errors" + firewall "github.com/netbirdio/netbird/client/firewall/manager" + nbnet "github.com/netbirdio/netbird/client/net" +) + +func (r *family) AddNatRule(pair firewall.RouterPair) error { + if err := r.refreshRulesMap(); err != nil { + return fmt.Errorf(refreshRulesMapError, err) + } + + // Resolve every rule's match expressions before queueing any of them: a + // message buffered on the shared connection cannot be un-queued, so + // returning an error after queueing would leave the next caller's Flush + // to commit a rule nothing tracks. + var legacyExprs []expr.Any + if r.legacyManagement { + log.Warnf("This peer is connected to a NetBird Management service with an older version. Allowing all traffic for %s", pair.Destination) + + var err error + legacyExprs, err = r.legacyRouteRuleExprs(pair) + if err != nil { + return fmt.Errorf("build legacy routing rule: %w", err) + } + } + + inverse := firewall.GetInversePair(pair) + var natExprs, inverseExprs []expr.Any + if pair.Masquerade { + var err error + natExprs, err = r.natRuleExprs(pair) + if err != nil { + r.dropNetworkMatch(legacyExprs) + return fmt.Errorf("build nat rule: %w", err) + } + + inverseExprs, err = r.natRuleExprs(inverse) + if err != nil { + r.dropNetworkMatch(legacyExprs) + r.dropNetworkMatch(natExprs) + return fmt.Errorf("build inverse nat rule: %w", err) + } + } + + if legacyExprs != nil { + r.queueLegacyRouteRule(pair, legacyExprs) + } + if pair.Masquerade { + r.queueNatRule(pair, natExprs) + r.queueNatRule(inverse, inverseExprs) + } + + if err := r.conn.Flush(); err != nil { + r.rollbackRules(pair) + return fmt.Errorf("insert rules for %s: %w", pair.Destination, err) + } + + return nil +} + +// rollbackRules cleans up unflushed rules and their set counters after a flush failure. +func (r *family) rollbackRules(pair firewall.RouterPair) { + keys := []firewall.RuleID{ + pair.GenKey(firewall.ForwardingFormat), + pair.GenKey(firewall.PreroutingFormat), + firewall.GetInversePair(pair).GenKey(firewall.PreroutingFormat), + } + for _, key := range keys { + rule, ok := r.rules[key] + if !ok { + continue + } + if err := r.decrementSetCounter(rule); err != nil { + log.Warnf("rollback set counter for %s: %v", key, err) + } + delete(r.rules, key) + } +} + +// natRuleExprs resolves the match expressions of the pair's prerouting +// marking rule. It reserves the ipset references the matches need but queues +// nothing on the connection, so its error paths leave the connection clean. +func (r *family) natRuleExprs(pair firewall.RouterPair) ([]expr.Any, error) { + sourceExp, err := r.applyNetwork(pair.Source, nil, true) + if err != nil { + return nil, fmt.Errorf("apply source: %w", err) + } + + destExp, err := r.applyNetwork(pair.Destination, nil, false) + if err != nil { + r.dropNetworkMatch(sourceExp) + return nil, fmt.Errorf("apply destination: %w", err) + } + + op := expr.CmpOpEq + if pair.Inverse { + op = expr.CmpOpNeq + } + + exprs := []expr.Any{ + &expr.Meta{ + Key: expr.MetaKeyIIFNAME, + Register: 1, + }, + &expr.Cmp{ + Op: op, + Register: 1, + Data: ifname(r.wgIface.Name()), + }, + } + // We only care about NEW connections to mark them and later identify them in the postrouting chain for masquerading. + // Masquerading will take care of the conntrack state, which means we won't need to mark established connections. + exprs = append(exprs, getCtNewExprs()...) + + exprs = append(exprs, sourceExp...) + exprs = append(exprs, destExp...) + + markValue := nbnet.PreroutingFwmarkMasquerade + if pair.Inverse { + markValue = nbnet.PreroutingFwmarkMasqueradeReturn + } + + exprs = append(exprs, + &expr.Immediate{ + Register: 1, + Data: binaryutil.NativeEndian.PutUint32(markValue), + }, + &expr.Meta{ + Key: expr.MetaKeyMARK, + SourceRegister: true, + Register: 1, + }, + ) + + return exprs, nil +} + +// queueNatRule replaces any tracked rule for the pair and queues the new +// prerouting marking rule on the connection. Failures are logged rather than +// returned: the caller has already queued messages that only a Flush can +// commit, so it must not return early. +func (r *family) queueNatRule(pair firewall.RouterPair, exprs []expr.Any) { + ruleID := pair.GenKey(firewall.PreroutingFormat) + + if _, exists := r.rules[ruleID]; exists { + if err := r.removeNatRule(pair); err != nil { + // The rule this replaces may still be in the kernel. Keep tracking + // it and skip the new one: overwriting the entry would leave the old + // rule installed with nothing that can find it again, while keeping + // it lets the next update retry the whole replacement. + log.Errorf("replace prerouting rule %s: %v", ruleID, err) + r.dropNetworkMatch(exprs) + return + } + } + + // Ensure nat rules come first, so the mark can be overwritten. + // Currently overwritten by the dst-type LOCAL rules for redirected traffic. + r.rules[ruleID] = r.conn.InsertRule(&nftables.Rule{ + Table: r.workTable, + Chain: r.chains[chainNameManglePrerouting], + Exprs: exprs, + UserData: []byte(ruleID), + }) +} + +func (r *family) addPostroutingRules() { + // First masquerade rule for traffic coming in from WireGuard interface + exprs := []expr.Any{ + // Match on the first fwmark + &expr.Meta{ + Key: expr.MetaKeyMARK, + Register: 1, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: binaryutil.NativeEndian.PutUint32(nbnet.PreroutingFwmarkMasquerade), + }, + + // We need to exclude the loopback interface as this changes the ebpf proxy port + &expr.Meta{ + Key: expr.MetaKeyOIFNAME, + Register: 1, + }, + &expr.Cmp{ + Op: expr.CmpOpNeq, + Register: 1, + Data: ifname("lo"), + }, + &expr.Counter{}, + &expr.Masq{}, + } + + r.conn.AddRule(&nftables.Rule{ + Table: r.workTable, + Chain: r.chains[chainNameRoutingNat], + Exprs: exprs, + }) + + // Second masquerade rule for traffic going out through WireGuard interface + exprs2 := []expr.Any{ + // Match on the second fwmark + &expr.Meta{ + Key: expr.MetaKeyMARK, + Register: 1, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: binaryutil.NativeEndian.PutUint32(nbnet.PreroutingFwmarkMasqueradeReturn), + }, + + // Match WireGuard interface + &expr.Meta{ + Key: expr.MetaKeyOIFNAME, + Register: 1, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: ifname(r.wgIface.Name()), + }, + &expr.Counter{}, + &expr.Masq{}, + } + + r.conn.AddRule(&nftables.Rule{ + Table: r.workTable, + Chain: r.chains[chainNameRoutingNat], + Exprs: exprs2, + }) +} + +// addMSSClampingRules adds MSS clamping rules to prevent fragmentation for forwarded traffic. +func (r *family) addMSSClampingRules() error { + overhead := uint16(ipv4TCPHeaderSize) + if r.af.tableFamily == nftables.TableFamilyIPv6 { + overhead = ipv6TCPHeaderSize + } + if r.mtu <= overhead { + log.Debugf("MTU %d too small for MSS clamping (overhead %d), skipping", r.mtu, overhead) + return nil + } + mss := r.mtu - overhead + + exprsOut := []expr.Any{ + &expr.Meta{ + Key: expr.MetaKeyOIFNAME, + Register: 1, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: ifname(r.wgIface.Name()), + }, + &expr.Meta{ + Key: expr.MetaKeyL4PROTO, + Register: 1, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: []byte{unix.IPPROTO_TCP}, + }, + &expr.Payload{ + DestRegister: 1, + Base: expr.PayloadBaseTransportHeader, + Offset: 13, + Len: 1, + }, + &expr.Bitwise{ + DestRegister: 1, + SourceRegister: 1, + Len: 1, + Mask: []byte{0x02}, + Xor: []byte{0x00}, + }, + &expr.Cmp{ + Op: expr.CmpOpNeq, + Register: 1, + Data: []byte{0x00}, + }, + &expr.Counter{}, + &expr.Exthdr{ + DestRegister: 1, + Type: 2, + Offset: 2, + Len: 2, + Op: expr.ExthdrOpTcpopt, + }, + &expr.Cmp{ + Op: expr.CmpOpGt, + Register: 1, + Data: binaryutil.BigEndian.PutUint16(uint16(mss)), + }, + &expr.Immediate{ + Register: 1, + Data: binaryutil.BigEndian.PutUint16(uint16(mss)), + }, + &expr.Exthdr{ + SourceRegister: 1, + Type: 2, + Offset: 2, + Len: 2, + Op: expr.ExthdrOpTcpopt, + }, + } + + r.conn.AddRule(&nftables.Rule{ + Table: r.workTable, + Chain: r.chains[chainNameMangleForward], + Exprs: exprsOut, + }) + + return r.conn.Flush() +} + +func buildLegacyRouteRuleExpressions(sourceExp, destExp []expr.Any) []expr.Any { + exprs := make([]expr.Any, 0, len(sourceExp)+len(destExp)+2) + exprs = append(exprs, sourceExp...) + exprs = append(exprs, destExp...) + exprs = append(exprs, + &expr.Counter{}, + &expr.Verdict{Kind: expr.VerdictAccept}, + ) + return exprs +} + +// legacyRouteRuleExprs resolves the match expressions of the pair's legacy +// forwarding rule, queueing nothing on the connection. +func (r *family) legacyRouteRuleExprs(pair firewall.RouterPair) ([]expr.Any, error) { + sourceExp, err := r.applyNetwork(pair.Source, nil, true) + if err != nil { + return nil, fmt.Errorf("apply source: %w", err) + } + + destExp, err := r.applyNetwork(pair.Destination, nil, false) + if err != nil { + r.dropNetworkMatch(sourceExp) + return nil, fmt.Errorf("apply destination: %w", err) + } + + return buildLegacyRouteRuleExpressions(sourceExp, destExp), nil +} + +// queueLegacyRouteRule replaces any tracked rule for the pair and queues the +// new legacy forwarding rule. Failures are logged for the same reason as in +// queueNatRule. +func (r *family) queueLegacyRouteRule(pair firewall.RouterPair, exprs []expr.Any) { + ruleID := pair.GenKey(firewall.ForwardingFormat) + + if _, exists := r.rules[ruleID]; exists { + if err := r.removeLegacyRouteRule(pair); err != nil { + // Keep the old rule tracked instead of losing it, as in queueNatRule. + log.Errorf("replace legacy forwarding rule %s: %v", ruleID, err) + r.dropNetworkMatch(exprs) + return + } + } + + r.rules[ruleID] = r.conn.AddRule(&nftables.Rule{ + Table: r.workTable, + Chain: r.chains[chainNameRoutingFw], + Exprs: exprs, + UserData: []byte(ruleID), + }) +} + +// removeLegacyRouteRule removes a legacy routing rule for mgmt servers pre route acls +func (r *family) removeLegacyRouteRule(pair firewall.RouterPair) error { + ruleID := pair.GenKey(firewall.ForwardingFormat) + + rule, exists := r.rules[ruleID] + if !exists { + return nil + } + + return r.deleteLegacyRuleEntry(ruleID, rule) +} + +// deleteLegacyRuleEntry removes one legacy forwarding rule and drops its +// ipset references. It also clears stale entries that never got a handle. +func (r *family) deleteLegacyRuleEntry(ruleID firewall.RuleID, rule *nftables.Rule) error { + if rule.Handle == 0 { + log.Warnf("legacy forwarding rule %s has no handle, removing stale entry", ruleID) + if err := r.decrementSetCounter(rule); err != nil { + log.Warnf("decrement set counter for stale rule %s: %v", ruleID, err) + } + delete(r.rules, ruleID) + return nil + } + + if err := r.conn.DelRule(rule); err != nil { + return fmt.Errorf("remove legacy forwarding rule %s: %w", ruleID, err) + } + + delete(r.rules, ruleID) + + if err := r.decrementSetCounter(rule); err != nil { + return fmt.Errorf("decrement set counter: %w", err) + } + + return nil +} + +// GetLegacyManagement returns the route manager's legacy management mode +func (r *family) GetLegacyManagement() bool { + return r.legacyManagement +} + +// SetLegacyManagement sets the route manager to use legacy management mode +func (r *family) SetLegacyManagement(isLegacy bool) { + r.legacyManagement = isLegacy +} + +// RemoveAllLegacyRouteRules removes all legacy routing rules for mgmt servers pre route acls +func (r *family) RemoveAllLegacyRouteRules() error { + if err := r.refreshRulesMap(); err != nil { + return fmt.Errorf(refreshRulesMapError, err) + } + + var merr *multierror.Error + var found bool + for k, rule := range r.rules { + if !strings.HasPrefix(string(k), firewall.ForwardingFormatPrefix) { + continue + } + found = true + if err := r.deleteLegacyRuleEntry(k, rule); err != nil { + merr = multierror.Append(merr, err) + } + } + + // Commit the queued deletes here instead of leaving them for whichever + // caller flushes next: the tracking entries are already gone, so an + // uncommitted delete would leave a rule in the kernel that nothing can + // find again. + if found { + if err := r.conn.Flush(); err != nil { + merr = multierror.Append(merr, fmt.Errorf(flushError, err)) + } + } + + return nberrors.FormatErrorOrNil(merr) +} + +func (r *family) removeNatPreroutingRules() error { + table := &nftables.Table{ + Name: tableNat, + Family: r.af.tableFamily, + } + chain := &nftables.Chain{ + Name: chainNameNatPrerouting, + Table: table, + Hooknum: nftables.ChainHookPrerouting, + Priority: nftables.ChainPriorityNATDest, + Type: nftables.ChainTypeNAT, + } + rules, err := r.conn.GetRules(table, chain) + if err != nil { + return fmt.Errorf("get rules from nat table: %w", err) + } + + var merr *multierror.Error + + // Delete rules that have our UserData suffix + for _, rule := range rules { + if len(rule.UserData) == 0 || !strings.HasSuffix(string(rule.UserData), string(dnatSuffix)) { + continue + } + if err := r.conn.DelRule(rule); err != nil { + merr = multierror.Append(merr, fmt.Errorf("delete rule %s: %w", rule.UserData, err)) + } + } + + if err := r.conn.Flush(); err != nil { + merr = multierror.Append(merr, fmt.Errorf(flushError, err)) + } + return nberrors.FormatErrorOrNil(merr) +} + +func (r *family) RemoveNatRule(pair firewall.RouterPair) error { + if err := r.refreshRulesMap(); err != nil { + return fmt.Errorf(refreshRulesMapError, err) + } + + var merr *multierror.Error + + if pair.Masquerade { + if err := r.removeNatRule(pair); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove prerouting rule: %w", err)) + } + + if err := r.removeNatRule(firewall.GetInversePair(pair)); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove inverse prerouting rule: %w", err)) + } + } + + if err := r.removeLegacyRouteRule(pair); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove legacy routing rule: %w", err)) + } + + // Set counters are decremented in the sub-methods above before flush. If flush fails, + // counters will be off until the next successful removal or refresh cycle. + if err := r.conn.Flush(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("flush remove nat rules %s: %w", pair.Destination, err)) + } + + return nberrors.FormatErrorOrNil(merr) +} + +func (r *family) removeNatRule(pair firewall.RouterPair) error { + ruleID := pair.GenKey(firewall.PreroutingFormat) + + rule, exists := r.rules[ruleID] + if !exists { + log.Debugf("prerouting rule %s not found", ruleID) + return nil + } + + if rule.Handle == 0 { + log.Warnf("prerouting rule %s has no handle, removing stale entry", ruleID) + if err := r.decrementSetCounter(rule); err != nil { + log.Warnf("decrement set counter for stale rule %s: %v", ruleID, err) + } + delete(r.rules, ruleID) + return nil + } + + if err := r.conn.DelRule(rule); err != nil { + return fmt.Errorf("remove prerouting rule %s -> %s: %w", pair.Source, pair.Destination, err) + } + + log.Debugf("removed prerouting rule %s -> %s", pair.Source, pair.Destination) + + delete(r.rules, ruleID) + + if err := r.decrementSetCounter(rule); err != nil { + return fmt.Errorf("decrement set counter: %w", err) + } + + return nil +} diff --git a/client/firewall/nftables/rule_linux.go b/client/firewall/nftables/rule_linux.go index a90b74e36..8f3c0aebc 100644 --- a/client/firewall/nftables/rule_linux.go +++ b/client/firewall/nftables/rule_linux.go @@ -1,21 +1,26 @@ package nftables import ( - "net" + "net/netip" "github.com/google/nftables" + + "github.com/netbirdio/netbird/client/firewall/manager" ) -// Rule to handle management of rules +// Rule wraps an installed filter rule (peer or route). Source set +// membership is encoded in the rule's expressions; DeleteFilterRule +// recovers the set name via findSets so the refcounter can drop the +// right reference. mangleRule is set only for peer rules. type Rule struct { nftRule *nftables.Rule mangleRule *nftables.Rule - nftSet *nftables.Set - ruleID string - ip net.IP + // sources is the canonical source list this rule was created for. + sources []netip.Prefix + id manager.RuleID } -// GetRuleID returns the rule id -func (r *Rule) ID() string { - return r.ruleID +// ID returns the rule id +func (r *Rule) ID() manager.RuleID { + return r.id } diff --git a/client/firewall/nftables/testhelpers_linux_test.go b/client/firewall/nftables/testhelpers_linux_test.go new file mode 100644 index 000000000..72db3f7d2 --- /dev/null +++ b/client/firewall/nftables/testhelpers_linux_test.go @@ -0,0 +1,27 @@ +//go:build privileged + +package nftables + +import ( + "fmt" + "net" + "net/netip" +) + +func pfx(ip net.IP) []netip.Prefix { + if ip == nil { + return []netip.Prefix{netip.PrefixFrom(netip.IPv4Unspecified(), 0)} + } + if ip.IsUnspecified() { + if ip.To4() != nil { + return []netip.Prefix{netip.PrefixFrom(netip.IPv4Unspecified(), 0)} + } + return []netip.Prefix{netip.PrefixFrom(netip.IPv6Unspecified(), 0)} + } + a, ok := netip.AddrFromSlice(ip) + if !ok { + panic(fmt.Sprintf("invalid IP length: %d", len(ip))) + } + a = a.Unmap() + return []netip.Prefix{netip.PrefixFrom(a, a.BitLen())} +} diff --git a/client/firewall/uspfilter/allow_netbird.go b/client/firewall/uspfilter/allow_netbird.go deleted file mode 100644 index b120cdf12..000000000 --- a/client/firewall/uspfilter/allow_netbird.go +++ /dev/null @@ -1,37 +0,0 @@ -//go:build !windows - -package uspfilter - -import ( - log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/firewall/firewalld" - "github.com/netbirdio/netbird/client/internal/statemanager" -) - -// Close cleans up the firewall manager by removing all rules and closing trackers -func (m *Manager) Close(stateManager *statemanager.Manager) error { - m.mutex.Lock() - defer m.mutex.Unlock() - - m.resetState() - - if m.nativeFirewall != nil { - return m.nativeFirewall.Close(stateManager) - } - if err := firewalld.UntrustInterface(m.wgIface.Name()); err != nil { - log.Warnf("failed to untrust interface in firewalld: %v", err) - } - return nil -} - -// AllowNetbird allows netbird interface traffic -func (m *Manager) AllowNetbird() error { - if m.nativeFirewall != nil { - return m.nativeFirewall.AllowNetbird() - } - if err := firewalld.TrustInterface(m.wgIface.Name()); err != nil { - log.Warnf("failed to trust interface in firewalld: %v", err) - } - return nil -} diff --git a/client/firewall/uspfilter/common/iface.go b/client/firewall/uspfilter/common/iface.go deleted file mode 100644 index 9c06eb3f7..000000000 --- a/client/firewall/uspfilter/common/iface.go +++ /dev/null @@ -1,17 +0,0 @@ -package common - -import ( - wgdevice "golang.zx2c4.com/wireguard/device" - - "github.com/netbirdio/netbird/client/iface/device" - "github.com/netbirdio/netbird/client/iface/wgaddr" -) - -// IFaceMapper defines subset methods of interface required for manager -type IFaceMapper interface { - Name() string - SetFilter(device.PacketFilter) error - Address() wgaddr.Address - GetWGDevice() *wgdevice.Device - GetDevice() *device.FilteredDevice -} diff --git a/client/firewall/uspfilter/conntrack/cap_test.go b/client/firewall/uspfilter/conntrack/cap_test.go new file mode 100644 index 000000000..ee6b72e7f --- /dev/null +++ b/client/firewall/uspfilter/conntrack/cap_test.go @@ -0,0 +1,125 @@ +package conntrack + +import ( + "net/netip" + "testing" + + "github.com/google/gopacket/layers" + "github.com/stretchr/testify/require" +) + +func TestTCPCapEvicts(t *testing.T) { + t.Setenv(EnvTCPMaxEntries, "4") + + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + require.Equal(t, 4, tracker.maxEntries) + + src := netip.MustParseAddr("100.64.0.1") + dst := netip.MustParseAddr("100.64.0.2") + + for i := 0; i < 10; i++ { + tracker.TrackOutbound(src, dst, uint16(10000+i), 80, TCPSyn, 0) + } + require.LessOrEqual(t, len(tracker.connections), 4, + "TCP table must not exceed the configured cap") + require.Greater(t, len(tracker.connections), 0, + "some entries must remain after eviction") + + // The most recently admitted flow must be present: eviction must make + // room for new entries, not silently drop them. + require.Contains(t, tracker.connections, + ConnKey{SrcIP: src, DstIP: dst, SrcPort: uint16(10009), DstPort: 80}, + "newest TCP flow must be admitted after eviction") + // A pre-cap flow must have been evicted to fit the last one. + require.NotContains(t, tracker.connections, + ConnKey{SrcIP: src, DstIP: dst, SrcPort: uint16(10000), DstPort: 80}, + "oldest TCP flow should have been evicted") +} + +func TestTCPCapPrefersTombstonedForEviction(t *testing.T) { + t.Setenv(EnvTCPMaxEntries, "3") + + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + src := netip.MustParseAddr("100.64.0.1") + dst := netip.MustParseAddr("100.64.0.2") + + // Fill to cap with 3 live connections. + for i := 0; i < 3; i++ { + tracker.TrackOutbound(src, dst, uint16(20000+i), 80, TCPSyn, 0) + } + require.Len(t, tracker.connections, 3) + + // Tombstone one by sending RST through IsValidInbound. + tombstonedKey := ConnKey{SrcIP: src, DstIP: dst, SrcPort: 20001, DstPort: 80} + require.True(t, tracker.IsValidInbound(dst, src, 80, 20001, TCPRst|TCPAck, 0)) + require.True(t, tracker.connections[tombstonedKey].IsTombstone()) + + // Another live connection forces eviction. The tombstone must go first. + tracker.TrackOutbound(src, dst, uint16(29999), 80, TCPSyn, 0) + + _, tombstonedStillPresent := tracker.connections[tombstonedKey] + require.False(t, tombstonedStillPresent, + "tombstoned entry should be evicted before live entries") + require.LessOrEqual(t, len(tracker.connections), 3) + + // Both live pre-cap entries must survive: eviction must prefer the + // tombstone, not just satisfy the size bound by dropping any entry. + require.Contains(t, tracker.connections, + ConnKey{SrcIP: src, DstIP: dst, SrcPort: uint16(20000), DstPort: 80}, + "live entries must not be evicted while a tombstone exists") + require.Contains(t, tracker.connections, + ConnKey{SrcIP: src, DstIP: dst, SrcPort: uint16(20002), DstPort: 80}, + "live entries must not be evicted while a tombstone exists") +} + +func TestUDPCapEvicts(t *testing.T) { + t.Setenv(EnvUDPMaxEntries, "5") + + tracker := NewUDPTracker(DefaultUDPTimeout, logger, flowLogger) + defer tracker.Close() + require.Equal(t, 5, tracker.maxEntries) + + src := netip.MustParseAddr("100.64.0.1") + dst := netip.MustParseAddr("100.64.0.2") + + for i := 0; i < 12; i++ { + tracker.TrackOutbound(src, dst, uint16(30000+i), 53, 0) + } + require.LessOrEqual(t, len(tracker.connections), 5) + require.Greater(t, len(tracker.connections), 0) + + require.Contains(t, tracker.connections, + ConnKey{SrcIP: src, DstIP: dst, SrcPort: uint16(30011), DstPort: 53}, + "newest UDP flow must be admitted after eviction") + require.NotContains(t, tracker.connections, + ConnKey{SrcIP: src, DstIP: dst, SrcPort: uint16(30000), DstPort: 53}, + "oldest UDP flow should have been evicted") +} + +func TestICMPCapEvicts(t *testing.T) { + t.Setenv(EnvICMPMaxEntries, "3") + + tracker := NewICMPTracker(DefaultICMPTimeout, logger, flowLogger) + defer tracker.Close() + require.Equal(t, 3, tracker.maxEntries) + + src := netip.MustParseAddr("100.64.0.1") + dst := netip.MustParseAddr("100.64.0.2") + + echoReq := layers.CreateICMPv4TypeCode(uint8(layers.ICMPv4TypeEchoRequest), 0) + for i := 0; i < 8; i++ { + tracker.TrackOutbound(src, dst, uint16(i), echoReq, nil, 64) + } + require.LessOrEqual(t, len(tracker.connections), 3) + require.Greater(t, len(tracker.connections), 0) + + require.Contains(t, tracker.connections, + ICMPConnKey{SrcIP: src, DstIP: dst, ID: uint16(7)}, + "newest ICMP flow must be admitted after eviction") + require.NotContains(t, tracker.connections, + ICMPConnKey{SrcIP: src, DstIP: dst, ID: uint16(0)}, + "oldest ICMP flow should have been evicted") +} diff --git a/client/firewall/uspfilter/conntrack/common.go b/client/firewall/uspfilter/conntrack/common.go index 7be0dd78f..e497a0bff 100644 --- a/client/firewall/uspfilter/conntrack/common.go +++ b/client/firewall/uspfilter/conntrack/common.go @@ -1,16 +1,63 @@ package conntrack import ( - "fmt" + "net" "net/netip" + "os" + "strconv" "sync/atomic" "time" "github.com/google/uuid" + nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" ) +// evictSampleSize bounds how many map entries we scan per eviction call. +// Keeps eviction O(1) even at cap under sustained load; the sampled-LRU +// heuristic is good enough for a conntrack table that only overflows under +// abuse. +const evictSampleSize = 8 + +// envDuration parses an os.Getenv(name) as a time.Duration. Falls back to +// def on empty or invalid; logs a warning on invalid. +func envDuration(logger *nblog.Logger, name string, def time.Duration) time.Duration { + v := os.Getenv(name) + if v == "" { + return def + } + d, err := time.ParseDuration(v) + if err != nil { + logger.Warn3("invalid %s=%q: %v, using default", name, v, err) + return def + } + if d <= 0 { + logger.Warn2("invalid %s=%q: must be positive, using default", name, v) + return def + } + return d +} + +// envInt parses an os.Getenv(name) as an int. Falls back to def on empty, +// invalid, or non-positive. Logs a warning on invalid input. +func envInt(logger *nblog.Logger, name string, def int) int { + v := os.Getenv(name) + if v == "" { + return def + } + n, err := strconv.Atoi(v) + switch { + case err != nil: + logger.Warn3("invalid %s=%q: %v, using default", name, v, err) + return def + case n <= 0: + logger.Warn2("invalid %s=%q: must be positive, using default", name, v) + return def + } + return n +} + // BaseConnTrack provides common fields and locking for all connection types type BaseConnTrack struct { FlowId uuid.UUID @@ -64,5 +111,7 @@ type ConnKey struct { } func (c ConnKey) String() string { - return fmt.Sprintf("%s:%d → %s:%d", c.SrcIP.Unmap(), c.SrcPort, c.DstIP.Unmap(), c.DstPort) + return net.JoinHostPort(c.SrcIP.Unmap().String(), strconv.Itoa(int(c.SrcPort))) + + " → " + + net.JoinHostPort(c.DstIP.Unmap().String(), strconv.Itoa(int(c.DstPort))) } diff --git a/client/firewall/uspfilter/conntrack/common_test.go b/client/firewall/uspfilter/conntrack/common_test.go index d868dd1fb..7e67b98fa 100644 --- a/client/firewall/uspfilter/conntrack/common_test.go +++ b/client/firewall/uspfilter/conntrack/common_test.go @@ -13,6 +13,54 @@ import ( var logger = log.NewFromLogrus(logrus.StandardLogger()) var flowLogger = netflow.NewManager(nil, []byte{}, nil).GetLogger() +func TestConnKey_String(t *testing.T) { + tests := []struct { + name string + key ConnKey + expect string + }{ + { + name: "IPv4", + key: ConnKey{ + SrcIP: netip.MustParseAddr("192.168.1.1"), + DstIP: netip.MustParseAddr("10.0.0.1"), + SrcPort: 12345, + DstPort: 80, + }, + expect: "192.168.1.1:12345 → 10.0.0.1:80", + }, + { + name: "IPv6", + key: ConnKey{ + SrcIP: netip.MustParseAddr("2001:db8::1"), + DstIP: netip.MustParseAddr("2001:db8::2"), + SrcPort: 54321, + DstPort: 443, + }, + expect: "[2001:db8::1]:54321 → [2001:db8::2]:443", + }, + { + name: "IPv4-mapped IPv6 unmaps", + key: ConnKey{ + SrcIP: netip.MustParseAddr("::ffff:10.0.0.1"), + DstIP: netip.MustParseAddr("::ffff:10.0.0.2"), + SrcPort: 1000, + DstPort: 2000, + }, + expect: "10.0.0.1:1000 → 10.0.0.2:2000", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := tc.key.String() + if got != tc.expect { + t.Errorf("got %q, want %q", got, tc.expect) + } + }) + } +} + // Memory pressure tests func BenchmarkMemoryPressure(b *testing.B) { b.Run("TCPHighLoad", func(b *testing.B) { diff --git a/client/firewall/uspfilter/conntrack/defaults_desktop.go b/client/firewall/uspfilter/conntrack/defaults_desktop.go new file mode 100644 index 000000000..2f07f5984 --- /dev/null +++ b/client/firewall/uspfilter/conntrack/defaults_desktop.go @@ -0,0 +1,11 @@ +//go:build !ios && !android + +package conntrack + +// Default per-tracker entry caps on desktop/server platforms. These mirror +// typical Linux netfilter nf_conntrack_max territory with ample headroom. +const ( + DefaultMaxTCPEntries = 65536 + DefaultMaxUDPEntries = 16384 + DefaultMaxICMPEntries = 2048 +) diff --git a/client/firewall/uspfilter/conntrack/defaults_mobile.go b/client/firewall/uspfilter/conntrack/defaults_mobile.go new file mode 100644 index 000000000..c9e05d229 --- /dev/null +++ b/client/firewall/uspfilter/conntrack/defaults_mobile.go @@ -0,0 +1,13 @@ +//go:build ios || android + +package conntrack + +// Default per-tracker entry caps on mobile platforms. iOS network extensions +// are capped at ~50 MB; Android runs under aggressive memory pressure. These +// values keep conntrack footprint well under 5 MB worst case (TCPConnTrack +// is ~200 B plus map overhead). +const ( + DefaultMaxTCPEntries = 4096 + DefaultMaxUDPEntries = 2048 + DefaultMaxICMPEntries = 512 +) diff --git a/client/firewall/uspfilter/conntrack/icmp.go b/client/firewall/uspfilter/conntrack/icmp.go index 50b663642..3c96548b5 100644 --- a/client/firewall/uspfilter/conntrack/icmp.go +++ b/client/firewall/uspfilter/conntrack/icmp.go @@ -5,6 +5,7 @@ import ( "fmt" "net" "net/netip" + "strconv" "sync" "time" @@ -21,9 +22,14 @@ const ( // ICMPCleanupInterval is how often we check for stale ICMP connections ICMPCleanupInterval = 15 * time.Second - // MaxICMPPayloadLength is the maximum length of ICMP payload we consider for original packet info, - // which includes the IP header (20 bytes) and transport header (8 bytes) - MaxICMPPayloadLength = 28 + // MaxICMPPayloadLength is the maximum length of ICMP payload we consider for original packet info. + // IPv4: 20-byte header + 8-byte transport = 28 bytes. + // IPv6: 40-byte header + 8-byte transport = 48 bytes. + MaxICMPPayloadLength = 48 + // minICMPPayloadIPv4 is the minimum embedded packet length for IPv4 ICMP errors. + minICMPPayloadIPv4 = 28 + // minICMPPayloadIPv6 is the minimum embedded packet length for IPv6 ICMP errors. + minICMPPayloadIPv6 = 48 ) // ICMPConnKey uniquely identifies an ICMP connection @@ -44,6 +50,9 @@ type ICMPConnTrack struct { ICMPCode uint8 } +// EnvICMPMaxEntries caps the ICMP conntrack table size. +const EnvICMPMaxEntries = "NB_CONNTRACK_ICMP_MAX" + // ICMPTracker manages ICMP connection states type ICMPTracker struct { logger *nblog.Logger @@ -52,6 +61,7 @@ type ICMPTracker struct { cleanupTicker *time.Ticker tickerCancel context.CancelFunc mutex sync.RWMutex + maxEntries int flowLogger nftypes.FlowLogger } @@ -65,7 +75,7 @@ type ICMPInfo struct { // String implements fmt.Stringer for lazy evaluation in log messages func (info ICMPInfo) String() string { - if info.isErrorMessage() && info.PayloadLen >= MaxICMPPayloadLength { + if info.isErrorMessage() && info.PayloadLen >= minICMPPayloadIPv4 { if origInfo := info.parseOriginalPacket(); origInfo != "" { return fmt.Sprintf("%s (original: %s)", info.TypeCode, origInfo) } @@ -74,42 +84,72 @@ func (info ICMPInfo) String() string { return info.TypeCode.String() } -// isErrorMessage returns true if this ICMP type carries original packet info +// isErrorMessage returns true if this ICMP type carries original packet info. +// Covers both ICMPv4 and ICMPv6 error types. Without a family field we match +// both sets; type 3 overlaps (v4 DestUnreachable / v6 TimeExceeded) so it's +// kept as a literal. func (info ICMPInfo) isErrorMessage() bool { typ := info.TypeCode.Type() - return typ == 3 || // Destination Unreachable - typ == 5 || // Redirect - typ == 11 || // Time Exceeded - typ == 12 // Parameter Problem + // ICMPv4 error types + if typ == layers.ICMPv4TypeDestinationUnreachable || + typ == layers.ICMPv4TypeRedirect || + typ == layers.ICMPv4TypeTimeExceeded || + typ == layers.ICMPv4TypeParameterProblem { + return true + } + // ICMPv6 error types (type 3 already matched above as v4 DestUnreachable) + if typ == layers.ICMPv6TypeDestinationUnreachable || + typ == layers.ICMPv6TypePacketTooBig || + typ == layers.ICMPv6TypeParameterProblem { + return true + } + return false } // parseOriginalPacket extracts info about the original packet from ICMP payload func (info ICMPInfo) parseOriginalPacket() string { - if info.PayloadLen < MaxICMPPayloadLength { + if info.PayloadLen == 0 { return "" } - // TODO: handle IPv6 - if version := (info.PayloadData[0] >> 4) & 0xF; version != 4 { + version := (info.PayloadData[0] >> 4) & 0xF + + var protocol uint8 + var srcIP, dstIP net.IP + var transportData []byte + + switch version { + case 4: + if info.PayloadLen < minICMPPayloadIPv4 { + return "" + } + protocol = info.PayloadData[9] + srcIP = net.IP(info.PayloadData[12:16]) + dstIP = net.IP(info.PayloadData[16:20]) + transportData = info.PayloadData[20:] + case 6: + if info.PayloadLen < minICMPPayloadIPv6 { + return "" + } + // Next Header field in IPv6 header + protocol = info.PayloadData[6] + srcIP = net.IP(info.PayloadData[8:24]) + dstIP = net.IP(info.PayloadData[24:40]) + transportData = info.PayloadData[40:] + default: return "" } - protocol := info.PayloadData[9] - srcIP := net.IP(info.PayloadData[12:16]) - dstIP := net.IP(info.PayloadData[16:20]) - - transportData := info.PayloadData[20:] - switch nftypes.Protocol(protocol) { case nftypes.TCP: srcPort := uint16(transportData[0])<<8 | uint16(transportData[1]) dstPort := uint16(transportData[2])<<8 | uint16(transportData[3]) - return fmt.Sprintf("TCP %s:%d → %s:%d", srcIP, srcPort, dstIP, dstPort) + return "TCP " + net.JoinHostPort(srcIP.String(), strconv.Itoa(int(srcPort))) + " → " + net.JoinHostPort(dstIP.String(), strconv.Itoa(int(dstPort))) case nftypes.UDP: srcPort := uint16(transportData[0])<<8 | uint16(transportData[1]) dstPort := uint16(transportData[2])<<8 | uint16(transportData[3]) - return fmt.Sprintf("UDP %s:%d → %s:%d", srcIP, srcPort, dstIP, dstPort) + return "UDP " + net.JoinHostPort(srcIP.String(), strconv.Itoa(int(srcPort))) + " → " + net.JoinHostPort(dstIP.String(), strconv.Itoa(int(dstPort))) case nftypes.ICMP: icmpType := transportData[0] @@ -135,6 +175,7 @@ func NewICMPTracker(timeout time.Duration, logger *nblog.Logger, flowLogger nfty timeout: timeout, cleanupTicker: time.NewTicker(ICMPCleanupInterval), tickerCancel: cancel, + maxEntries: envInt(logger, EnvICMPMaxEntries, DefaultMaxICMPEntries), flowLogger: flowLogger, } @@ -221,7 +262,9 @@ func (t *ICMPTracker) track( // non echo requests don't need tracking if typ != uint8(layers.ICMPv4TypeEchoRequest) { - t.logger.Trace3("New %s ICMP connection %s - %s", direction, key, icmpInfo) + if t.logger.Enabled(nblog.LevelTrace) { + t.logger.Trace3("New %s ICMP connection %s - %s", direction, key, icmpInfo) + } t.sendStartEvent(direction, srcIP, dstIP, typ, code, ruleId, size) return } @@ -240,16 +283,22 @@ func (t *ICMPTracker) track( conn.UpdateCounters(direction, size) t.mutex.Lock() + if t.maxEntries > 0 && len(t.connections) >= t.maxEntries { + t.evictOneLocked() + } t.connections[key] = conn t.mutex.Unlock() - t.logger.Trace3("New %s ICMP connection %s - %s", direction, key, icmpInfo) + if t.logger.Enabled(nblog.LevelTrace) { + t.logger.Trace3("New %s ICMP connection %s - %s", direction, key, icmpInfo) + } t.sendEvent(nftypes.TypeStart, conn, ruleId) } -// IsValidInbound checks if an inbound ICMP Echo Reply matches a tracked request +// IsValidInbound checks if an inbound ICMP Echo Reply matches a tracked request. +// Accepts both ICMPv4 (type 0) and ICMPv6 (type 129) echo replies. func (t *ICMPTracker) IsValidInbound(srcIP netip.Addr, dstIP netip.Addr, id uint16, icmpType uint8, size int) bool { - if icmpType != uint8(layers.ICMPv4TypeEchoReply) { + if icmpType != uint8(layers.ICMPv4TypeEchoReply) && icmpType != uint8(layers.ICMPv6TypeEchoReply) { return false } @@ -286,6 +335,34 @@ func (t *ICMPTracker) cleanupRoutine(ctx context.Context) { } } +// evictOneLocked removes one entry to make room. Caller must hold t.mutex. +// Bounded sample scan: picks the oldest among up to evictSampleSize entries. +func (t *ICMPTracker) evictOneLocked() { + var candKey ICMPConnKey + var candSeen int64 + haveCand := false + sampled := 0 + + for k, c := range t.connections { + seen := c.lastSeen.Load() + if !haveCand || seen < candSeen { + candKey = k + candSeen = seen + haveCand = true + } + sampled++ + if sampled >= evictSampleSize { + break + } + } + if haveCand { + if evicted := t.connections[candKey]; evicted != nil { + t.sendEvent(nftypes.TypeEnd, evicted, nil) + } + delete(t.connections, candKey) + } +} + func (t *ICMPTracker) cleanup() { t.mutex.Lock() defer t.mutex.Unlock() @@ -294,13 +371,22 @@ func (t *ICMPTracker) cleanup() { if conn.timeoutExceeded(t.timeout) { delete(t.connections, key) - t.logger.Trace5("Removed ICMP connection %s (timeout) [in: %d Pkts/%d B out: %d Pkts/%d B]", - key, conn.PacketsRx.Load(), conn.BytesRx.Load(), conn.PacketsTx.Load(), conn.BytesTx.Load()) + if t.logger.Enabled(nblog.LevelTrace) { + t.logger.Trace5("Removed ICMP connection %s (timeout) [in: %d Pkts/%d B out: %d Pkts/%d B]", + key, conn.PacketsRx.Load(), conn.BytesRx.Load(), conn.PacketsTx.Load(), conn.BytesTx.Load()) + } t.sendEvent(nftypes.TypeEnd, conn, nil) } } } +func icmpProtocolForAddr(ip netip.Addr) nftypes.Protocol { + if ip.Is6() { + return nftypes.ICMPv6 + } + return nftypes.ICMP +} + // Close stops the cleanup routine and releases resources func (t *ICMPTracker) Close() { t.tickerCancel() @@ -316,7 +402,7 @@ func (t *ICMPTracker) sendEvent(typ nftypes.Type, conn *ICMPConnTrack, ruleID [] Type: typ, RuleID: ruleID, Direction: conn.Direction, - Protocol: nftypes.ICMP, // TODO: adjust for IPv6/icmpv6 + Protocol: icmpProtocolForAddr(conn.SourceIP), SourceIP: conn.SourceIP, DestIP: conn.DestIP, ICMPType: conn.ICMPType, @@ -334,7 +420,7 @@ func (t *ICMPTracker) sendStartEvent(direction nftypes.Direction, srcIP netip.Ad Type: nftypes.TypeStart, RuleID: ruleID, Direction: direction, - Protocol: nftypes.ICMP, + Protocol: icmpProtocolForAddr(srcIP), SourceIP: srcIP, DestIP: dstIP, ICMPType: typ, diff --git a/client/firewall/uspfilter/conntrack/icmp_test.go b/client/firewall/uspfilter/conntrack/icmp_test.go index b15b42cf0..6d1f87162 100644 --- a/client/firewall/uspfilter/conntrack/icmp_test.go +++ b/client/firewall/uspfilter/conntrack/icmp_test.go @@ -5,6 +5,42 @@ import ( "testing" ) +func TestICMPConnKey_String(t *testing.T) { + tests := []struct { + name string + key ICMPConnKey + expect string + }{ + { + name: "IPv4", + key: ICMPConnKey{ + SrcIP: netip.MustParseAddr("192.168.1.1"), + DstIP: netip.MustParseAddr("10.0.0.1"), + ID: 1234, + }, + expect: "192.168.1.1 → 10.0.0.1 (id 1234)", + }, + { + name: "IPv6", + key: ICMPConnKey{ + SrcIP: netip.MustParseAddr("2001:db8::1"), + DstIP: netip.MustParseAddr("2001:db8::2"), + ID: 5678, + }, + expect: "2001:db8::1 → 2001:db8::2 (id 5678)", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := tc.key.String() + if got != tc.expect { + t.Errorf("got %q, want %q", got, tc.expect) + } + }) + } +} + func BenchmarkICMPTracker(b *testing.B) { b.Run("TrackOutbound", func(b *testing.B) { tracker := NewICMPTracker(DefaultICMPTimeout, logger, flowLogger) diff --git a/client/firewall/uspfilter/conntrack/tcp.go b/client/firewall/uspfilter/conntrack/tcp.go index 335a3abab..9edc9af22 100644 --- a/client/firewall/uspfilter/conntrack/tcp.go +++ b/client/firewall/uspfilter/conntrack/tcp.go @@ -38,6 +38,27 @@ const ( TCPHandshakeTimeout = 60 * time.Second // TCPCleanupInterval is how often we check for stale connections TCPCleanupInterval = 5 * time.Minute + // FinWaitTimeout bounds FIN_WAIT_1 / FIN_WAIT_2 / CLOSING states. + // Matches Linux netfilter nf_conntrack_tcp_timeout_fin_wait. + FinWaitTimeout = 60 * time.Second + // CloseWaitTimeout bounds CLOSE_WAIT. Matches Linux default; apps + // holding CloseWait longer than this should bump the env var. + CloseWaitTimeout = 60 * time.Second + // LastAckTimeout bounds LAST_ACK. Matches Linux default. + LastAckTimeout = 30 * time.Second +) + +// Env vars to override per-state teardown timeouts. Values parsed by +// time.ParseDuration (e.g. "120s", "2m"). Invalid values fall back to the +// defaults above with a warning. +const ( + EnvTCPFinWaitTimeout = "NB_CONNTRACK_TCP_FIN_WAIT_TIMEOUT" + EnvTCPCloseWaitTimeout = "NB_CONNTRACK_TCP_CLOSE_WAIT_TIMEOUT" + EnvTCPLastAckTimeout = "NB_CONNTRACK_TCP_LAST_ACK_TIMEOUT" + + // EnvTCPMaxEntries caps the TCP conntrack table size. Oldest entries + // (tombstones first) are evicted when the cap is reached. + EnvTCPMaxEntries = "NB_CONNTRACK_TCP_MAX" ) // TCPState represents the state of a TCP connection @@ -133,14 +154,18 @@ func (t *TCPConnTrack) SetTombstone() { // TCPTracker manages TCP connection states type TCPTracker struct { - logger *nblog.Logger - connections map[ConnKey]*TCPConnTrack - mutex sync.RWMutex - cleanupTicker *time.Ticker - tickerCancel context.CancelFunc - timeout time.Duration - waitTimeout time.Duration - flowLogger nftypes.FlowLogger + logger *nblog.Logger + connections map[ConnKey]*TCPConnTrack + mutex sync.RWMutex + cleanupTicker *time.Ticker + tickerCancel context.CancelFunc + timeout time.Duration + waitTimeout time.Duration + finWaitTimeout time.Duration + closeWaitTimeout time.Duration + lastAckTimeout time.Duration + maxEntries int + flowLogger nftypes.FlowLogger } // NewTCPTracker creates a new TCP connection tracker @@ -155,13 +180,17 @@ func NewTCPTracker(timeout time.Duration, logger *nblog.Logger, flowLogger nftyp ctx, cancel := context.WithCancel(context.Background()) tracker := &TCPTracker{ - logger: logger, - connections: make(map[ConnKey]*TCPConnTrack), - cleanupTicker: time.NewTicker(TCPCleanupInterval), - tickerCancel: cancel, - timeout: timeout, - waitTimeout: waitTimeout, - flowLogger: flowLogger, + logger: logger, + connections: make(map[ConnKey]*TCPConnTrack), + cleanupTicker: time.NewTicker(TCPCleanupInterval), + tickerCancel: cancel, + timeout: timeout, + waitTimeout: waitTimeout, + finWaitTimeout: envDuration(logger, EnvTCPFinWaitTimeout, FinWaitTimeout), + closeWaitTimeout: envDuration(logger, EnvTCPCloseWaitTimeout, CloseWaitTimeout), + lastAckTimeout: envDuration(logger, EnvTCPLastAckTimeout, LastAckTimeout), + maxEntries: envInt(logger, EnvTCPMaxEntries, DefaultMaxTCPEntries), + flowLogger: flowLogger, } go tracker.cleanupRoutine(ctx) @@ -209,6 +238,12 @@ func (t *TCPTracker) track(srcIP, dstIP netip.Addr, srcPort, dstPort uint16, fla if exists || flags&TCPSyn == 0 { return } + // Reject illegal SYN combinations (SYN+FIN, SYN+RST, …) so they don't + // create spurious conntrack entries. Not mandated by RFC 9293 but a + // common hardening (Linux netfilter/nftables rejects these too). + if !isValidFlagCombination(flags) { + return + } conn := &TCPConnTrack{ BaseConnTrack: BaseConnTrack{ @@ -225,20 +260,65 @@ func (t *TCPTracker) track(srcIP, dstIP netip.Addr, srcPort, dstPort uint16, fla conn.state.Store(int32(TCPStateNew)) conn.DNATOrigPort.Store(uint32(origPort)) - if origPort != 0 { - t.logger.Trace4("New %s TCP connection: %s (port DNAT %d -> %d)", direction, key, origPort, dstPort) - } else { - t.logger.Trace2("New %s TCP connection: %s", direction, key) + if t.logger.Enabled(nblog.LevelTrace) { + if origPort != 0 { + t.logger.Trace4("New %s TCP connection: %s (port DNAT %d -> %d)", direction, key, origPort, dstPort) + } else { + t.logger.Trace2("New %s TCP connection: %s", direction, key) + } } t.updateState(key, conn, flags, direction, size) t.mutex.Lock() + if t.maxEntries > 0 && len(t.connections) >= t.maxEntries { + t.evictOneLocked() + } t.connections[key] = conn t.mutex.Unlock() t.sendEvent(nftypes.TypeStart, conn, ruleID) } +// evictOneLocked removes one entry to make room. Caller must hold t.mutex. +// Bounded scan: samples up to evictSampleSize pseudo-random entries (Go map +// iteration order is randomized), preferring a tombstone. If no tombstone +// found in the sample, evicts the oldest among the sampled entries. O(1) +// worst case — cheap enough to run on every insert at cap during abuse. +func (t *TCPTracker) evictOneLocked() { + var candKey ConnKey + var candSeen int64 + haveCand := false + sampled := 0 + + for k, c := range t.connections { + if c.IsTombstone() { + delete(t.connections, k) + return + } + seen := c.lastSeen.Load() + if !haveCand || seen < candSeen { + candKey = k + candSeen = seen + haveCand = true + } + sampled++ + if sampled >= evictSampleSize { + break + } + } + if haveCand { + if evicted := t.connections[candKey]; evicted != nil { + // TypeEnd is already emitted at the state transition to + // TimeWait and when a connection is tombstoned. Only emit + // here when we're reaping a still-active flow. + if evicted.GetState() != TCPStateTimeWait && !evicted.IsTombstone() { + t.sendEvent(nftypes.TypeEnd, evicted, nil) + } + } + delete(t.connections, candKey) + } +} + // IsValidInbound checks if an inbound TCP packet matches a tracked connection func (t *TCPTracker) IsValidInbound(srcIP, dstIP netip.Addr, srcPort, dstPort uint16, flags uint8, size int) bool { key := ConnKey{ @@ -256,12 +336,19 @@ func (t *TCPTracker) IsValidInbound(srcIP, dstIP netip.Addr, srcPort, dstPort ui return false } + // Reject illegal flag combinations regardless of state. These never belong + // to a legitimate flow and must not advance or tear down state. + if !isValidFlagCombination(flags) { + if t.logger.Enabled(nblog.LevelWarn) { + t.logger.Warn3("TCP illegal flag combination %x for connection %s (state %s)", flags, key, conn.GetState()) + } + return false + } + currentState := conn.GetState() if !t.isValidStateForFlags(currentState, flags) { - t.logger.Warn3("TCP state %s is not valid with flags %x for connection %s", currentState, flags, key) - // allow all flags for established for now - if currentState == TCPStateEstablished { - return true + if t.logger.Enabled(nblog.LevelWarn) { + t.logger.Warn3("TCP state %s is not valid with flags %x for connection %s", currentState, flags, key) } return false } @@ -270,116 +357,208 @@ func (t *TCPTracker) IsValidInbound(srcIP, dstIP netip.Addr, srcPort, dstPort ui return true } -// updateState updates the TCP connection state based on flags +// updateState updates the TCP connection state based on flags. func (t *TCPTracker) updateState(key ConnKey, conn *TCPConnTrack, flags uint8, packetDir nftypes.Direction, size int) { - conn.UpdateLastSeen() conn.UpdateCounters(packetDir, size) + // Malformed flag combinations must not refresh lastSeen or drive state, + // otherwise spoofed packets keep a dead flow alive past its timeout. + if !isValidFlagCombination(flags) { + return + } + + conn.UpdateLastSeen() + currentState := conn.GetState() if flags&TCPRst != 0 { - if conn.CompareAndSwapState(currentState, TCPStateClosed) { - conn.SetTombstone() - t.logger.Trace6("TCP connection reset: %s (dir: %s) [in: %d Pkts/%d B, out: %d Pkts/%d B]", - key, packetDir, conn.PacketsRx.Load(), conn.BytesRx.Load(), conn.PacketsTx.Load(), conn.BytesTx.Load()) - t.sendEvent(nftypes.TypeEnd, conn, nil) - } + // Hardening beyond RFC 9293 §3.10.7.4: without sequence tracking we + // cannot apply the RFC 5961 in-window RST check, so we conservatively + // reject RSTs that the spec would accept (TIME-WAIT with in-window + // SEQ, SynSent from same direction as own SYN, etc.). + t.handleRst(key, conn, currentState, packetDir) return } - var newState TCPState - switch currentState { - case TCPStateNew: - if flags&TCPSyn != 0 && flags&TCPAck == 0 { - if conn.Direction == nftypes.Egress { - newState = TCPStateSynSent - } else { - newState = TCPStateSynReceived - } - } + newState := nextState(currentState, conn.Direction, packetDir, flags) + if newState == 0 || !conn.CompareAndSwapState(currentState, newState) { + return + } + t.onTransition(key, conn, currentState, newState, packetDir) +} - case TCPStateSynSent: - if flags&TCPSyn != 0 && flags&TCPAck != 0 { - if packetDir != conn.Direction { - newState = TCPStateEstablished - } else { - // Simultaneous open - newState = TCPStateSynReceived - } - } +// handleRst processes a RST segment. Late RSTs in TimeWait and spoofed RSTs +// from the SYN direction are ignored; otherwise the flow is tombstoned. +func (t *TCPTracker) handleRst(key ConnKey, conn *TCPConnTrack, currentState TCPState, packetDir nftypes.Direction) { + // TimeWait exists to absorb late segments; don't let a late RST + // tombstone the entry and break same-4-tuple reuse. + if currentState == TCPStateTimeWait { + return + } + // A RST from the same direction as the SYN cannot be a legitimate + // response and must not tear down a half-open connection. + if currentState == TCPStateSynSent && packetDir == conn.Direction { + return + } + if !conn.CompareAndSwapState(currentState, TCPStateClosed) { + return + } + conn.SetTombstone() + if t.logger.Enabled(nblog.LevelTrace) { + t.logger.Trace6("TCP connection reset: %s (dir: %s) [in: %d Pkts/%d B, out: %d Pkts/%d B]", + key, packetDir, conn.PacketsRx.Load(), conn.BytesRx.Load(), conn.PacketsTx.Load(), conn.BytesTx.Load()) + } + t.sendEvent(nftypes.TypeEnd, conn, nil) +} - case TCPStateSynReceived: - if flags&TCPAck != 0 && flags&TCPSyn == 0 { - if packetDir == conn.Direction { - newState = TCPStateEstablished - } - } +// stateTransition describes one state's transition logic. It receives the +// packet's flags plus whether the packet direction matches the connection's +// origin direction (same=true means same side as the SYN initiator). Return 0 +// for no transition. +type stateTransition func(flags uint8, connDir nftypes.Direction, same bool) TCPState - case TCPStateEstablished: - if flags&TCPFin != 0 { - if packetDir == conn.Direction { - newState = TCPStateFinWait1 - } else { - newState = TCPStateCloseWait - } - } +// stateTable maps each state to its transition function. Centralized here so +// nextState stays trivial and each rule is easy to read in isolation. +var stateTable = map[TCPState]stateTransition{ + TCPStateNew: transNew, + TCPStateSynSent: transSynSent, + TCPStateSynReceived: transSynReceived, + TCPStateEstablished: transEstablished, + TCPStateFinWait1: transFinWait1, + TCPStateFinWait2: transFinWait2, + TCPStateClosing: transClosing, + TCPStateCloseWait: transCloseWait, + TCPStateLastAck: transLastAck, +} - case TCPStateFinWait1: - if packetDir != conn.Direction { - switch { - case flags&TCPFin != 0 && flags&TCPAck != 0: - newState = TCPStateClosing - case flags&TCPFin != 0: - newState = TCPStateClosing - case flags&TCPAck != 0: - newState = TCPStateFinWait2 - } - } +// nextState returns the target TCP state for the given current state and +// packet, or 0 if the packet does not trigger a transition. +func nextState(currentState TCPState, connDir, packetDir nftypes.Direction, flags uint8) TCPState { + fn, ok := stateTable[currentState] + if !ok { + return 0 + } + return fn(flags, connDir, packetDir == connDir) +} - case TCPStateFinWait2: - if flags&TCPFin != 0 { - newState = TCPStateTimeWait +func transNew(flags uint8, connDir nftypes.Direction, _ bool) TCPState { + if flags&TCPSyn != 0 && flags&TCPAck == 0 { + if connDir == nftypes.Egress { + return TCPStateSynSent } + return TCPStateSynReceived + } + return 0 +} - case TCPStateClosing: - if flags&TCPAck != 0 { - newState = TCPStateTimeWait +func transSynSent(flags uint8, _ nftypes.Direction, same bool) TCPState { + if flags&TCPSyn != 0 && flags&TCPAck != 0 { + if same { + return TCPStateSynReceived // simultaneous open } + return TCPStateEstablished + } + return 0 +} - case TCPStateCloseWait: - if flags&TCPFin != 0 { - newState = TCPStateLastAck - } +func transSynReceived(flags uint8, _ nftypes.Direction, same bool) TCPState { + if flags&TCPAck != 0 && flags&TCPSyn == 0 && same { + return TCPStateEstablished + } + return 0 +} - case TCPStateLastAck: - if flags&TCPAck != 0 { - newState = TCPStateClosed - } +func transEstablished(flags uint8, _ nftypes.Direction, same bool) TCPState { + if flags&TCPFin == 0 { + return 0 + } + if same { + return TCPStateFinWait1 + } + return TCPStateCloseWait +} + +// transFinWait1 handles the active-close peer response. A FIN carrying our +// ACK piggybacked goes straight to TIME-WAIT (RFC 9293 §3.10.7.4, FIN-WAIT-1: +// "if our FIN has been ACKed... enter the TIME-WAIT state"); a lone FIN moves +// to CLOSING; a pure ACK of our FIN moves to FIN-WAIT-2. +func transFinWait1(flags uint8, _ nftypes.Direction, same bool) TCPState { + if same { + return 0 + } + if flags&TCPFin != 0 && flags&TCPAck != 0 { + return TCPStateTimeWait + } + switch { + case flags&TCPFin != 0: + return TCPStateClosing + case flags&TCPAck != 0: + return TCPStateFinWait2 + } + return 0 +} + +// transFinWait2 ignores own-side FIN retransmits; only the peer's FIN advances. +func transFinWait2(flags uint8, _ nftypes.Direction, same bool) TCPState { + if flags&TCPFin != 0 && !same { + return TCPStateTimeWait + } + return 0 +} + +// transClosing completes a simultaneous close on the peer's ACK. +func transClosing(flags uint8, _ nftypes.Direction, same bool) TCPState { + if flags&TCPAck != 0 && !same { + return TCPStateTimeWait + } + return 0 +} + +// transCloseWait only advances to LastAck when WE send FIN, ignoring peer retransmits. +func transCloseWait(flags uint8, _ nftypes.Direction, same bool) TCPState { + if flags&TCPFin != 0 && same { + return TCPStateLastAck + } + return 0 +} + +// transLastAck closes the flow only on the peer's ACK (not our own ACK retransmits). +func transLastAck(flags uint8, _ nftypes.Direction, same bool) TCPState { + if flags&TCPAck != 0 && !same { + return TCPStateClosed + } + return 0 +} + +// onTransition handles logging and flow-event emission after a successful +// state transition. TimeWait and Closed are terminal for flow accounting. +func (t *TCPTracker) onTransition(key ConnKey, conn *TCPConnTrack, from, to TCPState, packetDir nftypes.Direction) { + traceOn := t.logger.Enabled(nblog.LevelTrace) + if traceOn { + t.logger.Trace4("TCP connection %s transitioned from %s to %s (dir: %s)", key, from, to, packetDir) } - if newState != 0 && conn.CompareAndSwapState(currentState, newState) { - t.logger.Trace4("TCP connection %s transitioned from %s to %s (dir: %s)", key, currentState, newState, packetDir) - - switch newState { - case TCPStateTimeWait: + switch to { + case TCPStateTimeWait: + if traceOn { t.logger.Trace5("TCP connection %s completed [in: %d Pkts/%d B, out: %d Pkts/%d B]", key, conn.PacketsRx.Load(), conn.BytesRx.Load(), conn.PacketsTx.Load(), conn.BytesTx.Load()) - t.sendEvent(nftypes.TypeEnd, conn, nil) - - case TCPStateClosed: - conn.SetTombstone() + } + t.sendEvent(nftypes.TypeEnd, conn, nil) + case TCPStateClosed: + conn.SetTombstone() + if traceOn { t.logger.Trace5("TCP connection %s closed gracefully [in: %d Pkts/%d, B out: %d Pkts/%d B]", key, conn.PacketsRx.Load(), conn.BytesRx.Load(), conn.PacketsTx.Load(), conn.BytesTx.Load()) - t.sendEvent(nftypes.TypeEnd, conn, nil) } + t.sendEvent(nftypes.TypeEnd, conn, nil) } } -// isValidStateForFlags checks if the TCP flags are valid for the current connection state +// isValidStateForFlags checks if the TCP flags are valid for the current +// connection state. Caller must have already verified the flag combination is +// legal via isValidFlagCombination. func (t *TCPTracker) isValidStateForFlags(state TCPState, flags uint8) bool { - if !isValidFlagCombination(flags) { - return false - } if flags&TCPRst != 0 { if state == TCPStateSynSent { return flags&TCPAck != 0 @@ -449,15 +628,24 @@ func (t *TCPTracker) cleanup() { timeout = t.waitTimeout case TCPStateEstablished: timeout = t.timeout + case TCPStateFinWait1, TCPStateFinWait2, TCPStateClosing: + timeout = t.finWaitTimeout + case TCPStateCloseWait: + timeout = t.closeWaitTimeout + case TCPStateLastAck: + timeout = t.lastAckTimeout default: + // SynSent / SynReceived / New timeout = TCPHandshakeTimeout } if conn.timeoutExceeded(timeout) { delete(t.connections, key) - t.logger.Trace6("Cleaned up timed-out TCP connection %s (%s) [in: %d Pkts/%d, B out: %d Pkts/%d B]", - key, conn.GetState(), conn.PacketsRx.Load(), conn.BytesRx.Load(), conn.PacketsTx.Load(), conn.BytesTx.Load()) + if t.logger.Enabled(nblog.LevelTrace) { + t.logger.Trace6("Cleaned up timed-out TCP connection %s (%s) [in: %d Pkts/%d, B out: %d Pkts/%d B]", + key, conn.GetState(), conn.PacketsRx.Load(), conn.BytesRx.Load(), conn.PacketsTx.Load(), conn.BytesTx.Load()) + } // event already handled by state change if currentState != TCPStateTimeWait { diff --git a/client/firewall/uspfilter/conntrack/tcp_rst_bugs_test.go b/client/firewall/uspfilter/conntrack/tcp_rst_bugs_test.go new file mode 100644 index 000000000..81d4f5710 --- /dev/null +++ b/client/firewall/uspfilter/conntrack/tcp_rst_bugs_test.go @@ -0,0 +1,100 @@ +package conntrack + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/require" +) + +// RST hygiene tests: the tracker currently closes the flow on any RST that +// matches the 4-tuple, regardless of direction or state. These tests cover +// the minimum checks we want (no SEQ tracking). + +func TestTCPRstInSynSentWrongDirection(t *testing.T) { + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + srcIP := netip.MustParseAddr("100.64.0.1") + dstIP := netip.MustParseAddr("100.64.0.2") + srcPort := uint16(12345) + dstPort := uint16(80) + key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort} + + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPSyn, 0) + conn := tracker.connections[key] + require.Equal(t, TCPStateSynSent, conn.GetState()) + + // A RST arriving in the same direction as the SYN (i.e. TrackOutbound) + // cannot be a legitimate response. It must not close the connection. + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPRst|TCPAck, 0) + require.Equal(t, TCPStateSynSent, conn.GetState(), + "RST in same direction as SYN must not close connection") + require.False(t, conn.IsTombstone()) +} + +func TestTCPRstInTimeWaitIgnored(t *testing.T) { + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + srcIP := netip.MustParseAddr("100.64.0.1") + dstIP := netip.MustParseAddr("100.64.0.2") + srcPort := uint16(12345) + dstPort := uint16(80) + key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort} + + // Drive to TIME-WAIT via active close. + establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort) + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0) + require.True(t, tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPAck, 0)) + require.True(t, 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()) + require.False(t, conn.IsTombstone(), "TIME-WAIT must not be tombstoned") + + // Late RST during TIME-WAIT must not tombstone the entry (TIME-WAIT + // exists to absorb late segments). + tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPRst, 0) + require.Equal(t, TCPStateTimeWait, conn.GetState(), + "RST in TIME-WAIT must not transition state") + require.False(t, conn.IsTombstone(), + "RST in TIME-WAIT must not tombstone the entry") +} + +func TestTCPIllegalFlagCombos(t *testing.T) { + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + srcIP := netip.MustParseAddr("100.64.0.1") + dstIP := netip.MustParseAddr("100.64.0.2") + srcPort := uint16(12345) + dstPort := uint16(80) + key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort} + + establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort) + conn := tracker.connections[key] + + // Illegal combos must be rejected and must not change state. + combos := []struct { + name string + flags uint8 + }{ + {"SYN+RST", TCPSyn | TCPRst}, + {"FIN+RST", TCPFin | TCPRst}, + {"SYN+FIN", TCPSyn | TCPFin}, + {"SYN+FIN+RST", TCPSyn | TCPFin | TCPRst}, + } + + for _, c := range combos { + t.Run(c.name, func(t *testing.T) { + before := conn.GetState() + valid := tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, c.flags, 0) + require.False(t, valid, "illegal flag combo must be rejected: %s", c.name) + require.Equal(t, before, conn.GetState(), + "illegal flag combo must not change state") + require.False(t, conn.IsTombstone()) + }) + } +} diff --git a/client/firewall/uspfilter/conntrack/tcp_state_bugs_test.go b/client/firewall/uspfilter/conntrack/tcp_state_bugs_test.go new file mode 100644 index 000000000..32112cd58 --- /dev/null +++ b/client/firewall/uspfilter/conntrack/tcp_state_bugs_test.go @@ -0,0 +1,235 @@ +package conntrack + +import ( + "net/netip" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// These tests exercise cases where the TCP state machine currently advances +// on retransmitted or wrong-direction segments and tears the flow down +// prematurely. They are expected to fail until the direction checks are added. + +func TestTCPCloseWaitRetransmittedPeerFIN(t *testing.T) { + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + srcIP := netip.MustParseAddr("100.64.0.1") + dstIP := netip.MustParseAddr("100.64.0.2") + srcPort := uint16(12345) + dstPort := uint16(80) + key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort} + + establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort) + + // Peer sends FIN -> CloseWait (our app has not yet closed). + valid := tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0) + require.True(t, valid) + conn := tracker.connections[key] + require.Equal(t, TCPStateCloseWait, conn.GetState()) + + // Peer retransmits their FIN (ACK may have been delayed). We have NOT + // sent our FIN yet, so state must remain CloseWait. + valid = tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0) + require.True(t, valid, "retransmitted peer FIN must still be accepted") + require.Equal(t, TCPStateCloseWait, conn.GetState(), + "retransmitted peer FIN must not advance CloseWait to LastAck") + + // Our app finally closes -> LastAck. + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0) + require.Equal(t, TCPStateLastAck, conn.GetState()) + + // Peer ACK closes. + valid = tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPAck, 0) + require.True(t, valid) + require.Equal(t, TCPStateClosed, conn.GetState()) +} + +func TestTCPFinWait2RetransmittedOwnFIN(t *testing.T) { + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + srcIP := netip.MustParseAddr("100.64.0.1") + dstIP := netip.MustParseAddr("100.64.0.2") + srcPort := uint16(12345) + dstPort := uint16(80) + key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort} + + establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort) + + // We initiate close. + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0) + valid := tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPAck, 0) + require.True(t, valid) + conn := tracker.connections[key] + require.Equal(t, TCPStateFinWait2, conn.GetState()) + + // Stray retransmit of our own FIN (same direction as originator) must + // NOT advance FinWait2 to TimeWait; only the peer's FIN should. + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0) + require.Equal(t, TCPStateFinWait2, conn.GetState(), + "own FIN retransmit must not advance FinWait2 to TimeWait") + + // Peer FIN -> TimeWait. + valid = tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0) + require.True(t, valid) + require.Equal(t, TCPStateTimeWait, conn.GetState()) +} + +func TestTCPLastAckDirectionCheck(t *testing.T) { + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + srcIP := netip.MustParseAddr("100.64.0.1") + dstIP := netip.MustParseAddr("100.64.0.2") + srcPort := uint16(12345) + dstPort := uint16(80) + key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort} + + establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort) + + // Drive to LastAck: peer FIN -> CloseWait, our FIN -> LastAck. + require.True(t, tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0)) + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0) + conn := tracker.connections[key] + require.Equal(t, TCPStateLastAck, conn.GetState()) + + // Our own ACK retransmit (same direction as originator) must NOT close. + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPAck, 0) + require.Equal(t, TCPStateLastAck, conn.GetState(), + "own ACK retransmit in LastAck must not transition to Closed") + + // Peer's ACK -> Closed. + require.True(t, tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPAck, 0)) + require.Equal(t, TCPStateClosed, conn.GetState()) +} + +func TestTCPFinWait1OwnAckDoesNotAdvance(t *testing.T) { + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + srcIP := netip.MustParseAddr("100.64.0.1") + dstIP := netip.MustParseAddr("100.64.0.2") + srcPort := uint16(12345) + dstPort := uint16(80) + key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort} + + establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort) + + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0) + conn := tracker.connections[key] + require.Equal(t, TCPStateFinWait1, conn.GetState()) + + // Our own ACK retransmit (same direction as originator) must not advance. + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPAck, 0) + require.Equal(t, TCPStateFinWait1, conn.GetState(), + "own ACK in FinWait1 must not advance to FinWait2") +} + +func TestTCPPerStateTeardownTimeouts(t *testing.T) { + // Verify cleanup reaps entries in each teardown state at the configured + // per-state timeout, not at the single handshake timeout. + t.Setenv(EnvTCPFinWaitTimeout, "50ms") + t.Setenv(EnvTCPCloseWaitTimeout, "80ms") + t.Setenv(EnvTCPLastAckTimeout, "30ms") + + dstIP := netip.MustParseAddr("100.64.0.2") + dstPort := uint16(80) + + // Drives a connection to the target state, forces its lastSeen well + // beyond the configured timeout, runs cleanup, and asserts reaping. + cases := []struct { + name string + // drive takes a fresh tracker and returns the conn key after + // transitioning the flow into the intended teardown state. + drive func(t *testing.T, tr *TCPTracker, srcIP netip.Addr, srcPort uint16) (ConnKey, TCPState) + }{ + { + name: "FinWait1", + drive: func(t *testing.T, tr *TCPTracker, srcIP netip.Addr, srcPort uint16) (ConnKey, TCPState) { + establishConnection(t, tr, srcIP, dstIP, srcPort, dstPort) + tr.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0) // → FinWait1 + return ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort}, TCPStateFinWait1 + }, + }, + { + name: "FinWait2", + drive: func(t *testing.T, tr *TCPTracker, srcIP netip.Addr, srcPort uint16) (ConnKey, TCPState) { + establishConnection(t, tr, srcIP, dstIP, srcPort, dstPort) + tr.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0) // FinWait1 + require.True(t, tr.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPAck, 0)) // → FinWait2 + return ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort}, TCPStateFinWait2 + }, + }, + { + name: "CloseWait", + drive: func(t *testing.T, tr *TCPTracker, srcIP netip.Addr, srcPort uint16) (ConnKey, TCPState) { + establishConnection(t, tr, srcIP, dstIP, srcPort, dstPort) + require.True(t, tr.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0)) // → CloseWait + return ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort}, TCPStateCloseWait + }, + }, + { + name: "LastAck", + drive: func(t *testing.T, tr *TCPTracker, srcIP netip.Addr, srcPort uint16) (ConnKey, TCPState) { + establishConnection(t, tr, srcIP, dstIP, srcPort, dstPort) + require.True(t, tr.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0)) // CloseWait + tr.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0) // → LastAck + return ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort}, TCPStateLastAck + }, + }, + } + + // Use a unique source port per subtest so nothing aliases. + port := uint16(12345) + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + require.Equal(t, 50*time.Millisecond, tracker.finWaitTimeout) + require.Equal(t, 80*time.Millisecond, tracker.closeWaitTimeout) + require.Equal(t, 30*time.Millisecond, tracker.lastAckTimeout) + + srcIP := netip.MustParseAddr("100.64.0.1") + port++ + key, wantState := c.drive(t, tracker, srcIP, port) + conn := tracker.connections[key] + require.NotNil(t, conn) + require.Equal(t, wantState, conn.GetState()) + + // Age the entry past the largest per-state timeout. + conn.lastSeen.Store(time.Now().Add(-500 * time.Millisecond).UnixNano()) + tracker.cleanup() + _, exists := tracker.connections[key] + require.False(t, exists, "%s entry should be reaped", c.name) + }) + } +} + +func TestTCPEstablishedPSHACKInFinStates(t *testing.T) { + // Verifies FIN|PSH|ACK and bare ACK keepalives are not dropped in FIN + // teardown states, which some stacks emit during close. + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + srcIP := netip.MustParseAddr("100.64.0.1") + dstIP := netip.MustParseAddr("100.64.0.2") + srcPort := uint16(12345) + dstPort := uint16(80) + + establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort) + + // Peer FIN -> CloseWait. + require.True(t, tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0)) + + // Peer pushes trailing data + FIN|PSH|ACK (legal). + require.True(t, tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPPush|TCPAck, 100), + "FIN|PSH|ACK in CloseWait must be accepted") + + // Bare ACK keepalive from peer in CloseWait must be accepted. + require.True(t, tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPAck, 0), + "bare ACK in CloseWait must be accepted") +} diff --git a/client/firewall/uspfilter/conntrack/udp.go b/client/firewall/uspfilter/conntrack/udp.go index a3b6a418b..335c5832a 100644 --- a/client/firewall/uspfilter/conntrack/udp.go +++ b/client/firewall/uspfilter/conntrack/udp.go @@ -17,6 +17,9 @@ const ( DefaultUDPTimeout = 30 * time.Second // UDPCleanupInterval is how often we check for stale connections UDPCleanupInterval = 15 * time.Second + + // EnvUDPMaxEntries caps the UDP conntrack table size. + EnvUDPMaxEntries = "NB_CONNTRACK_UDP_MAX" ) // UDPConnTrack represents a UDP connection state @@ -34,6 +37,7 @@ type UDPTracker struct { cleanupTicker *time.Ticker tickerCancel context.CancelFunc mutex sync.RWMutex + maxEntries int flowLogger nftypes.FlowLogger } @@ -51,6 +55,7 @@ func NewUDPTracker(timeout time.Duration, logger *nblog.Logger, flowLogger nftyp timeout: timeout, cleanupTicker: time.NewTicker(UDPCleanupInterval), tickerCancel: cancel, + maxEntries: envInt(logger, EnvUDPMaxEntries, DefaultMaxUDPEntries), flowLogger: flowLogger, } @@ -117,13 +122,18 @@ func (t *UDPTracker) track(srcIP netip.Addr, dstIP netip.Addr, srcPort uint16, d conn.UpdateCounters(direction, size) t.mutex.Lock() + if t.maxEntries > 0 && len(t.connections) >= t.maxEntries { + t.evictOneLocked() + } t.connections[key] = conn t.mutex.Unlock() - if origPort != 0 { - t.logger.Trace4("New %s UDP connection: %s (port DNAT %d -> %d)", direction, key, origPort, dstPort) - } else { - t.logger.Trace2("New %s UDP connection: %s", direction, key) + if t.logger.Enabled(nblog.LevelTrace) { + if origPort != 0 { + t.logger.Trace4("New %s UDP connection: %s (port DNAT %d -> %d)", direction, key, origPort, dstPort) + } else { + t.logger.Trace2("New %s UDP connection: %s", direction, key) + } } t.sendEvent(nftypes.TypeStart, conn, ruleID) } @@ -151,6 +161,34 @@ func (t *UDPTracker) IsValidInbound(srcIP netip.Addr, dstIP netip.Addr, srcPort return true } +// evictOneLocked removes one entry to make room. Caller must hold t.mutex. +// Bounded sample: picks the oldest among up to evictSampleSize entries. +func (t *UDPTracker) evictOneLocked() { + var candKey ConnKey + var candSeen int64 + haveCand := false + sampled := 0 + + for k, c := range t.connections { + seen := c.lastSeen.Load() + if !haveCand || seen < candSeen { + candKey = k + candSeen = seen + haveCand = true + } + sampled++ + if sampled >= evictSampleSize { + break + } + } + if haveCand { + if evicted := t.connections[candKey]; evicted != nil { + t.sendEvent(nftypes.TypeEnd, evicted, nil) + } + delete(t.connections, candKey) + } +} + // cleanupRoutine periodically removes stale connections func (t *UDPTracker) cleanupRoutine(ctx context.Context) { defer t.cleanupTicker.Stop() @@ -173,8 +211,10 @@ func (t *UDPTracker) cleanup() { if conn.timeoutExceeded(t.timeout) { delete(t.connections, key) - t.logger.Trace5("Removed UDP connection %s (timeout) [in: %d Pkts/%d B, out: %d Pkts/%d B]", - key, conn.PacketsRx.Load(), conn.BytesRx.Load(), conn.PacketsTx.Load(), conn.BytesTx.Load()) + if t.logger.Enabled(nblog.LevelTrace) { + t.logger.Trace5("Removed UDP connection %s (timeout) [in: %d Pkts/%d B, out: %d Pkts/%d B]", + key, conn.PacketsRx.Load(), conn.BytesRx.Load(), conn.PacketsTx.Load(), conn.BytesTx.Load()) + } t.sendEvent(nftypes.TypeEnd, conn, nil) } } diff --git a/client/firewall/uspfilter/filter.go b/client/firewall/uspfilter/filter.go index 24b3d0167..5e1366c1f 100644 --- a/client/firewall/uspfilter/filter.go +++ b/client/firewall/uspfilter/filter.go @@ -5,7 +5,6 @@ import ( "encoding/binary" "errors" "fmt" - "net" "net/netip" "os" "slices" @@ -18,15 +17,20 @@ import ( "github.com/google/gopacket" "github.com/google/gopacket/layers" "github.com/google/uuid" + "github.com/hashicorp/go-multierror" log "github.com/sirupsen/logrus" - "golang.org/x/exp/maps" + wgdevice "golang.zx2c4.com/wireguard/device" + nberrors "github.com/netbirdio/netbird/client/errors" + "github.com/netbirdio/netbird/client/firewall/firewalld" firewall "github.com/netbirdio/netbird/client/firewall/manager" "github.com/netbirdio/netbird/client/firewall/uspfilter/common" "github.com/netbirdio/netbird/client/firewall/uspfilter/conntrack" "github.com/netbirdio/netbird/client/firewall/uspfilter/forwarder" nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log" + "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/netstack" + "github.com/netbirdio/netbird/client/iface/wgaddr" nbid "github.com/netbirdio/netbird/client/internal/acl/id" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" "github.com/netbirdio/netbird/client/internal/statemanager" @@ -35,8 +39,10 @@ import ( const ( layerTypeAll = 255 - // ipTCPHeaderMinSize represents minimum IP (20) + TCP (20) header size for MSS calculation - ipTCPHeaderMinSize = 40 + // ipv4TCPHeaderMinSize represents minimum IPv4 (20) + TCP (20) header size for MSS calculation + ipv4TCPHeaderMinSize = 40 + // ipv6TCPHeaderMinSize represents minimum IPv6 (40) + TCP (20) header size for MSS calculation + ipv6TCPHeaderMinSize = 60 ) // serviceKey represents a protocol/port combination for netstack service registry @@ -55,7 +61,10 @@ const ( // EnvDisableMSSClamping disables TCP MSS clamping for forwarded traffic. EnvDisableMSSClamping = "NB_DISABLE_MSS_CLAMPING" - // EnvForceUserspaceRouter forces userspace routing even if native routing is available. + // EnvForceUserspaceRouter is a deprecated alias for + // NB_FORCE_USERSPACE_FIREWALL: the userspace firewall always routes in + // userspace, so forcing one forces the other. Kept for backward + // compatibility. EnvForceUserspaceRouter = "NB_FORCE_USERSPACE_ROUTER" // EnvEnableLocalForwarding enables forwarding of local traffic to the native stack for internal (non-NetBird) interfaces. @@ -67,14 +76,20 @@ const ( EnvEnableNetstackLocalForwarding = "NB_ENABLE_NETSTACK_LOCAL_FORWARDING" ) -var errNatNotSupported = errors.New("nat not supported with userspace firewall") +// errNotSupported is returned by firewall operations that only make sense with +// a kernel firewall (kernel NAT/DNAT, eBPF) and are not implemented in +// userspace mode, where they should not be called. +var errNotSupported = errors.New("not supported with userspace firewall") -// RuleSet is a set of rules grouped by a string key -type RuleSet map[string]PeerRule +// peerRules is the canonical list-based storage for peer ACL rules. +// Drop and accept rules live in separate slices; drop-before-accept +// ordering comes from consulting the deny slice (and its index) before +// the accept one. +type peerRules []*PeerRule -type RouteRules []*RouteRule +type routeRules []*RouteRule -func (r RouteRules) Sort() { +func (r routeRules) Sort() { slices.SortStableFunc(r, func(a, b *RouteRule) int { // Deny rules come first if a.action == firewall.ActionDrop && b.action != firewall.ActionDrop { @@ -83,22 +98,74 @@ func (r RouteRules) Sort() { if a.action != firewall.ActionDrop && b.action == firewall.ActionDrop { return 1 } - return strings.Compare(a.id, b.id) + return strings.Compare(string(a.id), string(b.id)) }) } +// peerRuleSpec carries the parameters that define a peer filter rule, +// threaded together through the build path so the builders take a single +// argument instead of a long parameter list. +type peerRuleSpec struct { + mgmtID []byte + sources []netip.Prefix + ipLayer gopacket.LayerType + proto firewall.Protocol + sPort *firewall.Port + dPort *firewall.Port + action firewall.Action +} + +// Iface is the network interface the userspace firewall attaches to: the +// methods of the WireGuard device it actually uses. +type Iface interface { + Name() string + Address() wgaddr.Address + SetFilter(device.PacketFilter) error + GetWGDevice() *wgdevice.Device +} + +// InterfaceAllower opens the NetBird interface in the host firewall so it +// doesn't drop traffic the userspace firewall handles, without taking over +// packet filtering. Implementations (nftables, iptables, firewalld, the windows +// netsh rule) are selected per platform and injected into Create; Apply runs at +// creation and Close on teardown. +type InterfaceAllower interface { + Apply() error + Close() error +} + +// Config holds the dependencies and options for the userspace firewall. +type Config struct { + // IFace is the overlay interface the filter attaches to. + IFace Iface + // InterfaceAllower opens the NetBird interface in foreign kernel filter + // chains so the kernel doesn't drop traffic the userspace firewall handles. + // Nil in netstack mode, on non-Linux platforms without a backend, or when + // neither nftables nor iptables is available. firewalld trust is applied by + // the manager regardless, since firewalld owns its own chains and we cannot + // insert into them. + InterfaceAllower InterfaceAllower + // DisableServerRoutes indicates whether server routes are disabled. + DisableServerRoutes bool + FlowLogger nftypes.FlowLogger + MTU uint16 +} + // Manager userspace firewall manager type Manager struct { - outgoingRules map[netip.Addr]RuleSet - incomingDenyRules map[netip.Addr]RuleSet - incomingRules map[netip.Addr]RuleSet - routeRules RouteRules - routeRulesMap map[nbid.RuleID]*RouteRule - decoders sync.Pool - wgIface common.IFaceMapper - nativeFirewall firewall.Manager + decoders sync.Pool + wgIface Iface + ifaceAllower InterfaceAllower + mutex sync.RWMutex - mutex sync.RWMutex + incomingDenyRules peerRules + incomingAcceptRules peerRules + incomingDenyIndex peerRuleIndex + incomingAcceptIndex peerRuleIndex + peerRulesMap map[nbid.RuleID]*PeerRule + + routeRules routeRules + routeRulesMap map[nbid.RuleID]*RouteRule // indicates whether server routes are disabled disableServerRoutes bool @@ -115,14 +182,16 @@ type Manager struct { localipmanager *localIPManager - udpTracker *conntrack.UDPTracker - icmpTracker *conntrack.ICMPTracker - tcpTracker *conntrack.TCPTracker - forwarder atomic.Pointer[forwarder.Forwarder] - logger *nblog.Logger - flowLogger nftypes.FlowLogger + udpTracker *conntrack.UDPTracker + icmpTracker *conntrack.ICMPTracker + tcpTracker *conntrack.TCPTracker + fragments *fragmentTracker + forwarder atomic.Pointer[forwarder.Forwarder] + pendingCapture atomic.Pointer[forwarder.PacketCapture] + logger *nblog.Logger + flowLogger nftypes.FlowLogger - blockRule firewall.Rule + blockRules []firewall.Rule // Internal 1:1 DNAT dnatEnabled atomic.Bool @@ -137,9 +206,10 @@ type Manager struct { netstackServices map[serviceKey]struct{} netstackServiceMutex sync.RWMutex - mtu uint16 - mssClampValue uint16 - mssClampEnabled bool + mtu uint16 + mssClampValueIPv4 uint16 + mssClampValueIPv6 uint16 + mssClampEnabled bool // Only one hook per protocol is supported. Outbound direction only. udpHookOut atomic.Pointer[common.PacketHook] @@ -156,27 +226,61 @@ type decoder struct { icmp4 layers.ICMPv4 icmp6 layers.ICMPv6 decoded []gopacket.LayerType - parser *gopacket.DecodingLayerParser + parser4 *gopacket.DecodingLayerParser + parser6 *gopacket.DecodingLayerParser dnatOrigPort uint16 } -// Create userspace firewall manager constructor -func Create(iface common.IFaceMapper, disableServerRoutes bool, flowLogger nftypes.FlowLogger, mtu uint16) (*Manager, error) { - return create(iface, nil, disableServerRoutes, flowLogger, mtu) +// decodePacket decodes packet data using the appropriate parser based on IP version. +func (d *decoder) decodePacket(data []byte) error { + if len(data) == 0 { + return errors.New("empty packet") + } + version := data[0] >> 4 + switch version { + case 4: + return d.parser4.DecodeLayers(data, &d.decoded) + case 6: + return d.parser6.DecodeLayers(data, &d.decoded) + default: + return fmt.Errorf("unknown IP version %d", version) + } } -func CreateWithNativeFirewall(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableServerRoutes bool, flowLogger nftypes.FlowLogger, mtu uint16) (*Manager, error) { - if nativeFirewall == nil { - return nil, errors.New("native firewall is nil") +// decodeTransport decodes the transport header of a first fragment (which +// gopacket leaves undecoded) into the decoder and appends its layer type to +// decoded, so the ACL pipeline can evaluate it like a normal packet. It returns +// false if the protocol is unsupported or the header is truncated. +func (d *decoder) decodeTransport(proto layers.IPProtocol, payload []byte) bool { + var l4 gopacket.DecodingLayer + var layerType gopacket.LayerType + var minLen int + switch proto { + case layers.IPProtocolTCP: + l4, layerType, minLen = &d.tcp, layers.LayerTypeTCP, 20 + case layers.IPProtocolUDP: + l4, layerType, minLen = &d.udp, layers.LayerTypeUDP, 8 + case layers.IPProtocolICMPv4: + l4, layerType, minLen = &d.icmp4, layers.LayerTypeICMPv4, 8 + case layers.IPProtocolICMPv6: + l4, layerType, minLen = &d.icmp6, layers.LayerTypeICMPv6, 8 + default: + return false } - mgr, err := create(iface, nativeFirewall, disableServerRoutes, flowLogger, mtu) - if err != nil { - return nil, err + // Reject a fragment too small to hold the full transport header before + // decoding: it can't be ACL-evaluated (tiny-fragment attack), and skipping + // the decode avoids gopacket allocating an error on the drop path. + if len(payload) < minLen { + return false } - return mgr, nil + if err := l4.DecodeFromBytes(payload, gopacket.NilDecodeFeedback); err != nil { + return false + } + d.decoded = append(d.decoded, layerType) + return true } func parseCreateEnv() (bool, bool, bool) { @@ -209,7 +313,7 @@ func parseCreateEnv() (bool, bool, bool) { return disableConntrack, enableLocalForwarding, disableMSSClamping } -func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableServerRoutes bool, flowLogger nftypes.FlowLogger, mtu uint16) (*Manager, error) { +func Create(cfg Config) (_ *Manager, err error) { disableConntrack, enableLocalForwarding, disableMSSClamping := parseCreateEnv() m := &Manager{ @@ -218,66 +322,160 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe d := &decoder{ decoded: []gopacket.LayerType{}, } - d.parser = gopacket.NewDecodingLayerParser( + d.parser4 = gopacket.NewDecodingLayerParser( layers.LayerTypeIPv4, &d.eth, &d.ip4, &d.ip6, &d.icmp4, &d.icmp6, &d.tcp, &d.udp, ) - d.parser.IgnoreUnsupported = true + d.parser4.IgnoreUnsupported = true + + d.parser6 = gopacket.NewDecodingLayerParser( + layers.LayerTypeIPv6, + &d.eth, &d.ip4, &d.ip6, &d.icmp4, &d.icmp6, &d.tcp, &d.udp, + ) + d.parser6.IgnoreUnsupported = true return d }, }, - nativeFirewall: nativeFirewall, - outgoingRules: make(map[netip.Addr]RuleSet), - incomingDenyRules: make(map[netip.Addr]RuleSet), - incomingRules: make(map[netip.Addr]RuleSet), - wgIface: iface, + wgIface: cfg.IFace, + ifaceAllower: cfg.InterfaceAllower, localipmanager: newLocalIPManager(), - disableServerRoutes: disableServerRoutes, + disableServerRoutes: cfg.DisableServerRoutes, stateful: !disableConntrack, logger: nblog.NewFromLogrus(log.StandardLogger()), - flowLogger: flowLogger, + flowLogger: cfg.FlowLogger, netstack: netstack.IsEnabled(), localForwarding: enableLocalForwarding, + peerRulesMap: make(map[nbid.RuleID]*PeerRule), routeRulesMap: make(map[nbid.RuleID]*RouteRule), dnatMappings: make(map[netip.Addr]netip.Addr), portDNATRules: []portDNATRule{}, netstackServices: make(map[serviceKey]struct{}), - mtu: mtu, + mtu: cfg.MTU, } m.routingEnabled.Store(false) + // Release the allower (and its monitor) if setup fails after it was wired in. + defer func() { + if err != nil { + m.closeAllowerOnError() + } + }() + if !disableMSSClamping { - m.mssClampEnabled = true - m.mssClampValue = mtu - ipTCPHeaderMinSize + m.enableMSSClamping(cfg.MTU) } - if err := m.localipmanager.UpdateLocalIPs(iface); err != nil { + if err := m.localipmanager.UpdateLocalIPs(cfg.IFace); err != nil { return nil, fmt.Errorf("update local IPs: %w", err) } - if disableConntrack { - log.Info("conntrack is disabled") - } else { - m.udpTracker = conntrack.NewUDPTracker(conntrack.DefaultUDPTimeout, m.logger, flowLogger) - m.icmpTracker = conntrack.NewICMPTracker(conntrack.DefaultICMPTimeout, m.logger, flowLogger) - m.tcpTracker = conntrack.NewTCPTracker(conntrack.DefaultTCPTimeout, m.logger, flowLogger) - } + m.fragments = newFragmentTracker(m.logger) + m.setupConntrack(disableConntrack) if m.netstack && m.localForwarding { if err := m.initForwarder(); err != nil { log.Errorf("failed to initialize forwarder: %v", err) } } - if err := iface.SetFilter(m); err != nil { + if err := cfg.IFace.SetFilter(m); err != nil { + m.fragments.Close() return nil, fmt.Errorf("set filter: %w", err) } + + m.openHostFirewall(cfg.IFace.Name()) + return m, nil } -func (m *Manager) blockInvalidRouted(iface common.IFaceMapper) (firewall.Rule, error) { +// closeAllowerOnError releases the allower (and its monitor) when Create fails +// after the allower was wired in. +func (m *Manager) closeAllowerOnError() { + if m.ifaceAllower == nil { + return + } + if err := m.ifaceAllower.Close(); err != nil { + log.Warnf("close interface allower after failed firewall setup: %v", err) + } +} + +// enableMSSClamping enables MSS clamping and computes the per-family clamp values. +func (m *Manager) enableMSSClamping(mtu uint16) { + m.mssClampEnabled = true + if mtu > ipv4TCPHeaderMinSize { + m.mssClampValueIPv4 = mtu - ipv4TCPHeaderMinSize + } + if mtu > ipv6TCPHeaderMinSize { + m.mssClampValueIPv6 = mtu - ipv6TCPHeaderMinSize + } +} + +// setupConntrack initializes the stateful trackers unless conntrack is disabled. +func (m *Manager) setupConntrack(disabled bool) { + if disabled { + log.Info("conntrack is disabled") + return + } + m.udpTracker = conntrack.NewUDPTracker(conntrack.DefaultUDPTimeout, m.logger, m.flowLogger) + m.icmpTracker = conntrack.NewICMPTracker(conntrack.DefaultICMPTimeout, m.logger, m.flowLogger) + m.tcpTracker = conntrack.NewTCPTracker(conntrack.DefaultTCPTimeout, m.logger, m.flowLogger) +} + +// openHostFirewall opens the NetBird interface in the kernel firewall so it +// doesn't drop traffic the userspace firewall handles. Best-effort: failures +// here shouldn't prevent the firewall from coming up. +func (m *Manager) openHostFirewall(ifaceName string) { + if m.ifaceAllower != nil { + if err := m.ifaceAllower.Apply(); err != nil { + log.Errorf("failed to allow netbird interface traffic: %v", err) + } + } + // firewalld owns its own chains we can't insert into, so trust the interface + // there in addition to the allower. Netstack has no kernel interface. + if !m.netstack { + if err := firewalld.TrustInterface(ifaceName); err != nil { + log.Warnf("failed to trust interface in firewalld: %v", err) + } + } +} + +// Close cleans up the firewall manager: removes rules, closes trackers, and +// closes the interface allower. +func (m *Manager) Close(*statemanager.Manager) error { + m.mutex.Lock() + defer m.mutex.Unlock() + + m.resetState() + + var merr *multierror.Error + if m.ifaceAllower != nil { + if err := m.ifaceAllower.Close(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("close interface allower: %w", err)) + } + } + if !m.netstack { + if err := firewalld.UntrustInterface(m.wgIface.Name()); err != nil { + merr = multierror.Append(merr, fmt.Errorf("untrust interface in firewalld: %w", err)) + } + } + return nberrors.FormatErrorOrNil(merr) +} + +// blockInvalidRouted installs drop rules for traffic to the wg overlay that +// arrives via the routing path. v4 and v6 are independent: a v6 install +// failure leaves v4 protection in place (and vice versa) so the returned +// slice always contains whatever was successfully installed, even on error. +// Callers must persist the slice so DisableRouting can clean partial state. +func (m *Manager) blockInvalidRouted(iface Iface) ([]firewall.Rule, error) { wgPrefix := iface.Address().Network log.Debugf("blocking invalid routed traffic for %s", wgPrefix) - rule, err := m.addRouteFiltering( + sources := []netip.Prefix{netip.PrefixFrom(netip.IPv4Unspecified(), 0)} + v6Net := iface.Address().IPv6Net + if v6Net.IsValid() { + sources = append(sources, netip.PrefixFrom(netip.IPv6Unspecified(), 0)) + } + + var rules []firewall.Rule + v4Rule, err := m.addRouteRule( nil, - []netip.Prefix{netip.PrefixFrom(netip.IPv4Unspecified(), 0)}, + sources, firewall.Network{Prefix: wgPrefix}, firewall.ProtocolALL, nil, @@ -285,29 +483,41 @@ func (m *Manager) blockInvalidRouted(iface common.IFaceMapper) (firewall.Rule, e firewall.ActionDrop, ) if err != nil { - return nil, fmt.Errorf("block wg nte : %w", err) + return rules, fmt.Errorf("block wg v4 net: %w", err) + } + rules = append(rules, v4Rule) + + if v6Net.IsValid() { + log.Debugf("blocking invalid routed traffic for %s", v6Net) + v6Rule, err := m.addRouteRule( + nil, + sources, + firewall.Network{Prefix: v6Net}, + firewall.ProtocolALL, + nil, + nil, + firewall.ActionDrop, + ) + if err != nil { + return rules, fmt.Errorf("block wg v6 net: %w", err) + } + rules = append(rules, v6Rule) } // TODO: Block networks that we're a client of - return rule, nil + return rules, nil } func (m *Manager) determineRouting() error { - var disableUspRouting, forceUserspaceRouter bool - var err error + var disableUspRouting bool if val := os.Getenv(EnvDisableUserspaceRouting); val != "" { + var err error disableUspRouting, err = strconv.ParseBool(val) if err != nil { log.Warnf("failed to parse %s: %v", EnvDisableUserspaceRouting, err) } } - if val := os.Getenv(EnvForceUserspaceRouter); val != "" { - forceUserspaceRouter, err = strconv.ParseBool(val) - if err != nil { - log.Warnf("failed to parse %s: %v", EnvForceUserspaceRouter, err) - } - } switch { case disableUspRouting: @@ -322,26 +532,11 @@ func (m *Manager) determineRouting() error { log.Info("server routes are disabled") - case forceUserspaceRouter: - m.routingEnabled.Store(true) - m.nativeRouter.Store(false) - - log.Info("userspace routing is forced") - - case !m.netstack && m.nativeFirewall != nil: - // if the OS supports routing natively, then we don't need to filter/route ourselves - // netstack mode won't support native routing as there is no interface - - m.routingEnabled.Store(true) - m.nativeRouter.Store(true) - - log.Info("native routing is enabled") - default: m.routingEnabled.Store(true) m.nativeRouter.Store(false) - log.Info("userspace routing enabled by default") + log.Info("userspace routing enabled") } if m.routingEnabled.Load() && !m.nativeRouter.Load() { @@ -351,6 +546,19 @@ func (m *Manager) determineRouting() error { return nil } +// SetPacketCapture sets or clears packet capture on the forwarder endpoint. +// This captures outbound response packets that bypass the FilteredDevice in netstack mode. +func (m *Manager) SetPacketCapture(pc forwarder.PacketCapture) { + if pc == nil { + m.pendingCapture.Store(nil) + } else { + m.pendingCapture.Store(&pc) + } + if fwder := m.forwarder.Load(); fwder != nil { + fwder.SetCapture(pc) + } +} + // initForwarder initializes the forwarder, it disables routing on errors func (m *Manager) initForwarder() error { if m.forwarder.Load() != nil { @@ -372,6 +580,11 @@ func (m *Manager) initForwarder() error { m.forwarder.Store(forwarder) + // Re-load after store: a concurrent SetPacketCapture may have seen forwarder as nil and only updated pendingCapture. + if pc := m.pendingCapture.Load(); pc != nil { + forwarder.SetCapture(*pc) + } + log.Debug("forwarder initialized") return nil @@ -389,96 +602,118 @@ func (m *Manager) IsStateful() bool { return m.stateful } -func (m *Manager) AddNatRule(pair firewall.RouterPair) error { - if m.nativeRouter.Load() && m.nativeFirewall != nil { - return m.nativeFirewall.AddNatRule(pair) - } - +func (m *Manager) AddNatRule(firewall.RouterPair) error { // userspace routed packets are always SNATed to the inbound direction // TODO: implement outbound SNAT return nil } // RemoveNatRule removes a routing firewall rule -func (m *Manager) RemoveNatRule(pair firewall.RouterPair) error { - if m.nativeRouter.Load() && m.nativeFirewall != nil { - return m.nativeFirewall.RemoveNatRule(pair) - } +func (m *Manager) RemoveNatRule(firewall.RouterPair) error { return nil } -// AddPeerFiltering rule to the firewall -// -// If comment argument is empty firewall manager should set -// rule ID as comment for the rule -func (m *Manager) AddPeerFiltering( +// addPeerRule installs an input-chain rule that matches packets +// by source only. Called from AddFilterRule when the caller doesn't +// specify a destination. Sources are expected to share one address +// family; the family selects the ipLayer so the ICMP variant matches +// what the decoder produces. +func (m *Manager) addPeerRule( id []byte, - ip net.IP, + sources []netip.Prefix, proto firewall.Protocol, sPort *firewall.Port, dPort *firewall.Port, action firewall.Action, - _ string, -) ([]firewall.Rule, error) { - // TODO: fix in upper layers - i, ok := netip.AddrFromSlice(ip) - if !ok { - return nil, fmt.Errorf("invalid IP: %s", ip) - } - - i = i.Unmap() - r := PeerRule{ - id: uuid.New().String(), - mgmtId: id, - ip: i, - ipLayer: layers.LayerTypeIPv6, - matchByIP: true, - drop: action == firewall.ActionDrop, - } - if i.Is4() { - r.ipLayer = layers.LayerTypeIPv4 - } - - if s := r.ip.String(); s == "0.0.0.0" || s == "::" { - r.matchByIP = false - } - - r.sPort = sPort - r.dPort = dPort - - r.protoLayer = protoToLayer(proto, r.ipLayer) - - m.mutex.Lock() - var targetMap map[netip.Addr]RuleSet - if r.drop { - targetMap = m.incomingDenyRules - } else { - targetMap = m.incomingRules - } - - if _, ok := targetMap[r.ip]; !ok { - targetMap[r.ip] = make(RuleSet) - } - targetMap[r.ip][r.id] = r - m.mutex.Unlock() - return []firewall.Rule{&r}, nil -} - -func (m *Manager) AddRouteFiltering( - id []byte, - sources []netip.Prefix, - destination firewall.Network, - proto firewall.Protocol, - sPort, dPort *firewall.Port, - action firewall.Action, ) (firewall.Rule, error) { m.mutex.Lock() defer m.mutex.Unlock() - return m.addRouteFiltering(id, sources, destination, proto, sPort, dPort, action) + // Sources are a single family; normalize v4-mapped prefixes to plain + // v4 and pick the matching IP layer. A /0 source matches any address + // of its own family only, mirroring the kernel backends. + normalized := make([]netip.Prefix, len(sources)) + ipLayer := layers.LayerTypeIPv4 + for i, p := range sources { + normalized[i] = firewall.UnmapPrefix(p) + if normalized[i].Addr().Is6() { + ipLayer = layers.LayerTypeIPv6 + } + } + spec := peerRuleSpec{ + mgmtID: id, + sources: normalized, + ipLayer: ipLayer, + proto: proto, + sPort: sPort, + dPort: dPort, + action: action, + } + return m.addOnePeerRule(spec), nil } -func (m *Manager) addRouteFiltering( +// addOnePeerRule builds and registers a single-family peer rule, or +// returns the existing rule when one with the same content key is +// already installed. The caller must hold m.mutex. The content key is +// the shared GenerateRuleID with an empty destination, so peer rules +// dedup the same way route rules and the kernel backends do; it is +// order-independent, so callers passing the same sources in any order +// dedup to one rule. +// +// There is no refcount: a content key is installed once and deleted on +// the first DeleteFilterRule for that key. The caller must therefore +// key its own tracking by the returned rule id so add and delete stay +// balanced per content key; the acl manager does this via +// peerRulesPairs. +func (m *Manager) addOnePeerRule(spec peerRuleSpec) *PeerRule { + ruleID := nbid.GenerateRuleID(spec.sources, firewall.Network{}, spec.proto, spec.sPort, spec.dPort, spec.action) + if existing, ok := m.peerRulesMap[ruleID]; ok { + return existing + } + + rule := m.buildPeerRule(ruleID, spec) + m.registerPeerRule(rule) + return rule +} + +func (m *Manager) buildPeerRule(ruleID nbid.RuleID, spec peerRuleSpec) *PeerRule { + r := &PeerRule{ + id: ruleID, + mgmtId: spec.mgmtID, + sources: spec.sources, + action: spec.action, + srcPort: spec.sPort, + dstPort: spec.dPort, + } + r.sourceAddrs = make(map[netip.Addr]struct{}, len(spec.sources)) + for _, p := range spec.sources { + if p.Bits() == p.Addr().BitLen() { + r.sourceAddrs[p.Addr()] = struct{}{} + } + } + r.protoLayer = protoToLayer(spec.proto, spec.ipLayer) + return r +} + +// registerPeerRule records a freshly built peer rule in the matching +// slice, index, and dedup map. The caller must hold m.mutex. +func (m *Manager) registerPeerRule(r *PeerRule) { + if r.action == firewall.ActionDrop { + m.incomingDenyRules = append(m.incomingDenyRules, r) + m.incomingDenyIndex.add(r) + } else { + m.incomingAcceptRules = append(m.incomingAcceptRules, r) + m.incomingAcceptIndex.add(r) + } + m.peerRulesMap[r.id] = r +} + +// AddFilterRule is the unified entry point for both peer (input chain) +// and route (forward chain) filtering rules. The destination +// distinguishes the two semantics: a zero Network installs an +// input-side rule that matches by source only; a set Network installs +// a forward-side rule that also matches the destination. +func (m *Manager) AddFilterRule( id []byte, sources []netip.Prefix, destination firewall.Network, @@ -486,23 +721,53 @@ func (m *Manager) addRouteFiltering( sPort, dPort *firewall.Port, action firewall.Action, ) (firewall.Rule, error) { - if m.nativeRouter.Load() && m.nativeFirewall != nil { - return m.nativeFirewall.AddRouteFiltering(id, sources, destination, proto, sPort, dPort, action) + if len(sources) == 0 { + return nil, firewall.ErrNoSources } - ruleKey := nbid.GenerateRouteRuleKey(sources, destination, proto, sPort, dPort, action) + if destination.IsZero() { + return m.addPeerRule(id, sources, proto, sPort, dPort, action) + } - if existingRule, ok := m.routeRulesMap[ruleKey]; ok { + m.mutex.Lock() + defer m.mutex.Unlock() + return m.addRouteRule(id, sources, destination, proto, sPort, dPort, action) +} + +// DeleteFilterRule deletes a filtering rule. The rule's underlying type +// is used to route to the correct internal path. +func (m *Manager) DeleteFilterRule(rule firewall.Rule) error { + m.mutex.Lock() + defer m.mutex.Unlock() + + if r, ok := rule.(*PeerRule); ok { + return m.deletePeerRuleLocked(r) + } + + // Anything else is a route rule (matched on the forward path). + return m.deleteRouteRule(rule) +} + +func (m *Manager) addRouteRule( + id []byte, + sources []netip.Prefix, + destination firewall.Network, + proto firewall.Protocol, + sPort, dPort *firewall.Port, + action firewall.Action, +) (firewall.Rule, error) { + ruleID := nbid.GenerateRuleID(sources, destination, proto, sPort, dPort, action) + + if existingRule, ok := m.routeRulesMap[ruleID]; ok { return existingRule, nil } rule := RouteRule{ - // TODO: consolidate these IDs - id: string(ruleKey), + id: ruleID, mgmtId: id, sources: sources, dstSet: destination.Set, - protoLayer: protoToLayer(proto, layers.LayerTypeIPv4), + protoLayer: protoToLayer(proto, ipLayerFromPrefix(destination.Prefix)), srcPort: sPort, dstPort: dPort, action: action, @@ -513,78 +778,58 @@ func (m *Manager) addRouteFiltering( m.routeRules = append(m.routeRules, &rule) m.routeRules.Sort() - m.routeRulesMap[ruleKey] = &rule + m.routeRulesMap[ruleID] = &rule return &rule, nil } -func (m *Manager) DeleteRouteRule(rule firewall.Rule) error { - m.mutex.Lock() - defer m.mutex.Unlock() - - return m.deleteRouteRule(rule) -} - func (m *Manager) deleteRouteRule(rule firewall.Rule) error { - if m.nativeRouter.Load() && m.nativeFirewall != nil { - return m.nativeFirewall.DeleteRouteRule(rule) + ruleID := rule.ID() + trimmed, _, ok := removeRuleByID(m.routeRules, ruleID) + if !ok { + return fmt.Errorf("route rule not found: %s", ruleID) } - - ruleKey := nbid.RuleID(rule.ID()) - if _, ok := m.routeRulesMap[ruleKey]; !ok { - return fmt.Errorf("route rule not found: %s", ruleKey) - } - - idx := slices.IndexFunc(m.routeRules, func(r *RouteRule) bool { - return r.id == string(ruleKey) - }) - if idx < 0 { - return fmt.Errorf("route rule not found in slice: %s", ruleKey) - } - - m.routeRules = slices.Delete(m.routeRules, idx, idx+1) - delete(m.routeRulesMap, ruleKey) + m.routeRules = trimmed + delete(m.routeRulesMap, ruleID) return nil } -// DeletePeerRule from the firewall by rule definition -func (m *Manager) DeletePeerRule(rule firewall.Rule) error { - m.mutex.Lock() - defer m.mutex.Unlock() +// deletePeerRuleLocked removes a peer rule from the matching slice, +// index, and dedup map. The caller must hold m.mutex. +func (m *Manager) deletePeerRuleLocked(r *PeerRule) error { + target, index := &m.incomingAcceptRules, &m.incomingAcceptIndex + if r.action == firewall.ActionDrop { + target, index = &m.incomingDenyRules, &m.incomingDenyIndex + } - r, ok := rule.(*PeerRule) + trimmed, stored, ok := removeRuleByID(*target, r.id) if !ok { - return fmt.Errorf("delete rule: invalid rule type: %T", rule) - } - - var sourceMap map[netip.Addr]RuleSet - if r.drop { - sourceMap = m.incomingDenyRules - } else { - sourceMap = m.incomingRules - } - - if ruleset, ok := sourceMap[r.ip]; ok { - if _, exists := ruleset[r.id]; !exists { - return fmt.Errorf("delete rule: no rule with such id: %v", r.id) - } - delete(ruleset, r.id) - if len(ruleset) == 0 { - delete(sourceMap, r.ip) - } - } else { return fmt.Errorf("delete rule: no rule with such id: %v", r.id) } - + *target = trimmed + index.remove(stored) + delete(m.peerRulesMap, r.id) return nil } -// SetLegacyManagement doesn't need to be implemented for this manager -func (m *Manager) SetLegacyManagement(isLegacy bool) error { - if m.nativeFirewall == nil { - return nil +// removeRuleByID removes the first rule whose id matches ruleID from +// rules, preserving order. It returns the trimmed slice, the removed +// rule, and whether a match was found. +func removeRuleByID[S ~[]T, T firewall.Rule](rules S, ruleID firewall.RuleID) (S, T, bool) { + idx := slices.IndexFunc(rules, func(r T) bool { return r.ID() == ruleID }) + var removed T + if idx < 0 { + return rules, removed, false } - return m.nativeFirewall.SetLegacyManagement(isLegacy) + removed = rules[idx] + return slices.Delete(rules, idx, idx+1), removed, true +} + +// SetLegacyManagement is a no-op for the userspace firewall: it only matters +// when an old management server can't send route firewall rules, which the +// userspace router doesn't rely on. +func (m *Manager) SetLegacyManagement(bool) error { + return nil } // Flush doesn't need to be implemented for this manager @@ -593,11 +838,14 @@ func (m *Manager) Flush() error { return nil } // resetState clears all firewall rules and closes connection trackers. // Must be called with m.mutex held. func (m *Manager) resetState() { - maps.Clear(m.outgoingRules) - maps.Clear(m.incomingDenyRules) - maps.Clear(m.incomingRules) - maps.Clear(m.routeRulesMap) + m.incomingDenyRules = m.incomingDenyRules[:0] + m.incomingAcceptRules = m.incomingAcceptRules[:0] + m.incomingDenyIndex.reset() + m.incomingAcceptIndex.reset() + clear(m.peerRulesMap) + clear(m.routeRulesMap) m.routeRules = m.routeRules[:0] + m.blockRules = nil m.udpHookOut.Store(nil) m.tcpHookOut.Store(nil) @@ -613,7 +861,12 @@ func (m *Manager) resetState() { m.tcpTracker.Close() } + if m.fragments != nil { + m.fragments.Close() + } + if fwder := m.forwarder.Load(); fwder != nil { + fwder.SetCapture(nil) fwder.Stop() } @@ -626,21 +879,15 @@ func (m *Manager) resetState() { } } -// SetupEBPFProxyNoTrack creates notrack rules for eBPF proxy loopback traffic. -func (m *Manager) SetupEBPFProxyNoTrack(proxyPort, wgPort uint16) error { - if m.nativeFirewall == nil { - return nil - } - return m.nativeFirewall.SetupEBPFProxyNoTrack(proxyPort, wgPort) +// SetupEBPFProxyNoTrack is not supported by the userspace firewall: eBPF isn't +// used in userspace mode, so this should never be called. +func (m *Manager) SetupEBPFProxyNoTrack(uint16, uint16) error { + return errNotSupported } // UpdateSet updates the rule destinations associated with the given set // by merging the existing prefixes with the new ones, then deduplicating. func (m *Manager) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error { - if m.nativeRouter.Load() && m.nativeFirewall != nil { - return m.nativeFirewall.UpdateSet(set, prefixes) - } - m.mutex.Lock() defer m.mutex.Unlock() @@ -656,11 +903,7 @@ func (m *Manager) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error { } destinations := matches[0].destinations - for _, prefix := range prefixes { - if prefix.Addr().Is4() { - destinations = append(destinations, prefix) - } - } + destinations = append(destinations, prefixes...) slices.SortFunc(destinations, func(a, b netip.Prefix) int { cmp := a.Addr().Compare(b.Addr()) @@ -699,7 +942,7 @@ func (m *Manager) filterOutbound(packetData []byte, size int) bool { d := m.decoders.Get().(*decoder) defer m.decoders.Put(d) - if err := d.parser.DecodeLayers(packetData, &d.decoded); err != nil { + if err := d.decodePacket(packetData); err != nil { return false } @@ -709,7 +952,9 @@ func (m *Manager) filterOutbound(packetData []byte, size int) bool { srcIP, dstIP := m.extractIPs(d) if !srcIP.IsValid() { - m.logger.Error1("Unknown network layer: %v", d.decoded[0]) + if m.logger.Enabled(nblog.LevelError) { + m.logger.Error1("Unknown network layer: %v", d.decoded[0]) + } return false } @@ -740,11 +985,11 @@ func (m *Manager) extractIPs(d *decoder) (srcIP, dstIP netip.Addr) { case layers.LayerTypeIPv4: src, _ := netip.AddrFromSlice(d.ip4.SrcIP) dst, _ := netip.AddrFromSlice(d.ip4.DstIP) - return src, dst + return src.Unmap(), dst.Unmap() case layers.LayerTypeIPv6: src, _ := netip.AddrFromSlice(d.ip6.SrcIP) dst, _ := netip.AddrFromSlice(d.ip6.DstIP) - return src, dst + return src.Unmap(), dst.Unmap() default: return netip.Addr{}, netip.Addr{} } @@ -783,12 +1028,32 @@ func (m *Manager) clampTCPMSS(packetData []byte, d *decoder) bool { return false } + var mssClampValue uint16 + var ipHeaderSize int + switch d.decoded[0] { + case layers.LayerTypeIPv4: + mssClampValue = m.mssClampValueIPv4 + ipHeaderSize = int(d.ip4.IHL) * 4 + if ipHeaderSize < 20 { + return false + } + case layers.LayerTypeIPv6: + mssClampValue = m.mssClampValueIPv6 + ipHeaderSize = 40 + default: + return false + } + + if mssClampValue == 0 { + return false + } + mssOptionIndex := -1 var currentMSS uint16 for i, opt := range d.tcp.Options { if opt.OptionType == layers.TCPOptionKindMSS && len(opt.OptionData) == 2 { currentMSS = binary.BigEndian.Uint16(opt.OptionData) - if currentMSS > m.mssClampValue { + if currentMSS > mssClampValue { mssOptionIndex = i break } @@ -799,20 +1064,17 @@ func (m *Manager) clampTCPMSS(packetData []byte, d *decoder) bool { return false } - ipHeaderSize := int(d.ip4.IHL) * 4 - if ipHeaderSize < 20 { + if !m.updateMSSOption(packetData, d, mssOptionIndex, mssClampValue, ipHeaderSize) { return false } - if !m.updateMSSOption(packetData, d, mssOptionIndex, ipHeaderSize) { - return false + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace2("Clamped TCP MSS from %d to %d", currentMSS, mssClampValue) } - - m.logger.Trace2("Clamped TCP MSS from %d to %d", currentMSS, m.mssClampValue) return true } -func (m *Manager) updateMSSOption(packetData []byte, d *decoder, mssOptionIndex, ipHeaderSize int) bool { +func (m *Manager) updateMSSOption(packetData []byte, d *decoder, mssOptionIndex int, mssClampValue uint16, ipHeaderSize int) bool { tcpHeaderStart := ipHeaderSize tcpOptionsStart := tcpHeaderStart + 20 @@ -827,7 +1089,7 @@ func (m *Manager) updateMSSOption(packetData []byte, d *decoder, mssOptionIndex, } mssValueOffset := optOffset + 2 - binary.BigEndian.PutUint16(packetData[mssValueOffset:mssValueOffset+2], m.mssClampValue) + binary.BigEndian.PutUint16(packetData[mssValueOffset:mssValueOffset+2], mssClampValue) m.recalculateTCPChecksum(packetData, d, tcpHeaderStart) return true @@ -837,18 +1099,32 @@ func (m *Manager) recalculateTCPChecksum(packetData []byte, d *decoder, tcpHeade tcpLayer := packetData[tcpHeaderStart:] tcpLength := len(packetData) - tcpHeaderStart + // Zero out existing checksum tcpLayer[16] = 0 tcpLayer[17] = 0 + // Build pseudo-header checksum based on IP version var pseudoSum uint32 - pseudoSum += uint32(d.ip4.SrcIP[0])<<8 | uint32(d.ip4.SrcIP[1]) - pseudoSum += uint32(d.ip4.SrcIP[2])<<8 | uint32(d.ip4.SrcIP[3]) - pseudoSum += uint32(d.ip4.DstIP[0])<<8 | uint32(d.ip4.DstIP[1]) - pseudoSum += uint32(d.ip4.DstIP[2])<<8 | uint32(d.ip4.DstIP[3]) - pseudoSum += uint32(d.ip4.Protocol) - pseudoSum += uint32(tcpLength) + switch d.decoded[0] { + case layers.LayerTypeIPv4: + pseudoSum += uint32(d.ip4.SrcIP[0])<<8 | uint32(d.ip4.SrcIP[1]) + pseudoSum += uint32(d.ip4.SrcIP[2])<<8 | uint32(d.ip4.SrcIP[3]) + pseudoSum += uint32(d.ip4.DstIP[0])<<8 | uint32(d.ip4.DstIP[1]) + pseudoSum += uint32(d.ip4.DstIP[2])<<8 | uint32(d.ip4.DstIP[3]) + pseudoSum += uint32(d.ip4.Protocol) + pseudoSum += uint32(tcpLength) + case layers.LayerTypeIPv6: + for i := 0; i < 16; i += 2 { + pseudoSum += uint32(d.ip6.SrcIP[i])<<8 | uint32(d.ip6.SrcIP[i+1]) + } + for i := 0; i < 16; i += 2 { + pseudoSum += uint32(d.ip6.DstIP[i])<<8 | uint32(d.ip6.DstIP[i+1]) + } + pseudoSum += uint32(tcpLength) + pseudoSum += uint32(layers.IPProtocolTCP) + } - var sum = pseudoSum + sum := pseudoSum for i := 0; i < tcpLength-1; i += 2 { sum += uint32(tcpLayer[i])<<8 | uint32(tcpLayer[i+1]) } @@ -886,6 +1162,9 @@ func (m *Manager) trackOutbound(d *decoder, srcIP, dstIP netip.Addr, packetData } case layers.LayerTypeICMPv4: m.icmpTracker.TrackOutbound(srcIP, dstIP, d.icmp4.Id, d.icmp4.TypeCode, d.icmp4.Payload, size) + case layers.LayerTypeICMPv6: + id, tc := icmpv6EchoFields(d) + m.icmpTracker.TrackOutbound(srcIP, dstIP, id, tc, d.icmp6.Payload, size) } } @@ -899,6 +1178,9 @@ func (m *Manager) trackInbound(d *decoder, srcIP, dstIP netip.Addr, ruleID []byt m.tcpTracker.TrackInbound(srcIP, dstIP, uint16(d.tcp.SrcPort), uint16(d.tcp.DstPort), flags, ruleID, size, d.dnatOrigPort) case layers.LayerTypeICMPv4: m.icmpTracker.TrackInbound(srcIP, dstIP, d.icmp4.Id, d.icmp4.TypeCode, ruleID, d.icmp4.Payload, size) + case layers.LayerTypeICMPv6: + id, tc := icmpv6EchoFields(d) + m.icmpTracker.TrackInbound(srcIP, dstIP, id, tc, ruleID, d.icmp6.Payload, size) } d.dnatOrigPort = 0 @@ -929,17 +1211,24 @@ func (m *Manager) filterInbound(packetData []byte, size int) bool { return true } - // TODO: pass fragments of routed packets to forwarder + // gopacket does not decode the transport header of any IP fragment, so + // fragments take a dedicated path: the first fragment's header is decoded + // and ACL-evaluated here, and the remaining fragments inherit its verdict. if fragment { - m.logger.Trace4("packet is a fragment: src=%v dst=%v id=%v flags=%v", - srcIP, dstIP, d.ip4.Id, d.ip4.Flags) - return false + return m.filterInboundFragment(d, srcIP, dstIP, size) } + return m.filterInboundDecoded(d, srcIP, dstIP, packetData, size) +} + +// filterInboundDecoded runs the ACL, DNAT and conntrack pipeline on a fully +// decoded (non-fragment) inbound packet. It returns true if the packet should +// be dropped. +func (m *Manager) filterInboundDecoded(d *decoder, srcIP, dstIP netip.Addr, packetData []byte, size int) bool { // TODO: optimize port DNAT by caching matched rules in conntrack if translated := m.translateInboundPortDNAT(packetData, d, srcIP, dstIP); translated { // Re-decode after port DNAT translation to update port information - if err := d.parser.DecodeLayers(packetData, &d.decoded); err != nil { + if err := d.decodePacket(packetData); err != nil { m.logger.Error1("failed to re-decode packet after port DNAT: %v", err) return true } @@ -948,7 +1237,7 @@ func (m *Manager) filterInbound(packetData []byte, size int) bool { if translated := m.translateInboundReverse(packetData, d); translated { // Re-decode after translation to get original addresses - if err := d.parser.DecodeLayers(packetData, &d.decoded); err != nil { + if err := d.decodePacket(packetData); err != nil { m.logger.Error1("failed to re-decode packet after reverse DNAT: %v", err) return true } @@ -966,31 +1255,226 @@ func (m *Manager) filterInbound(packetData []byte, size int) bool { return m.handleRoutedTraffic(d, srcIP, dstIP, packetData, size) } +// fragmentMeta holds the reassembly identity and layout of an IP fragment, +// extracted uniformly for IPv4 and IPv6. +type fragmentMeta struct { + key fragmentKey + // offset is the fragment offset in 8-byte units (zero for the first + // fragment). + offset uint16 + // moreFragments is the More Fragments bit. A first fragment with it unset is + // an IPv6 atomic fragment (a complete datagram, RFC 6946): it has no trailing + // fragments to inherit a verdict, so it must not be recorded. + moreFragments bool + proto layers.IPProtocol + // l4payload is the fragmentable payload of this fragment. For the first + // fragment it starts with the transport header. + l4payload []byte + // headerEndOctets is the first fragment's payload length in 8-byte units: + // the smallest offset a trailing fragment may start at without overlapping + // the inspected transport header. + headerEndOctets uint16 +} + +// fragmentMetadata extracts the fragment identity and layout from a decoded IP +// fragment. It returns false for fragments it can't interpret (e.g. an IPv6 +// fragment header shorter than 8 bytes), which are then dropped. +func fragmentMetadata(d *decoder, srcIP, dstIP netip.Addr) (fragmentMeta, bool) { + switch d.decoded[0] { + case layers.LayerTypeIPv4: + payload := d.ip4.Payload + return fragmentMeta{ + key: fragmentKey{srcIP: srcIP, dstIP: dstIP, id: uint32(d.ip4.Id), proto: uint8(d.ip4.Protocol)}, + offset: d.ip4.FragOffset, + moreFragments: d.ip4.Flags&layers.IPv4MoreFragments != 0, + proto: d.ip4.Protocol, + l4payload: payload, + headerEndOctets: octets(len(payload)), + }, true + + case layers.LayerTypeIPv6: + // IPv6 fragment extension header: 8 bytes, followed by the fragmentable + // payload. Layout: next header (1), reserved (1), offset+flags (2), id (4). + payload := d.ip6.Payload + if len(payload) < 8 { + return fragmentMeta{}, false + } + nextHeader := layers.IPProtocol(payload[0]) + offsetFlags := binary.BigEndian.Uint16(payload[2:4]) + id := binary.BigEndian.Uint32(payload[4:8]) + l4 := payload[8:] + return fragmentMeta{ + key: fragmentKey{srcIP: srcIP, dstIP: dstIP, id: id, proto: uint8(nextHeader)}, + offset: offsetFlags >> 3, + moreFragments: offsetFlags&1 != 0, + proto: nextHeader, + l4payload: l4, + headerEndOctets: octets(len(l4)), + }, true + + default: + return fragmentMeta{}, false + } +} + +// octets rounds a byte length up to whole 8-byte units, the granularity of the +// IP fragment offset field. +func octets(nbytes int) uint16 { + return uint16((nbytes + 7) / 8) +} + +// filterInboundFragment decides the fate of an IP fragment. gopacket stops +// decoding at the network layer for every fragment, so the first fragment's +// transport header is decoded and ACL-evaluated here and its verdict recorded; +// the remaining (headerless) fragments inherit that verdict. Anything that +// cannot be tied to an allowed, non-overlapping first fragment is dropped. +func (m *Manager) filterInboundFragment(d *decoder, srcIP, dstIP netip.Addr, size int) bool { + meta, ok := fragmentMetadata(d, srcIP, dstIP) + if !ok { + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace2("dropping unsupported fragment: src=%v dst=%v", srcIP, dstIP) + } + return true + } + + if meta.offset != 0 { + return m.filterTrailingFragment(meta, srcIP, dstIP) + } + + // A new first fragment supersedes any recorded verdict for this datagram, so + // a re-sent or overlapping offset-zero fragment can't inherit the old one. + m.fragments.poison(meta.key) + + // First fragment: decode its transport header so the ACL can evaluate it. A + // decode failure means the fragment is too small to hold the full transport + // header (RFC 1858 §3 tiny-fragment attack); it can't be evaluated, so drop it. + if !d.decodeTransport(meta.proto, meta.l4payload) { + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace3("dropping first fragment without full L4 header: src=%v dst=%v id=%v", + srcIP, dstIP, meta.key.id) + } + return true + } + + return m.filterFirstFragment(d, meta, srcIP, dstIP, size) +} + +// filterTrailingFragment applies a recorded first-fragment verdict to a +// non-first fragment. +func (m *Manager) filterTrailingFragment(meta fragmentMeta, srcIP, dstIP netip.Addr) bool { + switch m.fragments.verdict(meta.key, meta.offset) { + case fragmentAllow: + return false + case fragmentOverlap: + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace3("dropping overlapping fragment rewriting inspected header: src=%v dst=%v id=%v", + srcIP, dstIP, meta.key.id) + } + return true + default: + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace3("dropping fragment with no allowed first fragment: src=%v dst=%v id=%v", + srcIP, dstIP, meta.key.id) + } + return true + } +} + +// filterFirstFragment runs the verdict part of the inbound pipeline on a first +// fragment with its transport header decoded. It mirrors filterInboundDecoded +// but skips DNAT (port rewriting on fragments is unsupported) and forwarder +// injection (fragments are left to the stack to reassemble, not forwarded). +// Allowed fragments have their verdict recorded so the datagram's trailing +// fragments inherit it. +func (m *Manager) filterFirstFragment(d *decoder, meta fragmentMeta, srcIP, dstIP netip.Addr, size int) bool { + if m.stateful && m.isValidTrackedConnection(d, srcIP, dstIP, size) { + m.recordFirstFragment(meta) + return false + } + + if m.localipmanager.IsLocalIP(dstIP) { + ruleID, blocked := m.peerACLsBlock(srcIP, d, nil) + if blocked { + m.storeDropFlow("Dropping local first fragment (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", + d, srcIP, dstIP, ruleID, size) + return true + } + m.trackInbound(d, srcIP, dstIP, ruleID, size) + m.recordFirstFragment(meta) + return false + } + + if !m.routingEnabled.Load() { + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace2("Dropping routed fragment (routing disabled): src=%s dst=%s", srcIP, dstIP) + } + return true + } + if m.nativeRouter.Load() { + m.trackInbound(d, srcIP, dstIP, nil, size) + m.recordFirstFragment(meta) + return false + } + + // TODO: pass fragments of routed packets to the forwarder; until then + // allowed routed fragments go to the native stack. + srcPort, dstPort := getPortsFromPacket(d) + ruleID, pass := m.routeACLsPass(srcIP, dstIP, d.decoded[1], srcPort, dstPort) + if !pass { + m.storeDropFlow("Dropping routed first fragment (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", + d, srcIP, dstIP, ruleID, size) + return true + } + + m.recordFirstFragment(meta) + return false +} + +// recordFirstFragment caches an allowed first fragment's verdict for its +// trailing fragments to inherit. Atomic fragments (no More Fragments bit) are +// complete datagrams with no trailing fragments, so they are not cached and +// cannot exhaust the verdict table. +func (m *Manager) recordFirstFragment(meta fragmentMeta) { + if !meta.moreFragments { + return + } + m.fragments.recordAllowed(meta.key, meta.headerEndOctets) +} + +// storeDropFlow logs and records a netflow drop event for an inbound packet +// denied by the ACLs. msg is the trace format taking rule id, protocol, source +// and destination. +func (m *Manager) storeDropFlow(msg string, d *decoder, srcIP, dstIP netip.Addr, ruleID []byte, size int) { + pnum := getProtocolFromPacket(d) + srcPort, dstPort := getPortsFromPacket(d) + + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace6(msg, ruleID, pnum, srcIP, srcPort, dstIP, dstPort) + } + + m.flowLogger.StoreEvent(nftypes.EventFields{ + FlowID: uuid.New(), + Type: nftypes.TypeDrop, + RuleID: ruleID, + Direction: nftypes.Ingress, + Protocol: pnum, + SourceIP: srcIP, + DestIP: dstIP, + SourcePort: srcPort, + DestPort: dstPort, + // TODO: icmp type/code + RxPackets: 1, + RxBytes: uint64(size), + }) +} + // handleLocalTraffic handles local traffic. // If it returns true, the packet should be dropped. func (m *Manager) handleLocalTraffic(d *decoder, srcIP, dstIP netip.Addr, packetData []byte, size int) bool { ruleID, blocked := m.peerACLsBlock(srcIP, d, packetData) if blocked { - pnum := getProtocolFromPacket(d) - srcPort, dstPort := getPortsFromPacket(d) - - m.logger.Trace6("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", - ruleID, pnum, srcIP, srcPort, dstIP, dstPort) - - m.flowLogger.StoreEvent(nftypes.EventFields{ - FlowID: uuid.New(), - Type: nftypes.TypeDrop, - RuleID: ruleID, - Direction: nftypes.Ingress, - Protocol: pnum, - SourceIP: srcIP, - DestIP: dstIP, - SourcePort: srcPort, - DestPort: dstPort, - // TODO: icmp type/code - RxPackets: 1, - RxBytes: uint64(size), - }) + m.storeDropFlow("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", + d, srcIP, dstIP, ruleID, size) return true } @@ -1025,8 +1509,10 @@ func (m *Manager) handleForwardedLocalTraffic(packetData []byte) bool { func (m *Manager) handleRoutedTraffic(d *decoder, srcIP, dstIP netip.Addr, packetData []byte, size int) bool { // Drop if routing is disabled if !m.routingEnabled.Load() { - m.logger.Trace2("Dropping routed packet (routing disabled): src=%s dst=%s", - srcIP, dstIP) + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace2("Dropping routed packet (routing disabled): src=%s dst=%s", + srcIP, dstIP) + } return true } @@ -1041,25 +1527,8 @@ func (m *Manager) handleRoutedTraffic(d *decoder, srcIP, dstIP netip.Addr, packe ruleID, pass := m.routeACLsPass(srcIP, dstIP, protoLayer, srcPort, dstPort) if !pass { - proto := getProtocolFromPacket(d) - - m.logger.Trace6("Dropping routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", - ruleID, proto, srcIP, srcPort, dstIP, dstPort) - - m.flowLogger.StoreEvent(nftypes.EventFields{ - FlowID: uuid.New(), - Type: nftypes.TypeDrop, - RuleID: ruleID, - Direction: nftypes.Ingress, - Protocol: proto, - SourceIP: srcIP, - DestIP: dstIP, - SourcePort: srcPort, - DestPort: dstPort, - // TODO: icmp type/code - RxPackets: 1, - RxBytes: uint64(size), - }) + m.storeDropFlow("Dropping routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", + d, srcIP, dstIP, ruleID, size) return true } @@ -1080,6 +1549,48 @@ func (m *Manager) handleRoutedTraffic(d *decoder, srcIP, dstIP netip.Addr, packe return true } +// icmpv6EchoFields extracts the echo identifier from an ICMPv6 packet and maps +// the ICMPv6 type code to an ICMPv4TypeCode so the ICMP conntrack can handle +// both families uniformly. The echo ID is in the first two payload bytes. +func icmpv6EchoFields(d *decoder) (id uint16, tc layers.ICMPv4TypeCode) { + if len(d.icmp6.Payload) >= 2 { + id = uint16(d.icmp6.Payload[0])<<8 | uint16(d.icmp6.Payload[1]) + } + // Map ICMPv6 echo types to ICMPv4 equivalents for unified tracking. + switch d.icmp6.TypeCode.Type() { + case layers.ICMPv6TypeEchoRequest: + tc = layers.CreateICMPv4TypeCode(layers.ICMPv4TypeEchoRequest, 0) + case layers.ICMPv6TypeEchoReply: + tc = layers.CreateICMPv4TypeCode(layers.ICMPv4TypeEchoReply, 0) + default: + tc = layers.CreateICMPv4TypeCode(d.icmp6.TypeCode.Type(), d.icmp6.TypeCode.Code()) + } + return id, tc +} + +// protoLayerMatches checks if a packet's protocol layer matches a rule's expected +// protocol layer. ICMPv4 and ICMPv6 are treated as equivalent when matching +// ICMP rules since management sends a single ICMP rule for both families. +func protoLayerMatches(ruleLayer, packetLayer gopacket.LayerType) bool { + if ruleLayer == packetLayer { + return true + } + if ruleLayer == layers.LayerTypeICMPv4 && packetLayer == layers.LayerTypeICMPv6 { + return true + } + if ruleLayer == layers.LayerTypeICMPv6 && packetLayer == layers.LayerTypeICMPv4 { + return true + } + return false +} + +func ipLayerFromPrefix(p netip.Prefix) gopacket.LayerType { + if p.Addr().Is6() { + return layers.LayerTypeIPv6 + } + return layers.LayerTypeIPv4 +} + func protoToLayer(proto firewall.Protocol, ipLayer gopacket.LayerType) gopacket.LayerType { switch proto { case firewall.ProtocolTCP: @@ -1103,8 +1614,10 @@ func getProtocolFromPacket(d *decoder) nftypes.Protocol { return nftypes.TCP case layers.LayerTypeUDP: return nftypes.UDP - case layers.LayerTypeICMPv4, layers.LayerTypeICMPv6: + case layers.LayerTypeICMPv4: return nftypes.ICMP + case layers.LayerTypeICMPv6: + return nftypes.ICMPv6 default: return nftypes.ProtocolUnknown } @@ -1125,8 +1638,10 @@ func getPortsFromPacket(d *decoder) (srcPort, dstPort uint16) { // It returns true, false if the packet is valid and not a fragment. // It returns true, true if the packet is a fragment and valid. func (m *Manager) isValidPacket(d *decoder, packetData []byte) (bool, bool) { - if err := d.parser.DecodeLayers(packetData, &d.decoded); err != nil { - m.logger.Trace1("couldn't decode packet, err: %s", err) + if err := d.decodePacket(packetData); err != nil { + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace1("couldn't decode packet, err: %s", err) + } return false, false } @@ -1138,10 +1653,21 @@ func (m *Manager) isValidPacket(d *decoder, packetData []byte) (bool, bool) { } // Fragments are also valid - if l == 1 && d.decoded[0] == layers.LayerTypeIPv4 { - ip4 := d.ip4 - if ip4.Flags&layers.IPv4MoreFragments != 0 || ip4.FragOffset != 0 { - return true, true + if l == 1 { + switch d.decoded[0] { + case layers.LayerTypeIPv4: + if d.ip4.Flags&layers.IPv4MoreFragments != 0 || d.ip4.FragOffset != 0 { + return true, true + } + case layers.LayerTypeIPv6: + // IPv6 uses Fragment extension header (NextHeader=44). If gopacket + // only decoded the IPv6 layer, the transport is in a fragment. + // TODO: handle non-Fragment extension headers (HopByHop, Routing, + // DestOpts) by walking the chain. gopacket's parser does not + // support them as DecodingLayers; today we drop such packets. + if d.ip6.NextHeader == layers.IPProtocolIPv6Fragment { + return true, true + } } } @@ -1179,21 +1705,35 @@ func (m *Manager) isValidTrackedConnection(d *decoder, srcIP, dstIP netip.Addr, size, ) - // TODO: ICMPv6 + case layers.LayerTypeICMPv6: + id, _ := icmpv6EchoFields(d) + return m.icmpTracker.IsValidInbound( + srcIP, + dstIP, + id, + d.icmp6.TypeCode.Type(), + size, + ) } return false } -// isSpecialICMP returns true if the packet is a special ICMP packet that should be allowed +// isSpecialICMP returns true if the packet is a special ICMP error packet that should be allowed. func (m *Manager) isSpecialICMP(d *decoder) bool { - if d.decoded[1] != layers.LayerTypeICMPv4 { - return false + switch d.decoded[1] { + case layers.LayerTypeICMPv4: + icmpType := d.icmp4.TypeCode.Type() + return icmpType == layers.ICMPv4TypeDestinationUnreachable || + icmpType == layers.ICMPv4TypeTimeExceeded + case layers.LayerTypeICMPv6: + icmpType := d.icmp6.TypeCode.Type() + return icmpType == layers.ICMPv6TypeDestinationUnreachable || + icmpType == layers.ICMPv6TypePacketTooBig || + icmpType == layers.ICMPv6TypeTimeExceeded || + icmpType == layers.ICMPv6TypeParameterProblem } - - icmpType := d.icmp4.TypeCode.Type() - return icmpType == layers.ICMPv4TypeDestinationUnreachable || - icmpType == layers.ICMPv4TypeTimeExceeded + return false } func (m *Manager) peerACLsBlock(srcIP netip.Addr, d *decoder, packetData []byte) ([]byte, bool) { @@ -1204,20 +1744,12 @@ func (m *Manager) peerACLsBlock(srcIP netip.Addr, d *decoder, packetData []byte) return nil, false } - if mgmtId, filter, ok := validateRule(srcIP, packetData, m.incomingDenyRules[srcIP], d); ok { + if mgmtId, filter, ok := m.incomingDenyIndex.match(srcIP, d); ok { return mgmtId, filter } - - if mgmtId, filter, ok := validateRule(srcIP, packetData, m.incomingRules[srcIP], d); ok { + if mgmtId, filter, ok := m.incomingAcceptIndex.match(srcIP, d); ok { return mgmtId, filter } - if mgmtId, filter, ok := validateRule(srcIP, packetData, m.incomingRules[netip.IPv4Unspecified()], d); ok { - return mgmtId, filter - } - if mgmtId, filter, ok := validateRule(srcIP, packetData, m.incomingRules[netip.IPv6Unspecified()], d); ok { - return mgmtId, filter - } - return nil, true } @@ -1238,39 +1770,6 @@ func portsMatch(rulePort *firewall.Port, packetPort uint16) bool { return false } -func validateRule(ip netip.Addr, packetData []byte, rules map[string]PeerRule, d *decoder) ([]byte, bool, bool) { - payloadLayer := d.decoded[1] - - for _, rule := range rules { - if rule.matchByIP && ip.Compare(rule.ip) != 0 { - continue - } - - if rule.protoLayer == layerTypeAll { - return rule.mgmtId, rule.drop, true - } - - if payloadLayer != rule.protoLayer { - continue - } - - switch payloadLayer { - case layers.LayerTypeTCP: - if portsMatch(rule.sPort, uint16(d.tcp.SrcPort)) && portsMatch(rule.dPort, uint16(d.tcp.DstPort)) { - return rule.mgmtId, rule.drop, true - } - case layers.LayerTypeUDP: - if portsMatch(rule.sPort, uint16(d.udp.SrcPort)) && portsMatch(rule.dPort, uint16(d.udp.DstPort)) { - return rule.mgmtId, rule.drop, true - } - case layers.LayerTypeICMPv4, layers.LayerTypeICMPv6: - return rule.mgmtId, rule.drop, true - } - } - - return nil, false, false -} - // routeACLsPass returns true if the packet is allowed by the route ACLs func (m *Manager) routeACLsPass(srcIP, dstIP netip.Addr, protoLayer gopacket.LayerType, srcPort, dstPort uint16) ([]byte, bool) { m.mutex.RLock() @@ -1285,8 +1784,7 @@ func (m *Manager) routeACLsPass(srcIP, dstIP netip.Addr, protoLayer gopacket.Lay } func (m *Manager) ruleMatches(rule *RouteRule, srcAddr, dstAddr netip.Addr, protoLayer gopacket.LayerType, srcPort, dstPort uint16) bool { - // TODO: handle ipv6 vs ipv4 icmp rules - if rule.protoLayer != layerTypeAll && rule.protoLayer != protoLayer { + if rule.protoLayer != layerTypeAll && !protoLayerMatches(rule.protoLayer, protoLayer) { return false } @@ -1347,13 +1845,17 @@ func (m *Manager) EnableRouting() error { return nil } - rule, err := m.blockInvalidRouted(m.wgIface) + rules, err := m.blockInvalidRouted(m.wgIface) + m.blockRules = rules if err != nil { + // Roll back so forwarding can't stay active without the full set of + // block rules. + if derr := m.disableRouting(); derr != nil { + log.Warnf("roll back routing after block rule failure: %v", derr) + } return fmt.Errorf("block invalid routed: %w", err) } - m.blockRule = rule - return nil } @@ -1361,6 +1863,10 @@ func (m *Manager) DisableRouting() error { m.mutex.Lock() defer m.mutex.Unlock() + return m.disableRouting() +} + +func (m *Manager) disableRouting() error { fwder := m.forwarder.Load() if fwder == nil { return nil @@ -1369,9 +1875,16 @@ func (m *Manager) DisableRouting() error { m.routingEnabled.Store(false) m.nativeRouter.Store(false) - // don't stop forwarder if in use by netstack + var merr *multierror.Error + for _, rule := range m.blockRules { + if err := m.deleteRouteRule(rule); err != nil { + merr = multierror.Append(merr, fmt.Errorf("delete block rule: %w", err)) + } + } + m.blockRules = nil + if m.netstack && m.localForwarding { - return nil + return nberrors.FormatErrorOrNil(merr) } fwder.Stop() @@ -1379,14 +1892,7 @@ func (m *Manager) DisableRouting() error { log.Debug("forwarder stopped") - if m.blockRule != nil { - if err := m.deleteRouteRule(m.blockRule); err != nil { - return fmt.Errorf("delete block rule: %w", err) - } - m.blockRule = nil - } - - return nil + return nberrors.FormatErrorOrNil(merr) } // RegisterNetstackService registers a service as listening on the netstack for the given protocol and port @@ -1440,7 +1946,8 @@ func (m *Manager) shouldForward(d *decoder, dstIP netip.Addr) bool { } // traffic to our other local interfaces (not NetBird IP) - always forward - if dstIP != m.wgIface.Address().IP { + addr := m.wgIface.Address() + if dstIP != addr.IP && (!addr.IPv6.IsValid() || dstIP != addr.IPv6) { return true } diff --git a/client/firewall/uspfilter/filter_bench_test.go b/client/firewall/uspfilter/filter_bench_test.go index 10ff62ed3..72f3417f2 100644 --- a/client/firewall/uspfilter/filter_bench_test.go +++ b/client/firewall/uspfilter/filter_bench_test.go @@ -94,7 +94,7 @@ func BenchmarkCoreFiltering(b *testing.B) { stateful: false, setupFunc: func(m *Manager) { // Single rule allowing all traffic - _, err := m.AddPeerFiltering(nil, net.ParseIP("0.0.0.0"), fw.ProtocolALL, nil, nil, fw.ActionAccept, "") + _, err := m.AddFilterRule(nil, pfx(net.ParseIP("0.0.0.0")), fw.Network{}, fw.ProtocolALL, nil, nil, fw.ActionAccept) require.NoError(b, err) }, desc: "Baseline: Single 'allow all' rule without connection tracking", @@ -114,15 +114,13 @@ func BenchmarkCoreFiltering(b *testing.B) { // Add explicit rules matching return traffic pattern for i := 0; i < 1000; i++ { // Simulate realistic ruleset size ip := generateRandomIPs(1)[0] - _, err := m.AddPeerFiltering( + _, err := m.AddFilterRule( nil, - ip, + pfx(ip), fw.Network{}, fw.ProtocolTCP, &fw.Port{Values: []uint16{uint16(1024 + i)}}, &fw.Port{Values: []uint16{80}}, - fw.ActionAccept, - "", - ) + fw.ActionAccept) require.NoError(b, err) } }, @@ -133,15 +131,13 @@ func BenchmarkCoreFiltering(b *testing.B) { stateful: true, setupFunc: func(m *Manager) { // Add some basic rules but rely on state for established connections - _, err := m.AddPeerFiltering( + _, err := m.AddFilterRule( nil, - net.ParseIP("0.0.0.0"), + pfx(net.ParseIP("0.0.0.0")), fw.Network{}, fw.ProtocolTCP, nil, nil, - fw.ActionDrop, - "", - ) + fw.ActionDrop) require.NoError(b, err) }, desc: "Connection tracking with established connections", @@ -168,9 +164,12 @@ func BenchmarkCoreFiltering(b *testing.B) { } // Create manager and basic setup - manager, _ := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) + require.NoError(b, err) defer b.Cleanup(func() { require.NoError(b, manager.Close(nil)) }) @@ -208,9 +207,12 @@ func BenchmarkStateScaling(b *testing.B) { for _, count := range connCounts { b.Run(fmt.Sprintf("conns_%d", count), func(b *testing.B) { - manager, _ := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) + require.NoError(b, err) b.Cleanup(func() { require.NoError(b, manager.Close(nil)) }) @@ -251,9 +253,12 @@ func BenchmarkEstablishmentOverhead(b *testing.B) { for _, sc := range scenarios { b.Run(sc.name, func(b *testing.B) { - manager, _ := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) + require.NoError(b, err) b.Cleanup(func() { require.NoError(b, manager.Close(nil)) }) @@ -409,9 +414,12 @@ func BenchmarkRoutedNetworkReturn(b *testing.B) { for _, sc := range scenarios { b.Run(sc.name, func(b *testing.B) { - manager, _ := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) + require.NoError(b, err) b.Cleanup(func() { require.NoError(b, manager.Close(nil)) }) @@ -536,9 +544,12 @@ func BenchmarkLongLivedConnections(b *testing.B) { require.NoError(b, os.Unsetenv("NB_DISABLE_CONNTRACK")) } - manager, _ := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) + require.NoError(b, err) defer b.Cleanup(func() { require.NoError(b, manager.Close(nil)) }) @@ -546,7 +557,7 @@ func BenchmarkLongLivedConnections(b *testing.B) { // Setup initial state based on scenario if sc.rules { // Single rule to allow all return traffic from port 80 - _, err := manager.AddPeerFiltering(nil, net.ParseIP("0.0.0.0"), fw.ProtocolTCP, &fw.Port{Values: []uint16{80}}, nil, fw.ActionAccept, "") + _, err := manager.AddFilterRule(nil, pfx(net.ParseIP("0.0.0.0")), fw.Network{}, fw.ProtocolTCP, &fw.Port{Values: []uint16{80}}, nil, fw.ActionAccept) require.NoError(b, err) } @@ -619,9 +630,12 @@ func BenchmarkShortLivedConnections(b *testing.B) { require.NoError(b, os.Unsetenv("NB_DISABLE_CONNTRACK")) } - manager, _ := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) + require.NoError(b, err) defer b.Cleanup(func() { require.NoError(b, manager.Close(nil)) }) @@ -629,7 +643,7 @@ func BenchmarkShortLivedConnections(b *testing.B) { // Setup initial state based on scenario if sc.rules { // Single rule to allow all return traffic from port 80 - _, err := manager.AddPeerFiltering(nil, net.ParseIP("0.0.0.0"), fw.ProtocolTCP, &fw.Port{Values: []uint16{80}}, nil, fw.ActionAccept, "") + _, err := manager.AddFilterRule(nil, pfx(net.ParseIP("0.0.0.0")), fw.Network{}, fw.ProtocolTCP, &fw.Port{Values: []uint16{80}}, nil, fw.ActionAccept) require.NoError(b, err) } @@ -730,16 +744,19 @@ func BenchmarkParallelLongLivedConnections(b *testing.B) { require.NoError(b, os.Unsetenv("NB_DISABLE_CONNTRACK")) } - manager, _ := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) + require.NoError(b, err) defer b.Cleanup(func() { require.NoError(b, manager.Close(nil)) }) // Setup initial state based on scenario if sc.rules { - _, err := manager.AddPeerFiltering(nil, net.ParseIP("0.0.0.0"), fw.ProtocolTCP, &fw.Port{Values: []uint16{80}}, nil, fw.ActionAccept, "") + _, err := manager.AddFilterRule(nil, pfx(net.ParseIP("0.0.0.0")), fw.Network{}, fw.ProtocolTCP, &fw.Port{Values: []uint16{80}}, nil, fw.ActionAccept) require.NoError(b, err) } @@ -810,15 +827,18 @@ func BenchmarkParallelShortLivedConnections(b *testing.B) { require.NoError(b, os.Unsetenv("NB_DISABLE_CONNTRACK")) } - manager, _ := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) + require.NoError(b, err) defer b.Cleanup(func() { require.NoError(b, manager.Close(nil)) }) if sc.rules { - _, err := manager.AddPeerFiltering(nil, net.ParseIP("0.0.0.0"), fw.ProtocolTCP, &fw.Port{Values: []uint16{80}}, nil, fw.ActionAccept, "") + _, err := manager.AddFilterRule(nil, pfx(net.ParseIP("0.0.0.0")), fw.Network{}, fw.ProtocolTCP, &fw.Port{Values: []uint16{80}}, nil, fw.ActionAccept) require.NoError(b, err) } @@ -931,7 +951,7 @@ func BenchmarkRouteACLs(b *testing.B) { for _, r := range rules { dst := fw.Network{Prefix: r.dest} - _, err := manager.AddRouteFiltering(nil, r.sources, dst, r.proto, nil, r.port, fw.ActionAccept) + _, err := manager.AddFilterRule(nil, r.sources, dst, r.proto, nil, r.port, fw.ActionAccept) if err != nil { b.Fatal(err) } @@ -1014,16 +1034,19 @@ func BenchmarkMSSClamping(b *testing.B) { for _, sc := range scenarios { b.Run(sc.name, func(b *testing.B) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(b, err) defer func() { require.NoError(b, manager.Close(nil)) }() manager.mssClampEnabled = true - manager.mssClampValue = 1240 + manager.mssClampValueIPv4 = 1240 + manager.mssClampValueIPv6 = 1220 srcIP := net.ParseIP("100.64.0.2") dstIP := net.ParseIP("8.8.8.8") @@ -1078,9 +1101,11 @@ func BenchmarkMSSClampingOverhead(b *testing.B) { for _, sc := range scenarios { b.Run(sc.name, func(b *testing.B) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(b, err) defer func() { require.NoError(b, manager.Close(nil)) @@ -1088,7 +1113,8 @@ func BenchmarkMSSClampingOverhead(b *testing.B) { manager.mssClampEnabled = sc.enabled if sc.enabled { - manager.mssClampValue = 1240 + manager.mssClampValueIPv4 = 1240 + manager.mssClampValueIPv6 = 1220 } srcIP := net.ParseIP("100.64.0.2") @@ -1132,16 +1158,19 @@ func BenchmarkMSSClampingMemory(b *testing.B) { for _, sc := range scenarios { b.Run(sc.name, func(b *testing.B) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(b, err) defer func() { require.NoError(b, manager.Close(nil)) }() manager.mssClampEnabled = true - manager.mssClampValue = 1240 + manager.mssClampValueIPv4 = 1240 + manager.mssClampValueIPv6 = 1220 srcIP := net.ParseIP("100.64.0.2") dstIP := net.ParseIP("8.8.8.8") diff --git a/client/firewall/uspfilter/filter_filter_test.go b/client/firewall/uspfilter/filter_filter_test.go index a8efbac1c..57b59c9bc 100644 --- a/client/firewall/uspfilter/filter_filter_test.go +++ b/client/firewall/uspfilter/filter_filter_test.go @@ -5,7 +5,7 @@ import ( "net/netip" "testing" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/google/gopacket" "github.com/google/gopacket/layers" "github.com/stretchr/testify/require" @@ -32,7 +32,7 @@ func TestPeerACLFiltering(t *testing.T) { }, } - manager, err := Create(ifaceMock, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(t, err) require.NotNil(t, manager) @@ -496,40 +496,32 @@ func TestPeerACLFiltering(t *testing.T) { t.Run(tc.name, func(t *testing.T) { if tc.ruleAction == fw.ActionDrop { // add general accept rule for the same IP to test drop rule precedence - rules, err := manager.AddPeerFiltering( + rules, err := manager.AddFilterRule( nil, - net.ParseIP(tc.ruleIP), + pfx(net.ParseIP(tc.ruleIP)), fw.Network{}, fw.ProtocolALL, nil, nil, - fw.ActionAccept, - "", - ) + fw.ActionAccept) require.NoError(t, err) - require.NotEmpty(t, rules) + require.NotNil(t, rules) t.Cleanup(func() { - for _, rule := range rules { - require.NoError(t, manager.DeletePeerRule(rule)) - } + require.NoError(t, manager.DeleteFilterRule(rules)) }) } - rules, err := manager.AddPeerFiltering( + rules, err := manager.AddFilterRule( nil, - net.ParseIP(tc.ruleIP), + pfx(net.ParseIP(tc.ruleIP)), fw.Network{}, tc.ruleProto, tc.ruleSrcPort, tc.ruleDstPort, - tc.ruleAction, - "", - ) + tc.ruleAction) require.NoError(t, err) - require.NotEmpty(t, rules) + require.NotNil(t, rules) t.Cleanup(func() { - for _, rule := range rules { - require.NoError(t, manager.DeletePeerRule(rule)) - } + require.NoError(t, manager.DeleteFilterRule(rules)) }) packet := createTestPacket(t, tc.srcIP, tc.dstIP, tc.proto, tc.srcPort, tc.dstPort) @@ -539,53 +531,242 @@ func TestPeerACLFiltering(t *testing.T) { } } +func TestPeerACLFilteringIPv6(t *testing.T) { + localIP := netip.MustParseAddr("100.10.0.100") + localIPv6 := netip.MustParseAddr("fd00::100") + wgNet := netip.MustParsePrefix("100.10.0.0/16") + wgNetV6 := netip.MustParsePrefix("fd00::/64") + + ifaceMock := &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + AddressFunc: func() wgaddr.Address { + return wgaddr.Address{ + IP: localIP, + Network: wgNet, + IPv6: localIPv6, + IPv6Net: wgNetV6, + } + }, + } + + manager, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: iface.DefaultMTU}) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, manager.Close(nil)) }) + + err = manager.UpdateLocalIPs() + require.NoError(t, err) + + testCases := []struct { + name string + srcIP string + dstIP string + proto fw.Protocol + srcPort uint16 + dstPort uint16 + ruleIP string + ruleProto fw.Protocol + ruleDstPort *fw.Port + ruleAction fw.Action + shouldBeBlocked bool + }{ + { + name: "IPv6: allow TCP from peer", + srcIP: "fd00::1", + dstIP: "fd00::100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 443, + ruleIP: "fd00::1", + ruleProto: fw.ProtocolTCP, + ruleDstPort: &fw.Port{Values: []uint16{443}}, + ruleAction: fw.ActionAccept, + shouldBeBlocked: false, + }, + { + name: "IPv6: allow UDP from peer", + srcIP: "fd00::1", + dstIP: "fd00::100", + proto: fw.ProtocolUDP, + srcPort: 12345, + dstPort: 53, + ruleIP: "fd00::1", + ruleProto: fw.ProtocolUDP, + ruleDstPort: &fw.Port{Values: []uint16{53}}, + ruleAction: fw.ActionAccept, + shouldBeBlocked: false, + }, + { + name: "IPv6: allow ICMPv6 from peer", + srcIP: "fd00::1", + dstIP: "fd00::100", + proto: fw.ProtocolICMP, + ruleIP: "fd00::1", + ruleProto: fw.ProtocolICMP, + ruleAction: fw.ActionAccept, + shouldBeBlocked: false, + }, + { + name: "IPv6: block TCP without rule", + srcIP: "fd00::2", + dstIP: "fd00::100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 443, + ruleIP: "fd00::1", + ruleProto: fw.ProtocolTCP, + ruleDstPort: &fw.Port{Values: []uint16{443}}, + ruleAction: fw.ActionAccept, + shouldBeBlocked: true, + }, + { + name: "IPv6: drop rule", + srcIP: "fd00::1", + dstIP: "fd00::100", + proto: fw.ProtocolTCP, + srcPort: 12345, + dstPort: 22, + ruleIP: "fd00::1", + ruleProto: fw.ProtocolTCP, + ruleDstPort: &fw.Port{Values: []uint16{22}}, + ruleAction: fw.ActionDrop, + shouldBeBlocked: true, + }, + { + name: "IPv6: allow all protocols", + srcIP: "fd00::1", + dstIP: "fd00::100", + proto: fw.ProtocolUDP, + srcPort: 12345, + dstPort: 9999, + ruleIP: "fd00::1", + ruleProto: fw.ProtocolALL, + ruleAction: fw.ActionAccept, + shouldBeBlocked: false, + }, + { + name: "IPv6: v4 wildcard ICMP rule does not match ICMPv6", + srcIP: "fd00::1", + dstIP: "fd00::100", + proto: fw.ProtocolICMP, + ruleIP: "0.0.0.0", + ruleProto: fw.ProtocolICMP, + ruleAction: fw.ActionAccept, + shouldBeBlocked: true, + }, + { + name: "IPv4: v6 wildcard ICMP rule does not match ICMPv4", + srcIP: "100.10.0.1", + dstIP: "100.10.0.100", + proto: fw.ProtocolICMP, + ruleIP: "::", + ruleProto: fw.ProtocolICMP, + ruleAction: fw.ActionAccept, + shouldBeBlocked: true, + }, + } + + t.Run("IPv6 implicit DROP (no rules)", func(t *testing.T) { + packet := createTestPacket(t, "fd00::1", "fd00::100", fw.ProtocolTCP, 12345, 443) + isDropped := manager.FilterInbound(packet, 0) + require.True(t, isDropped, "IPv6 packet should be dropped when no rules exist") + }) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.ruleAction == fw.ActionDrop { + rules, err := manager.AddFilterRule(nil, pfx(net.ParseIP(tc.ruleIP)), fw.Network{}, fw.ProtocolALL, nil, nil, fw.ActionAccept) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, manager.DeleteFilterRule(rules)) + }) + } + + rules, err := manager.AddFilterRule(nil, pfx(net.ParseIP(tc.ruleIP)), fw.Network{}, tc.ruleProto, nil, tc.ruleDstPort, tc.ruleAction) + require.NoError(t, err) + require.NotNil(t, rules) + t.Cleanup(func() { + require.NoError(t, manager.DeleteFilterRule(rules)) + }) + + packet := createTestPacket(t, tc.srcIP, tc.dstIP, tc.proto, tc.srcPort, tc.dstPort) + isDropped := manager.FilterInbound(packet, 0) + require.Equal(t, tc.shouldBeBlocked, isDropped, "packet filter result mismatch") + }) + } +} + func createTestPacket(t *testing.T, srcIP, dstIP string, proto fw.Protocol, srcPort, dstPort uint16) []byte { t.Helper() + src := net.ParseIP(srcIP) + dst := net.ParseIP(dstIP) + buf := gopacket.NewSerializeBuffer() opts := gopacket.SerializeOptions{ ComputeChecksums: true, FixLengths: true, } - ipLayer := &layers.IPv4{ - Version: 4, - TTL: 64, - SrcIP: net.ParseIP(srcIP), - DstIP: net.ParseIP(dstIP), - } + // Detect address family + isV6 := src.To4() == nil var err error - switch proto { - case fw.ProtocolTCP: - ipLayer.Protocol = layers.IPProtocolTCP - tcp := &layers.TCP{ - SrcPort: layers.TCPPort(srcPort), - DstPort: layers.TCPPort(dstPort), - } - err = tcp.SetNetworkLayerForChecksum(ipLayer) - require.NoError(t, err) - err = gopacket.SerializeLayers(buf, opts, ipLayer, tcp) - case fw.ProtocolUDP: - ipLayer.Protocol = layers.IPProtocolUDP - udp := &layers.UDP{ - SrcPort: layers.UDPPort(srcPort), - DstPort: layers.UDPPort(dstPort), + if isV6 { + ip6 := &layers.IPv6{ + Version: 6, + HopLimit: 64, + SrcIP: src, + DstIP: dst, } - err = udp.SetNetworkLayerForChecksum(ipLayer) - require.NoError(t, err) - err = gopacket.SerializeLayers(buf, opts, ipLayer, udp) - case fw.ProtocolICMP: - ipLayer.Protocol = layers.IPProtocolICMPv4 - icmp := &layers.ICMPv4{ - TypeCode: layers.CreateICMPv4TypeCode(layers.ICMPv4TypeEchoRequest, 0), + switch proto { + case fw.ProtocolTCP: + ip6.NextHeader = layers.IPProtocolTCP + tcp := &layers.TCP{SrcPort: layers.TCPPort(srcPort), DstPort: layers.TCPPort(dstPort)} + _ = tcp.SetNetworkLayerForChecksum(ip6) + err = gopacket.SerializeLayers(buf, opts, ip6, tcp) + case fw.ProtocolUDP: + ip6.NextHeader = layers.IPProtocolUDP + udp := &layers.UDP{SrcPort: layers.UDPPort(srcPort), DstPort: layers.UDPPort(dstPort)} + _ = udp.SetNetworkLayerForChecksum(ip6) + err = gopacket.SerializeLayers(buf, opts, ip6, udp) + case fw.ProtocolICMP: + ip6.NextHeader = layers.IPProtocolICMPv6 + icmp := &layers.ICMPv6{ + TypeCode: layers.CreateICMPv6TypeCode(layers.ICMPv6TypeEchoRequest, 0), + } + _ = icmp.SetNetworkLayerForChecksum(ip6) + err = gopacket.SerializeLayers(buf, opts, ip6, icmp) + default: + err = gopacket.SerializeLayers(buf, opts, ip6) + } + } else { + ip4 := &layers.IPv4{ + Version: 4, + TTL: 64, + SrcIP: src, + DstIP: dst, } - err = gopacket.SerializeLayers(buf, opts, ipLayer, icmp) - default: - err = gopacket.SerializeLayers(buf, opts, ipLayer) + switch proto { + case fw.ProtocolTCP: + ip4.Protocol = layers.IPProtocolTCP + tcp := &layers.TCP{SrcPort: layers.TCPPort(srcPort), DstPort: layers.TCPPort(dstPort)} + _ = tcp.SetNetworkLayerForChecksum(ip4) + err = gopacket.SerializeLayers(buf, opts, ip4, tcp) + case fw.ProtocolUDP: + ip4.Protocol = layers.IPProtocolUDP + udp := &layers.UDP{SrcPort: layers.UDPPort(srcPort), DstPort: layers.UDPPort(dstPort)} + _ = udp.SetNetworkLayerForChecksum(ip4) + err = gopacket.SerializeLayers(buf, opts, ip4, udp) + case fw.ProtocolICMP: + ip4.Protocol = layers.IPProtocolICMPv4 + icmp := &layers.ICMPv4{TypeCode: layers.CreateICMPv4TypeCode(layers.ICMPv4TypeEchoRequest, 0)} + err = gopacket.SerializeLayers(buf, opts, ip4, icmp) + default: + err = gopacket.SerializeLayers(buf, opts, ip4) + } } require.NoError(t, err) @@ -617,7 +798,7 @@ func setupRoutedManager(tb testing.TB, network string) *Manager { }, } - manager, err := Create(ifaceMock, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(tb, err) require.NoError(tb, manager.EnableRouting()) require.NotNil(tb, manager) @@ -1222,7 +1403,7 @@ func TestRouteACLFiltering(t *testing.T) { t.Run(tc.name, func(t *testing.T) { if tc.rule.action == fw.ActionDrop { // add general accept rule to test drop rule - rule, err := manager.AddRouteFiltering( + rule, err := manager.AddFilterRule( nil, []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")}, fw.Network{Prefix: netip.MustParsePrefix("0.0.0.0/0")}, @@ -1232,13 +1413,13 @@ func TestRouteACLFiltering(t *testing.T) { fw.ActionAccept, ) require.NoError(t, err) - require.NotNil(t, rule) + require.NotEmpty(t, rule) t.Cleanup(func() { - require.NoError(t, manager.DeleteRouteRule(rule)) + require.NoError(t, manager.DeleteFilterRule(rule)) }) } - rule, err := manager.AddRouteFiltering( + rule, err := manager.AddFilterRule( nil, tc.rule.sources, tc.rule.dest, @@ -1248,10 +1429,10 @@ func TestRouteACLFiltering(t *testing.T) { tc.rule.action, ) require.NoError(t, err) - require.NotNil(t, rule) + require.NotEmpty(t, rule) t.Cleanup(func() { - require.NoError(t, manager.DeleteRouteRule(rule)) + require.NoError(t, manager.DeleteFilterRule(rule)) }) srcIP := netip.MustParseAddr(tc.srcIP) @@ -1419,9 +1600,9 @@ func TestRouteACLOrder(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - var rules []fw.Rule + var addedRules []fw.Rule for _, r := range tc.rules { - rule, err := manager.AddRouteFiltering( + rule, err := manager.AddFilterRule( nil, r.sources, r.dest, @@ -1432,12 +1613,12 @@ func TestRouteACLOrder(t *testing.T) { ) require.NoError(t, err) require.NotNil(t, rule) - rules = append(rules, rule) + addedRules = append(addedRules, rule) } t.Cleanup(func() { - for _, rule := range rules { - require.NoError(t, manager.DeleteRouteRule(rule)) + for _, rule := range addedRules { + require.NoError(t, manager.DeleteFilterRule(rule)) } }) @@ -1463,7 +1644,7 @@ func TestRouteACLSet(t *testing.T) { }, } - manager, err := Create(ifaceMock, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, manager.Close(nil)) @@ -1472,7 +1653,7 @@ func TestRouteACLSet(t *testing.T) { set := fw.NewDomainSet(domain.List{"example.org"}) // Add rule that uses the set (initially empty) - rule, err := manager.AddRouteFiltering( + rule, err := manager.AddFilterRule( nil, []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")}, fw.Network{Set: set}, @@ -1498,3 +1679,103 @@ func TestRouteACLSet(t *testing.T) { _, isAllowed = manager.routeACLsPass(srcIP, dstIP, protoToLayer(fw.ProtocolTCP, layers.LayerTypeIPv4), 12345, 80) require.True(t, isAllowed, "After set update, traffic to the added network should be allowed") } + +// TestRouteACLFilteringIPv6 tests IPv6 route ACL matching directly via routeACLsPass. +// Note: full FilterInbound for routed IPv6 traffic drops at the forwarder stage (IPv4-only) +// but the ACL decision itself is correct. +func TestRouteACLFilteringIPv6(t *testing.T) { + manager := setupRoutedManager(t, "10.10.0.100/16") + + v6Dst := netip.MustParsePrefix("fd00:dead:beef::/48") + _, err := manager.AddFilterRule( + nil, + []netip.Prefix{netip.MustParsePrefix("fd00::/16")}, + fw.Network{Prefix: v6Dst}, + fw.ProtocolTCP, + nil, + &fw.Port{Values: []uint16{80}}, + fw.ActionAccept, + ) + require.NoError(t, err) + + _, err = manager.AddFilterRule( + nil, + []netip.Prefix{netip.MustParsePrefix("fd00::/16")}, + fw.Network{Prefix: netip.MustParsePrefix("fd00:dead:beef:1::/64")}, + fw.ProtocolALL, + nil, + nil, + fw.ActionDrop, + ) + require.NoError(t, err) + + tests := []struct { + name string + srcIP netip.Addr + dstIP netip.Addr + proto gopacket.LayerType + srcPort uint16 + dstPort uint16 + allowed bool + }{ + { + name: "IPv6 TCP to allowed dest", + srcIP: netip.MustParseAddr("fd00::1"), + dstIP: netip.MustParseAddr("fd00:dead:beef::80"), + proto: layers.LayerTypeTCP, + srcPort: 12345, + dstPort: 80, + allowed: true, + }, + { + name: "IPv6 TCP wrong port", + srcIP: netip.MustParseAddr("fd00::1"), + dstIP: netip.MustParseAddr("fd00:dead:beef::80"), + proto: layers.LayerTypeTCP, + srcPort: 12345, + dstPort: 443, + allowed: false, + }, + { + name: "IPv6 UDP not matched by TCP rule", + srcIP: netip.MustParseAddr("fd00::1"), + dstIP: netip.MustParseAddr("fd00:dead:beef::80"), + proto: layers.LayerTypeUDP, + srcPort: 12345, + dstPort: 80, + allowed: false, + }, + { + name: "IPv6 ICMPv6 matches ICMP rule via protoLayerMatches", + srcIP: netip.MustParseAddr("fd00::1"), + dstIP: netip.MustParseAddr("fd00:dead:beef::80"), + proto: layers.LayerTypeICMPv6, + allowed: false, + }, + { + name: "IPv6 to denied subnet", + srcIP: netip.MustParseAddr("fd00::1"), + dstIP: netip.MustParseAddr("fd00:dead:beef:1::1"), + proto: layers.LayerTypeTCP, + srcPort: 12345, + dstPort: 80, + allowed: false, + }, + { + name: "IPv6 source outside allowed range", + srcIP: netip.MustParseAddr("fe80::1"), + dstIP: netip.MustParseAddr("fd00:dead:beef::80"), + proto: layers.LayerTypeTCP, + srcPort: 12345, + dstPort: 80, + allowed: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, pass := manager.routeACLsPass(tc.srcIP, tc.dstIP, tc.proto, tc.srcPort, tc.dstPort) + require.Equal(t, tc.allowed, pass, "route ACL result mismatch") + }) + } +} diff --git a/client/firewall/uspfilter/filter_routeacl_test.go b/client/firewall/uspfilter/filter_routeacl_test.go index 68572a01c..2a0980e83 100644 --- a/client/firewall/uspfilter/filter_routeacl_test.go +++ b/client/firewall/uspfilter/filter_routeacl_test.go @@ -4,7 +4,7 @@ import ( "net/netip" "testing" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/google/gopacket/layers" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -29,7 +29,7 @@ func TestAddRouteFilteringReturnsExistingRule(t *testing.T) { destination := fw.Network{Prefix: netip.MustParsePrefix("192.168.1.0/24")} // Add rule first time - rule1, err := manager.AddRouteFiltering( + rule1, err := manager.AddFilterRule( []byte("policy-1"), sources, destination, @@ -42,7 +42,7 @@ func TestAddRouteFilteringReturnsExistingRule(t *testing.T) { require.NotNil(t, rule1) // Add the same rule again - rule2, err := manager.AddRouteFiltering( + rule2, err := manager.AddFilterRule( []byte("policy-1"), sources, destination, @@ -74,7 +74,7 @@ func TestAddRouteFilteringDifferentRulesGetDifferentIDs(t *testing.T) { sources := []netip.Prefix{netip.MustParsePrefix("100.64.1.0/24")} // Add first rule - rule1, err := manager.AddRouteFiltering( + rule1, err := manager.AddFilterRule( []byte("policy-1"), sources, fw.Network{Prefix: netip.MustParsePrefix("192.168.1.0/24")}, @@ -86,7 +86,7 @@ func TestAddRouteFilteringDifferentRulesGetDifferentIDs(t *testing.T) { require.NoError(t, err) // Add different rule (different destination) - rule2, err := manager.AddRouteFiltering( + rule2, err := manager.AddFilterRule( []byte("policy-2"), sources, fw.Network{Prefix: netip.MustParsePrefix("192.168.2.0/24")}, // Different! @@ -115,7 +115,7 @@ func TestRouteRuleUpdateDoesNotCauseGap(t *testing.T) { sources := []netip.Prefix{netip.MustParsePrefix("100.64.1.0/24")} destination := fw.Network{Prefix: netip.MustParsePrefix("192.168.1.0/24")} - rule1, err := manager.AddRouteFiltering( + rule1, err := manager.AddFilterRule( []byte("policy-1"), sources, destination, @@ -132,7 +132,7 @@ func TestRouteRuleUpdateDoesNotCauseGap(t *testing.T) { require.True(t, pass, "Traffic should pass with rule in place") // Re-add same rule (simulates network map update) - rule2, err := manager.AddRouteFiltering( + rule2, err := manager.AddFilterRule( []byte("policy-1"), sources, destination, @@ -147,7 +147,7 @@ func TestRouteRuleUpdateDoesNotCauseGap(t *testing.T) { // won't delete rule1 during cleanup. If IDs differed, deleting rule1 // would remove the only matching rule and cause a traffic gap. if rule1.ID() != rule2.ID() { - err = manager.DeleteRouteRule(rule1) + err = manager.DeleteFilterRule(rule1) require.NoError(t, err) } @@ -156,6 +156,59 @@ func TestRouteRuleUpdateDoesNotCauseGap(t *testing.T) { "Traffic should still pass after rule update - no gap should occur") } +// TestBlockInvalidRoutedDualStack verifies that when the interface has an +// IPv6 overlay address, blockInvalidRouted installs a block rule for both +// the v4 and v6 WG prefixes and that routed traffic to the v6 prefix is +// denied. The v4-only soft-skip path is covered by +// TestBlockInvalidRoutedIdempotent, whose mock leaves IPv6Net invalid. +func TestBlockInvalidRoutedDualStack(t *testing.T) { + ctrl := gomock.NewController(t) + dev := mocks.NewMockDevice(ctrl) + dev.EXPECT().MTU().Return(1500, nil).AnyTimes() + + wgNet := netip.MustParsePrefix("100.64.0.1/16") + wgNet6 := netip.MustParsePrefix("fd00:1234::1/64") + + ifaceMock := &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + AddressFunc: func() wgaddr.Address { + return wgaddr.Address{ + IP: wgNet.Addr(), + Network: wgNet, + IPv6: wgNet6.Addr(), + IPv6Net: wgNet6, + } + }, + GetDeviceFunc: func() *device.FilteredDevice { + return &device.FilteredDevice{Device: dev} + }, + GetWGDeviceFunc: func() *wgdevice.Device { + return &wgdevice.Device{} + }, + } + + manager, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: iface.DefaultMTU}) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, manager.Close(nil)) + }) + + rules, err := manager.blockInvalidRouted(ifaceMock) + require.NoError(t, err) + require.Len(t, rules, 2, "dual-stack interface must produce a v4 and a v6 block rule") + + manager.mutex.RLock() + ruleCount := len(manager.routeRules) + manager.mutex.RUnlock() + assert.Equal(t, 2, ruleCount, "should have one block rule per family") + + // v6 routed traffic to the WG prefix must be denied. + srcIP := netip.MustParseAddr("2001:db8::1") + dstIP := netip.MustParseAddr("fd00:1234::50") + _, pass := manager.routeACLsPass(srcIP, dstIP, layers.LayerTypeTCP, 12345, 80) + assert.False(t, pass, "block rule should deny routed traffic to the v6 WG prefix") +} + // TestBlockInvalidRoutedIdempotent verifies that blockInvalidRouted creates // exactly one drop rule for the WireGuard network prefix, and calling it again // returns the same rule without duplicating. @@ -182,28 +235,28 @@ func TestBlockInvalidRoutedIdempotent(t *testing.T) { }, } - manager, err := Create(ifaceMock, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, manager.Close(nil)) }) // Call blockInvalidRouted directly multiple times - rule1, err := manager.blockInvalidRouted(ifaceMock) + rules1, err := manager.blockInvalidRouted(ifaceMock) require.NoError(t, err) - require.NotNil(t, rule1) + require.NotEmpty(t, rules1) - rule2, err := manager.blockInvalidRouted(ifaceMock) + rules2, err := manager.blockInvalidRouted(ifaceMock) require.NoError(t, err) - require.NotNil(t, rule2) + require.NotEmpty(t, rules2) - rule3, err := manager.blockInvalidRouted(ifaceMock) + rules3, err := manager.blockInvalidRouted(ifaceMock) require.NoError(t, err) - require.NotNil(t, rule3) + require.NotEmpty(t, rules3) - // 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") + // All calls should return the same v4 block rule (idempotent install). + assert.Equal(t, rules1[0].ID(), rules2[0].ID(), "Second call should return same v4 rule") + assert.Equal(t, rules2[0].ID(), rules3[0].ID(), "Third call should return same v4 rule") // Should have exactly 1 route rule manager.mutex.RLock() @@ -245,7 +298,7 @@ func TestBlockRuleNotAccumulatedOnRepeatedEnableRouting(t *testing.T) { }, } - manager, err := Create(ifaceMock, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, manager.Close(nil)) @@ -274,7 +327,7 @@ func TestRouteRuleCountStableAcrossUpdates(t *testing.T) { // Simulate 5 network map updates with the same route rule for i := 0; i < 5; i++ { - rule, err := manager.AddRouteFiltering( + rule, err := manager.AddFilterRule( []byte("policy-1"), sources, destination, @@ -304,7 +357,7 @@ func TestDeleteRouteRuleAfterIdempotentAdd(t *testing.T) { destination := fw.Network{Prefix: netip.MustParsePrefix("192.168.1.0/24")} // Add same rule twice - rule1, err := manager.AddRouteFiltering( + rule1, err := manager.AddFilterRule( []byte("policy-1"), sources, destination, @@ -315,7 +368,7 @@ func TestDeleteRouteRuleAfterIdempotentAdd(t *testing.T) { ) require.NoError(t, err) - rule2, err := manager.AddRouteFiltering( + rule2, err := manager.AddFilterRule( []byte("policy-1"), sources, destination, @@ -329,7 +382,7 @@ func TestDeleteRouteRuleAfterIdempotentAdd(t *testing.T) { require.Equal(t, rule1.ID(), rule2.ID(), "Should return same rule ID") // Delete using first reference - err = manager.DeleteRouteRule(rule1) + err = manager.DeleteFilterRule(rule1) require.NoError(t, err) // Verify traffic no longer passes @@ -364,7 +417,7 @@ func setupTestManager(t *testing.T) *Manager { }, } - manager, err := Create(ifaceMock, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(t, err) require.NoError(t, manager.EnableRouting()) diff --git a/client/firewall/uspfilter/filter_test.go b/client/firewall/uspfilter/filter_test.go index 5fb9fef0e..280ce9311 100644 --- a/client/firewall/uspfilter/filter_test.go +++ b/client/firewall/uspfilter/filter_test.go @@ -78,18 +78,19 @@ func TestManagerCreate(t *testing.T) { SetFilterFunc: func(device.PacketFilter) error { return nil }, } - m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + m, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) if err != nil { t.Errorf("failed to create Manager: %v", err) return } + t.Cleanup(func() { require.NoError(t, m.Close(nil)) }) if m == nil { t.Error("Manager is nil") } } -func TestManagerAddPeerFiltering(t *testing.T) { +func TestManagerAddFilterRule(t *testing.T) { isSetFilterCalled := false ifaceMock := &IFaceMock{ SetFilterFunc: func(device.PacketFilter) error { @@ -98,18 +99,19 @@ func TestManagerAddPeerFiltering(t *testing.T) { }, } - m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + m, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) if err != nil { t.Errorf("failed to create Manager: %v", err) return } + t.Cleanup(func() { require.NoError(t, m.Close(nil)) }) ip := net.ParseIP("192.168.1.1") proto := fw.ProtocolTCP port := &fw.Port{Values: []uint16{80}} action := fw.ActionDrop - rule, err := m.AddPeerFiltering(nil, ip, proto, nil, port, action, "") + rule, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, port, action) if err != nil { t.Errorf("failed to add filtering: %v", err) return @@ -131,74 +133,47 @@ func TestManagerDeleteRule(t *testing.T) { SetFilterFunc: func(device.PacketFilter) error { return nil }, } - m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + m, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) if err != nil { t.Errorf("failed to create Manager: %v", err) return } + t.Cleanup(func() { require.NoError(t, m.Close(nil)) }) ip := netip.MustParseAddr("192.168.1.1") proto := fw.ProtocolTCP port := &fw.Port{Values: []uint16{80}} action := fw.ActionDrop - rule2, err := m.AddPeerFiltering(nil, ip.AsSlice(), proto, nil, port, action, "") + rule2, err := m.AddFilterRule(nil, pfx(ip.AsSlice()), fw.Network{}, proto, nil, port, action) if err != nil { t.Errorf("failed to add filtering: %v", err) return } - // Check rules exist in appropriate maps - for _, r := range rule2 { - peerRule, ok := r.(*PeerRule) - if !ok { - t.Errorf("rule should be a PeerRule") - continue - } - // Check if rule exists in deny or allow maps based on action - var found bool - if peerRule.drop { - _, found = m.incomingDenyRules[ip][r.ID()] - } else { - _, found = m.incomingRules[ip][r.ID()] - } - if !found { - t.Errorf("rule2 is not in the expected rules map") + peerRule, ok := rule2.(*PeerRule) + require.True(t, ok, "rule should be a peer rule") + + inMap := func() bool { + if peerRule.action == fw.ActionDrop { + return findRuleByID(m.incomingDenyRules, ip, rule2.ID()) } + return findRuleByID(m.incomingAcceptRules, ip, rule2.ID()) } - for _, r := range rule2 { - err = m.DeletePeerRule(r) - if err != nil { - t.Errorf("failed to delete rule: %v", err) - return - } - } + require.True(t, inMap(), "rule2 should be in the expected rules list") - // Check rules are removed from appropriate maps - for _, r := range rule2 { - peerRule, ok := r.(*PeerRule) - if !ok { - t.Errorf("rule should be a PeerRule") - continue - } - // Check if rule is removed from deny or allow maps based on action - var found bool - if peerRule.drop { - _, found = m.incomingDenyRules[ip][r.ID()] - } else { - _, found = m.incomingRules[ip][r.ID()] - } - if found { - t.Errorf("rule2 should be removed from the rules map") - } - } + require.NoError(t, m.DeleteFilterRule(rule2), "failed to delete rule") + + require.False(t, inMap(), "rule2 should be removed from the rules list") } func TestSetUDPPacketHook(t *testing.T) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, nbiface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, manager.Close(nil)) }) @@ -220,9 +195,11 @@ func TestSetUDPPacketHook(t *testing.T) { } func TestSetTCPPacketHook(t *testing.T) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, nbiface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, manager.Close(nil)) }) @@ -250,7 +227,7 @@ func TestPeerRuleLifecycleDenyRules(t *testing.T) { SetFilterFunc: func(device.PacketFilter) error { return nil }, } - m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + m, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) require.NoError(t, err) defer func() { require.NoError(t, m.Close(nil)) @@ -260,36 +237,34 @@ func TestPeerRuleLifecycleDenyRules(t *testing.T) { addr := netip.MustParseAddr("192.168.1.1") // Add multiple deny rules for different ports - rule1, err := m.AddPeerFiltering(nil, ip, fw.ProtocolTCP, nil, - &fw.Port{Values: []uint16{22}}, fw.ActionDrop, "") + rule1, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{22}}, fw.ActionDrop) require.NoError(t, err) - rule2, err := m.AddPeerFiltering(nil, ip, fw.ProtocolTCP, nil, - &fw.Port{Values: []uint16{80}}, fw.ActionDrop, "") + rule2, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionDrop) require.NoError(t, err) m.mutex.RLock() - denyCount := len(m.incomingDenyRules[addr]) + denyCount := countRulesForAddr(m.incomingDenyRules, addr) m.mutex.RUnlock() require.Equal(t, 2, denyCount, "Should have exactly 2 deny rules") // Delete the first deny rule - err = m.DeletePeerRule(rule1[0]) + err = m.DeleteFilterRule(rule1) require.NoError(t, err) m.mutex.RLock() - denyCount = len(m.incomingDenyRules[addr]) + denyCount = countRulesForAddr(m.incomingDenyRules, addr) m.mutex.RUnlock() require.Equal(t, 1, denyCount, "Should have 1 deny rule after deleting first") // Delete the second deny rule - err = m.DeletePeerRule(rule2[0]) + err = m.DeleteFilterRule(rule2) require.NoError(t, err) m.mutex.RLock() - _, exists := m.incomingDenyRules[addr] + exists := countRulesForAddr(m.incomingDenyRules, addr) > 0 m.mutex.RUnlock() - require.False(t, exists, "Deny rules IP entry should be cleaned up when empty") + require.False(t, exists, "Deny rules should be cleaned up when empty") } // TestPeerRuleAddAndDeleteDontLeak verifies that repeatedly adding and deleting @@ -299,7 +274,7 @@ func TestPeerRuleAddAndDeleteDontLeak(t *testing.T) { SetFilterFunc: func(device.PacketFilter) error { return nil }, } - m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + m, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) require.NoError(t, err) defer func() { require.NoError(t, m.Close(nil)) @@ -311,27 +286,21 @@ func TestPeerRuleAddAndDeleteDontLeak(t *testing.T) { // Simulate 10 network map updates: add rule, delete old, add new for i := 0; i < 10; i++ { // Add a deny rule - rules, err := m.AddPeerFiltering(nil, ip, fw.ProtocolTCP, nil, - &fw.Port{Values: []uint16{22}}, fw.ActionDrop, "") + rules, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{22}}, fw.ActionDrop) require.NoError(t, err) // Add an allow rule - allowRules, err := m.AddPeerFiltering(nil, ip, fw.ProtocolTCP, nil, - &fw.Port{Values: []uint16{80}}, fw.ActionAccept, "") + allowRules, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionAccept) require.NoError(t, err) // Delete them (simulating ACL manager cleanup) - for _, r := range rules { - require.NoError(t, m.DeletePeerRule(r)) - } - for _, r := range allowRules { - require.NoError(t, m.DeletePeerRule(r)) - } + require.NoError(t, m.DeleteFilterRule(rules)) + require.NoError(t, m.DeleteFilterRule(allowRules)) } m.mutex.RLock() - denyCount := len(m.incomingDenyRules[addr]) - allowCount := len(m.incomingRules[addr]) + denyCount := countRulesForAddr(m.incomingDenyRules, addr) + allowCount := countRulesForAddr(m.incomingAcceptRules, addr) m.mutex.RUnlock() require.Equal(t, 0, denyCount, "No deny rules should remain after cleanup") @@ -345,7 +314,7 @@ func TestMixedAllowDenyRulesSameIP(t *testing.T) { SetFilterFunc: func(device.PacketFilter) error { return nil }, } - m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + m, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) require.NoError(t, err) defer func() { require.NoError(t, m.Close(nil)) @@ -354,41 +323,39 @@ func TestMixedAllowDenyRulesSameIP(t *testing.T) { ip := net.ParseIP("192.168.1.1") // Add allow rule for port 80 - allowRule, err := m.AddPeerFiltering(nil, ip, fw.ProtocolTCP, nil, - &fw.Port{Values: []uint16{80}}, fw.ActionAccept, "") + allowRule, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionAccept) require.NoError(t, err) // Add deny rule for port 22 - denyRule, err := m.AddPeerFiltering(nil, ip, fw.ProtocolTCP, nil, - &fw.Port{Values: []uint16{22}}, fw.ActionDrop, "") + denyRule, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{22}}, fw.ActionDrop) require.NoError(t, err) addr := netip.MustParseAddr("192.168.1.1") m.mutex.RLock() - allowCount := len(m.incomingRules[addr]) - denyCount := len(m.incomingDenyRules[addr]) + allowCount := countRulesForAddr(m.incomingAcceptRules, addr) + denyCount := countRulesForAddr(m.incomingDenyRules, addr) m.mutex.RUnlock() require.Equal(t, 1, allowCount, "Should have 1 allow rule") require.Equal(t, 1, denyCount, "Should have 1 deny rule") // Delete allow rule should not affect deny rule - err = m.DeletePeerRule(allowRule[0]) + err = m.DeleteFilterRule(allowRule) require.NoError(t, err) m.mutex.RLock() - denyCountAfter := len(m.incomingDenyRules[addr]) + denyCountAfter := countRulesForAddr(m.incomingDenyRules, addr) m.mutex.RUnlock() require.Equal(t, 1, denyCountAfter, "Deny rule should still exist after deleting allow rule") // Delete deny rule - err = m.DeletePeerRule(denyRule[0]) + err = m.DeleteFilterRule(denyRule) require.NoError(t, err) m.mutex.RLock() - _, denyExists := m.incomingDenyRules[addr] - _, allowExists := m.incomingRules[addr] + denyExists := countRulesForAddr(m.incomingDenyRules, addr) > 0 + allowExists := countRulesForAddr(m.incomingAcceptRules, addr) > 0 m.mutex.RUnlock() require.False(t, denyExists, "Deny rules should be empty") @@ -400,7 +367,7 @@ func TestManagerReset(t *testing.T) { SetFilterFunc: func(device.PacketFilter) error { return nil }, } - m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + m, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) if err != nil { t.Errorf("failed to create Manager: %v", err) return @@ -411,7 +378,7 @@ func TestManagerReset(t *testing.T) { port := &fw.Port{Values: []uint16{80}} action := fw.ActionDrop - _, err = m.AddPeerFiltering(nil, ip, proto, nil, port, action, "") + _, err = m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, port, action) if err != nil { t.Errorf("failed to add filtering: %v", err) return @@ -423,7 +390,7 @@ func TestManagerReset(t *testing.T) { return } - if len(m.outgoingRules) != 0 || len(m.incomingRules) != 0 || len(m.incomingDenyRules) != 0 { + if len(m.incomingAcceptRules) != 0 || len(m.incomingDenyRules) != 0 { t.Errorf("rules are not empty") } } @@ -439,7 +406,7 @@ func TestNotMatchByIP(t *testing.T) { }, } - m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + m, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) if err != nil { t.Errorf("failed to create Manager: %v", err) return @@ -449,7 +416,7 @@ func TestNotMatchByIP(t *testing.T) { proto := fw.ProtocolUDP action := fw.ActionAccept - _, err = m.AddPeerFiltering(nil, ip, proto, nil, nil, action, "") + _, err = m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, nil, action) if err != nil { t.Errorf("failed to add filtering: %v", err) return @@ -502,7 +469,7 @@ func TestRemovePacketHook(t *testing.T) { } // creating manager instance - manager, err := Create(iface, false, flowLogger, nbiface.DefaultMTU) + manager, err := Create(Config{IFace: iface, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) if err != nil { t.Fatalf("Failed to create Manager: %s", err) } @@ -519,9 +486,11 @@ func TestRemovePacketHook(t *testing.T) { } func TestProcessOutgoingHooks(t *testing.T) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, nbiface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) require.NoError(t, err) manager.udpTracker.Close() @@ -535,11 +504,16 @@ func TestProcessOutgoingHooks(t *testing.T) { d := &decoder{ decoded: []gopacket.LayerType{}, } - d.parser = gopacket.NewDecodingLayerParser( + d.parser4 = gopacket.NewDecodingLayerParser( layers.LayerTypeIPv4, &d.eth, &d.ip4, &d.ip6, &d.icmp4, &d.icmp6, &d.tcp, &d.udp, ) - d.parser.IgnoreUnsupported = true + d.parser4.IgnoreUnsupported = true + d.parser6 = gopacket.NewDecodingLayerParser( + layers.LayerTypeIPv6, + &d.eth, &d.ip4, &d.ip6, &d.icmp4, &d.icmp6, &d.tcp, &d.udp, + ) + d.parser6.IgnoreUnsupported = true return d }, } @@ -601,7 +575,7 @@ func TestUSPFilterCreatePerformance(t *testing.T) { ifaceMock := &IFaceMock{ SetFilterFunc: func(device.PacketFilter) error { return nil }, } - manager, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + manager, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) require.NoError(t, err) time.Sleep(time.Second) @@ -616,7 +590,7 @@ func TestUSPFilterCreatePerformance(t *testing.T) { start := time.Now() for i := 0; i < testMax; i++ { port := &fw.Port{Values: []uint16{uint16(1000 + i)}} - _, err = manager.AddPeerFiltering(nil, ip, "tcp", nil, port, fw.ActionAccept, "") + _, err = manager.AddFilterRule(nil, pfx(ip), fw.Network{}, "tcp", nil, port, fw.ActionAccept) require.NoError(t, err, "failed to add rule") } @@ -626,9 +600,11 @@ func TestUSPFilterCreatePerformance(t *testing.T) { } func TestStatefulFirewall_UDPTracking(t *testing.T) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, nbiface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) require.NoError(t, err) manager.udpTracker.Close() // Close the existing tracker @@ -638,11 +614,16 @@ func TestStatefulFirewall_UDPTracking(t *testing.T) { d := &decoder{ decoded: []gopacket.LayerType{}, } - d.parser = gopacket.NewDecodingLayerParser( + d.parser4 = gopacket.NewDecodingLayerParser( layers.LayerTypeIPv4, &d.eth, &d.ip4, &d.ip6, &d.icmp4, &d.icmp6, &d.tcp, &d.udp, ) - d.parser.IgnoreUnsupported = true + d.parser4.IgnoreUnsupported = true + d.parser6 = gopacket.NewDecodingLayerParser( + layers.LayerTypeIPv6, + &d.eth, &d.ip4, &d.ip6, &d.icmp4, &d.icmp6, &d.tcp, &d.udp, + ) + d.parser6.IgnoreUnsupported = true return d }, } @@ -835,7 +816,7 @@ func TestUpdateSetMerge(t *testing.T) { SetFilterFunc: func(device.PacketFilter) error { return nil }, } - manager, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + manager, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, manager.Close(nil)) @@ -848,7 +829,7 @@ func TestUpdateSetMerge(t *testing.T) { netip.MustParsePrefix("192.168.1.0/24"), } - rule, err := manager.AddRouteFiltering( + rule, err := manager.AddFilterRule( nil, []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")}, fw.Network{Set: set}, @@ -921,7 +902,7 @@ func TestUpdateSetDeduplication(t *testing.T) { SetFilterFunc: func(device.PacketFilter) error { return nil }, } - manager, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + manager, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, manager.Close(nil)) @@ -929,7 +910,7 @@ func TestUpdateSetDeduplication(t *testing.T) { set := fw.NewDomainSet(domain.List{"example.org"}) - rule, err := manager.AddRouteFiltering( + rule, err := manager.AddFilterRule( nil, []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")}, fw.Network{Set: set}, @@ -1041,15 +1022,15 @@ func TestMSSClamping(t *testing.T) { }, } - manager, err := Create(ifaceMock, false, flowLogger, 1280) + manager, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: 1280}) require.NoError(t, err) defer func() { require.NoError(t, manager.Close(nil)) }() require.True(t, manager.mssClampEnabled, "MSS clamping should be enabled by default") - expectedMSSValue := uint16(1280 - ipTCPHeaderMinSize) - require.Equal(t, expectedMSSValue, manager.mssClampValue, "MSS clamp value should be MTU - 40") + require.Equal(t, uint16(1280-ipv4TCPHeaderMinSize), manager.mssClampValueIPv4, "IPv4 MSS clamp value should be MTU - 40") + require.Equal(t, uint16(1280-ipv6TCPHeaderMinSize), manager.mssClampValueIPv6, "IPv6 MSS clamp value should be MTU - 60") err = manager.UpdateLocalIPs() require.NoError(t, err) @@ -1067,7 +1048,7 @@ func TestMSSClamping(t *testing.T) { require.Len(t, d.tcp.Options, 1, "Should have MSS option") require.Equal(t, uint8(layers.TCPOptionKindMSS), uint8(d.tcp.Options[0].OptionType)) actualMSS := binary.BigEndian.Uint16(d.tcp.Options[0].OptionData) - require.Equal(t, expectedMSSValue, actualMSS, "MSS should be clamped to MTU - 40") + require.Equal(t, manager.mssClampValueIPv4, actualMSS, "MSS should be clamped to MTU - 40") }) t.Run("SYN packet with low MSS unchanged", func(t *testing.T) { @@ -1091,7 +1072,7 @@ func TestMSSClamping(t *testing.T) { d := parsePacket(t, packet) require.Len(t, d.tcp.Options, 1, "Should have MSS option") actualMSS := binary.BigEndian.Uint16(d.tcp.Options[0].OptionData) - require.Equal(t, expectedMSSValue, actualMSS, "MSS in SYN-ACK should be clamped") + require.Equal(t, manager.mssClampValueIPv4, actualMSS, "MSS in SYN-ACK should be clamped") }) t.Run("Non-SYN packet unchanged", func(t *testing.T) { @@ -1233,7 +1214,7 @@ func TestShouldForward(t *testing.T) { return wgaddr.Address{IP: wgIP, Network: netip.PrefixFrom(wgIP, 24)} } - manager, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + manager, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) require.NoError(t, err) defer func() { require.NoError(t, manager.Close(nil)) @@ -1263,13 +1244,18 @@ func TestShouldForward(t *testing.T) { d := &decoder{ decoded: []gopacket.LayerType{}, } - d.parser = gopacket.NewDecodingLayerParser( + d.parser4 = gopacket.NewDecodingLayerParser( layers.LayerTypeIPv4, &d.eth, &d.ip4, &d.ip6, &d.icmp4, &d.icmp6, &d.tcp, &d.udp, ) - d.parser.IgnoreUnsupported = true + d.parser4.IgnoreUnsupported = true + d.parser6 = gopacket.NewDecodingLayerParser( + layers.LayerTypeIPv6, + &d.eth, &d.ip4, &d.ip6, &d.icmp4, &d.icmp6, &d.tcp, &d.udp, + ) + d.parser6.IgnoreUnsupported = true - err = d.parser.DecodeLayers(buf.Bytes(), &d.decoded) + err = d.decodePacket(buf.Bytes()) require.NoError(t, err) return d @@ -1329,6 +1315,44 @@ func TestShouldForward(t *testing.T) { }, } + // Add IPv6 to the interface and test dual-stack cases + wgIPv6 := netip.MustParseAddr("fd00::1") + otherIPv6 := netip.MustParseAddr("fd00::2") + ifaceMock.AddressFunc = func() wgaddr.Address { + return wgaddr.Address{ + IP: wgIP, + Network: netip.PrefixFrom(wgIP, 24), + IPv6: wgIPv6, + IPv6Net: netip.PrefixFrom(wgIPv6, 64), + } + } + + // Re-create manager to pick up the new address with IPv6 + require.NoError(t, manager.Close(nil)) + manager, err = Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) + require.NoError(t, err) + + v6Cases := []struct { + name string + dstIP netip.Addr + expected bool + description string + }{ + {"v6 traffic to other address", otherIPv6, true, "should forward v6 traffic not destined to our v6 address"}, + {"v6 traffic to our v6 IP", wgIPv6, false, "should not forward traffic destined to our v6 address"}, + {"v4 traffic to other with v6 configured", otherIP, true, "should forward v4 traffic when v6 configured"}, + {"v4 traffic to our v4 IP with v6 configured", wgIP, false, "should not forward traffic to our v4 address"}, + } + for _, tt := range v6Cases { + t.Run(tt.name, func(t *testing.T) { + manager.localForwarding = true + manager.netstack = false + decoder := createTCPDecoder(8080) + result := manager.shouldForward(decoder, tt.dstIP) + require.Equal(t, tt.expected, result, tt.description) + }) + } + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Configure manager diff --git a/client/firewall/uspfilter/forwarder/endpoint.go b/client/firewall/uspfilter/forwarder/endpoint.go index 692a24140..fab776f2a 100644 --- a/client/firewall/uspfilter/forwarder/endpoint.go +++ b/client/firewall/uspfilter/forwarder/endpoint.go @@ -1,7 +1,8 @@ package forwarder import ( - "fmt" + "net" + "strconv" "sync/atomic" wgdevice "golang.zx2c4.com/wireguard/device" @@ -12,12 +13,19 @@ import ( nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log" ) +// PacketCapture captures raw packets for debugging. Implementations must be +// safe for concurrent use and must not block. +type PacketCapture interface { + Offer(data []byte, outbound bool) +} + // endpoint implements stack.LinkEndpoint and handles integration with the wireguard device type endpoint struct { logger *nblog.Logger dispatcher stack.NetworkDispatcher device *wgdevice.Device mtu atomic.Uint32 + capture atomic.Pointer[PacketCapture] } func (e *endpoint) Attach(dispatcher stack.NetworkDispatcher) { @@ -47,20 +55,31 @@ func (e *endpoint) LinkAddress() tcpip.LinkAddress { func (e *endpoint) WritePackets(pkts stack.PacketBufferList) (int, tcpip.Error) { var written int for _, pkt := range pkts.AsSlice() { - netHeader := header.IPv4(pkt.NetworkHeader().View().AsSlice()) - data := stack.PayloadSince(pkt.NetworkHeader()) if data == nil { continue } - // Send the packet through WireGuard - address := netHeader.DestinationAddress() - err := e.device.CreateOutboundPacket(data.AsSlice(), address.AsSlice()) - if err != nil { + raw := pkt.NetworkHeader().View().AsSlice() + if len(raw) == 0 { + continue + } + var address tcpip.Address + if raw[0]>>4 == 6 { + address = header.IPv6(raw).DestinationAddress() + } else { + address = header.IPv4(raw).DestinationAddress() + } + + pktBytes := data.AsSlice() + if err := e.device.CreateOutboundPacket(pktBytes, address.AsSlice()); err != nil { e.logger.Error1("CreateOutboundPacket: %v", err) continue } + + if pc := e.capture.Load(); pc != nil { + (*pc).Offer(pktBytes, true) + } written++ } @@ -103,5 +122,7 @@ type epID stack.TransportEndpointID func (i epID) String() string { // src and remote is swapped - return fmt.Sprintf("%s:%d → %s:%d", i.RemoteAddress, i.RemotePort, i.LocalAddress, i.LocalPort) + return net.JoinHostPort(i.RemoteAddress.String(), strconv.Itoa(int(i.RemotePort))) + + " → " + + net.JoinHostPort(i.LocalAddress.String(), strconv.Itoa(int(i.LocalPort))) } diff --git a/client/firewall/uspfilter/forwarder/forwarder.go b/client/firewall/uspfilter/forwarder/forwarder.go index d17c3cd5c..7308b38bd 100644 --- a/client/firewall/uspfilter/forwarder/forwarder.go +++ b/client/firewall/uspfilter/forwarder/forwarder.go @@ -5,23 +5,27 @@ import ( "fmt" "net" "net/netip" + "os" "runtime" + "strconv" "sync" "time" log "github.com/sirupsen/logrus" + wgdevice "golang.zx2c4.com/wireguard/device" "gvisor.dev/gvisor/pkg/buffer" "gvisor.dev/gvisor/pkg/tcpip" "gvisor.dev/gvisor/pkg/tcpip/header" "gvisor.dev/gvisor/pkg/tcpip/network/ipv4" + "gvisor.dev/gvisor/pkg/tcpip/network/ipv6" "gvisor.dev/gvisor/pkg/tcpip/stack" "gvisor.dev/gvisor/pkg/tcpip/transport/icmp" "gvisor.dev/gvisor/pkg/tcpip/transport/tcp" "gvisor.dev/gvisor/pkg/tcpip/transport/udp" - "github.com/netbirdio/netbird/client/firewall/uspfilter/common" "github.com/netbirdio/netbird/client/firewall/uspfilter/conntrack" nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log" + "github.com/netbirdio/netbird/client/iface/wgaddr" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" ) @@ -30,31 +34,48 @@ const ( defaultMaxInFlight = 1024 iosReceiveWindow = 16384 iosMaxInFlight = 256 + + // envForceTCPRACK overrides the platform default for gVisor's RACK loss + // detection. Set to a truthy value to force RACK on, or a falsy value to + // force it off, on any platform. + envForceTCPRACK = "NB_FORCE_TCP_RACK" ) +// IFace provides the WireGuard device and overlay addresses the forwarder needs. +type IFace interface { + GetWGDevice() *wgdevice.Device + Address() wgaddr.Address +} + type Forwarder struct { logger *nblog.Logger flowLogger nftypes.FlowLogger // ruleIdMap is used to store the rule ID for a given connection - ruleIdMap sync.Map - stack *stack.Stack - endpoint *endpoint - udpForwarder *udpForwarder - ctx context.Context - cancel context.CancelFunc - ip tcpip.Address - netstack bool - hasRawICMPAccess bool - pingSemaphore chan struct{} + ruleIdMap sync.Map + stack *stack.Stack + endpoint *endpoint + udpForwarder *udpForwarder + ctx context.Context + cancel context.CancelFunc + ip tcpip.Address + ipv6 tcpip.Address + netstack bool + hasRawICMPAccess bool + hasRawICMPv6Access bool + pingSemaphore chan struct{} } -func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.FlowLogger, netstack bool, mtu uint16) (*Forwarder, error) { +func New(iface IFace, logger *nblog.Logger, flowLogger nftypes.FlowLogger, netstack bool, mtu uint16) (*Forwarder, error) { s := stack.New(stack.Options{ - NetworkProtocols: []stack.NetworkProtocolFactory{ipv4.NewProtocol}, + NetworkProtocols: []stack.NetworkProtocolFactory{ + ipv4.NewProtocol, + ipv6.NewProtocol, + }, TransportProtocols: []stack.TransportProtocolFactory{ tcp.NewProtocol, udp.NewProtocol, icmp.NewProtocol4, + icmp.NewProtocol6, }, HandleLocal: false, }) @@ -73,7 +94,7 @@ func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.Flow protoAddr := tcpip.ProtocolAddress{ Protocol: ipv4.ProtocolNumber, AddressWithPrefix: tcpip.AddressWithPrefix{ - Address: tcpip.AddrFromSlice(iface.Address().IP.AsSlice()), + Address: tcpip.AddrFrom4(iface.Address().IP.As4()), PrefixLen: iface.Address().Network.Bits(), }, } @@ -82,6 +103,19 @@ func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.Flow return nil, fmt.Errorf("failed to add protocol address: %s", err) } + if v6 := iface.Address().IPv6; v6.IsValid() { + v6Addr := tcpip.ProtocolAddress{ + Protocol: ipv6.ProtocolNumber, + AddressWithPrefix: tcpip.AddressWithPrefix{ + Address: tcpip.AddrFrom16(v6.As16()), + PrefixLen: iface.Address().IPv6Net.Bits(), + }, + } + if err := s.AddProtocolAddress(nicID, v6Addr, stack.AddressProperties{}); err != nil { + return nil, fmt.Errorf("add IPv6 protocol address: %s", err) + } + } + defaultSubnet, err := tcpip.NewSubnet( tcpip.AddrFrom4([4]byte{0, 0, 0, 0}), tcpip.MaskFromBytes([]byte{0, 0, 0, 0}), @@ -90,6 +124,14 @@ func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.Flow return nil, fmt.Errorf("creating default subnet: %w", err) } + defaultSubnetV6, err := tcpip.NewSubnet( + tcpip.AddrFrom16([16]byte{}), + tcpip.MaskFromBytes(make([]byte, 16)), + ) + if err != nil { + return nil, fmt.Errorf("creating default v6 subnet: %w", err) + } + if err := s.SetPromiscuousMode(nicID, true); err != nil { return nil, fmt.Errorf("set promiscuous mode: %s", err) } @@ -98,10 +140,8 @@ func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.Flow } s.SetRouteTable([]tcpip.Route{ - { - Destination: defaultSubnet, - NIC: nicID, - }, + {Destination: defaultSubnet, NIC: nicID}, + {Destination: defaultSubnetV6, NIC: nicID}, }) ctx, cancel := context.WithCancel(context.Background()) @@ -114,7 +154,8 @@ func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.Flow ctx: ctx, cancel: cancel, netstack: netstack, - ip: tcpip.AddrFromSlice(iface.Address().IP.AsSlice()), + ip: tcpip.AddrFrom4(iface.Address().IP.As4()), + ipv6: addrFromNetipAddr(iface.Address().IPv6), pingSemaphore: make(chan struct{}, 3), } @@ -125,13 +166,18 @@ func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.Flow maxInFlight = iosMaxInFlight } + configureTCPRecovery(s) + tcpForwarder := tcp.NewForwarder(s, receiveWindow, maxInFlight, f.handleTCP) s.SetTransportProtocolHandler(tcp.ProtocolNumber, tcpForwarder.HandlePacket) udpForwarder := udp.NewForwarder(s, f.handleUDP) s.SetTransportProtocolHandler(udp.ProtocolNumber, udpForwarder.HandlePacket) - s.SetTransportProtocolHandler(icmp.ProtocolNumber4, f.handleICMP) + // ICMP is handled directly in InjectIncomingPacket, bypassing gVisor's + // network layer. This avoids duplicate echo replies (v4) and the v6 + // auto-reply bug where gVisor responds at the network layer before + // our transport handler fires. f.checkICMPCapability() @@ -139,9 +185,41 @@ func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.Flow return f, nil } +// SetCapture sets or clears the packet capture on the forwarder endpoint. +// This captures outbound packets that bypass the FilteredDevice (netstack forwarding). +func (f *Forwarder) SetCapture(pc PacketCapture) { + if pc == nil { + f.endpoint.capture.Store(nil) + return + } + f.endpoint.capture.Store(&pc) +} + func (f *Forwarder) InjectIncomingPacket(payload []byte) error { - if len(payload) < header.IPv4MinimumSize { - return fmt.Errorf("packet too small: %d bytes", len(payload)) + if len(payload) == 0 { + return fmt.Errorf("empty packet") + } + + var protoNum tcpip.NetworkProtocolNumber + switch payload[0] >> 4 { + case 4: + if len(payload) < header.IPv4MinimumSize { + return fmt.Errorf("IPv4 packet too small: %d bytes", len(payload)) + } + if f.handleICMPDirect(payload) { + return nil + } + protoNum = ipv4.ProtocolNumber + case 6: + if len(payload) < header.IPv6MinimumSize { + return fmt.Errorf("IPv6 packet too small: %d bytes", len(payload)) + } + if f.handleICMPDirect(payload) { + return nil + } + protoNum = ipv6.ProtocolNumber + default: + return fmt.Errorf("unknown IP version: %d", payload[0]>>4) } pkt := stack.NewPacketBuffer(stack.PacketBufferOptions{ @@ -150,11 +228,160 @@ func (f *Forwarder) InjectIncomingPacket(payload []byte) error { defer pkt.DecRef() if f.endpoint.dispatcher != nil { - f.endpoint.dispatcher.DeliverNetworkPacket(ipv4.ProtocolNumber, pkt) + f.endpoint.dispatcher.DeliverNetworkPacket(protoNum, pkt) } return nil } +// handleICMPDirect intercepts ICMP packets from raw IP payloads before they +// enter gVisor. It synthesizes the TransportEndpointID and PacketBuffer that +// the existing handlers expect, then dispatches to handleICMP/handleICMPv6. +// This bypasses gVisor's network layer which causes duplicate v4 echo replies +// and auto-replies to all v6 echo requests in promiscuous mode. +// +// Unlike gVisor's network layer, this does not validate ICMP checksums or +// reassemble IP fragments. Fragmented ICMP packets fall through to gVisor. +func parseICMPv4(payload []byte) (ipHdrLen, icmpLen int, src, dst tcpip.Address, ok bool) { + if len(payload) < header.IPv4MinimumSize { + return 0, 0, src, dst, false + } + ip := header.IPv4(payload) + if ip.Protocol() != uint8(header.ICMPv4ProtocolNumber) { + return 0, 0, src, dst, false + } + if ip.FragmentOffset() != 0 || ip.Flags()&header.IPv4FlagMoreFragments != 0 { + return 0, 0, src, dst, false + } + ipHdrLen = int(ip.HeaderLength()) + totalLen := int(ip.TotalLength()) + if ipHdrLen < header.IPv4MinimumSize || ipHdrLen > totalLen || totalLen > len(payload) { + return 0, 0, src, dst, false + } + icmpLen = totalLen - ipHdrLen + if icmpLen < header.ICMPv4MinimumSize { + return 0, 0, src, dst, false + } + return ipHdrLen, icmpLen, ip.SourceAddress(), ip.DestinationAddress(), true +} + +func parseICMPv6(payload []byte) (ipHdrLen, icmpLen int, src, dst tcpip.Address, ok bool) { + if len(payload) < header.IPv6MinimumSize { + return 0, 0, src, dst, false + } + ip := header.IPv6(payload) + declaredLen := int(ip.PayloadLength()) + hdrEnd := header.IPv6MinimumSize + declaredLen + if hdrEnd > len(payload) { + return 0, 0, src, dst, false + } + icmpStart, ok := skipIPv6ExtensionsToICMPv6(payload, ip.NextHeader(), hdrEnd) + if !ok { + return 0, 0, src, dst, false + } + icmpLen = hdrEnd - icmpStart + if icmpLen < header.ICMPv6MinimumSize { + return 0, 0, src, dst, false + } + return icmpStart, icmpLen, ip.SourceAddress(), ip.DestinationAddress(), true +} + +// skipIPv6ExtensionsToICMPv6 walks the IPv6 extension-header chain starting +// after the fixed header. It advances past Hop-by-Hop, Routing, and +// Destination Options headers (which share the NextHeader+ExtLen+6+ExtLen*8 +// layout) and returns the offset of the ICMPv6 payload. Fragment, ESP, AH, +// and unknown identifiers are reported as not handleable so the caller can +// defer to gVisor. +func skipIPv6ExtensionsToICMPv6(payload []byte, next uint8, hdrEnd int) (int, bool) { + off := header.IPv6MinimumSize + for { + if next == uint8(header.ICMPv6ProtocolNumber) { + return off, true + } + if !isWalkableIPv6ExtHdr(next) { + return 0, false + } + newOff, newNext, ok := advanceIPv6ExtHdr(payload, off, hdrEnd) + if !ok { + return 0, false + } + off = newOff + next = newNext + } +} + +func isWalkableIPv6ExtHdr(id uint8) bool { + switch id { + case uint8(header.IPv6HopByHopOptionsExtHdrIdentifier), + uint8(header.IPv6RoutingExtHdrIdentifier), + uint8(header.IPv6DestinationOptionsExtHdrIdentifier): + return true + } + return false +} + +func advanceIPv6ExtHdr(payload []byte, off, hdrEnd int) (int, uint8, bool) { + if off+8 > hdrEnd { + return 0, 0, false + } + extLen := (int(payload[off+1]) + 1) * 8 + if off+extLen > hdrEnd { + return 0, 0, false + } + return off + extLen, payload[off], true +} + +func (f *Forwarder) handleICMPDirect(payload []byte) bool { + if len(payload) == 0 { + return false + } + var ( + ipHdrLen int + icmpLen int + srcAddr tcpip.Address + dstAddr tcpip.Address + ok bool + ) + version := payload[0] >> 4 + switch version { + case 4: + ipHdrLen, icmpLen, srcAddr, dstAddr, ok = parseICMPv4(payload) + case 6: + ipHdrLen, icmpLen, srcAddr, dstAddr, ok = parseICMPv6(payload) + } + if !ok { + return false + } + + // Let gVisor handle ICMP destined for our own addresses natively. + // Its network-layer auto-reply is correct and efficient for local traffic. + if f.ip.Equal(dstAddr) || f.ipv6.Equal(dstAddr) { + return false + } + + id := stack.TransportEndpointID{ + LocalAddress: dstAddr, + RemoteAddress: srcAddr, + } + + // Trim the buffer to the IP-declared length so gVisor doesn't see padding. + pkt := stack.NewPacketBuffer(stack.PacketBufferOptions{ + Payload: buffer.MakeWithData(payload[:ipHdrLen+icmpLen]), + }) + defer pkt.DecRef() + + if _, ok := pkt.NetworkHeader().Consume(ipHdrLen); !ok { + return false + } + if _, ok := pkt.TransportHeader().Consume(icmpLen); !ok { + return false + } + + if version == 6 { + return f.handleICMPv6(id, pkt) + } + return f.handleICMP(id, pkt) +} + // Stop gracefully shuts down the forwarder func (f *Forwarder) Stop() { f.cancel() @@ -167,11 +394,14 @@ func (f *Forwarder) Stop() { f.stack.Wait() } -func (f *Forwarder) determineDialAddr(addr tcpip.Address) net.IP { +func (f *Forwarder) determineDialAddr(addr tcpip.Address) netip.Addr { if f.netstack && f.ip.Equal(addr) { - return net.IPv4(127, 0, 0, 1) + return netip.AddrFrom4([4]byte{127, 0, 0, 1}) } - return addr.AsSlice() + if f.netstack && f.ipv6.Equal(addr) { + return netip.IPv6Loopback() + } + return addrToNetipAddr(addr) } func (f *Forwarder) RegisterRuleID(srcIP, dstIP netip.Addr, srcPort, dstPort uint16, ruleID []byte) { @@ -205,23 +435,78 @@ func buildKey(srcIP, dstIP netip.Addr, srcPort, dstPort uint16) conntrack.ConnKe } } +// addrFromNetipAddr converts a netip.Addr to a gvisor tcpip.Address without allocating. +func addrFromNetipAddr(addr netip.Addr) tcpip.Address { + if !addr.IsValid() { + return tcpip.Address{} + } + if addr.Is4() { + return tcpip.AddrFrom4(addr.As4()) + } + return tcpip.AddrFrom16(addr.As16()) +} + +// addrToNetipAddr converts a gvisor tcpip.Address to netip.Addr without allocating. +func addrToNetipAddr(addr tcpip.Address) netip.Addr { + switch addr.Len() { + case 4: + return netip.AddrFrom4(addr.As4()) + case 16: + return netip.AddrFrom16(addr.As16()) + default: + return netip.Addr{} + } +} + // checkICMPCapability tests whether we have raw ICMP socket access at startup. func (f *Forwarder) checkICMPCapability() { + f.hasRawICMPAccess = probeRawICMP("ip4:icmp", "0.0.0.0", f.logger) + f.hasRawICMPv6Access = probeRawICMP("ip6:ipv6-icmp", "::", f.logger) +} + +func probeRawICMP(network, addr string, logger *nblog.Logger) bool { ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() lc := net.ListenConfig{} - conn, err := lc.ListenPacket(ctx, "ip4:icmp", "0.0.0.0") + conn, err := lc.ListenPacket(ctx, network, addr) if err != nil { - f.hasRawICMPAccess = false - f.logger.Debug("forwarder: No raw ICMP socket access, will use ping binary fallback") - return + logger.Debug1("forwarder: no raw %s socket access, will use ping binary fallback", network) + return false } if err := conn.Close(); err != nil { - f.logger.Debug1("forwarder: Failed to close ICMP capability test socket: %v", err) + logger.Debug2("forwarder: failed to close %s capability test socket: %v", network, err) } - f.hasRawICMPAccess = true - f.logger.Debug("forwarder: Raw ICMP socket access available") + logger.Debug1("forwarder: raw %s socket access available", network) + return true +} + +// configureTCPRecovery disables gVisor's RACK loss detection on Windows, where +// it interacts poorly with the host and collapses throughput on routed TCP +// connections (gVisor issue #9778). Other platforms keep the default. The +// EnvForceTCPRACK environment variable overrides the platform default. +func configureTCPRecovery(s *stack.Stack) { + disableRACK := runtime.GOOS == "windows" + + if val := os.Getenv(envForceTCPRACK); val != "" { + force, err := strconv.ParseBool(val) + if err != nil { + log.Warnf("parse %s: %v", envForceTCPRACK, err) + } else { + disableRACK = !force + } + } + + if !disableRACK { + return + } + + opt := tcpip.TCPRecovery(0) + if err := s.SetTransportProtocolOption(tcp.ProtocolNumber, &opt); err != nil { + log.Warnf("disable TCP RACK loss detection: %v", err) + return + } + log.Info("forwarder: TCP RACK loss detection disabled") } diff --git a/client/firewall/uspfilter/forwarder/forwarder_test.go b/client/firewall/uspfilter/forwarder/forwarder_test.go new file mode 100644 index 000000000..ad74e8493 --- /dev/null +++ b/client/firewall/uspfilter/forwarder/forwarder_test.go @@ -0,0 +1,162 @@ +package forwarder + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gvisor.dev/gvisor/pkg/tcpip" + "gvisor.dev/gvisor/pkg/tcpip/header" +) + +const echoRequestSize = 8 + +func makeIPv6(t *testing.T, src, dst netip.Addr, nextHdr uint8, payload []byte) []byte { + t.Helper() + buf := make([]byte, header.IPv6MinimumSize+len(payload)) + ip := header.IPv6(buf) + ip.Encode(&header.IPv6Fields{ + PayloadLength: uint16(len(payload)), + TransportProtocol: 0, // overwritten below to allow any value + HopLimit: 64, + SrcAddr: tcpipAddrFromNetip(src), + DstAddr: tcpipAddrFromNetip(dst), + }) + buf[6] = nextHdr + copy(buf[header.IPv6MinimumSize:], payload) + return buf +} + +func tcpipAddrFromNetip(a netip.Addr) tcpip.Address { + b := a.As16() + return tcpip.AddrFrom16(b) +} + +func echoRequest() []byte { + icmp := make([]byte, echoRequestSize) + icmp[0] = uint8(header.ICMPv6EchoRequest) + return icmp +} + +// extHdr builds a generic IPv6 extension header (HBH/Routing/DestOpts) of the +// given total octet length (must be multiple of 8, >= 8) with the given next +// header. +func extHdr(t *testing.T, next uint8, totalLen int) []byte { + t.Helper() + require.GreaterOrEqual(t, totalLen, 8) + require.Equal(t, 0, totalLen%8) + buf := make([]byte, totalLen) + buf[0] = next + buf[1] = uint8(totalLen/8 - 1) + return buf +} + +func TestParseICMPv6_NoExtensions(t *testing.T) { + src := netip.MustParseAddr("fd00::1") + dst := netip.MustParseAddr("fd00::2") + pkt := makeIPv6(t, src, dst, uint8(header.ICMPv6ProtocolNumber), echoRequest()) + + off, icmpLen, _, _, ok := parseICMPv6(pkt) + require.True(t, ok) + assert.Equal(t, header.IPv6MinimumSize, off) + assert.Equal(t, echoRequestSize, icmpLen) +} + +func TestParseICMPv6_SingleExtension(t *testing.T) { + src := netip.MustParseAddr("fd00::1") + dst := netip.MustParseAddr("fd00::2") + hbh := extHdr(t, uint8(header.ICMPv6ProtocolNumber), 8) + payload := append([]byte{}, hbh...) + payload = append(payload, echoRequest()...) + pkt := makeIPv6(t, src, dst, uint8(header.IPv6HopByHopOptionsExtHdrIdentifier), payload) + + off, icmpLen, _, _, ok := parseICMPv6(pkt) + require.True(t, ok) + assert.Equal(t, header.IPv6MinimumSize+8, off) + assert.Equal(t, echoRequestSize, icmpLen) +} + +func TestParseICMPv6_ChainedExtensions(t *testing.T) { + src := netip.MustParseAddr("fd00::1") + dst := netip.MustParseAddr("fd00::2") + dest := extHdr(t, uint8(header.ICMPv6ProtocolNumber), 16) + rt := extHdr(t, uint8(header.IPv6DestinationOptionsExtHdrIdentifier), 8) + hbh := extHdr(t, uint8(header.IPv6RoutingExtHdrIdentifier), 8) + payload := append(append(append([]byte{}, hbh...), rt...), dest...) + payload = append(payload, echoRequest()...) + pkt := makeIPv6(t, src, dst, uint8(header.IPv6HopByHopOptionsExtHdrIdentifier), payload) + + off, icmpLen, _, _, ok := parseICMPv6(pkt) + require.True(t, ok) + assert.Equal(t, header.IPv6MinimumSize+8+8+16, off) + assert.Equal(t, echoRequestSize, icmpLen) +} + +func TestParseICMPv6_FragmentDefersToGVisor(t *testing.T) { + src := netip.MustParseAddr("fd00::1") + dst := netip.MustParseAddr("fd00::2") + pkt := makeIPv6(t, src, dst, uint8(header.IPv6FragmentExtHdrIdentifier), make([]byte, 8)) + + _, _, _, _, ok := parseICMPv6(pkt) + assert.False(t, ok) +} + +func TestParseICMPv6_TruncatedExtension(t *testing.T) { + src := netip.MustParseAddr("fd00::1") + dst := netip.MustParseAddr("fd00::2") + // Extension claims 16 bytes but only 8 remain after the IP header. + hbh := []byte{uint8(header.ICMPv6ProtocolNumber), 1, 0, 0, 0, 0, 0, 0} + pkt := makeIPv6(t, src, dst, uint8(header.IPv6HopByHopOptionsExtHdrIdentifier), hbh) + + _, _, _, _, ok := parseICMPv6(pkt) + assert.False(t, ok) +} + +func TestParseICMPv6_TruncatedICMPPayload(t *testing.T) { + src := netip.MustParseAddr("fd00::1") + dst := netip.MustParseAddr("fd00::2") + // PayloadLength claims 8 bytes of ICMPv6 but the buffer only holds 4. + pkt := makeIPv6(t, src, dst, uint8(header.ICMPv6ProtocolNumber), make([]byte, 8)) + pkt = pkt[:header.IPv6MinimumSize+4] + + _, _, _, _, ok := parseICMPv6(pkt) + assert.False(t, ok) +} + +func TestParseICMPv4_RejectsShortIHL(t *testing.T) { + pkt := make([]byte, 28) + pkt[0] = 0x44 // version 4, IHL 4 (16 bytes - below minimum) + pkt[9] = uint8(header.ICMPv4ProtocolNumber) + header.IPv4(pkt).SetTotalLength(28) + + _, _, _, _, ok := parseICMPv4(pkt) + assert.False(t, ok) +} + +func TestParseICMPv4_RejectsTotalLenOverBuffer(t *testing.T) { + pkt := make([]byte, header.IPv4MinimumSize+header.ICMPv4MinimumSize) + ip := header.IPv4(pkt) + ip.Encode(&header.IPv4Fields{ + TotalLength: uint16(len(pkt) + 16), + Protocol: uint8(header.ICMPv4ProtocolNumber), + TTL: 64, + }) + + _, _, _, _, ok := parseICMPv4(pkt) + assert.False(t, ok) +} + +func TestParseICMPv4_RejectsFragment(t *testing.T) { + pkt := make([]byte, header.IPv4MinimumSize+header.ICMPv4MinimumSize) + ip := header.IPv4(pkt) + ip.Encode(&header.IPv4Fields{ + TotalLength: uint16(len(pkt)), + Protocol: uint8(header.ICMPv4ProtocolNumber), + TTL: 64, + Flags: header.IPv4FlagMoreFragments, + }) + + _, _, _, _, ok := parseICMPv4(pkt) + assert.False(t, ok) +} diff --git a/client/firewall/uspfilter/forwarder/icmp.go b/client/firewall/uspfilter/forwarder/icmp.go index cb3db325d..94a50570f 100644 --- a/client/firewall/uspfilter/forwarder/icmp.go +++ b/client/firewall/uspfilter/forwarder/icmp.go @@ -13,6 +13,7 @@ import ( "gvisor.dev/gvisor/pkg/tcpip/header" "gvisor.dev/gvisor/pkg/tcpip/stack" + nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" ) @@ -35,7 +36,7 @@ func (f *Forwarder) handleICMP(id stack.TransportEndpointID, pkt *stack.PacketBu } icmpData := stack.PayloadSince(pkt.TransportHeader()).AsSlice() - conn, err := f.forwardICMPPacket(id, icmpData, uint8(icmpHdr.Type()), uint8(icmpHdr.Code()), 100*time.Millisecond) + conn, err := f.forwardICMPPacket(id, icmpData, uint8(icmpHdr.Type()), uint8(icmpHdr.Code()), false, 100*time.Millisecond) if err != nil { f.logger.Error2("forwarder: Failed to forward ICMP packet for %v: %v", epID(id), err) return true @@ -58,7 +59,7 @@ func (f *Forwarder) handleICMPEcho(flowID uuid.UUID, id stack.TransportEndpointI defer func() { <-f.pingSemaphore }() if f.hasRawICMPAccess { - f.handleICMPViaSocket(flowID, id, icmpType, icmpCode, icmpData, rxBytes) + f.handleICMPViaSocket(flowID, id, icmpType, icmpCode, icmpData, rxBytes, false) } else { f.handleICMPViaPing(flowID, id, icmpType, icmpCode, icmpData, rxBytes) } @@ -72,18 +73,23 @@ func (f *Forwarder) handleICMPEcho(flowID uuid.UUID, id stack.TransportEndpointI // forwardICMPPacket creates a raw ICMP socket and sends the packet, returning the connection. // The caller is responsible for closing the returned connection. -func (f *Forwarder) forwardICMPPacket(id stack.TransportEndpointID, payload []byte, icmpType, icmpCode uint8, timeout time.Duration) (net.PacketConn, error) { +func (f *Forwarder) forwardICMPPacket(id stack.TransportEndpointID, payload []byte, icmpType, icmpCode uint8, v6 bool, timeout time.Duration) (net.PacketConn, error) { ctx, cancel := context.WithTimeout(f.ctx, timeout) defer cancel() + network, listenAddr := "ip4:icmp", "0.0.0.0" + if v6 { + network, listenAddr = "ip6:ipv6-icmp", "::" + } + lc := net.ListenConfig{} - conn, err := lc.ListenPacket(ctx, "ip4:icmp", "0.0.0.0") + conn, err := lc.ListenPacket(ctx, network, listenAddr) if err != nil { return nil, fmt.Errorf("create ICMP socket: %w", err) } dstIP := f.determineDialAddr(id.LocalAddress) - dst := &net.IPAddr{IP: dstIP} + dst := &net.IPAddr{IP: dstIP.AsSlice()} if _, err = conn.WriteTo(payload, dst); err != nil { if closeErr := conn.Close(); closeErr != nil { @@ -92,17 +98,19 @@ func (f *Forwarder) forwardICMPPacket(id stack.TransportEndpointID, payload []by return nil, fmt.Errorf("write ICMP packet: %w", err) } - f.logger.Trace3("forwarder: Forwarded ICMP packet %v type %v code %v", - epID(id), icmpType, icmpCode) + if f.logger.Enabled(nblog.LevelTrace) { + f.logger.Trace3("forwarder: Forwarded ICMP packet %v type %v code %v", + epID(id), icmpType, icmpCode) + } return conn, nil } -// handleICMPViaSocket handles ICMP echo requests using raw sockets. -func (f *Forwarder) handleICMPViaSocket(flowID uuid.UUID, id stack.TransportEndpointID, icmpType, icmpCode uint8, icmpData []byte, rxBytes int) { +// handleICMPViaSocket handles ICMP echo requests using raw sockets for both v4 and v6. +func (f *Forwarder) handleICMPViaSocket(flowID uuid.UUID, id stack.TransportEndpointID, icmpType, icmpCode uint8, icmpData []byte, rxBytes int, v6 bool) { sendTime := time.Now() - conn, err := f.forwardICMPPacket(id, icmpData, icmpType, icmpCode, 5*time.Second) + conn, err := f.forwardICMPPacket(id, icmpData, icmpType, icmpCode, v6, 5*time.Second) if err != nil { f.logger.Error2("forwarder: Failed to send ICMP packet for %v: %v", epID(id), err) return @@ -113,16 +121,22 @@ func (f *Forwarder) handleICMPViaSocket(flowID uuid.UUID, id stack.TransportEndp } }() - txBytes := f.handleEchoResponse(conn, id) + txBytes := f.handleEchoResponse(conn, id, v6) rtt := time.Since(sendTime).Round(10 * time.Microsecond) - f.logger.Trace4("forwarder: Forwarded ICMP echo reply %v type %v code %v (rtt=%v, raw socket)", - epID(id), icmpType, icmpCode, rtt) + if f.logger.Enabled(nblog.LevelTrace) { + proto := "ICMP" + if v6 { + proto = "ICMPv6" + } + f.logger.Trace5("forwarder: Forwarded %s echo reply %v type %v code %v (rtt=%v, raw socket)", + proto, epID(id), icmpType, icmpCode, rtt) + } f.sendICMPEvent(nftypes.TypeEnd, flowID, id, icmpType, icmpCode, uint64(rxBytes), uint64(txBytes)) } -func (f *Forwarder) handleEchoResponse(conn net.PacketConn, id stack.TransportEndpointID) int { +func (f *Forwarder) handleEchoResponse(conn net.PacketConn, id stack.TransportEndpointID, v6 bool) int { if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { f.logger.Error1("forwarder: Failed to set read deadline for ICMP response: %v", err) return 0 @@ -137,6 +151,19 @@ func (f *Forwarder) handleEchoResponse(conn net.PacketConn, id stack.TransportEn return 0 } + if v6 { + // Recompute checksum: the raw socket response has a checksum computed + // over the real endpoint addresses, but we inject with overlay addresses. + icmpHdr := header.ICMPv6(response[:n]) + icmpHdr.SetChecksum(0) + icmpHdr.SetChecksum(header.ICMPv6Checksum(header.ICMPv6ChecksumParams{ + Header: icmpHdr, + Src: id.LocalAddress, + Dst: id.RemoteAddress, + })) + return f.injectICMPv6Reply(id, response[:n]) + } + return f.injectICMPReply(id, response[:n]) } @@ -150,19 +177,23 @@ func (f *Forwarder) sendICMPEvent(typ nftypes.Type, flowID uuid.UUID, id stack.T txPackets = 1 } - srcIp := netip.AddrFrom4(id.RemoteAddress.As4()) - dstIp := netip.AddrFrom4(id.LocalAddress.As4()) + srcIp := addrToNetipAddr(id.RemoteAddress) + dstIp := addrToNetipAddr(id.LocalAddress) + + proto := nftypes.ICMP + if srcIp.Is6() { + proto = nftypes.ICMPv6 + } fields := nftypes.EventFields{ FlowID: flowID, Type: typ, Direction: nftypes.Ingress, - Protocol: nftypes.ICMP, - // TODO: handle ipv6 - SourceIP: srcIp, - DestIP: dstIp, - ICMPType: icmpType, - ICMPCode: icmpCode, + Protocol: proto, + SourceIP: srcIp, + DestIP: dstIp, + ICMPType: icmpType, + ICMPCode: icmpCode, RxBytes: rxBytes, TxBytes: txBytes, @@ -198,37 +229,183 @@ func (f *Forwarder) handleICMPViaPing(flowID uuid.UUID, id stack.TransportEndpoi } rtt := time.Since(pingStart).Round(10 * time.Microsecond) - f.logger.Trace3("forwarder: Forwarded ICMP echo request %v type %v code %v", - epID(id), icmpType, icmpCode) + if f.logger.Enabled(nblog.LevelTrace) { + f.logger.Trace3("forwarder: Forwarded ICMP echo request %v type %v code %v", + epID(id), icmpType, icmpCode) + } txBytes := f.synthesizeEchoReply(id, icmpData) - f.logger.Trace4("forwarder: Forwarded ICMP echo reply %v type %v code %v (rtt=%v, ping binary)", + if f.logger.Enabled(nblog.LevelTrace) { + f.logger.Trace4("forwarder: Forwarded ICMP echo reply %v type %v code %v (rtt=%v, ping binary)", + epID(id), icmpType, icmpCode, rtt) + } + + f.sendICMPEvent(nftypes.TypeEnd, flowID, id, icmpType, icmpCode, uint64(rxBytes), uint64(txBytes)) +} + +// handleICMPv6 handles ICMPv6 packets from the network stack. +func (f *Forwarder) handleICMPv6(id stack.TransportEndpointID, pkt *stack.PacketBuffer) bool { + icmpHdr := header.ICMPv6(pkt.TransportHeader().View().AsSlice()) + + flowID := uuid.New() + f.sendICMPEvent(nftypes.TypeStart, flowID, id, uint8(icmpHdr.Type()), uint8(icmpHdr.Code()), 0, 0) + + if icmpHdr.Type() == header.ICMPv6EchoRequest { + return f.handleICMPv6Echo(flowID, id, pkt, uint8(icmpHdr.Type()), uint8(icmpHdr.Code())) + } + + // For non-echo types (Destination Unreachable, Packet Too Big, etc), forward without waiting + if !f.hasRawICMPv6Access { + f.logger.Debug2("forwarder: Cannot handle ICMPv6 type %v without raw socket access for %v", icmpHdr.Type(), epID(id)) + return false + } + + icmpData := stack.PayloadSince(pkt.TransportHeader()).AsSlice() + conn, err := f.forwardICMPPacket(id, icmpData, uint8(icmpHdr.Type()), uint8(icmpHdr.Code()), true, 100*time.Millisecond) + if err != nil { + f.logger.Error2("forwarder: Failed to forward ICMPv6 packet for %v: %v", epID(id), err) + return true + } + if err := conn.Close(); err != nil { + f.logger.Debug1("forwarder: Failed to close ICMPv6 socket: %v", err) + } + + return true +} + +// handleICMPv6Echo handles ICMPv6 echo requests via raw socket or ping binary fallback. +func (f *Forwarder) handleICMPv6Echo(flowID uuid.UUID, id stack.TransportEndpointID, pkt *stack.PacketBuffer, icmpType, icmpCode uint8) bool { + select { + case f.pingSemaphore <- struct{}{}: + icmpData := stack.PayloadSince(pkt.TransportHeader()).ToSlice() + rxBytes := pkt.Size() + + go func() { + defer func() { <-f.pingSemaphore }() + + if f.hasRawICMPv6Access { + f.handleICMPViaSocket(flowID, id, icmpType, icmpCode, icmpData, rxBytes, true) + } else { + f.handleICMPv6ViaPing(flowID, id, icmpType, icmpCode, icmpData, rxBytes) + } + }() + default: + f.logger.Debug3("forwarder: ICMPv6 rate limit exceeded for %v type %v code %v", epID(id), icmpType, icmpCode) + } + return true +} + +// handleICMPv6ViaPing uses the system ping6 binary for ICMPv6 echo. +func (f *Forwarder) handleICMPv6ViaPing(flowID uuid.UUID, id stack.TransportEndpointID, icmpType, icmpCode uint8, icmpData []byte, rxBytes int) { + ctx, cancel := context.WithTimeout(f.ctx, 5*time.Second) + defer cancel() + + dstIP := f.determineDialAddr(id.LocalAddress) + cmd := buildPingCommand(ctx, dstIP, 5*time.Second) + + pingStart := time.Now() + if err := cmd.Run(); err != nil { + f.logger.Warn4("forwarder: Ping6 failed for %v type %v code %v: %v", epID(id), icmpType, icmpCode, err) + return + } + rtt := time.Since(pingStart).Round(10 * time.Microsecond) + + f.logger.Trace3("forwarder: Forwarded ICMPv6 echo request %v type %v code %v", + epID(id), icmpType, icmpCode) + + txBytes := f.synthesizeICMPv6EchoReply(id, icmpData) + + f.logger.Trace4("forwarder: Forwarded ICMPv6 echo reply %v type %v code %v (rtt=%v, ping binary)", epID(id), icmpType, icmpCode, rtt) f.sendICMPEvent(nftypes.TypeEnd, flowID, id, icmpType, icmpCode, uint64(rxBytes), uint64(txBytes)) } +// synthesizeICMPv6EchoReply creates an ICMPv6 echo reply and injects it back. +func (f *Forwarder) synthesizeICMPv6EchoReply(id stack.TransportEndpointID, icmpData []byte) int { + replyICMP := make([]byte, len(icmpData)) + copy(replyICMP, icmpData) + + replyHdr := header.ICMPv6(replyICMP) + replyHdr.SetType(header.ICMPv6EchoReply) + replyHdr.SetChecksum(0) + // ICMPv6Checksum computes the pseudo-header internally from Src/Dst. + // Header contains the full ICMP message, so PayloadCsum/PayloadLen are zero. + replyHdr.SetChecksum(header.ICMPv6Checksum(header.ICMPv6ChecksumParams{ + Header: replyHdr, + Src: id.LocalAddress, + Dst: id.RemoteAddress, + })) + + return f.injectICMPv6Reply(id, replyICMP) +} + +// injectICMPv6Reply wraps an ICMPv6 payload in an IPv6 header and sends to the peer. +func (f *Forwarder) injectICMPv6Reply(id stack.TransportEndpointID, icmpPayload []byte) int { + ipHdr := make([]byte, header.IPv6MinimumSize) + ip := header.IPv6(ipHdr) + ip.Encode(&header.IPv6Fields{ + PayloadLength: uint16(len(icmpPayload)), + TransportProtocol: header.ICMPv6ProtocolNumber, + HopLimit: 64, + SrcAddr: id.LocalAddress, + DstAddr: id.RemoteAddress, + }) + + fullPacket := make([]byte, 0, len(ipHdr)+len(icmpPayload)) + fullPacket = append(fullPacket, ipHdr...) + fullPacket = append(fullPacket, icmpPayload...) + + if err := f.endpoint.device.CreateOutboundPacket(fullPacket, id.RemoteAddress.AsSlice()); err != nil { + f.logger.Error1("forwarder: Failed to send ICMPv6 reply to peer: %v", err) + return 0 + } + + if pc := f.endpoint.capture.Load(); pc != nil { + (*pc).Offer(fullPacket, true) + } + + return len(fullPacket) +} + +const ( + pingBin = "ping" + ping6Bin = "ping6" +) + // buildPingCommand creates a platform-specific ping command. -func buildPingCommand(ctx context.Context, target net.IP, timeout time.Duration) *exec.Cmd { +// Most platforms auto-detect IPv6 from raw addresses. macOS/iOS/OpenBSD require ping6. +func buildPingCommand(ctx context.Context, target netip.Addr, timeout time.Duration) *exec.Cmd { timeoutSec := int(timeout.Seconds()) if timeoutSec < 1 { timeoutSec = 1 } + isV6 := target.Is6() + timeoutStr := fmt.Sprintf("%d", timeoutSec) + switch runtime.GOOS { case "linux", "android": - return exec.CommandContext(ctx, "ping", "-c", "1", "-W", fmt.Sprintf("%d", timeoutSec), "-q", target.String()) + return exec.CommandContext(ctx, pingBin, "-c", "1", "-W", timeoutStr, "-q", target.String()) case "darwin", "ios": - return exec.CommandContext(ctx, "ping", "-c", "1", "-t", fmt.Sprintf("%d", timeoutSec), "-q", target.String()) + bin := pingBin + if isV6 { + bin = ping6Bin + } + return exec.CommandContext(ctx, bin, "-c", "1", "-t", timeoutStr, "-q", target.String()) case "freebsd": - return exec.CommandContext(ctx, "ping", "-c", "1", "-t", fmt.Sprintf("%d", timeoutSec), target.String()) + return exec.CommandContext(ctx, pingBin, "-c", "1", "-t", timeoutStr, target.String()) case "openbsd", "netbsd": - return exec.CommandContext(ctx, "ping", "-c", "1", "-w", fmt.Sprintf("%d", timeoutSec), target.String()) + bin := pingBin + if isV6 { + bin = ping6Bin + } + return exec.CommandContext(ctx, bin, "-c", "1", "-w", timeoutStr, target.String()) case "windows": - return exec.CommandContext(ctx, "ping", "-n", "1", "-w", fmt.Sprintf("%d", timeoutSec*1000), target.String()) + return exec.CommandContext(ctx, pingBin, "-n", "1", "-w", fmt.Sprintf("%d", timeoutSec*1000), target.String()) default: - return exec.CommandContext(ctx, "ping", "-c", "1", target.String()) + return exec.CommandContext(ctx, pingBin, "-c", "1", target.String()) } } @@ -270,5 +447,9 @@ func (f *Forwarder) injectICMPReply(id stack.TransportEndpointID, icmpPayload [] return 0 } + if pc := f.endpoint.capture.Load(); pc != nil { + (*pc).Offer(fullPacket, true) + } + return len(fullPacket) } diff --git a/client/firewall/uspfilter/forwarder/tcp.go b/client/firewall/uspfilter/forwarder/tcp.go index aef420061..c65ebcde0 100644 --- a/client/firewall/uspfilter/forwarder/tcp.go +++ b/client/firewall/uspfilter/forwarder/tcp.go @@ -1,12 +1,8 @@ package forwarder import ( - "context" - "fmt" - "io" "net" - "net/netip" - "sync" + "strconv" "github.com/google/uuid" @@ -16,7 +12,9 @@ import ( "gvisor.dev/gvisor/pkg/tcpip/transport/tcp" "gvisor.dev/gvisor/pkg/waiter" + nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" + "github.com/netbirdio/netbird/util/netrelay" ) // handleTCP is called by the TCP forwarder for new connections. @@ -33,12 +31,14 @@ func (f *Forwarder) handleTCP(r *tcp.ForwarderRequest) { } }() - dialAddr := fmt.Sprintf("%s:%d", f.determineDialAddr(id.LocalAddress), id.LocalPort) + dialAddr := net.JoinHostPort(f.determineDialAddr(id.LocalAddress).String(), strconv.Itoa(int(id.LocalPort))) outConn, err := (&net.Dialer{}).DialContext(f.ctx, "tcp", dialAddr) if err != nil { r.Complete(true) - f.logger.Trace2("forwarder: dial error for %v: %v", epID(id), err) + if f.logger.Enabled(nblog.LevelTrace) { + f.logger.Trace2("forwarder: dial error for %v: %v", epID(id), err) + } return } @@ -61,64 +61,22 @@ func (f *Forwarder) handleTCP(r *tcp.ForwarderRequest) { inConn := gonet.NewTCPConn(&wq, ep) success = true - f.logger.Trace1("forwarder: established TCP connection %v", epID(id)) + if f.logger.Enabled(nblog.LevelTrace) { + f.logger.Trace1("forwarder: established TCP connection %v", epID(id)) + } go f.proxyTCP(id, inConn, outConn, ep, flowID) } func (f *Forwarder) proxyTCP(id stack.TransportEndpointID, inConn *gonet.TCPConn, outConn net.Conn, ep tcpip.Endpoint, flowID uuid.UUID) { + // netrelay.Relay copies bidirectionally with proper half-close propagation + // and fully closes both conns before returning. + bytesFromInToOut, bytesFromOutToIn := netrelay.Relay(f.ctx, inConn, outConn, netrelay.Options{ + Logger: f.logger, + }) - ctx, cancel := context.WithCancel(f.ctx) - defer cancel() - - go func() { - <-ctx.Done() - // Close connections and endpoint. - if err := inConn.Close(); err != nil && !isClosedError(err) { - f.logger.Debug1("forwarder: inConn close error: %v", err) - } - if err := outConn.Close(); err != nil && !isClosedError(err) { - f.logger.Debug1("forwarder: outConn close error: %v", err) - } - - ep.Close() - }() - - var wg sync.WaitGroup - wg.Add(2) - - var ( - bytesFromInToOut int64 // bytes from client to server (tx for client) - bytesFromOutToIn int64 // bytes from server to client (rx for client) - errInToOut error - errOutToIn error - ) - - go func() { - bytesFromInToOut, errInToOut = io.Copy(outConn, inConn) - cancel() - wg.Done() - }() - - go func() { - - bytesFromOutToIn, errOutToIn = io.Copy(inConn, outConn) - cancel() - wg.Done() - }() - - wg.Wait() - - if errInToOut != nil { - if !isClosedError(errInToOut) { - f.logger.Error2("proxyTCP: copy error (in → out) for %s: %v", epID(id), errInToOut) - } - } - if errOutToIn != nil { - if !isClosedError(errOutToIn) { - f.logger.Error2("proxyTCP: copy error (out → in) for %s: %v", epID(id), errOutToIn) - } - } + // Close the netstack endpoint after both conns are drained. + ep.Close() var rxPackets, txPackets uint64 if tcpStats, ok := ep.Stats().(*tcp.Stats); ok { @@ -127,21 +85,22 @@ func (f *Forwarder) proxyTCP(id stack.TransportEndpointID, inConn *gonet.TCPConn txPackets = tcpStats.SegmentsReceived.Value() } - f.logger.Trace5("forwarder: Removed TCP connection %s [in: %d Pkts/%d B, out: %d Pkts/%d B]", epID(id), rxPackets, bytesFromOutToIn, txPackets, bytesFromInToOut) + if f.logger.Enabled(nblog.LevelTrace) { + f.logger.Trace5("forwarder: Removed TCP connection %s [in: %d Pkts/%d B, out: %d Pkts/%d B]", epID(id), rxPackets, bytesFromOutToIn, txPackets, bytesFromInToOut) + } f.sendTCPEvent(nftypes.TypeEnd, flowID, id, uint64(bytesFromOutToIn), uint64(bytesFromInToOut), rxPackets, txPackets) } func (f *Forwarder) sendTCPEvent(typ nftypes.Type, flowID uuid.UUID, id stack.TransportEndpointID, rxBytes, txBytes, rxPackets, txPackets uint64) { - srcIp := netip.AddrFrom4(id.RemoteAddress.As4()) - dstIp := netip.AddrFrom4(id.LocalAddress.As4()) + srcIp := addrToNetipAddr(id.RemoteAddress) + dstIp := addrToNetipAddr(id.LocalAddress) fields := nftypes.EventFields{ - FlowID: flowID, - Type: typ, - Direction: nftypes.Ingress, - Protocol: nftypes.TCP, - // TODO: handle ipv6 + FlowID: flowID, + Type: typ, + Direction: nftypes.Ingress, + Protocol: nftypes.TCP, SourceIP: srcIp, DestIP: dstIp, SourcePort: id.RemotePort, diff --git a/client/firewall/uspfilter/forwarder/udp.go b/client/firewall/uspfilter/forwarder/udp.go index f175e275b..d840ef06b 100644 --- a/client/firewall/uspfilter/forwarder/udp.go +++ b/client/firewall/uspfilter/forwarder/udp.go @@ -6,7 +6,7 @@ import ( "fmt" "io" "net" - "net/netip" + "strconv" "sync" "sync/atomic" "time" @@ -125,7 +125,9 @@ func (f *udpForwarder) cleanup() { delete(f.conns, idle.id) f.Unlock() - f.logger.Trace1("forwarder: cleaned up idle UDP connection %v", epID(idle.id)) + if f.logger.Enabled(nblog.LevelTrace) { + f.logger.Trace1("forwarder: cleaned up idle UDP connection %v", epID(idle.id)) + } } } } @@ -144,7 +146,9 @@ func (f *Forwarder) handleUDP(r *udp.ForwarderRequest) bool { _, exists := f.udpForwarder.conns[id] f.udpForwarder.RUnlock() if exists { - f.logger.Trace1("forwarder: existing UDP connection for %v", epID(id)) + if f.logger.Enabled(nblog.LevelTrace) { + f.logger.Trace1("forwarder: existing UDP connection for %v", epID(id)) + } return true } @@ -158,7 +162,7 @@ func (f *Forwarder) handleUDP(r *udp.ForwarderRequest) bool { } }() - dstAddr := fmt.Sprintf("%s:%d", f.determineDialAddr(id.LocalAddress), id.LocalPort) + dstAddr := net.JoinHostPort(f.determineDialAddr(id.LocalAddress).String(), strconv.Itoa(int(id.LocalPort))) outConn, err := (&net.Dialer{}).DialContext(f.ctx, "udp", dstAddr) if err != nil { f.logger.Debug2("forwarder: UDP dial error for %v: %v", epID(id), err) @@ -206,7 +210,9 @@ func (f *Forwarder) handleUDP(r *udp.ForwarderRequest) bool { f.udpForwarder.Unlock() success = true - f.logger.Trace1("forwarder: established UDP connection %v", epID(id)) + if f.logger.Enabled(nblog.LevelTrace) { + f.logger.Trace1("forwarder: established UDP connection %v", epID(id)) + } go f.proxyUDP(connCtx, pConn, id, ep) return true @@ -265,7 +271,9 @@ func (f *Forwarder) proxyUDP(ctx context.Context, pConn *udpPacketConn, id stack txPackets = udpStats.PacketsReceived.Value() } - f.logger.Trace5("forwarder: Removed UDP connection %s [in: %d Pkts/%d B, out: %d Pkts/%d B]", epID(id), rxPackets, rxBytes, txPackets, txBytes) + if f.logger.Enabled(nblog.LevelTrace) { + f.logger.Trace5("forwarder: Removed UDP connection %s [in: %d Pkts/%d B, out: %d Pkts/%d B]", epID(id), rxPackets, rxBytes, txPackets, txBytes) + } f.udpForwarder.Lock() delete(f.udpForwarder.conns, id) @@ -276,15 +284,14 @@ func (f *Forwarder) proxyUDP(ctx context.Context, pConn *udpPacketConn, id stack // sendUDPEvent stores flow events for UDP connections func (f *Forwarder) sendUDPEvent(typ nftypes.Type, flowID uuid.UUID, id stack.TransportEndpointID, rxBytes, txBytes, rxPackets, txPackets uint64) { - srcIp := netip.AddrFrom4(id.RemoteAddress.As4()) - dstIp := netip.AddrFrom4(id.LocalAddress.As4()) + srcIp := addrToNetipAddr(id.RemoteAddress) + dstIp := addrToNetipAddr(id.LocalAddress) fields := nftypes.EventFields{ - FlowID: flowID, - Type: typ, - Direction: nftypes.Ingress, - Protocol: nftypes.UDP, - // TODO: handle ipv6 + FlowID: flowID, + Type: typ, + Direction: nftypes.Ingress, + Protocol: nftypes.UDP, SourceIP: srcIp, DestIP: dstIp, SourcePort: id.RemotePort, diff --git a/client/firewall/uspfilter/fragment.go b/client/firewall/uspfilter/fragment.go new file mode 100644 index 000000000..accc54365 --- /dev/null +++ b/client/firewall/uspfilter/fragment.go @@ -0,0 +1,204 @@ +package uspfilter + +import ( + "context" + "net/netip" + "os" + "strconv" + "sync" + "time" + + nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log" +) + +const ( + // defaultFragmentTimeout bounds how long a first-fragment verdict is kept + // while the remaining fragments arrive. It mirrors the Linux IP reassembly + // timeout (net.ipv4.ipfrag_time). + defaultFragmentTimeout = 30 * time.Second + // fragmentCleanupInterval is how often expired verdicts are purged. + fragmentCleanupInterval = 10 * time.Second + // defaultMaxFragmentEntries caps the number of concurrently tracked + // fragmented datagrams. The table stays bounded because each datagram is a + // single small entry regardless of how many fragments it is split into, and + // the 13-bit IPv4 fragment-offset field limits any datagram to 64 KiB. + defaultMaxFragmentEntries = 16384 + + // EnvFragmentMaxEntries overrides defaultMaxFragmentEntries. + EnvFragmentMaxEntries = "NB_FRAGMENT_MAX_ENTRIES" +) + +// fragmentVerdict is the decision for a trailing (headerless) fragment. +type fragmentVerdict int + +const ( + // fragmentDeny drops the fragment: no allowed first fragment is on record. + fragmentDeny fragmentVerdict = iota + // fragmentAllow passes the fragment: it belongs to an allowed datagram and + // does not overlap the already-inspected transport header. + fragmentAllow + // fragmentOverlap drops the fragment and poisons its datagram: it overlaps + // the transport header the ACL inspected (RFC 1858 §4, RFC 3128; RFC 5722 + // requires discarding the whole datagram on overlap for IPv6). + fragmentOverlap +) + +// fragmentKey identifies a fragmented datagram. It matches the RFC 791 / RFC +// 8200 reassembly key: source, destination, protocol and identification. The id +// is 32-bit to hold both the IPv4 (16-bit) and IPv6 (32-bit) identification. +type fragmentKey struct { + srcIP netip.Addr + dstIP netip.Addr + id uint32 + proto uint8 +} + +// fragmentEntry records the verdict of an allowed first fragment. +type fragmentEntry struct { + // headerEndOctets is the offset, in 8-byte units, at which the first + // fragment's payload ended. A trailing fragment starting before this + // overlaps bytes the ACL already inspected and is rejected. + headerEndOctets uint16 + // recordedAt is when the first fragment was accepted. The verdict expires a + // fixed timeout later and is not refreshed, mirroring the kernel reassembly + // timer so a trailing-fragment flood can't keep a datagram alive. + recordedAt time.Time +} + +// fragmentTracker records the ACL verdict of a datagram's first fragment so the +// remaining fragments, which carry no L4 header, can inherit the decision +// without reassembling the datagram. Only allowed first fragments are stored; +// anything that cannot be tied to an allowed, non-overlapping first fragment is +// dropped (fail closed). +type fragmentTracker struct { + logger *nblog.Logger + mutex sync.Mutex + entries map[fragmentKey]fragmentEntry + timeout time.Duration + // maxEntries caps the table; atCapacity dedups the capacity warning until + // the table drains below the cap again. + maxEntries int + atCapacity bool + cleanupTicker *time.Ticker + cancel context.CancelFunc +} + +func newFragmentTracker(logger *nblog.Logger) *fragmentTracker { + ctx, cancel := context.WithCancel(context.Background()) + t := &fragmentTracker{ + logger: logger, + entries: make(map[fragmentKey]fragmentEntry), + timeout: defaultFragmentTimeout, + maxEntries: fragmentMaxEntries(logger), + cleanupTicker: time.NewTicker(fragmentCleanupInterval), + cancel: cancel, + } + go t.cleanupRoutine(ctx) + return t +} + +func fragmentMaxEntries(logger *nblog.Logger) int { + v := os.Getenv(EnvFragmentMaxEntries) + if v == "" { + return defaultMaxFragmentEntries + } + n, err := strconv.Atoi(v) + if err != nil || n <= 0 { + logger.Warn2("invalid %s=%q, using default", EnvFragmentMaxEntries, v) + return defaultMaxFragmentEntries + } + return n +} + +// recordAllowed stores the verdict of an allowed first fragment. headerEndOctets +// is the first fragment's payload length in 8-byte units. When the table is full +// the record is dropped, which fails closed: the datagram's trailing fragments +// will be denied. +func (t *fragmentTracker) recordAllowed(key fragmentKey, headerEndOctets uint16) { + t.mutex.Lock() + defer t.mutex.Unlock() + + if t.entries == nil { + return + } + if _, ok := t.entries[key]; !ok && len(t.entries) >= t.maxEntries { + if !t.atCapacity { + t.atCapacity = true + t.logger.Warn2("fragment verdict table at capacity (%d/%d): trailing fragments of new datagrams will be dropped", + len(t.entries), t.maxEntries) + } + return + } + t.entries[key] = fragmentEntry{ + headerEndOctets: headerEndOctets, + recordedAt: time.Now(), + } +} + +// poison drops any recorded verdict for a datagram, so its later fragments are +// denied until a new allowed first fragment is recorded. Called on every +// offset-zero fragment to defeat offset-zero overlap rewrites (RFC 3128). +func (t *fragmentTracker) poison(key fragmentKey) { + t.mutex.Lock() + defer t.mutex.Unlock() + delete(t.entries, key) +} + +// verdict decides the fate of a trailing fragment at fragOffsetOctets (the IPv4 +// fragment offset, in 8-byte units). A fragment overlapping the inspected +// header poisons the datagram: the entry is removed so all further fragments of +// that datagram are denied too. +func (t *fragmentTracker) verdict(key fragmentKey, fragOffsetOctets uint16) fragmentVerdict { + t.mutex.Lock() + defer t.mutex.Unlock() + + entry, ok := t.entries[key] + if !ok { + return fragmentDeny + } + if time.Since(entry.recordedAt) > t.timeout { + delete(t.entries, key) + return fragmentDeny + } + if fragOffsetOctets < entry.headerEndOctets { + delete(t.entries, key) + return fragmentOverlap + } + return fragmentAllow +} + +func (t *fragmentTracker) cleanupRoutine(ctx context.Context) { + defer t.cleanupTicker.Stop() + for { + select { + case <-t.cleanupTicker.C: + t.cleanup() + case <-ctx.Done(): + return + } + } +} + +func (t *fragmentTracker) cleanup() { + t.mutex.Lock() + defer t.mutex.Unlock() + + for key, entry := range t.entries { + if time.Since(entry.recordedAt) > t.timeout { + delete(t.entries, key) + } + } + + if len(t.entries) < t.maxEntries { + t.atCapacity = false + } +} + +// Close stops the cleanup routine and releases resources. +func (t *fragmentTracker) Close() { + t.cancel() + + t.mutex.Lock() + t.entries = nil + t.mutex.Unlock() +} diff --git a/client/firewall/uspfilter/fragment_bench_test.go b/client/firewall/uspfilter/fragment_bench_test.go new file mode 100644 index 000000000..a9e6d2d13 --- /dev/null +++ b/client/firewall/uspfilter/fragment_bench_test.go @@ -0,0 +1,115 @@ +package uspfilter + +import ( + "encoding/binary" + "testing" +) + +// benchFilterInbound drives filterInbound over a fixed packet in a tight loop. +// Packets are built once, outside the timed region, so the benchmark measures +// only pipeline cost, which is what an attacker can amplify. +func benchFilterInbound(b *testing.B, pkt []byte) { + b.Helper() + b.ReportAllocs() + b.SetBytes(int64(len(pkt))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + m := benchManager + m.filterInbound(pkt, len(pkt)) + } +} + +// benchManager is a package-level manager reused across fragment benchmarks so +// setup cost stays out of the timed region. +var benchManager *Manager + +func setupBenchManager(b *testing.B) *Manager { + b.Helper() + m := newFragmentTestManager(b) + allowUDP(b, m, 8080) + // Disable conntrack so the allowed-first-fragment path measures transport + // decode + ACL every iteration instead of matching the connection tracked + // on the first iteration. + m.stateful = false + benchManager = m + return m +} + +// BenchmarkInbound_NormalPacket is the baseline: a full, non-fragmented UDP +// packet that passes the ACL. Fragment paths should stay comparable to this. +func BenchmarkInbound_NormalPacket(b *testing.B) { + setupBenchManager(b) + pkt := normalUDPPacket(b, 8080, 32) + benchFilterInbound(b, pkt) +} + +// BenchmarkInbound_FirstFragmentAllowed measures the first-fragment path: +// transport decode + ACL evaluation + verdict record. +func BenchmarkInbound_FirstFragmentAllowed(b *testing.B) { + setupBenchManager(b) + pkt := firstFragmentUDP(b, 0x2000, 8080, 32) + benchFilterInbound(b, pkt) +} + +// BenchmarkInbound_TrailingFragmentAllowed measures the common trailing-fragment +// path: a single map lookup after the first fragment is on record. +func BenchmarkInbound_TrailingFragmentAllowed(b *testing.B) { + m := setupBenchManager(b) + first := firstFragmentUDP(b, 0x3000, 8080, 32) + m.filterInbound(first, len(first)) + pkt := trailingFragment(b, 0x3000, 5, false, 24) + benchFilterInbound(b, pkt) +} + +// BenchmarkInbound_TrailingFragmentNoFirst is the primary DoS vector: an +// attacker floods trailing fragments with no first fragment on record. Each is +// a map miss and must be cheap. +func BenchmarkInbound_TrailingFragmentNoFirst(b *testing.B) { + setupBenchManager(b) + pkt := trailingFragment(b, 0x4000, 185, false, 40) + benchFilterInbound(b, pkt) +} + +// BenchmarkInbound_TinyFirstFragment measures the tiny-fragment drop path: a +// first fragment too small to decode a transport header. +func BenchmarkInbound_TinyFirstFragment(b *testing.B) { + setupBenchManager(b) + pkt := trailingFragment(b, 0x5000, 0, true, 4) + benchFilterInbound(b, pkt) +} + +// BenchmarkInbound_TrailingFragmentDistinctIDs is the worst case for the +// verdict table: an attacker varies the datagram id on every packet so no first +// fragment ever matches. Verdict lookups always miss and nothing is recorded, +// so the table cannot grow. Each iteration rewrites the id field in place. +func BenchmarkInbound_TrailingFragmentDistinctIDs(b *testing.B) { + setupBenchManager(b) + pkt := trailingFragment(b, 0x6000, 185, false, 40) + m := benchManager + + b.ReportAllocs() + b.SetBytes(int64(len(pkt))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + // IPv4 identification field is at bytes 4:6. + binary.BigEndian.PutUint16(pkt[4:6], uint16(i)) + m.filterInbound(pkt, len(pkt)) + } +} + +// BenchmarkInbound_FirstFragmentDistinctIDs measures sustained first-fragment +// pressure with distinct ids: transport decode + ACL + verdict insert until the +// table caps, exercising the map growth and capacity guard. +func BenchmarkInbound_FirstFragmentDistinctIDs(b *testing.B) { + setupBenchManager(b) + pkt := firstFragmentUDP(b, 0x7000, 8080, 32) + m := benchManager + + b.ReportAllocs() + b.SetBytes(int64(len(pkt))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + binary.BigEndian.PutUint16(pkt[4:6], uint16(i)) + m.filterInbound(pkt, len(pkt)) + } +} diff --git a/client/firewall/uspfilter/fragment_test.go b/client/firewall/uspfilter/fragment_test.go new file mode 100644 index 000000000..8f99864be --- /dev/null +++ b/client/firewall/uspfilter/fragment_test.go @@ -0,0 +1,554 @@ +package uspfilter + +import ( + "encoding/binary" + "net" + "net/netip" + "testing" + "time" + + "github.com/google/gopacket" + "github.com/google/gopacket/layers" + "github.com/stretchr/testify/require" + + fw "github.com/netbirdio/netbird/client/firewall/manager" + nbiface "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/device" + "github.com/netbirdio/netbird/client/iface/wgaddr" +) + +const ( + fragTestSrc = "100.10.0.1" + fragTestDst = "100.10.0.100" + fragTestSrcV6 = "fd00::1" + fragTestDstV6 = "fd00::100" +) + +func newFragmentTestManager(tb testing.TB) *Manager { + tb.Helper() + + ifaceMock := &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + AddressFunc: func() wgaddr.Address { + return wgaddr.Address{ + IP: netip.MustParseAddr(fragTestDst), + Network: netip.MustParsePrefix("100.10.0.0/16"), + IPv6: netip.MustParseAddr(fragTestDstV6), + IPv6Net: netip.MustParsePrefix("fd00::/64"), + } + }, + } + + m, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) + require.NoError(tb, err) + require.NoError(tb, m.UpdateLocalIPs()) + tb.Cleanup(func() { require.NoError(tb, m.Close(nil)) }) + return m +} + +// firstFragmentUDPTo builds the first fragment of a fragmented UDP datagram to +// the given destination: it carries the full UDP header plus payloadLen bytes +// of data, with the More Fragments flag set and offset zero. +func firstFragmentUDPTo(tb testing.TB, dst string, id uint16, dstPort uint16, payloadLen int) []byte { + tb.Helper() + + ip := &layers.IPv4{ + Version: 4, + TTL: 64, + Id: id, + Protocol: layers.IPProtocolUDP, + SrcIP: net.ParseIP(fragTestSrc), + DstIP: net.ParseIP(dst), + Flags: layers.IPv4MoreFragments, + } + udp := &layers.UDP{SrcPort: 40000, DstPort: layers.UDPPort(dstPort)} + require.NoError(tb, udp.SetNetworkLayerForChecksum(ip)) + + buf := gopacket.NewSerializeBuffer() + opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true} + require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, udp, gopacket.Payload(make([]byte, payloadLen)))) + return buf.Bytes() +} + +func firstFragmentUDP(tb testing.TB, id uint16, dstPort uint16, payloadLen int) []byte { + tb.Helper() + return firstFragmentUDPTo(tb, fragTestDst, id, dstPort, payloadLen) +} + +// firstFragmentTCP builds the first fragment of a fragmented TCP datagram: the +// full 20-byte TCP header plus 12 bytes of data, with the More Fragments flag +// set and offset zero. +func firstFragmentTCP(tb testing.TB, id uint16, dstPort uint16) []byte { + tb.Helper() + + ip := &layers.IPv4{ + Version: 4, + TTL: 64, + Id: id, + Protocol: layers.IPProtocolTCP, + SrcIP: net.ParseIP(fragTestSrc), + DstIP: net.ParseIP(fragTestDst), + Flags: layers.IPv4MoreFragments, + } + tcp := &layers.TCP{SrcPort: 40000, DstPort: layers.TCPPort(dstPort), SYN: true, Window: 64240} + require.NoError(tb, tcp.SetNetworkLayerForChecksum(ip)) + + buf := gopacket.NewSerializeBuffer() + opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true} + require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, tcp, gopacket.Payload(make([]byte, 12)))) + return buf.Bytes() +} + +// trailingFragmentTo builds a non-first fragment to the given destination: an +// IPv4 header at the given fragment offset (in 8-byte units) carrying raw +// payload and no L4 header. +func trailingFragmentTo(tb testing.TB, dst string, proto layers.IPProtocol, id uint16, fragOffsetOctets uint16, moreFragments bool, payloadLen int) []byte { + tb.Helper() + + ip := &layers.IPv4{ + Version: 4, + TTL: 64, + Id: id, + Protocol: proto, + SrcIP: net.ParseIP(fragTestSrc), + DstIP: net.ParseIP(dst), + FragOffset: fragOffsetOctets, + } + if moreFragments { + ip.Flags = layers.IPv4MoreFragments + } + + buf := gopacket.NewSerializeBuffer() + opts := gopacket.SerializeOptions{FixLengths: true} + require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, gopacket.Payload(make([]byte, payloadLen)))) + return buf.Bytes() +} + +func trailingFragment(tb testing.TB, id uint16, fragOffsetOctets uint16, moreFragments bool, payloadLen int) []byte { + tb.Helper() + return trailingFragmentTo(tb, fragTestDst, layers.IPProtocolUDP, id, fragOffsetOctets, moreFragments, payloadLen) +} + +// outboundUDPPacket builds a complete outbound UDP packet from the local +// address, used to establish conntrack state for reply-direction tests. +func outboundUDPPacket(tb testing.TB, srcPort, dstPort uint16) []byte { + tb.Helper() + + ip := &layers.IPv4{ + Version: 4, + TTL: 64, + Id: 1, + Protocol: layers.IPProtocolUDP, + SrcIP: net.ParseIP(fragTestDst), + DstIP: net.ParseIP(fragTestSrc), + } + udp := &layers.UDP{SrcPort: layers.UDPPort(srcPort), DstPort: layers.UDPPort(dstPort)} + require.NoError(tb, udp.SetNetworkLayerForChecksum(ip)) + + buf := gopacket.NewSerializeBuffer() + opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true} + require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, udp, gopacket.Payload(make([]byte, 16)))) + return buf.Bytes() +} + +// normalUDPPacket builds a complete, non-fragmented UDP packet for baseline +// comparisons against the fragment paths. +func normalUDPPacket(tb testing.TB, dstPort uint16, payloadLen int) []byte { + tb.Helper() + + ip := &layers.IPv4{ + Version: 4, + TTL: 64, + Id: 1, + Protocol: layers.IPProtocolUDP, + SrcIP: net.ParseIP(fragTestSrc), + DstIP: net.ParseIP(fragTestDst), + } + udp := &layers.UDP{SrcPort: 40000, DstPort: layers.UDPPort(dstPort)} + require.NoError(tb, udp.SetNetworkLayerForChecksum(ip)) + + buf := gopacket.NewSerializeBuffer() + opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true} + require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, udp, gopacket.Payload(make([]byte, payloadLen)))) + return buf.Bytes() +} + +func allowUDP(tb testing.TB, m *Manager, dstPort uint16) { + tb.Helper() + _, err := m.AddFilterRule(nil, pfx(net.ParseIP(fragTestSrc)), fw.Network{}, fw.ProtocolUDP, nil, + &fw.Port{Values: []uint16{dstPort}}, fw.ActionAccept) + require.NoError(tb, err) +} + +// TestFragment_TrailingWithoutFirstDropped is the core bypass repro: a trailing +// fragment with no allowed first fragment on record must be dropped. Before the +// fix, filterInbound returned false (allow) for any fragment. +func TestFragment_TrailingWithoutFirstDropped(t *testing.T) { + m := newFragmentTestManager(t) + + frag := trailingFragment(t, 0x1234, 185, false, 40) + require.True(t, m.filterInbound(frag, len(frag)), + "trailing fragment without an allowed first fragment must be dropped") +} + +// TestFragment_AllowedFirstPassesTrailing verifies that once a first fragment +// passes the ACL, its trailing fragments inherit the allow verdict. +func TestFragment_AllowedFirstPassesTrailing(t *testing.T) { + m := newFragmentTestManager(t) + allowUDP(t, m, 8080) + + // First fragment: UDP header (8) + 32 payload = 40 octets -> headerEnd = 5. + first := firstFragmentUDP(t, 0x2222, 8080, 32) + require.False(t, m.filterInbound(first, len(first)), + "allowed first fragment should pass and be recorded") + + trailing := trailingFragment(t, 0x2222, 5, false, 24) + require.False(t, m.filterInbound(trailing, len(trailing)), + "trailing fragment of an allowed datagram should pass") +} + +// TestFragment_DeniedFirstDropsTrailing verifies that a first fragment blocked +// by the ACL leaves no verdict, so its trailing fragments are dropped. +func TestFragment_DeniedFirstDropsTrailing(t *testing.T) { + m := newFragmentTestManager(t) + // No accept rule: local traffic defaults to deny. + + first := firstFragmentUDP(t, 0x3333, 9999, 32) + require.True(t, m.filterInbound(first, len(first)), + "first fragment to a blocked port should be dropped by the ACL") + + trailing := trailingFragment(t, 0x3333, 5, false, 24) + require.True(t, m.filterInbound(trailing, len(trailing)), + "trailing fragment of a denied datagram must be dropped") +} + +// TestFragment_OverlappingHeaderDropped covers the RFC 1858 §4 / RFC 3128 +// overlapping-fragment rewrite: a trailing fragment starting inside the range +// the ACL already inspected is dropped and poisons the datagram. TCP is used so +// the overlap lands on real header bytes (the flags at byte 13). +func TestFragment_OverlappingHeaderDropped(t *testing.T) { + m := newFragmentTestManager(t) + _, err := m.AddFilterRule(nil, pfx(net.ParseIP(fragTestSrc)), fw.Network{}, fw.ProtocolTCP, nil, + &fw.Port{Values: []uint16{8080}}, fw.ActionAccept) + require.NoError(t, err) + + // First fragment: TCP header (20) + 12 data = 32 bytes -> headerEnd = 4 octets. + first := firstFragmentTCP(t, 0x4444, 8080) + require.False(t, m.filterInbound(first, len(first))) + + // Overlapping fragment at offset 1 (byte 8) falls inside the inspected TCP + // header, so it could rewrite the flags or port on reassembly. + overlap := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x4444, 1, true, 32) + require.True(t, m.filterInbound(overlap, len(overlap)), + "fragment overlapping the inspected header must be dropped") + + // The datagram is now poisoned: a later, non-overlapping fragment is also + // dropped because the verdict was removed. + later := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x4444, 4, false, 24) + require.True(t, m.filterInbound(later, len(later)), + "fragments after an overlap must be dropped (datagram poisoned)") +} + +// TestFragment_OffsetZeroOverlapPoisons covers the RFC 3128 offset-zero rewrite: +// an allowed first fragment followed by a denied offset-zero fragment for the +// same datagram must not leave the earlier allow verdict in place. +func TestFragment_OffsetZeroOverlapPoisons(t *testing.T) { + m := newFragmentTestManager(t) + allowUDP(t, m, 8080) + + allowed := firstFragmentUDP(t, 0x5A5A, 8080, 32) + require.False(t, m.filterInbound(allowed, len(allowed)), + "allowed first fragment should pass and be recorded") + + // A second offset-zero fragment to a denied port supersedes the datagram's + // verdict; it is dropped and must not leave the allow in place. + denied := firstFragmentUDP(t, 0x5A5A, 9999, 32) + require.True(t, m.filterInbound(denied, len(denied)), + "denied offset-zero fragment must be dropped") + + trailing := trailingFragment(t, 0x5A5A, 5, false, 24) + require.True(t, m.filterInbound(trailing, len(trailing)), + "trailing fragment must be denied after the datagram was poisoned") +} + +// TestFragment_TinyFirstDropped covers the tiny-fragment attack: a first +// fragment too small to contain the full transport header can't be +// ACL-evaluated and must be dropped. +func TestFragment_TinyFirstDropped(t *testing.T) { + m := newFragmentTestManager(t) + allowUDP(t, m, 8080) + + // IPv4 header + 4 raw bytes, MF set, offset 0: too small for the 8-byte UDP + // header, so it decodes to L3 only. + tiny := trailingFragment(t, 0x5555, 0, true, 4) + require.True(t, m.filterInbound(tiny, len(tiny)), + "tiny first fragment without a full L4 header must be dropped") +} + +// TestFragment_TCPFirstFragment verifies the TCP arm of the transport decode: a +// first fragment carrying the full 20-byte TCP header is ACL-evaluated and its +// trailing fragments inherit the verdict. +func TestFragment_TCPFirstFragment(t *testing.T) { + m := newFragmentTestManager(t) + _, err := m.AddFilterRule(nil, pfx(net.ParseIP(fragTestSrc)), fw.Network{}, fw.ProtocolTCP, nil, + &fw.Port{Values: []uint16{8080}}, fw.ActionAccept) + require.NoError(t, err) + + // TCP header (20) + 12 data = 32 bytes -> headerEnd = 4 octets. + first := firstFragmentTCP(t, 0x6666, 8080) + require.False(t, m.filterInbound(first, len(first)), + "allowed TCP first fragment should pass and be recorded") + + trailing := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x6666, 4, false, 24) + require.False(t, m.filterInbound(trailing, len(trailing)), + "trailing fragment of an allowed TCP datagram should pass") +} + +// TestFragment_TCPTinyFirstDropped verifies the TCP minimum header length: 12 +// bytes would satisfy a UDP header but falls short of the 20-byte TCP header. +func TestFragment_TCPTinyFirstDropped(t *testing.T) { + m := newFragmentTestManager(t) + _, err := m.AddFilterRule(nil, pfx(net.ParseIP(fragTestSrc)), fw.Network{}, fw.ProtocolTCP, nil, + &fw.Port{Values: []uint16{8080}}, fw.ActionAccept) + require.NoError(t, err) + + tiny := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x7777, 0, true, 12) + require.True(t, m.filterInbound(tiny, len(tiny)), + "first fragment shorter than the TCP header must be dropped") +} + +// TestFragment_ConntrackAllowsFirstFragment verifies the conntrack branch: reply +// fragments of an outbound-established UDP flow pass without any inbound rule. +func TestFragment_ConntrackAllowsFirstFragment(t *testing.T) { + m := newFragmentTestManager(t) + + out := outboundUDPPacket(t, 12345, 40000) + require.False(t, m.filterOutbound(out, len(out))) + + first := firstFragmentUDP(t, 0x8888, 12345, 32) + require.False(t, m.filterInbound(first, len(first)), + "reply first fragment should pass via conntrack") + + trailing := trailingFragment(t, 0x8888, 5, false, 24) + require.False(t, m.filterInbound(trailing, len(trailing)), + "trailing fragment of a tracked flow should pass") +} + +// TestFragment_RoutingDisabledDropsFragment verifies routed first fragments are +// dropped when routing is disabled. +func TestFragment_RoutingDisabledDropsFragment(t *testing.T) { + m := newFragmentTestManager(t) + m.routingEnabled.Store(false) + + first := firstFragmentUDPTo(t, "198.51.100.10", 0x9999, 8080, 32) + require.True(t, m.filterInbound(first, len(first)), + "routed first fragment must be dropped when routing is disabled") +} + +// TestFragment_RouteACL verifies the route-ACL branch: fragments to a non-local +// destination follow the route rules, allowed datagrams pass their trailing +// fragments and denied ones don't. +func TestFragment_RouteACL(t *testing.T) { + m := newFragmentTestManager(t) + m.routingEnabled.Store(true) + m.nativeRouter.Store(false) + + _, err := m.AddFilterRule( + []byte("rt-1"), + []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")}, + fw.Network{Prefix: netip.MustParsePrefix("198.51.100.0/24")}, + fw.ProtocolUDP, + nil, + &fw.Port{Values: []uint16{8080}}, + fw.ActionAccept, + ) + require.NoError(t, err) + + first := firstFragmentUDPTo(t, "198.51.100.10", 0xAAAA, 8080, 32) + require.False(t, m.filterInbound(first, len(first)), + "route-ACL-allowed first fragment should pass") + trailing := trailingFragmentTo(t, "198.51.100.10", layers.IPProtocolUDP, 0xAAAA, 5, false, 24) + require.False(t, m.filterInbound(trailing, len(trailing)), + "trailing fragment of an allowed routed datagram should pass") + + denied := firstFragmentUDPTo(t, "198.51.100.10", 0xBBBB, 9999, 32) + require.True(t, m.filterInbound(denied, len(denied)), + "route-ACL-denied first fragment must be dropped") + deniedTrailing := trailingFragmentTo(t, "198.51.100.10", layers.IPProtocolUDP, 0xBBBB, 5, false, 24) + require.True(t, m.filterInbound(deniedTrailing, len(deniedTrailing)), + "trailing fragment of a denied routed datagram must be dropped") +} + +// TestFragment_ExpiredVerdictDropsTrailing verifies a verdict older than the +// tracker timeout no longer admits trailing fragments. +func TestFragment_ExpiredVerdictDropsTrailing(t *testing.T) { + m := newFragmentTestManager(t) + allowUDP(t, m, 8080) + + first := firstFragmentUDP(t, 0xCCCC, 8080, 32) + require.False(t, m.filterInbound(first, len(first))) + + m.fragments.mutex.Lock() + for key, entry := range m.fragments.entries { + entry.recordedAt = time.Now().Add(-defaultFragmentTimeout - time.Second) + m.fragments.entries[key] = entry + } + m.fragments.mutex.Unlock() + + trailing := trailingFragment(t, 0xCCCC, 5, false, 24) + require.True(t, m.filterInbound(trailing, len(trailing)), + "trailing fragment after verdict expiry must be dropped") +} + +// TestFragment_CapacityFailsClosed verifies the table cap: at capacity, new +// datagram verdicts are not recorded (their trailing fragments are dropped) +// while already-recorded datagrams keep working. +func TestFragment_CapacityFailsClosed(t *testing.T) { + m := newFragmentTestManager(t) + allowUDP(t, m, 8080) + + m.fragments.mutex.Lock() + m.fragments.maxEntries = 1 + m.fragments.mutex.Unlock() + + first1 := firstFragmentUDP(t, 0x0101, 8080, 32) + require.False(t, m.filterInbound(first1, len(first1))) + + first2 := firstFragmentUDP(t, 0x0202, 8080, 32) + require.False(t, m.filterInbound(first2, len(first2)), + "first fragment itself still passes at capacity") + + trailing2 := trailingFragment(t, 0x0202, 5, false, 24) + require.True(t, m.filterInbound(trailing2, len(trailing2)), + "trailing fragment of an unrecorded datagram must be dropped at capacity") + + trailing1 := trailingFragment(t, 0x0101, 5, false, 24) + require.False(t, m.filterInbound(trailing1, len(trailing1)), + "already-recorded datagram should keep passing at capacity") +} + +// v6FragmentHeader builds the 8-byte IPv6 fragment extension header for the +// given inner protocol, offset (8-byte units), More Fragments bit and id. +func v6FragmentHeader(proto layers.IPProtocol, offsetOctets uint16, moreFragments bool, id uint32) []byte { + offsetFlags := offsetOctets << 3 + if moreFragments { + offsetFlags |= 1 + } + hdr := make([]byte, 8) + hdr[0] = uint8(proto) + binary.BigEndian.PutUint16(hdr[2:4], offsetFlags) + binary.BigEndian.PutUint32(hdr[4:8], id) + return hdr +} + +func v6UDPHeader(dstPort uint16, dataLen int) []byte { + hdr := make([]byte, 8) + binary.BigEndian.PutUint16(hdr[0:2], 40000) + binary.BigEndian.PutUint16(hdr[2:4], dstPort) + binary.BigEndian.PutUint16(hdr[4:6], uint16(8+dataLen)) + return hdr +} + +// firstFragmentUDPv6 builds the first fragment of a fragmented IPv6 UDP +// datagram: fragment header (offset 0, More Fragments set) + full UDP header + +// data. +func firstFragmentUDPv6(tb testing.TB, id uint32, dstPort uint16, dataLen int) []byte { + tb.Helper() + return fragmentUDPv6(tb, id, dstPort, dataLen, true) +} + +// fragmentUDPv6 builds an offset-zero IPv6 UDP fragment. With moreFragments +// false it is an atomic fragment (a complete datagram, RFC 6946). +func fragmentUDPv6(tb testing.TB, id uint32, dstPort uint16, dataLen int, moreFragments bool) []byte { + tb.Helper() + + ip := &layers.IPv6{ + Version: 6, + NextHeader: layers.IPProtocolIPv6Fragment, + HopLimit: 64, + SrcIP: net.ParseIP(fragTestSrcV6), + DstIP: net.ParseIP(fragTestDstV6), + } + payload := append(v6FragmentHeader(layers.IPProtocolUDP, 0, moreFragments, id), v6UDPHeader(dstPort, dataLen)...) + payload = append(payload, make([]byte, dataLen)...) + + buf := gopacket.NewSerializeBuffer() + require.NoError(tb, gopacket.SerializeLayers(buf, gopacket.SerializeOptions{FixLengths: true}, ip, gopacket.Payload(payload))) + return buf.Bytes() +} + +// trailingFragmentV6 builds a non-first IPv6 fragment: fragment header at the +// given offset carrying raw data and no transport header. +func trailingFragmentV6(tb testing.TB, id uint32, offsetOctets uint16, moreFragments bool, dataLen int) []byte { + tb.Helper() + + ip := &layers.IPv6{ + Version: 6, + NextHeader: layers.IPProtocolIPv6Fragment, + HopLimit: 64, + SrcIP: net.ParseIP(fragTestSrcV6), + DstIP: net.ParseIP(fragTestDstV6), + } + payload := append(v6FragmentHeader(layers.IPProtocolUDP, offsetOctets, moreFragments, id), make([]byte, dataLen)...) + + buf := gopacket.NewSerializeBuffer() + require.NoError(tb, gopacket.SerializeLayers(buf, gopacket.SerializeOptions{FixLengths: true}, ip, gopacket.Payload(payload))) + return buf.Bytes() +} + +// TestFragmentV6_TrailingWithoutFirstDropped verifies the IPv6 bypass is closed: +// a trailing fragment with no allowed first fragment is dropped. +func TestFragmentV6_TrailingWithoutFirstDropped(t *testing.T) { + m := newFragmentTestManager(t) + + frag := trailingFragmentV6(t, 0xAABBCCDD, 100, false, 40) + require.True(t, m.filterInbound(frag, len(frag)), + "IPv6 trailing fragment without an allowed first fragment must be dropped") +} + +// TestFragmentV6_AllowedFirstPassesTrailing verifies IPv6 fragments are +// evaluated like IPv4: an allowed first fragment lets its trailing fragments +// through. +func TestFragmentV6_AllowedFirstPassesTrailing(t *testing.T) { + m := newFragmentTestManager(t) + _, err := m.AddFilterRule(nil, pfx(net.ParseIP(fragTestSrcV6)), fw.Network{}, fw.ProtocolUDP, nil, + &fw.Port{Values: []uint16{8080}}, fw.ActionAccept) + require.NoError(t, err) + + // First fragment: UDP header (8) + 32 data = 40 octets -> headerEnd = 5. + first := firstFragmentUDPv6(t, 0xAABBCCDD, 8080, 32) + require.False(t, m.filterInbound(first, len(first)), + "allowed IPv6 first fragment should pass and be recorded") + + trailing := trailingFragmentV6(t, 0xAABBCCDD, 5, false, 24) + require.False(t, m.filterInbound(trailing, len(trailing)), + "trailing fragment of an allowed IPv6 datagram should pass") +} + +// TestFragmentV6_AtomicNotCached verifies an IPv6 atomic fragment (fragment +// header with offset 0 and no More Fragments, a complete datagram per RFC 6946) +// is evaluated but not recorded, so a flood of allowed atomic fragments can't +// exhaust the verdict table. +func TestFragmentV6_AtomicNotCached(t *testing.T) { + m := newFragmentTestManager(t) + _, err := m.AddFilterRule(nil, pfx(net.ParseIP(fragTestSrcV6)), fw.Network{}, fw.ProtocolUDP, nil, + &fw.Port{Values: []uint16{8080}}, fw.ActionAccept) + require.NoError(t, err) + + atomic := fragmentUDPv6(t, 0xA70301C, 8080, 16, false) + require.False(t, m.filterInbound(atomic, len(atomic)), + "allowed IPv6 atomic fragment should pass") + + m.fragments.mutex.Lock() + n := len(m.fragments.entries) + m.fragments.mutex.Unlock() + require.Zero(t, n, "atomic fragment must not create a verdict entry") + + // A genuine fragmented datagram (More Fragments set) is still recorded. + first := fragmentUDPv6(t, 0xBEEF, 8080, 32, true) + require.False(t, m.filterInbound(first, len(first))) + m.fragments.mutex.Lock() + n = len(m.fragments.entries) + m.fragments.mutex.Unlock() + require.Equal(t, 1, n, "genuine first fragment must record a verdict") +} diff --git a/client/firewall/uspfilter/hooks_filter.go b/client/firewall/uspfilter/hooks_filter.go index 8d3cc0f5c..f3adf5f8b 100644 --- a/client/firewall/uspfilter/hooks_filter.go +++ b/client/firewall/uspfilter/hooks_filter.go @@ -13,7 +13,6 @@ const ( ipv4HeaderMinLen = 20 ipv4ProtoOffset = 9 ipv4FlagsOffset = 6 - ipv4DstOffset = 16 ipProtoUDP = 17 ipProtoTCP = 6 ipv4FragOffMask = 0x1fff diff --git a/client/firewall/uspfilter/allow_netbird_windows.go b/client/firewall/uspfilter/interface_allower_windows.go similarity index 55% rename from client/firewall/uspfilter/allow_netbird_windows.go rename to client/firewall/uspfilter/interface_allower_windows.go index 6aef2ecfd..7f525e28c 100644 --- a/client/firewall/uspfilter/allow_netbird_windows.go +++ b/client/firewall/uspfilter/interface_allower_windows.go @@ -5,9 +5,10 @@ import ( "os/exec" "syscall" + "github.com/hashicorp/go-multierror" log "github.com/sirupsen/logrus" - "github.com/netbirdio/netbird/client/internal/statemanager" + nberrors "github.com/netbirdio/netbird/client/errors" ) type action string @@ -18,49 +19,74 @@ const ( firewallRuleName = "Netbird" ) -// Close cleans up the firewall manager by removing all rules and closing trackers -func (m *Manager) Close(*statemanager.Manager) error { - m.mutex.Lock() - defer m.mutex.Unlock() +// WindowsInterfaceAllower opens the NetBird interface in the Windows firewall +// via netsh advfirewall rules. It implements InterfaceAllower for the userspace +// firewall on Windows. +type WindowsInterfaceAllower struct { + iface Iface +} - m.resetState() +// NewWindowsInterfaceAllower builds the Windows netsh-based interface allower. +func NewWindowsInterfaceAllower(iface Iface) *WindowsInterfaceAllower { + return &WindowsInterfaceAllower{iface: iface} +} +// Apply adds inbound-allow netsh rules for the interface's addresses. +func (a *WindowsInterfaceAllower) Apply() error { if !isWindowsFirewallReachable() { return nil } if !isFirewallRuleActive(firewallRuleName) { - return nil + if err := manageFirewallRule(firewallRuleName, + addRule, + "dir=in", + "enable=yes", + "action=allow", + "profile=any", + "localip="+a.iface.Address().IP.String(), + ); err != nil { + return err + } } - if err := manageFirewallRule(firewallRuleName, deleteRule); err != nil { - return fmt.Errorf("couldn't remove windows firewall: %w", err) + if v6 := a.iface.Address().IPv6; v6.IsValid() && !isFirewallRuleActive(firewallRuleName+"-v6") { + if err := manageFirewallRule(firewallRuleName+"-v6", + addRule, + "dir=in", + "enable=yes", + "action=allow", + "profile=any", + "localip="+v6.String(), + ); err != nil { + return err + } } return nil } -// AllowNetbird allows netbird interface traffic -func (m *Manager) AllowNetbird() error { +// Close removes the netsh rules added by Apply. +func (a *WindowsInterfaceAllower) Close() error { if !isWindowsFirewallReachable() { return nil } + var merr *multierror.Error if isFirewallRuleActive(firewallRuleName) { - return nil + if err := manageFirewallRule(firewallRuleName, deleteRule); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove windows firewall rule: %w", err)) + } } - return manageFirewallRule(firewallRuleName, - addRule, - "dir=in", - "enable=yes", - "action=allow", - "profile=any", - "localip="+m.wgIface.Address().IP.String(), - ) + if isFirewallRuleActive(firewallRuleName + "-v6") { + if err := manageFirewallRule(firewallRuleName+"-v6", deleteRule); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove windows v6 firewall rule: %w", err)) + } + } + return nberrors.FormatErrorOrNil(merr) } func manageFirewallRule(ruleName string, action action, extraArgs ...string) error { - args := []string{"advfirewall", "firewall", string(action), "rule", "name=" + ruleName} if action == addRule { args = append(args, extraArgs...) diff --git a/client/firewall/uspfilter/localip.go b/client/firewall/uspfilter/localip.go index f63fe3e45..869832732 100644 --- a/client/firewall/uspfilter/localip.go +++ b/client/firewall/uspfilter/localip.go @@ -4,89 +4,30 @@ import ( "fmt" "net" "net/netip" - "sync" + "sync/atomic" log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/firewall/uspfilter/common" ) -type localIPManager struct { - mu sync.RWMutex - - // fixed-size high array for upper byte of a IPv4 address - ipv4Bitmap [256]*ipv4LowBitmap +// localIPSnapshot is an immutable snapshot of local IP addresses, swapped +// atomically so reads are lock-free. +type localIPSnapshot struct { + ips map[netip.Addr]struct{} } -// ipv4LowBitmap is a map for the low 16 bits of a IPv4 address -type ipv4LowBitmap struct { - bitmap [8192]uint32 +type localIPManager struct { + snapshot atomic.Pointer[localIPSnapshot] } func newLocalIPManager() *localIPManager { - return &localIPManager{} + m := &localIPManager{} + m.snapshot.Store(&localIPSnapshot{ + ips: make(map[netip.Addr]struct{}), + }) + return m } -func (m *localIPManager) setBitmapBit(ip net.IP) { - ipv4 := ip.To4() - if ipv4 == nil { - return - } - high := uint16(ipv4[0]) - low := (uint16(ipv4[1]) << 8) | (uint16(ipv4[2]) << 4) | uint16(ipv4[3]) - - index := low / 32 - bit := low % 32 - - if m.ipv4Bitmap[high] == nil { - m.ipv4Bitmap[high] = &ipv4LowBitmap{} - } - - m.ipv4Bitmap[high].bitmap[index] |= 1 << bit -} - -func (m *localIPManager) setBitInBitmap(ip netip.Addr, bitmap *[256]*ipv4LowBitmap, ipv4Set map[netip.Addr]struct{}, ipv4Addresses *[]netip.Addr) { - if !ip.Is4() { - return - } - ipv4 := ip.AsSlice() - - high := uint16(ipv4[0]) - low := (uint16(ipv4[1]) << 8) | (uint16(ipv4[2]) << 4) | uint16(ipv4[3]) - - if bitmap[high] == nil { - bitmap[high] = &ipv4LowBitmap{} - } - - index := low / 32 - bit := low % 32 - bitmap[high].bitmap[index] |= 1 << bit - - if _, exists := ipv4Set[ip]; !exists { - ipv4Set[ip] = struct{}{} - *ipv4Addresses = append(*ipv4Addresses, ip) - } -} - -func (m *localIPManager) checkBitmapBit(ip []byte) bool { - high := uint16(ip[0]) - low := (uint16(ip[1]) << 8) | (uint16(ip[2]) << 4) | uint16(ip[3]) - - if m.ipv4Bitmap[high] == nil { - return false - } - - index := low / 32 - bit := low % 32 - return (m.ipv4Bitmap[high].bitmap[index] & (1 << bit)) != 0 -} - -func (m *localIPManager) processIP(ip netip.Addr, bitmap *[256]*ipv4LowBitmap, ipv4Set map[netip.Addr]struct{}, ipv4Addresses *[]netip.Addr) error { - m.setBitInBitmap(ip, bitmap, ipv4Set, ipv4Addresses) - return nil -} - -func (m *localIPManager) processInterface(iface net.Interface, bitmap *[256]*ipv4LowBitmap, ipv4Set map[netip.Addr]struct{}, ipv4Addresses *[]netip.Addr) { +func processInterface(iface net.Interface, ips map[netip.Addr]struct{}, addresses *[]netip.Addr) { addrs, err := iface.Addrs() if err != nil { log.Debugf("get addresses for interface %s failed: %v", iface.Name, err) @@ -104,39 +45,40 @@ func (m *localIPManager) processInterface(iface net.Interface, bitmap *[256]*ipv continue } - addr, ok := netip.AddrFromSlice(ip) + parsed, ok := netip.AddrFromSlice(ip) if !ok { log.Warnf("invalid IP address %s in interface %s", ip.String(), iface.Name) continue } - if err := m.processIP(addr.Unmap(), bitmap, ipv4Set, ipv4Addresses); err != nil { - log.Debugf("process IP failed: %v", err) - } + parsed = parsed.Unmap() + ips[parsed] = struct{}{} + *addresses = append(*addresses, parsed) } } -func (m *localIPManager) UpdateLocalIPs(iface common.IFaceMapper) (err error) { +// UpdateLocalIPs rebuilds the local IP snapshot and swaps it in atomically. +func (m *localIPManager) UpdateLocalIPs(iface Iface) (err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("panic: %v", r) } }() - var newIPv4Bitmap [256]*ipv4LowBitmap - ipv4Set := make(map[netip.Addr]struct{}) - var ipv4Addresses []netip.Addr + ips := make(map[netip.Addr]struct{}) + var addresses []netip.Addr - // 127.0.0.0/8 - newIPv4Bitmap[127] = &ipv4LowBitmap{} - for i := 0; i < 8192; i++ { - // #nosec G602 -- bitmap is defined as [8192]uint32, loop range is correct - newIPv4Bitmap[127].bitmap[i] = 0xFFFFFFFF - } + // loopback + ips[netip.AddrFrom4([4]byte{127, 0, 0, 1})] = struct{}{} + ips[netip.IPv6Loopback()] = struct{}{} if iface != nil { - if err := m.processIP(iface.Address().IP, &newIPv4Bitmap, ipv4Set, &ipv4Addresses); err != nil { - return err + ip := iface.Address().IP + ips[ip] = struct{}{} + addresses = append(addresses, ip) + if v6 := iface.Address().IPv6; v6.IsValid() { + ips[v6] = struct{}{} + addresses = append(addresses, v6) } } @@ -147,25 +89,24 @@ func (m *localIPManager) UpdateLocalIPs(iface common.IFaceMapper) (err error) { // TODO: filter out down interfaces (net.FlagUp). Also handle the reverse // case where an interface comes up between refreshes. for _, intf := range interfaces { - m.processInterface(intf, &newIPv4Bitmap, ipv4Set, &ipv4Addresses) + processInterface(intf, ips, &addresses) } } - m.mu.Lock() - m.ipv4Bitmap = newIPv4Bitmap - m.mu.Unlock() + m.snapshot.Store(&localIPSnapshot{ips: ips}) - log.Debugf("Local IPv4 addresses: %v", ipv4Addresses) + log.Debugf("Local IP addresses: %v", addresses) return nil } +// IsLocalIP checks if the given IP is a local address. Lock-free on the read path. func (m *localIPManager) IsLocalIP(ip netip.Addr) bool { - if !ip.Is4() { - return false + s := m.snapshot.Load() + + if ip.Is4() && ip.As4()[0] == 127 { + return true } - m.mu.RLock() - defer m.mu.RUnlock() - - return m.checkBitmapBit(ip.AsSlice()) + _, found := s.ips[ip] + return found } diff --git a/client/firewall/uspfilter/localip_bench_test.go b/client/firewall/uspfilter/localip_bench_test.go new file mode 100644 index 000000000..14e12bd08 --- /dev/null +++ b/client/firewall/uspfilter/localip_bench_test.go @@ -0,0 +1,72 @@ +package uspfilter + +import ( + "net/netip" + "testing" + + "github.com/netbirdio/netbird/client/iface/wgaddr" +) + +func setupManager(b *testing.B) *localIPManager { + b.Helper() + m := newLocalIPManager() + mock := &IFaceMock{ + AddressFunc: func() wgaddr.Address { + return wgaddr.Address{ + IP: netip.MustParseAddr("100.64.0.1"), + Network: netip.MustParsePrefix("100.64.0.0/16"), + IPv6: netip.MustParseAddr("fd00::1"), + IPv6Net: netip.MustParsePrefix("fd00::/64"), + } + }, + } + if err := m.UpdateLocalIPs(mock); err != nil { + b.Fatalf("UpdateLocalIPs: %v", err) + } + return m +} + +func BenchmarkIsLocalIP_v4_hit(b *testing.B) { + m := setupManager(b) + ip := netip.MustParseAddr("100.64.0.1") + b.ResetTimer() + for i := 0; i < b.N; i++ { + m.IsLocalIP(ip) + } +} + +func BenchmarkIsLocalIP_v4_miss(b *testing.B) { + m := setupManager(b) + ip := netip.MustParseAddr("8.8.8.8") + b.ResetTimer() + for i := 0; i < b.N; i++ { + m.IsLocalIP(ip) + } +} + +func BenchmarkIsLocalIP_v6_hit(b *testing.B) { + m := setupManager(b) + ip := netip.MustParseAddr("fd00::1") + b.ResetTimer() + for i := 0; i < b.N; i++ { + m.IsLocalIP(ip) + } +} + +func BenchmarkIsLocalIP_v6_miss(b *testing.B) { + m := setupManager(b) + ip := netip.MustParseAddr("2001:db8::1") + b.ResetTimer() + for i := 0; i < b.N; i++ { + m.IsLocalIP(ip) + } +} + +func BenchmarkIsLocalIP_loopback(b *testing.B) { + m := setupManager(b) + ip := netip.MustParseAddr("127.0.0.1") + b.ResetTimer() + for i := 0; i < b.N; i++ { + m.IsLocalIP(ip) + } +} diff --git a/client/firewall/uspfilter/localip_test.go b/client/firewall/uspfilter/localip_test.go index 6653947fa..0dc524c41 100644 --- a/client/firewall/uspfilter/localip_test.go +++ b/client/firewall/uspfilter/localip_test.go @@ -72,14 +72,45 @@ func TestLocalIPManager(t *testing.T) { expected: false, }, { - name: "IPv6 address", + name: "IPv6 address matches", setupAddr: wgaddr.Address{ - IP: netip.MustParseAddr("fe80::1"), + IP: netip.MustParseAddr("100.64.0.1"), + Network: netip.MustParsePrefix("100.64.0.0/16"), + IPv6: netip.MustParseAddr("fd00::1"), + IPv6Net: netip.MustParsePrefix("fd00::/64"), + }, + testIP: netip.MustParseAddr("fd00::1"), + expected: true, + }, + { + name: "IPv6 address does not match", + setupAddr: wgaddr.Address{ + IP: netip.MustParseAddr("100.64.0.1"), + Network: netip.MustParsePrefix("100.64.0.0/16"), + IPv6: netip.MustParseAddr("fd00::1"), + IPv6Net: netip.MustParsePrefix("fd00::/64"), + }, + testIP: netip.MustParseAddr("fd00::99"), + expected: false, + }, + { + name: "No aliasing between similar IPs", + setupAddr: wgaddr.Address{ + IP: netip.MustParseAddr("192.168.1.1"), Network: netip.MustParsePrefix("192.168.1.0/24"), }, - testIP: netip.MustParseAddr("fe80::1"), + testIP: netip.MustParseAddr("192.168.0.17"), expected: false, }, + { + name: "IPv6 loopback", + setupAddr: wgaddr.Address{ + IP: netip.MustParseAddr("100.64.0.1"), + Network: netip.MustParsePrefix("100.64.0.0/16"), + }, + testIP: netip.MustParseAddr("::1"), + expected: true, + }, } for _, tt := range tests { @@ -171,90 +202,3 @@ func TestLocalIPManager_AllInterfaces(t *testing.T) { }) } } - -// MapImplementation is a version using map[string]struct{} -type MapImplementation struct { - localIPs map[string]struct{} -} - -func BenchmarkIPChecks(b *testing.B) { - interfaces := make([]net.IP, 16) - for i := range interfaces { - interfaces[i] = net.IPv4(10, 0, byte(i>>8), byte(i)) - } - - // Setup bitmap - bitmapManager := newLocalIPManager() - for _, ip := range interfaces[:8] { // Add half of IPs - bitmapManager.setBitmapBit(ip) - } - - // Setup map version - mapManager := &MapImplementation{ - localIPs: make(map[string]struct{}), - } - for _, ip := range interfaces[:8] { - mapManager.localIPs[ip.String()] = struct{}{} - } - - b.Run("Bitmap_Hit", func(b *testing.B) { - ip := interfaces[4] - b.ResetTimer() - for i := 0; i < b.N; i++ { - bitmapManager.checkBitmapBit(ip) - } - }) - - b.Run("Bitmap_Miss", func(b *testing.B) { - ip := interfaces[12] - b.ResetTimer() - for i := 0; i < b.N; i++ { - bitmapManager.checkBitmapBit(ip) - } - }) - - b.Run("Map_Hit", func(b *testing.B) { - ip := interfaces[4] - b.ResetTimer() - for i := 0; i < b.N; i++ { - // nolint:gosimple - _ = mapManager.localIPs[ip.String()] - } - }) - - b.Run("Map_Miss", func(b *testing.B) { - ip := interfaces[12] - b.ResetTimer() - for i := 0; i < b.N; i++ { - // nolint:gosimple - _ = mapManager.localIPs[ip.String()] - } - }) -} - -func BenchmarkWGPosition(b *testing.B) { - wgIP := net.ParseIP("10.10.0.1") - - // Create two managers - one checks WG IP first, other checks it last - b.Run("WG_First", func(b *testing.B) { - bm := newLocalIPManager() - bm.setBitmapBit(wgIP) - b.ResetTimer() - for i := 0; i < b.N; i++ { - bm.checkBitmapBit(wgIP) - } - }) - - b.Run("WG_Last", func(b *testing.B) { - bm := newLocalIPManager() - // Fill with other IPs first - for i := 0; i < 15; i++ { - bm.setBitmapBit(net.IPv4(10, 0, byte(i>>8), byte(i))) - } - bm.setBitmapBit(wgIP) // Add WG IP last - b.ResetTimer() - for i := 0; i < b.N; i++ { - bm.checkBitmapBit(wgIP) - } - }) -} diff --git a/client/firewall/uspfilter/log/log.go b/client/firewall/uspfilter/log/log.go index c6ca55e70..03e7d4809 100644 --- a/client/firewall/uspfilter/log/log.go +++ b/client/firewall/uspfilter/log/log.go @@ -53,16 +53,17 @@ var levelStrings = map[Level]string{ } type logMessage struct { - level Level - format string - arg1 any - arg2 any - arg3 any - arg4 any - arg5 any - arg6 any - arg7 any - arg8 any + level Level + argCount uint8 + format string + arg1 any + arg2 any + arg3 any + arg4 any + arg5 any + arg6 any + arg7 any + arg8 any } // Logger is a high-performance, non-blocking logger @@ -107,6 +108,13 @@ func (l *Logger) SetLevel(level Level) { log.Debugf("Set uspfilter logger loglevel to %v", levelStrings[level]) } +// Enabled reports whether the given level is currently logged. Callers on the +// hot path should guard log sites with this to avoid boxing arguments into +// any when the level is off. +func (l *Logger) Enabled(level Level) bool { + return l.level.Load() >= uint32(level) +} + func (l *Logger) Error(format string) { if l.level.Load() >= uint32(LevelError) { select { @@ -155,7 +163,7 @@ func (l *Logger) Trace(format string) { func (l *Logger) Error1(format string, arg1 any) { if l.level.Load() >= uint32(LevelError) { select { - case l.msgChannel <- logMessage{level: LevelError, format: format, arg1: arg1}: + case l.msgChannel <- logMessage{level: LevelError, argCount: 1, format: format, arg1: arg1}: default: } } @@ -164,7 +172,16 @@ func (l *Logger) Error1(format string, arg1 any) { func (l *Logger) Error2(format string, arg1, arg2 any) { if l.level.Load() >= uint32(LevelError) { select { - case l.msgChannel <- logMessage{level: LevelError, format: format, arg1: arg1, arg2: arg2}: + case l.msgChannel <- logMessage{level: LevelError, argCount: 2, format: format, arg1: arg1, arg2: arg2}: + default: + } + } +} + +func (l *Logger) Warn2(format string, arg1, arg2 any) { + if l.level.Load() >= uint32(LevelWarn) { + select { + case l.msgChannel <- logMessage{level: LevelWarn, argCount: 2, format: format, arg1: arg1, arg2: arg2}: default: } } @@ -173,7 +190,7 @@ func (l *Logger) Error2(format string, arg1, arg2 any) { func (l *Logger) Warn3(format string, arg1, arg2, arg3 any) { if l.level.Load() >= uint32(LevelWarn) { select { - case l.msgChannel <- logMessage{level: LevelWarn, format: format, arg1: arg1, arg2: arg2, arg3: arg3}: + case l.msgChannel <- logMessage{level: LevelWarn, argCount: 3, format: format, arg1: arg1, arg2: arg2, arg3: arg3}: default: } } @@ -182,7 +199,7 @@ func (l *Logger) Warn3(format string, arg1, arg2, arg3 any) { func (l *Logger) Warn4(format string, arg1, arg2, arg3, arg4 any) { if l.level.Load() >= uint32(LevelWarn) { select { - case l.msgChannel <- logMessage{level: LevelWarn, format: format, arg1: arg1, arg2: arg2, arg3: arg3, arg4: arg4}: + case l.msgChannel <- logMessage{level: LevelWarn, argCount: 4, format: format, arg1: arg1, arg2: arg2, arg3: arg3, arg4: arg4}: default: } } @@ -191,7 +208,7 @@ func (l *Logger) Warn4(format string, arg1, arg2, arg3, arg4 any) { func (l *Logger) Debug1(format string, arg1 any) { if l.level.Load() >= uint32(LevelDebug) { select { - case l.msgChannel <- logMessage{level: LevelDebug, format: format, arg1: arg1}: + case l.msgChannel <- logMessage{level: LevelDebug, argCount: 1, format: format, arg1: arg1}: default: } } @@ -200,7 +217,7 @@ func (l *Logger) Debug1(format string, arg1 any) { func (l *Logger) Debug2(format string, arg1, arg2 any) { if l.level.Load() >= uint32(LevelDebug) { select { - case l.msgChannel <- logMessage{level: LevelDebug, format: format, arg1: arg1, arg2: arg2}: + case l.msgChannel <- logMessage{level: LevelDebug, argCount: 2, format: format, arg1: arg1, arg2: arg2}: default: } } @@ -209,16 +226,59 @@ func (l *Logger) Debug2(format string, arg1, arg2 any) { func (l *Logger) Debug3(format string, arg1, arg2, arg3 any) { if l.level.Load() >= uint32(LevelDebug) { select { - case l.msgChannel <- logMessage{level: LevelDebug, format: format, arg1: arg1, arg2: arg2, arg3: arg3}: + case l.msgChannel <- logMessage{level: LevelDebug, argCount: 3, format: format, arg1: arg1, arg2: arg2, arg3: arg3}: default: } } } +// Debugf is the variadic shape. Dispatches to Debug/Debug1/Debug2/Debug3 +// to avoid allocating an args slice on the fast path when the arg count is +// known (0-3). Args beyond 3 land on the general variadic path; callers on +// the hot path should prefer DebugN for known counts. +func (l *Logger) Debugf(format string, args ...any) { + if l.level.Load() < uint32(LevelDebug) { + return + } + switch len(args) { + case 0: + l.Debug(format) + case 1: + l.Debug1(format, args[0]) + case 2: + l.Debug2(format, args[0], args[1]) + case 3: + l.Debug3(format, args[0], args[1], args[2]) + default: + l.sendVariadic(LevelDebug, format, args) + } +} + +// sendVariadic packs a slice of arguments into a logMessage and non-blocking +// enqueues it. Used for arg counts beyond the fixed-arity fast paths. Args +// beyond the 8-arg slot limit are dropped so callers don't produce silently +// empty log lines via uint8 wraparound in argCount. +func (l *Logger) sendVariadic(level Level, format string, args []any) { + const maxArgs = 8 + n := len(args) + if n > maxArgs { + n = maxArgs + } + msg := logMessage{level: level, argCount: uint8(n), format: format} + slots := [maxArgs]*any{&msg.arg1, &msg.arg2, &msg.arg3, &msg.arg4, &msg.arg5, &msg.arg6, &msg.arg7, &msg.arg8} + for i := 0; i < n; i++ { + *slots[i] = args[i] + } + select { + case l.msgChannel <- msg: + default: + } +} + func (l *Logger) Trace1(format string, arg1 any) { if l.level.Load() >= uint32(LevelTrace) { select { - case l.msgChannel <- logMessage{level: LevelTrace, format: format, arg1: arg1}: + case l.msgChannel <- logMessage{level: LevelTrace, argCount: 1, format: format, arg1: arg1}: default: } } @@ -227,7 +287,7 @@ func (l *Logger) Trace1(format string, arg1 any) { func (l *Logger) Trace2(format string, arg1, arg2 any) { if l.level.Load() >= uint32(LevelTrace) { select { - case l.msgChannel <- logMessage{level: LevelTrace, format: format, arg1: arg1, arg2: arg2}: + case l.msgChannel <- logMessage{level: LevelTrace, argCount: 2, format: format, arg1: arg1, arg2: arg2}: default: } } @@ -236,7 +296,7 @@ func (l *Logger) Trace2(format string, arg1, arg2 any) { func (l *Logger) Trace3(format string, arg1, arg2, arg3 any) { if l.level.Load() >= uint32(LevelTrace) { select { - case l.msgChannel <- logMessage{level: LevelTrace, format: format, arg1: arg1, arg2: arg2, arg3: arg3}: + case l.msgChannel <- logMessage{level: LevelTrace, argCount: 3, format: format, arg1: arg1, arg2: arg2, arg3: arg3}: default: } } @@ -245,7 +305,7 @@ func (l *Logger) Trace3(format string, arg1, arg2, arg3 any) { func (l *Logger) Trace4(format string, arg1, arg2, arg3, arg4 any) { if l.level.Load() >= uint32(LevelTrace) { select { - case l.msgChannel <- logMessage{level: LevelTrace, format: format, arg1: arg1, arg2: arg2, arg3: arg3, arg4: arg4}: + case l.msgChannel <- logMessage{level: LevelTrace, argCount: 4, format: format, arg1: arg1, arg2: arg2, arg3: arg3, arg4: arg4}: default: } } @@ -254,7 +314,7 @@ func (l *Logger) Trace4(format string, arg1, arg2, arg3, arg4 any) { func (l *Logger) Trace5(format string, arg1, arg2, arg3, arg4, arg5 any) { if l.level.Load() >= uint32(LevelTrace) { select { - case l.msgChannel <- logMessage{level: LevelTrace, format: format, arg1: arg1, arg2: arg2, arg3: arg3, arg4: arg4, arg5: arg5}: + case l.msgChannel <- logMessage{level: LevelTrace, argCount: 5, format: format, arg1: arg1, arg2: arg2, arg3: arg3, arg4: arg4, arg5: arg5}: default: } } @@ -263,7 +323,7 @@ func (l *Logger) Trace5(format string, arg1, arg2, arg3, arg4, arg5 any) { func (l *Logger) Trace6(format string, arg1, arg2, arg3, arg4, arg5, arg6 any) { if l.level.Load() >= uint32(LevelTrace) { select { - case l.msgChannel <- logMessage{level: LevelTrace, format: format, arg1: arg1, arg2: arg2, arg3: arg3, arg4: arg4, arg5: arg5, arg6: arg6}: + case l.msgChannel <- logMessage{level: LevelTrace, argCount: 6, format: format, arg1: arg1, arg2: arg2, arg3: arg3, arg4: arg4, arg5: arg5, arg6: arg6}: default: } } @@ -273,7 +333,7 @@ func (l *Logger) Trace6(format string, arg1, arg2, arg3, arg4, arg5, arg6 any) { func (l *Logger) Trace8(format string, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8 any) { if l.level.Load() >= uint32(LevelTrace) { select { - case l.msgChannel <- logMessage{level: LevelTrace, format: format, arg1: arg1, arg2: arg2, arg3: arg3, arg4: arg4, arg5: arg5, arg6: arg6, arg7: arg7, arg8: arg8}: + case l.msgChannel <- logMessage{level: LevelTrace, argCount: 8, format: format, arg1: arg1, arg2: arg2, arg3: arg3, arg4: arg4, arg5: arg5, arg6: arg6, arg7: arg7, arg8: arg8}: default: } } @@ -286,35 +346,8 @@ func (l *Logger) formatMessage(buf *[]byte, msg logMessage) { *buf = append(*buf, levelStrings[msg.level]...) *buf = append(*buf, ' ') - // Count non-nil arguments for switch - argCount := 0 - if msg.arg1 != nil { - argCount++ - if msg.arg2 != nil { - argCount++ - if msg.arg3 != nil { - argCount++ - if msg.arg4 != nil { - argCount++ - if msg.arg5 != nil { - argCount++ - if msg.arg6 != nil { - argCount++ - if msg.arg7 != nil { - argCount++ - if msg.arg8 != nil { - argCount++ - } - } - } - } - } - } - } - } - var formatted string - switch argCount { + switch msg.argCount { case 0: formatted = msg.format case 1: diff --git a/client/firewall/uspfilter/nat.go b/client/firewall/uspfilter/nat.go index 8ed32eb5e..06312aabf 100644 --- a/client/firewall/uspfilter/nat.go +++ b/client/firewall/uspfilter/nat.go @@ -11,10 +11,9 @@ import ( "github.com/google/gopacket/layers" firewall "github.com/netbirdio/netbird/client/firewall/manager" + nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log" ) -var ErrIPv4Only = errors.New("only IPv4 is supported for DNAT") - var ( errInvalidIPHeaderLength = errors.New("invalid IP header length") ) @@ -25,10 +24,33 @@ const ( destinationPortOffset = 2 // IP address offsets in IPv4 header - sourceIPOffset = 12 - destinationIPOffset = 16 + ipv4SrcOffset = 12 + ipv4DstOffset = 16 + + // IP address offsets in IPv6 header + ipv6SrcOffset = 8 + ipv6DstOffset = 24 + + // IPv6 fixed header length + ipv6HeaderLen = 40 ) +// ipHeaderLen returns the IP header length based on the decoded layer type. +func ipHeaderLen(d *decoder) (int, error) { + switch d.decoded[0] { + case layers.LayerTypeIPv4: + n := int(d.ip4.IHL) * 4 + if n < 20 { + return 0, errInvalidIPHeaderLength + } + return n, nil + case layers.LayerTypeIPv6: + return ipv6HeaderLen, nil + default: + return 0, fmt.Errorf("unknown IP layer: %v", d.decoded[0]) + } +} + // ipv4Checksum calculates IPv4 header checksum. func ipv4Checksum(header []byte) uint16 { if len(header) < 20 { @@ -234,19 +256,22 @@ func (m *Manager) translateOutboundDNAT(packetData []byte, d *decoder) bool { return false } - dstIP := netip.AddrFrom4([4]byte{packetData[16], packetData[17], packetData[18], packetData[19]}) - + _, dstIP := extractPacketIPs(packetData, d) translatedIP, exists := m.getDNATTranslation(dstIP) if !exists { return false } - if err := m.rewritePacketIP(packetData, d, translatedIP, destinationIPOffset); err != nil { - m.logger.Error1("failed to rewrite packet destination: %v", err) + if err := m.rewritePacketIP(packetData, d, translatedIP, false); err != nil { + if m.logger.Enabled(nblog.LevelError) { + m.logger.Error1("failed to rewrite packet destination: %v", err) + } return false } - m.logger.Trace2("DNAT: %s -> %s", dstIP, translatedIP) + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace2("DNAT: %s -> %s", dstIP, translatedIP) + } return true } @@ -256,54 +281,115 @@ func (m *Manager) translateInboundReverse(packetData []byte, d *decoder) bool { return false } - srcIP := netip.AddrFrom4([4]byte{packetData[12], packetData[13], packetData[14], packetData[15]}) - + srcIP, _ := extractPacketIPs(packetData, d) originalIP, exists := m.findReverseDNATMapping(srcIP) if !exists { return false } - if err := m.rewritePacketIP(packetData, d, originalIP, sourceIPOffset); err != nil { - m.logger.Error1("failed to rewrite packet source: %v", err) + if err := m.rewritePacketIP(packetData, d, originalIP, true); err != nil { + if m.logger.Enabled(nblog.LevelError) { + m.logger.Error1("failed to rewrite packet source: %v", err) + } return false } - m.logger.Trace2("Reverse DNAT: %s -> %s", srcIP, originalIP) + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace2("Reverse DNAT: %s -> %s", srcIP, originalIP) + } return true } -// rewritePacketIP replaces an IP address (source or destination) in the packet and updates checksums. -func (m *Manager) rewritePacketIP(packetData []byte, d *decoder, newIP netip.Addr, ipOffset int) error { +// extractPacketIPs extracts src and dst IP addresses directly from raw packet bytes. +func extractPacketIPs(packetData []byte, d *decoder) (src, dst netip.Addr) { + switch d.decoded[0] { + case layers.LayerTypeIPv4: + src = netip.AddrFrom4([4]byte{packetData[ipv4SrcOffset], packetData[ipv4SrcOffset+1], packetData[ipv4SrcOffset+2], packetData[ipv4SrcOffset+3]}) + dst = netip.AddrFrom4([4]byte{packetData[ipv4DstOffset], packetData[ipv4DstOffset+1], packetData[ipv4DstOffset+2], packetData[ipv4DstOffset+3]}) + case layers.LayerTypeIPv6: + src = netip.AddrFrom16([16]byte(packetData[ipv6SrcOffset : ipv6SrcOffset+16])) + dst = netip.AddrFrom16([16]byte(packetData[ipv6DstOffset : ipv6DstOffset+16])) + } + return src, dst +} + +// rewritePacketIP replaces a source (isSource=true) or destination IP address in the packet and updates checksums. +func (m *Manager) rewritePacketIP(packetData []byte, d *decoder, newIP netip.Addr, isSource bool) error { + hdrLen, err := ipHeaderLen(d) + if err != nil { + return err + } + + switch d.decoded[0] { + case layers.LayerTypeIPv4: + return m.rewriteIPv4(packetData, d, newIP, hdrLen, isSource) + case layers.LayerTypeIPv6: + return m.rewriteIPv6(packetData, d, newIP, hdrLen, isSource) + default: + return fmt.Errorf("unknown IP layer: %v", d.decoded[0]) + } +} + +func (m *Manager) rewriteIPv4(packetData []byte, d *decoder, newIP netip.Addr, hdrLen int, isSource bool) error { if !newIP.Is4() { - return ErrIPv4Only + return fmt.Errorf("cannot write IPv6 address into IPv4 packet") + } + + offset := ipv4DstOffset + if isSource { + offset = ipv4SrcOffset } var oldIP [4]byte - copy(oldIP[:], packetData[ipOffset:ipOffset+4]) + copy(oldIP[:], packetData[offset:offset+4]) newIPBytes := newIP.As4() + copy(packetData[offset:offset+4], newIPBytes[:]) - copy(packetData[ipOffset:ipOffset+4], newIPBytes[:]) - - ipHeaderLen := int(d.ip4.IHL) * 4 - if ipHeaderLen < 20 || ipHeaderLen > len(packetData) { - return errInvalidIPHeaderLength - } - + // Recalculate IPv4 header checksum binary.BigEndian.PutUint16(packetData[10:12], 0) - ipChecksum := ipv4Checksum(packetData[:ipHeaderLen]) - binary.BigEndian.PutUint16(packetData[10:12], ipChecksum) + binary.BigEndian.PutUint16(packetData[10:12], ipv4Checksum(packetData[:hdrLen])) + // Update transport checksums incrementally if len(d.decoded) > 1 { switch d.decoded[1] { case layers.LayerTypeTCP: - m.updateTCPChecksum(packetData, ipHeaderLen, oldIP[:], newIPBytes[:]) + m.updateTCPChecksum(packetData, hdrLen, oldIP[:], newIPBytes[:]) case layers.LayerTypeUDP: - m.updateUDPChecksum(packetData, ipHeaderLen, oldIP[:], newIPBytes[:]) + m.updateUDPChecksum(packetData, hdrLen, oldIP[:], newIPBytes[:]) case layers.LayerTypeICMPv4: - m.updateICMPChecksum(packetData, ipHeaderLen) + m.updateICMPChecksum(packetData, hdrLen) } } + return nil +} +func (m *Manager) rewriteIPv6(packetData []byte, d *decoder, newIP netip.Addr, hdrLen int, isSource bool) error { + if !newIP.Is6() { + return fmt.Errorf("cannot write IPv4 address into IPv6 packet") + } + + offset := ipv6DstOffset + if isSource { + offset = ipv6SrcOffset + } + + var oldIP [16]byte + copy(oldIP[:], packetData[offset:offset+16]) + newIPBytes := newIP.As16() + copy(packetData[offset:offset+16], newIPBytes[:]) + + // IPv6 has no header checksum, only update transport checksums + if len(d.decoded) > 1 { + switch d.decoded[1] { + case layers.LayerTypeTCP: + m.updateTCPChecksum(packetData, hdrLen, oldIP[:], newIPBytes[:]) + case layers.LayerTypeUDP: + m.updateUDPChecksum(packetData, hdrLen, oldIP[:], newIPBytes[:]) + case layers.LayerTypeICMPv6: + // ICMPv6 checksum includes pseudo-header with addresses, use incremental update + m.updateICMPv6Checksum(packetData, hdrLen, oldIP[:], newIPBytes[:]) + } + } return nil } @@ -351,6 +437,20 @@ func (m *Manager) updateICMPChecksum(packetData []byte, ipHeaderLen int) { binary.BigEndian.PutUint16(icmpData[2:4], checksum) } +// updateICMPv6Checksum updates ICMPv6 checksum after address change. +// ICMPv6 uses a pseudo-header (like TCP/UDP), so incremental update applies. +func (m *Manager) updateICMPv6Checksum(packetData []byte, ipHeaderLen int, oldIP, newIP []byte) { + icmpStart := ipHeaderLen + if len(packetData) < icmpStart+4 { + return + } + + checksumOffset := icmpStart + 2 + oldChecksum := binary.BigEndian.Uint16(packetData[checksumOffset : checksumOffset+2]) + newChecksum := incrementalUpdate(oldChecksum, oldIP, newIP) + binary.BigEndian.PutUint16(packetData[checksumOffset:checksumOffset+2], newChecksum) +} + // incrementalUpdate performs incremental checksum update per RFC 1624. func incrementalUpdate(oldChecksum uint16, oldBytes, newBytes []byte) uint16 { sum := uint32(^oldChecksum) @@ -387,30 +487,24 @@ func incrementalUpdate(oldChecksum uint16, oldBytes, newBytes []byte) uint16 { } // AddDNATRule adds outbound DNAT rule for forwarding external traffic to NetBird network. -func (m *Manager) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) { - if m.nativeFirewall == nil { - return nil, errNatNotSupported - } - return m.nativeFirewall.AddDNATRule(rule) +func (m *Manager) AddDNATRule(firewall.ForwardRule) (firewall.Rule, error) { + return nil, errNotSupported } // DeleteDNATRule deletes outbound DNAT rule. -func (m *Manager) DeleteDNATRule(rule firewall.Rule) error { - if m.nativeFirewall == nil { - return errNatNotSupported - } - return m.nativeFirewall.DeleteDNATRule(rule) +func (m *Manager) DeleteDNATRule(firewall.Rule) error { + return errNotSupported } // addPortRedirection adds a port redirection rule. -func (m *Manager) addPortRedirection(targetIP netip.Addr, protocol gopacket.LayerType, sourcePort, targetPort uint16) error { +func (m *Manager) addPortRedirection(targetIP netip.Addr, protocol gopacket.LayerType, originalPort, translatedPort uint16) error { m.portDNATMutex.Lock() defer m.portDNATMutex.Unlock() rule := portDNATRule{ protocol: protocol, - origPort: sourcePort, - targetPort: targetPort, + origPort: originalPort, + targetPort: translatedPort, targetIP: targetIP, } @@ -421,8 +515,7 @@ func (m *Manager) addPortRedirection(targetIP netip.Addr, protocol gopacket.Laye } // AddInboundDNAT adds an inbound DNAT rule redirecting traffic from NetBird peers to local services. -// TODO: also delegate to nativeFirewall when available for kernel WG mode -func (m *Manager) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { +func (m *Manager) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { var layerType gopacket.LayerType switch protocol { case firewall.ProtocolTCP: @@ -433,16 +526,16 @@ func (m *Manager) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protoco return fmt.Errorf("unsupported protocol: %s", protocol) } - return m.addPortRedirection(localAddr, layerType, sourcePort, targetPort) + return m.addPortRedirection(localAddr, layerType, originalPort, translatedPort) } // removePortRedirection removes a port redirection rule. -func (m *Manager) removePortRedirection(targetIP netip.Addr, protocol gopacket.LayerType, sourcePort, targetPort uint16) error { +func (m *Manager) removePortRedirection(targetIP netip.Addr, protocol gopacket.LayerType, originalPort, translatedPort uint16) error { m.portDNATMutex.Lock() defer m.portDNATMutex.Unlock() m.portDNATRules = slices.DeleteFunc(m.portDNATRules, func(rule portDNATRule) bool { - return rule.protocol == protocol && rule.origPort == sourcePort && rule.targetPort == targetPort && rule.targetIP.Compare(targetIP) == 0 + return rule.protocol == protocol && rule.origPort == originalPort && rule.targetPort == translatedPort && rule.targetIP.Compare(targetIP) == 0 }) if len(m.portDNATRules) == 0 { @@ -453,7 +546,7 @@ func (m *Manager) removePortRedirection(targetIP netip.Addr, protocol gopacket.L } // RemoveInboundDNAT removes an inbound DNAT rule. -func (m *Manager) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { +func (m *Manager) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { var layerType gopacket.LayerType switch protocol { case firewall.ProtocolTCP: @@ -464,23 +557,19 @@ func (m *Manager) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Prot return fmt.Errorf("unsupported protocol: %s", protocol) } - return m.removePortRedirection(localAddr, layerType, sourcePort, targetPort) + return m.removePortRedirection(localAddr, layerType, originalPort, translatedPort) } -// AddOutputDNAT delegates to the native firewall if available. -func (m *Manager) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { - if m.nativeFirewall == nil { - return fmt.Errorf("output DNAT not supported without native firewall") - } - return m.nativeFirewall.AddOutputDNAT(localAddr, protocol, sourcePort, targetPort) +// AddOutputDNAT is not supported by the userspace firewall: it backs kernel DNS +// redirection, but userspace DNS is served in-process on the gVisor netstack, so +// this should never be called. +func (m *Manager) AddOutputDNAT(netip.Addr, firewall.Protocol, uint16, uint16) error { + return errNotSupported } -// RemoveOutputDNAT delegates to the native firewall if available. -func (m *Manager) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { - if m.nativeFirewall == nil { - return nil - } - return m.nativeFirewall.RemoveOutputDNAT(localAddr, protocol, sourcePort, targetPort) +// RemoveOutputDNAT is a no-op for the userspace firewall (see AddOutputDNAT). +func (m *Manager) RemoveOutputDNAT(netip.Addr, firewall.Protocol, uint16, uint16) error { + return nil } // translateInboundPortDNAT applies port-specific DNAT translation to inbound packets. @@ -521,7 +610,9 @@ func (m *Manager) applyPortRule(packetData []byte, d *decoder, srcIP, dstIP neti } if err := rewriteFn(packetData, d, rule.targetPort, destinationPortOffset); err != nil { - m.logger.Error1("failed to rewrite port: %v", err) + if m.logger.Enabled(nblog.LevelError) { + m.logger.Error1("failed to rewrite port: %v", err) + } return false } d.dnatOrigPort = rule.origPort @@ -532,12 +623,12 @@ func (m *Manager) applyPortRule(packetData []byte, d *decoder, srcIP, dstIP neti // rewriteTCPPort rewrites a TCP port (source or destination) and updates checksum. func (m *Manager) rewriteTCPPort(packetData []byte, d *decoder, newPort uint16, portOffset int) error { - ipHeaderLen := int(d.ip4.IHL) * 4 - if ipHeaderLen < 20 || ipHeaderLen > len(packetData) { - return errInvalidIPHeaderLength + hdrLen, err := ipHeaderLen(d) + if err != nil { + return err } - tcpStart := ipHeaderLen + tcpStart := hdrLen if len(packetData) < tcpStart+4 { return fmt.Errorf("packet too short for TCP header") } @@ -563,12 +654,12 @@ func (m *Manager) rewriteTCPPort(packetData []byte, d *decoder, newPort uint16, // rewriteUDPPort rewrites a UDP port (source or destination) and updates checksum. func (m *Manager) rewriteUDPPort(packetData []byte, d *decoder, newPort uint16, portOffset int) error { - ipHeaderLen := int(d.ip4.IHL) * 4 - if ipHeaderLen < 20 || ipHeaderLen > len(packetData) { - return errInvalidIPHeaderLength + hdrLen, err := ipHeaderLen(d) + if err != nil { + return err } - udpStart := ipHeaderLen + udpStart := hdrLen if len(packetData) < udpStart+8 { return fmt.Errorf("packet too short for UDP header") } diff --git a/client/firewall/uspfilter/nat_bench_test.go b/client/firewall/uspfilter/nat_bench_test.go index d2599e577..422c6b849 100644 --- a/client/firewall/uspfilter/nat_bench_test.go +++ b/client/firewall/uspfilter/nat_bench_test.go @@ -64,9 +64,11 @@ func BenchmarkDNATTranslation(b *testing.B) { for _, sc := range scenarios { b.Run(sc.name, func(b *testing.B) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(b, err) defer func() { require.NoError(b, manager.Close(nil)) @@ -124,9 +126,11 @@ func BenchmarkDNATTranslation(b *testing.B) { // BenchmarkDNATConcurrency tests DNAT performance under concurrent load func BenchmarkDNATConcurrency(b *testing.B) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(b, err) defer func() { require.NoError(b, manager.Close(nil)) @@ -196,9 +200,11 @@ func BenchmarkDNATScaling(b *testing.B) { for _, count := range mappingCounts { b.Run(fmt.Sprintf("mappings_%d", count), func(b *testing.B) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(b, err) defer func() { require.NoError(b, manager.Close(nil)) @@ -308,9 +314,11 @@ func BenchmarkChecksumUpdate(b *testing.B) { // BenchmarkDNATMemoryAllocations checks for memory allocations in DNAT operations func BenchmarkDNATMemoryAllocations(b *testing.B) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(b, err) defer func() { require.NoError(b, manager.Close(nil)) @@ -342,12 +350,17 @@ func BenchmarkDNATMemoryAllocations(b *testing.B) { // Parse the packet fresh each time to get a clean decoder d := &decoder{decoded: []gopacket.LayerType{}} - d.parser = gopacket.NewDecodingLayerParser( + d.parser4 = gopacket.NewDecodingLayerParser( layers.LayerTypeIPv4, &d.eth, &d.ip4, &d.ip6, &d.icmp4, &d.icmp6, &d.tcp, &d.udp, ) - d.parser.IgnoreUnsupported = true - err = d.parser.DecodeLayers(testPacket, &d.decoded) + d.parser4.IgnoreUnsupported = true + d.parser6 = gopacket.NewDecodingLayerParser( + layers.LayerTypeIPv6, + &d.eth, &d.ip4, &d.ip6, &d.icmp4, &d.icmp6, &d.tcp, &d.udp, + ) + d.parser6.IgnoreUnsupported = true + err = d.decodePacket(testPacket) assert.NoError(b, err) manager.translateOutboundDNAT(testPacket, d) @@ -371,12 +384,17 @@ func BenchmarkDirectIPExtraction(b *testing.B) { b.Run("decoder_extraction", func(b *testing.B) { // Create decoder once for comparison d := &decoder{decoded: []gopacket.LayerType{}} - d.parser = gopacket.NewDecodingLayerParser( + d.parser4 = gopacket.NewDecodingLayerParser( layers.LayerTypeIPv4, &d.eth, &d.ip4, &d.ip6, &d.icmp4, &d.icmp6, &d.tcp, &d.udp, ) - d.parser.IgnoreUnsupported = true - err := d.parser.DecodeLayers(packet, &d.decoded) + d.parser4.IgnoreUnsupported = true + d.parser6 = gopacket.NewDecodingLayerParser( + layers.LayerTypeIPv6, + &d.eth, &d.ip4, &d.ip6, &d.icmp4, &d.icmp6, &d.tcp, &d.udp, + ) + d.parser6.IgnoreUnsupported = true + err := d.decodePacket(packet) assert.NoError(b, err) for i := 0; i < b.N; i++ { @@ -471,9 +489,11 @@ func BenchmarkPortDNAT(b *testing.B) { for _, sc := range scenarios { b.Run(sc.name, func(b *testing.B) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(b, err) defer func() { require.NoError(b, manager.Close(nil)) diff --git a/client/firewall/uspfilter/nat_stateful_test.go b/client/firewall/uspfilter/nat_stateful_test.go index 21c6da06e..5fa5da027 100644 --- a/client/firewall/uspfilter/nat_stateful_test.go +++ b/client/firewall/uspfilter/nat_stateful_test.go @@ -13,9 +13,11 @@ import ( // TestPortDNATBasic tests basic port DNAT functionality func TestPortDNATBasic(t *testing.T) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(t, err) defer func() { require.NoError(t, manager.Close(nil)) @@ -49,9 +51,11 @@ func TestPortDNATBasic(t *testing.T) { // TestPortDNATMultipleRules tests multiple port DNAT rules func TestPortDNATMultipleRules(t *testing.T) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(t, err) defer func() { require.NoError(t, manager.Close(nil)) diff --git a/client/firewall/uspfilter/nat_test.go b/client/firewall/uspfilter/nat_test.go index 50743d006..5b5840383 100644 --- a/client/firewall/uspfilter/nat_test.go +++ b/client/firewall/uspfilter/nat_test.go @@ -15,9 +15,11 @@ import ( // TestDNATTranslationCorrectness verifies DNAT translation works correctly func TestDNATTranslationCorrectness(t *testing.T) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(t, err) defer func() { require.NoError(t, manager.Close(nil)) @@ -86,22 +88,29 @@ func parsePacket(t testing.TB, packetData []byte) *decoder { d := &decoder{ decoded: []gopacket.LayerType{}, } - d.parser = gopacket.NewDecodingLayerParser( + d.parser4 = gopacket.NewDecodingLayerParser( layers.LayerTypeIPv4, &d.eth, &d.ip4, &d.ip6, &d.icmp4, &d.icmp6, &d.tcp, &d.udp, ) - d.parser.IgnoreUnsupported = true + d.parser4.IgnoreUnsupported = true + d.parser6 = gopacket.NewDecodingLayerParser( + layers.LayerTypeIPv6, + &d.eth, &d.ip4, &d.ip6, &d.icmp4, &d.icmp6, &d.tcp, &d.udp, + ) + d.parser6.IgnoreUnsupported = true - err := d.parser.DecodeLayers(packetData, &d.decoded) + err := d.decodePacket(packetData) require.NoError(t, err) return d } // TestDNATMappingManagement tests adding/removing DNAT mappings func TestDNATMappingManagement(t *testing.T) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(t, err) defer func() { require.NoError(t, manager.Close(nil)) @@ -147,9 +156,11 @@ func TestDNATMappingManagement(t *testing.T) { } func TestInboundPortDNAT(t *testing.T) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(t, err) defer func() { require.NoError(t, manager.Close(nil)) @@ -197,9 +208,11 @@ func TestInboundPortDNAT(t *testing.T) { } func TestInboundPortDNATNegative(t *testing.T) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(t, err) defer func() { require.NoError(t, manager.Close(nil)) diff --git a/client/firewall/uspfilter/peer_acl_bench_test.go b/client/firewall/uspfilter/peer_acl_bench_test.go new file mode 100644 index 000000000..bcb0ca5c2 --- /dev/null +++ b/client/firewall/uspfilter/peer_acl_bench_test.go @@ -0,0 +1,333 @@ +//go:build uspbench + +package uspfilter + +import ( + "fmt" + "io" + "math/rand" + "net" + "net/netip" + "runtime" + "testing" + + "github.com/google/gopacket" + "github.com/google/gopacket/layers" + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/require" + + fw "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/device" + "github.com/netbirdio/netbird/client/iface/wgaddr" +) + +// BenchmarkPeerACLMatch measures the per-packet cost of the peer ACL +// matcher (peerACLsBlock) across realistic shapes: M distinct policy +// rules, each with K source peers in its set. +// +// With the reverse-source index, miss cost is independent of M and +// hit cost grows only with the number of rules touching a single +// srcIP, not with total rule count. +func BenchmarkPeerACLMatch(b *testing.B) { + shapes := []struct{ M, K int }{ + {1, 100}, {10, 100}, {50, 100}, {100, 100}, {100, 1000}, + } + families := []struct { + name string + v6 bool + }{{"v4", false}, {"v6", true}} + + for _, fam := range families { + for _, s := range shapes { + b.Run(fmt.Sprintf("%s/M=%d/K=%d/hit", fam.name, s.M, s.K), func(b *testing.B) { + runPeerACLBench(b, s.M, s.K, true, fam.v6) + }) + b.Run(fmt.Sprintf("%s/M=%d/K=%d/miss", fam.name, s.M, s.K), func(b *testing.B) { + runPeerACLBench(b, s.M, s.K, false, fam.v6) + }) + } + } +} + +func runPeerACLBench(b *testing.B, m, k int, hit, v6 bool) { + log.SetOutput(io.Discard) // keep manager logs out of the benchmark output + + // Miss packets are dropped, so they always traverse the full peer + // ACL matcher (every bucket) without short-circuiting and without + // touching conntrack. Disable conntrack for the miss case so it + // measures the matcher, not established-state lookups. The hit case + // keeps conntrack on: an accepted packet reaches trackInbound, which + // needs the trackers conntrack creates. + if !hit { + b.Setenv("NB_DISABLE_CONNTRACK", "1") + } + + bits := 32 + genPkt := generatePacket + addrs := uniqueAddrs + if v6 { + bits = 128 + genPkt = generatePacket6 + addrs = uniqueAddrs6 + } + + // dstIP must be a local IP so filterInbound takes the local-traffic + // path (handleLocalTraffic → peerACLsBlock) we are measuring; an + // address the manager doesn't own would be treated as routed and + // short-circuit before the peer matcher. + dstIP := addrs(1, 2)[0] + mockAddr := wgaddr.Address{IP: dstIP, Network: netip.PrefixFrom(dstIP, bits)} + if v6 { + // The local-IP manager needs a valid v4 address too; expose the v6 + // dst as the interface's IPv6 so IsLocalIP recognizes it. + mockAddr = wgaddr.Address{ + IP: netip.MustParseAddr("100.64.0.1"), + Network: netip.MustParsePrefix("100.64.0.0/16"), + IPv6: dstIP, + IPv6Net: netip.PrefixFrom(dstIP, bits), + } + } + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + AddressFunc: func() wgaddr.Address { return mockAddr }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) + require.NoError(b, err) + b.Cleanup(func() { require.NoError(b, manager.Close(nil)) }) + + // Generate M policies × K source peers, all distinct. + all := addrs(m*k, 1) + for i := 0; i < m; i++ { + sources := make([]netip.Prefix, k) + for j, a := range all[i*k : (i+1)*k] { + sources[j] = netip.PrefixFrom(a, bits) + } + _, err := manager.AddFilterRule( + nil, sources, fw.Network{}, fw.ProtocolTCP, nil, + &fw.Port{Values: []uint16{uint16(80 + i)}}, + fw.ActionAccept) + require.NoError(b, err) + } + + // Hit: cycle through real sources, picking the matching policy's port. + // Miss: a source from a disjoint range, port 80 (matches no policy). + var pktFn func(i int) []byte + if hit { + pktFn = func(i int) []byte { + policy := i % m + src := all[policy*k+(i%k)] + return genPkt(b, src.AsSlice(), dstIP.AsSlice(), + uint16(1024+i%60000), uint16(80+policy), layers.IPProtocolTCP) + } + } else { + miss := addrs(4096, 99) + pktFn = func(i int) []byte { + return genPkt(b, miss[i%len(miss)].AsSlice(), dstIP.AsSlice(), + uint16(1024+i%60000), 80, layers.IPProtocolTCP) + } + } + + // Pre-build a pool to avoid allocations dominating the measurement. + pool := make([][]byte, 1024) + for i := range pool { + pool[i] = pktFn(i) + } + + // Confirm the matcher is actually exercised: a hit packet must be + // allowed and a miss packet dropped. Without this the benchmark + // could silently time the routed early-return instead. + require.Equal(b, !hit, manager.filterInbound(pool[0], 0), + "benchmark must reach the peer ACL matcher") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + manager.filterInbound(pool[i%len(pool)], 0) + } +} + +// BenchmarkPeerACLIndexMemory reports the resident memory cost of +// the source-keyed index across realistic deployment shapes. Two +// dimensions matter: (M, K), the number of policies × peers-per-policy, +// and overlap, the fraction of peers shared between policies. +// +// The output uses ReportMetric("bytes/rule") so the cost can be +// compared across shapes directly. Total bytes = bytes/rule * M. +func BenchmarkPeerACLIndexMemory(b *testing.B) { + cases := []struct { + name string + M, K int + overlapFrac float64 // 0 = disjoint per-policy sources, 1 = all share the same pool + }{ + {"M=10/K=100/disjoint", 10, 100, 0}, + {"M=100/K=100/disjoint", 100, 100, 0}, + {"M=100/K=1000/disjoint", 100, 1000, 0}, + {"M=100/K=1000/overlap=0.5", 100, 1000, 0.5}, + {"M=100/K=1000/overlap=1.0", 100, 1000, 1.0}, + {"M=1000/K=100/overlap=1.0", 1000, 100, 1.0}, + } + + for _, c := range cases { + b.Run(c.name, func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + mgr, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) + require.NoError(b, err) + + populateIndexedRules(b, mgr, c.M, c.K, c.overlapFrac) + + runtime.GC() + var ms runtime.MemStats + runtime.ReadMemStats(&ms) + before := ms.HeapAlloc + + // Drop the manager's external roots so we can isolate + // the index cost. We hold the manager itself live; the + // index is what we measure on the second pass. + mgr.incomingAcceptIndex.reset() + mgr.incomingDenyIndex.reset() + mgr.incomingAcceptRules = mgr.incomingAcceptRules[:0] + mgr.incomingDenyRules = mgr.incomingDenyRules[:0] + runtime.GC() + runtime.ReadMemStats(&ms) + after := ms.HeapAlloc + + delta := int64(before) - int64(after) + if delta < 0 { + delta = 0 + } + b.ReportMetric(float64(delta)/float64(c.M), "bytes/rule") + b.ReportMetric(float64(delta), "bytes/total") + + require.NoError(b, mgr.Close(nil)) + } + }) + } +} + +func populateIndexedRules(b *testing.B, mgr *Manager, m, k int, overlapFrac float64) { + b.Helper() + pool := uniqueAddrs(k+m*k, 1) // big enough universe + sharedLen := int(float64(k) * overlapFrac) + if sharedLen > k { + sharedLen = k + } + shared := pool[:sharedLen] + uniquePool := pool[sharedLen:] + + for i := 0; i < m; i++ { + sources := make([]netip.Prefix, 0, k) + for _, a := range shared { + sources = append(sources, netip.PrefixFrom(a, 32)) + } + // each policy gets (k-sharedLen) addresses unique to it from the unique pool + unique := uniquePool[i*(k-sharedLen) : (i+1)*(k-sharedLen)] + for _, a := range unique { + sources = append(sources, netip.PrefixFrom(a, 32)) + } + _, err := mgr.AddFilterRule( + nil, sources, fw.Network{}, fw.ProtocolTCP, nil, + &fw.Port{Values: []uint16{uint16(80 + i)}}, + fw.ActionAccept) + require.NoError(b, err) + } +} + +// uniqueAddrs returns n distinct addrs. Seeds 1, 2 are used for +// policy sources / dst; seed 99 puts misses in 10/8. +func uniqueAddrs(n int, seed int64) []netip.Addr { + out := make([]netip.Addr, 0, n) + seen := make(map[netip.Addr]struct{}, n) + r := rand.New(rand.NewSource(seed)) + miss := seed == 99 + for len(out) < n { + var b [4]byte + if miss { + b[0] = 10 + b[1] = byte(r.Intn(256)) + } else { + b[0] = 100 + b[1] = byte(64 + r.Intn(63)) + } + b[2] = byte(r.Intn(256)) + b[3] = byte(1 + r.Intn(254)) + a := netip.AddrFrom4(b) + if _, ok := seen[a]; ok { + continue + } + seen[a] = struct{}{} + out = append(out, a) + } + return out +} + +// uniqueAddrs6 mirrors uniqueAddrs for IPv6: sources come from the ULA +// range fd00::/8, the miss set (seed 99) from 2001:db8::/32 so it is +// disjoint from any source. +func uniqueAddrs6(n int, seed int64) []netip.Addr { + out := make([]netip.Addr, 0, n) + seen := make(map[netip.Addr]struct{}, n) + r := rand.New(rand.NewSource(seed)) + miss := seed == 99 + for len(out) < n { + var b [16]byte + if miss { + b[0], b[1], b[2], b[3] = 0x20, 0x01, 0x0d, 0xb8 + } else { + b[0] = 0xfd + } + for x := 8; x < 16; x++ { + b[x] = byte(r.Intn(256)) + } + a := netip.AddrFrom16(b) + if _, ok := seen[a]; ok { + continue + } + seen[a] = struct{}{} + out = append(out, a) + } + return out +} + +// generatePacket6 builds an IPv6 TCP/UDP packet, mirroring +// generatePacket for the v4 case. +func generatePacket6(b *testing.B, srcIP, dstIP net.IP, srcPort, dstPort uint16, protocol layers.IPProtocol) []byte { + b.Helper() + + ipv6 := &layers.IPv6{ + Version: 6, + HopLimit: 64, + NextHeader: protocol, + SrcIP: srcIP, + DstIP: dstIP, + } + + var transportLayer gopacket.SerializableLayer + switch protocol { + case layers.IPProtocolTCP: + tcp := &layers.TCP{ + SrcPort: layers.TCPPort(srcPort), + DstPort: layers.TCPPort(dstPort), + SYN: true, + } + require.NoError(b, tcp.SetNetworkLayerForChecksum(ipv6)) + transportLayer = tcp + case layers.IPProtocolUDP: + udp := &layers.UDP{ + SrcPort: layers.UDPPort(srcPort), + DstPort: layers.UDPPort(dstPort), + } + require.NoError(b, udp.SetNetworkLayerForChecksum(ipv6)) + transportLayer = udp + } + + buf := gopacket.NewSerializeBuffer() + opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true} + require.NoError(b, gopacket.SerializeLayers(buf, opts, ipv6, transportLayer, gopacket.Payload("test"))) + return buf.Bytes() +} diff --git a/client/firewall/uspfilter/peer_acl_dedup_test.go b/client/firewall/uspfilter/peer_acl_dedup_test.go new file mode 100644 index 000000000..696766d80 --- /dev/null +++ b/client/firewall/uspfilter/peer_acl_dedup_test.go @@ -0,0 +1,150 @@ +package uspfilter + +import ( + "net" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + fw "github.com/netbirdio/netbird/client/firewall/manager" + nbiface "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/device" +) + +func newTestManager(t *testing.T) *Manager { + t.Helper() + ifaceMock := &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + } + m, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) + require.NoError(t, err, "create manager") + t.Cleanup(func() { require.NoError(t, m.Close(nil)) }) + return m +} + +// TestAddPeerFiltering_DeduplicatesIdenticalRules verifies that adding +// the same peer rule twice does not create two backing rules. The acl +// manager keys its own cache, but the firewall backend must be +// idempotent on its own so a double-apply cannot leak rules, matching +// the route path and the kernel backends. +func TestAddPeerFiltering_DeduplicatesIdenticalRules(t *testing.T) { + m := newTestManager(t) + + ip := net.ParseIP("192.168.1.1") + proto := fw.ProtocolTCP + port := &fw.Port{Values: []uint16{80}} + action := fw.ActionDrop + + first, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, port, action) + require.NoError(t, err, "first add") + + second, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, port, action) + require.NoError(t, err, "second add") + + assert.Equal(t, first.ID(), second.ID(), "duplicate add should return the same rule id") + assert.Len(t, m.incomingDenyRules, 1, "duplicate add must not create a second backing rule") +} + +// TestDeletePeerFiltering_NoRefcountSingleDeleteRemoves locks the +// backend's owner accounting for the same-owner case: a content key +// installed twice by the same owner registers one owner claim, so the +// first DeleteFilterRule removes the rule. Owner counting only kicks +// in for distinct management rule IDs (see the peer owner tests); the +// acl manager keys its tracking per (policy, content) and deletes once +// per key, so adds and deletes stay balanced. +func TestDeletePeerFiltering_NoRefcountSingleDeleteRemoves(t *testing.T) { + m := newTestManager(t) + + ip := net.ParseIP("192.168.1.1") + proto := fw.ProtocolTCP + port := &fw.Port{Values: []uint16{80}} + action := fw.ActionDrop + + first, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, port, action) + require.NoError(t, err, "first add") + + second, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, port, action) + require.NoError(t, err, "second add") + require.Equal(t, first.ID(), second.ID(), "dedup to one rule") + require.Len(t, m.incomingDenyRules, 1, "still one backing rule after duplicate add") + + require.NoError(t, m.DeleteFilterRule(first), "delete once") + assert.Empty(t, m.incomingDenyRules, "single delete removes the backing rule (no refcount)") + assert.NotContains(t, m.peerRulesMap, first.ID(), "dedup map entry cleared") +} + +// TestAddPeerFiltering_DeterministicID verifies the peer rule id is a +// content hash, not a random UUID: identical inputs produce the same id +// across independent managers. A random id breaks caller-side dedup. +func TestAddPeerFiltering_DeterministicID(t *testing.T) { + ip := net.ParseIP("10.0.0.5") + proto := fw.ProtocolUDP + port := &fw.Port{Values: []uint16{53}} + action := fw.ActionAccept + + m1 := newTestManager(t) + r1, err := m1.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, port, action) + require.NoError(t, err) + + m2 := newTestManager(t) + r2, err := m2.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, port, action) + require.NoError(t, err) + + assert.Equal(t, r1.ID(), r2.ID(), "same inputs must produce the same rule id") +} + +// TestAddPeerFiltering_DistinctRulesNotDeduped verifies that rules +// differing only by port are kept separate. +func TestAddPeerFiltering_DistinctRulesNotDeduped(t *testing.T) { + m := newTestManager(t) + + ip := net.ParseIP("192.168.1.1") + proto := fw.ProtocolTCP + action := fw.ActionAccept + + r80, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, &fw.Port{Values: []uint16{80}}, action) + require.NoError(t, err) + + r443, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, &fw.Port{Values: []uint16{443}}, action) + require.NoError(t, err) + + assert.NotEqual(t, r80.ID(), r443.ID(), "different ports must produce different rule ids") + assert.Len(t, m.incomingAcceptRules, 2, "distinct rules must both be stored") +} + +// TestAddPeerFiltering_SourceVsDestPortNotDeduped verifies that a rule +// matching on source port and one matching on destination port for the +// same selector do not collide: the port lands in a different slot, so +// the content key must differ. +func TestAddPeerFiltering_SourceVsDestPortNotDeduped(t *testing.T) { + m := newTestManager(t) + + ip := net.ParseIP("192.168.1.1") + proto := fw.ProtocolTCP + port := &fw.Port{Values: []uint16{80}} + action := fw.ActionAccept + + dPortRule, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, port, action) + require.NoError(t, err) + + sPortRule, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, port, nil, action) + require.NoError(t, err) + + assert.NotEqual(t, dPortRule.ID(), sPortRule.ID(), "source-port and dest-port matches must produce different rule ids") +} + +// TestAddFilterRule_EmptySourcesRejected verifies that an empty source +// list is rejected rather than treated as "match any". "Match any" must +// be an explicit /0, so a zeroed list can never silently widen a rule to +// every source. +func TestAddFilterRule_EmptySourcesRejected(t *testing.T) { + m := newTestManager(t) + + proto := fw.ProtocolTCP + port := &fw.Port{Values: []uint16{80}} + + _, err := m.AddFilterRule(nil, nil, fw.Network{}, proto, nil, port, fw.ActionAccept) + require.ErrorIs(t, err, fw.ErrNoSources, "empty sources must be rejected") + assert.Empty(t, m.incomingAcceptRules, "no rule should be stored for empty sources") +} diff --git a/client/firewall/uspfilter/peer_acl_ipv6_test.go b/client/firewall/uspfilter/peer_acl_ipv6_test.go new file mode 100644 index 000000000..282b44ebb --- /dev/null +++ b/client/firewall/uspfilter/peer_acl_ipv6_test.go @@ -0,0 +1,105 @@ +package uspfilter + +import ( + "net" + "net/netip" + "testing" + + "github.com/google/gopacket" + "github.com/google/gopacket/layers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + fw "github.com/netbirdio/netbird/client/firewall/manager" + nbiface "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/device" + "github.com/netbirdio/netbird/client/iface/wgaddr" +) + +func newV6TestManager(t *testing.T, localV6 string) *Manager { + t.Helper() + ifaceMock := &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + AddressFunc: func() wgaddr.Address { + return wgaddr.Address{ + IP: netip.MustParseAddr("100.10.0.100"), + Network: netip.MustParsePrefix("100.10.0.0/16"), + IPv6: netip.MustParseAddr(localV6), + IPv6Net: netip.MustParsePrefix("fd00::/64"), + } + }, + } + m, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) + require.NoError(t, err, "create manager") + t.Cleanup(func() { require.NoError(t, m.Close(nil)) }) + return m +} + +func v6UDPPacket(t *testing.T, src, dst string, dstPort uint16) []byte { + t.Helper() + ip6 := &layers.IPv6{ + Version: 6, + HopLimit: 64, + NextHeader: layers.IPProtocolUDP, + SrcIP: net.ParseIP(src), + DstIP: net.ParseIP(dst), + } + udp := &layers.UDP{SrcPort: 51334, DstPort: layers.UDPPort(dstPort)} + require.NoError(t, udp.SetNetworkLayerForChecksum(ip6)) + + buf := gopacket.NewSerializeBuffer() + opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true} + require.NoError(t, gopacket.SerializeLayers(buf, opts, ip6, udp, gopacket.Payload("test"))) + return buf.Bytes() +} + +// TestPeerACL_IPv6HostRule verifies the source index resolves /128 v6 +// rules: a matching v6 source is accepted, a non-matching one is +// denied by the default. This is the end-to-end proof that the index +// is not v4-only. +func TestPeerACL_IPv6HostRule(t *testing.T) { + m := newV6TestManager(t, "fd00::100") + + src := net.ParseIP("fd00::1") + _, err := m.AddFilterRule(nil, pfx(src), fw.Network{}, fw.ProtocolUDP, nil, &fw.Port{Values: []uint16{53}}, fw.ActionAccept) + require.NoError(t, err, "add v6 accept rule") + + require.False(t, m.filterInbound(v6UDPPacket(t, "fd00::1", "fd00::100", 53), 0), + "v6 packet from the allowed /128 source must be accepted") + require.True(t, m.filterInbound(v6UDPPacket(t, "fd00::2", "fd00::100", 53), 0), + "v6 packet from an unlisted source must be denied by default") +} + +// TestPeerACL_IPv6IndexBuckets verifies that v6 sources land in the +// right index bucket: a /128 in bySource keyed by its address, and +// coarser prefixes (including ::/0) in the nonHost slice. +func TestPeerACL_IPv6IndexBuckets(t *testing.T) { + m := newV6TestManager(t, "fd00::100") + port := &fw.Port{Values: []uint16{53}} + + host := netip.MustParseAddr("fd00::1") + _, err := m.AddFilterRule(nil, []netip.Prefix{netip.PrefixFrom(host, 128)}, fw.Network{}, fw.ProtocolUDP, nil, port, fw.ActionAccept) + require.NoError(t, err) + assert.Contains(t, m.incomingAcceptIndex.bySource, host, "/128 v6 source must be indexed by address") + + _, err = m.AddFilterRule(nil, []netip.Prefix{netip.MustParsePrefix("fd00:dead::/64")}, fw.Network{}, fw.ProtocolUDP, nil, port, fw.ActionAccept) + require.NoError(t, err) + require.Len(t, m.incomingAcceptIndex.nonHost, 1, "coarser v6 prefix must land in nonHost") + + _, err = m.AddFilterRule(nil, []netip.Prefix{netip.MustParsePrefix("::/0")}, fw.Network{}, fw.ProtocolUDP, nil, port, fw.ActionAccept) + require.NoError(t, err) + require.Len(t, m.incomingAcceptIndex.nonHost, 2, "::/0 source must also land in nonHost") +} + +// TestPeerACL_IPv4MappedSourceNormalized verifies a v4-mapped v6 +// source prefix is normalized to v4 so a plain v4 packet matches it. +func TestPeerACL_IPv4MappedSourceNormalized(t *testing.T) { + m := newTestManager(t) + + mapped := netip.MustParseAddr("::ffff:192.168.1.1") + _, err := m.AddFilterRule(nil, []netip.Prefix{netip.PrefixFrom(mapped, mapped.BitLen())}, fw.Network{}, fw.ProtocolUDP, nil, &fw.Port{Values: []uint16{53}}, fw.ActionAccept) + require.NoError(t, err) + + v4 := netip.MustParseAddr("192.168.1.1") + assert.Contains(t, m.incomingAcceptIndex.bySource, v4, "v4-mapped v6 source must be indexed as plain v4") +} diff --git a/client/firewall/uspfilter/peer_family_scope_test.go b/client/firewall/uspfilter/peer_family_scope_test.go new file mode 100644 index 000000000..1cf3498fb --- /dev/null +++ b/client/firewall/uspfilter/peer_family_scope_test.go @@ -0,0 +1,104 @@ +package uspfilter + +import ( + "net" + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + fw "github.com/netbirdio/netbird/client/firewall/manager" +) + +// peerACLCheck decodes the packet and runs it through the peer ACLs, +// returning the attributed management rule id and the drop verdict. +func peerACLCheck(t *testing.T, m *Manager, packet []byte) ([]byte, bool) { + t.Helper() + d := m.decoders.Get().(*decoder) + defer m.decoders.Put(d) + require.NoError(t, d.decodePacket(packet)) + src, _ := m.extractIPs(d) + return m.peerACLsBlock(src, d, packet) +} + +// TestPeerACL_MultiValuePortMatchesEachListedPort guards the multi-value +// port path: a rule listing several discrete destination ports must +// match a packet to each listed port and drop one that is not listed. +// Management currently splits a multi-port policy into one rule per port +// (and the wire format carries a single port), so this list shape is not +// emitted today; the test locks correct matching in case that changes. +func TestPeerACL_MultiValuePortMatchesEachListedPort(t *testing.T) { + m := newTestManager(t) + + src := net.ParseIP("192.168.1.1") + ports := &fw.Port{Values: []uint16{80, 443}} + _, err := m.AddFilterRule(nil, pfx(src), fw.Network{}, fw.ProtocolTCP, nil, ports, fw.ActionAccept) + require.NoError(t, err, "add multi-value port rule") + + for _, p := range []uint16{80, 443} { + _, blocked := peerACLCheck(t, m, createTestPacket(t, "192.168.1.1", "10.0.0.2", fw.ProtocolTCP, 12345, p)) + assert.False(t, blocked, "packet to listed port %d must match the rule", p) + } + + _, blocked := peerACLCheck(t, m, createTestPacket(t, "192.168.1.1", "10.0.0.2", fw.ProtocolTCP, 12345, 8080)) + assert.True(t, blocked, "packet to a port not in the list must not match the rule") +} + +// TestPeerACL_MatchAnyIsFamilyScoped verifies that a /0 source matches +// only packets of its own family: 0.0.0.0/0 must not match IPv6 packets +// and ::/0 must not match IPv4 packets, matching kernel backend +// semantics. +func TestPeerACL_MatchAnyIsFamilyScoped(t *testing.T) { + m := newTestManager(t) + + v4Packet := createTestPacket(t, "10.0.0.1", "10.0.0.2", fw.ProtocolUDP, 12345, 53) + v6Packet := v6UDPPacket(t, "fd00::1", "fd00::100", 53) + + v4Any := []netip.Prefix{netip.PrefixFrom(netip.IPv4Unspecified(), 0)} + rule, err := m.AddFilterRule(nil, v4Any, fw.Network{}, fw.ProtocolALL, nil, nil, fw.ActionAccept) + require.NoError(t, err, "add v4 /0 rule") + + _, blocked := peerACLCheck(t, m, v4Packet) + assert.False(t, blocked, "0.0.0.0/0 must match IPv4 packets") + _, blocked = peerACLCheck(t, m, v6Packet) + assert.True(t, blocked, "0.0.0.0/0 must not match IPv6 packets") + + require.NoError(t, m.DeleteFilterRule(rule)) + + v6Any := []netip.Prefix{netip.PrefixFrom(netip.IPv6Unspecified(), 0)} + _, err = m.AddFilterRule(nil, v6Any, fw.Network{}, fw.ProtocolALL, nil, nil, fw.ActionAccept) + require.NoError(t, err, "add v6 /0 rule") + + _, blocked = peerACLCheck(t, m, v6Packet) + assert.False(t, blocked, "::/0 must match IPv6 packets") + _, blocked = peerACLCheck(t, m, v4Packet) + assert.True(t, blocked, "::/0 must not match IPv4 packets") +} + +// TestRouteACL_MixedFamilyZeroSourcesStayFamilySafe verifies the route +// path keeps per-prefix family matching when a single rule carries both +// 0.0.0.0/0 and ::/0 sources, as blockInvalidRouted does. +func TestRouteACL_MixedFamilyZeroSourcesStayFamilySafe(t *testing.T) { + m := newTestManager(t) + + sources := []netip.Prefix{ + netip.PrefixFrom(netip.IPv4Unspecified(), 0), + netip.PrefixFrom(netip.IPv6Unspecified(), 0), + } + + _, err := m.AddFilterRule(nil, sources, fw.Network{Prefix: netip.MustParsePrefix("10.0.0.0/24")}, + fw.ProtocolALL, nil, nil, fw.ActionAccept) + require.NoError(t, err) + _, err = m.AddFilterRule(nil, sources, fw.Network{Prefix: netip.MustParsePrefix("fd00:1::/64")}, + fw.ProtocolALL, nil, nil, fw.ActionAccept) + require.NoError(t, err) + + v4Src := netip.MustParseAddr("192.168.1.1") + v6Src := netip.MustParseAddr("fd00::1") + + _, pass := m.routeACLsPass(v4Src, netip.MustParseAddr("10.0.0.5"), 255, 0, 0) + assert.True(t, pass, "v4 source must match the v4 destination rule via 0.0.0.0/0") + _, pass = m.routeACLsPass(v6Src, netip.MustParseAddr("fd00:1::5"), 255, 0, 0) + assert.True(t, pass, "v6 source must match the v6 destination rule via ::/0") +} diff --git a/client/firewall/uspfilter/peer_index.go b/client/firewall/uspfilter/peer_index.go new file mode 100644 index 000000000..552ffd2d7 --- /dev/null +++ b/client/firewall/uspfilter/peer_index.go @@ -0,0 +1,140 @@ +package uspfilter + +import ( + "net/netip" + "slices" + + "github.com/google/gopacket" + "github.com/google/gopacket/layers" + + firewall "github.com/netbirdio/netbird/client/firewall/manager" +) + +// peerRuleIndex is the source-side dispatcher consulted on the packet +// hot path. It splits rules into two buckets by the shape of their +// source list: +// +// - bySource: every source is a host prefix (/32 for v4, /128 for +// v6). Keyed by the concrete source address, so a hit guarantees +// the source filter passes and the matcher goes straight to +// proto/port checks. This is the common case for peer ACLs. +// - nonHost: any source list with a prefix coarser than a host, +// including a /0 "match any". Walked linearly with a per-rule +// Contains() check. Expected small or empty for typical peer ACLs. +// +// Maintained incrementally by add/remove, never rebuilt. +type peerRuleIndex struct { + bySource map[netip.Addr][]*PeerRule + nonHost []*PeerRule +} + +func (i *peerRuleIndex) add(r *PeerRule) { + if hasNonHostSource(r) { + i.nonHost = append(i.nonHost, r) + return + } + if i.bySource == nil { + i.bySource = make(map[netip.Addr][]*PeerRule) + } + for a := range r.sourceAddrs { + i.bySource[a] = append(i.bySource[a], r) + } +} + +func (i *peerRuleIndex) remove(r *PeerRule) { + if hasNonHostSource(r) { + i.nonHost = slices.DeleteFunc(i.nonHost, eqRule(r)) + return + } + if i.bySource == nil { + return + } + for a := range r.sourceAddrs { + entries := slices.DeleteFunc(i.bySource[a], eqRule(r)) + if len(entries) == 0 { + delete(i.bySource, a) + } else { + i.bySource[a] = entries + } + } +} + +func (i *peerRuleIndex) reset() { + i.bySource = nil + i.nonHost = i.nonHost[:0] +} + +// match returns the first rule matching src and the decoded packet. +// Host rules are found by direct map lookup; nonHost rules run a +// per-rule source Contains() check. Containment is family-scoped, so +// a /0 source matches every address of its own family only (0.0.0.0/0 +// never matches v6 sources and ::/0 never matches v4). Within either +// bucket the matcher runs the proto/port filter. +func (i *peerRuleIndex) match(src netip.Addr, d *decoder) ([]byte, bool, bool) { + payloadLayer := d.decoded[1] + + for _, rule := range i.bySource[src] { + if id, drop, ok := matchProto(rule, d, payloadLayer); ok { + return id, drop, true + } + } + for _, rule := range i.nonHost { + if !prefixesContain(rule.sources, src) { + continue + } + if id, drop, ok := matchProto(rule, d, payloadLayer); ok { + return id, drop, true + } + } + return nil, false, false +} + +func eqRule(target *PeerRule) func(*PeerRule) bool { + return func(p *PeerRule) bool { return p == target } +} + +// hasNonHostSource reports whether the rule has any source prefix +// that is not a single host address. Called only at add/remove time, +// not on the packet path. +func hasNonHostSource(r *PeerRule) bool { + for _, p := range r.sources { + if p.Bits() != p.Addr().BitLen() { + return true + } + } + return false +} + +// matchProto applies the proto/port half of a rule against the +// decoded packet. Source matching is the caller's responsibility. +func matchProto(rule *PeerRule, d *decoder, payloadLayer gopacket.LayerType) ([]byte, bool, bool) { + drop := rule.action == firewall.ActionDrop + if rule.protoLayer == layerTypeAll { + return rule.mgmtId, drop, true + } + if !protoLayerMatches(rule.protoLayer, payloadLayer) { + return nil, false, false + } + switch payloadLayer { + case layers.LayerTypeTCP: + if portsMatch(rule.srcPort, uint16(d.tcp.SrcPort)) && portsMatch(rule.dstPort, uint16(d.tcp.DstPort)) { + return rule.mgmtId, drop, true + } + case layers.LayerTypeUDP: + if portsMatch(rule.srcPort, uint16(d.udp.SrcPort)) && portsMatch(rule.dstPort, uint16(d.udp.DstPort)) { + return rule.mgmtId, drop, true + } + case layers.LayerTypeICMPv4, layers.LayerTypeICMPv6: + return rule.mgmtId, drop, true + } + return nil, false, false +} + +func prefixesContain(sources []netip.Prefix, src netip.Addr) bool { + for _, p := range sources { + if p.Contains(src) { + return true + } + } + return false +} diff --git a/client/firewall/uspfilter/rule.go b/client/firewall/uspfilter/rule.go index 08d68a78e..6d73b19ac 100644 --- a/client/firewall/uspfilter/rule.go +++ b/client/firewall/uspfilter/rule.go @@ -10,24 +10,43 @@ import ( // PeerRule to handle management of rules type PeerRule struct { - id string - mgmtId []byte - ip netip.Addr - ipLayer gopacket.LayerType - matchByIP bool - protoLayer gopacket.LayerType - sPort *firewall.Port - dPort *firewall.Port - drop bool + id firewall.RuleID + mgmtId []byte + // sources is the canonical list of source prefixes this rule + // matches against. + sources []netip.Prefix + // sourceAddrs is a fast-path membership set for host-prefix + // sources (/32 v4, /128 v6). Populated alongside sources; + // consulted before falling back to prefix scan. + sourceAddrs map[netip.Addr]struct{} + protoLayer gopacket.LayerType + srcPort *firewall.Port + dstPort *firewall.Port + action firewall.Action +} + +// matchesSource reports whether the given source address is covered +// by this rule's source list. Prefix containment is family-scoped, so +// a /0 source matches every address of its own family only. +func (r *PeerRule) matchesSource(src netip.Addr) bool { + if _, ok := r.sourceAddrs[src]; ok { + return true + } + for _, p := range r.sources { + if p.Contains(src) { + return true + } + } + return false } // ID returns the rule id -func (r *PeerRule) ID() string { +func (r *PeerRule) ID() firewall.RuleID { return r.id } type RouteRule struct { - id string + id firewall.RuleID mgmtId []byte sources []netip.Prefix dstSet firewall.Set @@ -39,6 +58,6 @@ type RouteRule struct { } // ID returns the rule id -func (r *RouteRule) ID() string { +func (r *RouteRule) ID() firewall.RuleID { return r.id } diff --git a/client/firewall/uspfilter/testhelpers_test.go b/client/firewall/uspfilter/testhelpers_test.go new file mode 100644 index 000000000..a0760f5ba --- /dev/null +++ b/client/firewall/uspfilter/testhelpers_test.go @@ -0,0 +1,50 @@ +package uspfilter + +import ( + "net" + "net/netip" + + firewall "github.com/netbirdio/netbird/client/firewall/manager" +) + +// countRulesForAddr reports how many rules in the given slice match +// the supplied source address. +func countRulesForAddr(rules peerRules, src netip.Addr) int { + n := 0 + for _, r := range rules { + if r.matchesSource(src) { + n++ + } + } + return n +} + +// findRuleByID returns true if the rules slice contains a rule with +// the given id whose source set covers src. +func findRuleByID(rules peerRules, src netip.Addr, id firewall.RuleID) bool { + for _, r := range rules { + if r.id == id && r.matchesSource(src) { + return true + } + } + return false +} + +// pfx converts a single net.IP into the []netip.Prefix form +// AddFilterRule expects. A nil or unspecified address becomes a /0 +// ("match any") prefix in the matching family; any other address +// becomes its /32 (or /128) host prefix. +func pfx(ip net.IP) []netip.Prefix { + if ip == nil { + return []netip.Prefix{netip.PrefixFrom(netip.IPv4Unspecified(), 0)} + } + if ip.IsUnspecified() { + if ip.To4() != nil { + return []netip.Prefix{netip.PrefixFrom(netip.IPv4Unspecified(), 0)} + } + return []netip.Prefix{netip.PrefixFrom(netip.IPv6Unspecified(), 0)} + } + a, _ := netip.AddrFromSlice(ip) + a = a.Unmap() + return []netip.Prefix{netip.PrefixFrom(a, a.BitLen())} +} diff --git a/client/firewall/uspfilter/tracer.go b/client/firewall/uspfilter/tracer.go index 69c2519bf..3c081314c 100644 --- a/client/firewall/uspfilter/tracer.go +++ b/client/firewall/uspfilter/tracer.go @@ -2,7 +2,9 @@ package uspfilter import ( "fmt" + "net" "net/netip" + "strconv" "time" "github.com/google/gopacket" @@ -112,10 +114,13 @@ func (t *PacketTrace) AddResultWithForwarder(stage PacketStage, message string, } func (p *PacketBuilder) Build() ([]byte, error) { - ip := p.buildIPLayer() - pktLayers := []gopacket.SerializableLayer{ip} + ipLayer, err := p.buildIPLayer() + if err != nil { + return nil, err + } + pktLayers := []gopacket.SerializableLayer{ipLayer} - transportLayer, err := p.buildTransportLayer(ip) + transportLayer, err := p.buildTransportLayer(ipLayer) if err != nil { return nil, err } @@ -129,30 +134,43 @@ func (p *PacketBuilder) Build() ([]byte, error) { return serializePacket(pktLayers) } -func (p *PacketBuilder) buildIPLayer() *layers.IPv4 { +func (p *PacketBuilder) buildIPLayer() (gopacket.SerializableLayer, error) { + if p.SrcIP.Is4() != p.DstIP.Is4() { + return nil, fmt.Errorf("mixed address families: src=%s dst=%s", p.SrcIP, p.DstIP) + } + proto := getIPProtocolNumber(p.Protocol, p.SrcIP.Is6()) + if p.SrcIP.Is6() { + return &layers.IPv6{ + Version: 6, + HopLimit: 64, + NextHeader: proto, + SrcIP: p.SrcIP.AsSlice(), + DstIP: p.DstIP.AsSlice(), + }, nil + } return &layers.IPv4{ Version: 4, TTL: 64, - Protocol: layers.IPProtocol(getIPProtocolNumber(p.Protocol)), + Protocol: proto, SrcIP: p.SrcIP.AsSlice(), DstIP: p.DstIP.AsSlice(), - } + }, nil } -func (p *PacketBuilder) buildTransportLayer(ip *layers.IPv4) ([]gopacket.SerializableLayer, error) { +func (p *PacketBuilder) buildTransportLayer(ipLayer gopacket.SerializableLayer) ([]gopacket.SerializableLayer, error) { switch p.Protocol { case "tcp": - return p.buildTCPLayer(ip) + return p.buildTCPLayer(ipLayer) case "udp": - return p.buildUDPLayer(ip) + return p.buildUDPLayer(ipLayer) case "icmp": - return p.buildICMPLayer() + return p.buildICMPLayer(ipLayer) default: return nil, fmt.Errorf("unsupported protocol: %s", p.Protocol) } } -func (p *PacketBuilder) buildTCPLayer(ip *layers.IPv4) ([]gopacket.SerializableLayer, error) { +func (p *PacketBuilder) buildTCPLayer(ipLayer gopacket.SerializableLayer) ([]gopacket.SerializableLayer, error) { tcp := &layers.TCP{ SrcPort: layers.TCPPort(p.SrcPort), DstPort: layers.TCPPort(p.DstPort), @@ -164,24 +182,44 @@ func (p *PacketBuilder) buildTCPLayer(ip *layers.IPv4) ([]gopacket.SerializableL PSH: p.TCPState != nil && p.TCPState.PSH, URG: p.TCPState != nil && p.TCPState.URG, } - if err := tcp.SetNetworkLayerForChecksum(ip); err != nil { - return nil, fmt.Errorf("set network layer for TCP checksum: %w", err) + if nl, ok := ipLayer.(gopacket.NetworkLayer); ok { + if err := tcp.SetNetworkLayerForChecksum(nl); err != nil { + return nil, fmt.Errorf("set network layer for TCP checksum: %w", err) + } } return []gopacket.SerializableLayer{tcp}, nil } -func (p *PacketBuilder) buildUDPLayer(ip *layers.IPv4) ([]gopacket.SerializableLayer, error) { +func (p *PacketBuilder) buildUDPLayer(ipLayer gopacket.SerializableLayer) ([]gopacket.SerializableLayer, error) { udp := &layers.UDP{ SrcPort: layers.UDPPort(p.SrcPort), DstPort: layers.UDPPort(p.DstPort), } - if err := udp.SetNetworkLayerForChecksum(ip); err != nil { - return nil, fmt.Errorf("set network layer for UDP checksum: %w", err) + if nl, ok := ipLayer.(gopacket.NetworkLayer); ok { + if err := udp.SetNetworkLayerForChecksum(nl); err != nil { + return nil, fmt.Errorf("set network layer for UDP checksum: %w", err) + } } return []gopacket.SerializableLayer{udp}, nil } -func (p *PacketBuilder) buildICMPLayer() ([]gopacket.SerializableLayer, error) { +func (p *PacketBuilder) buildICMPLayer(ipLayer gopacket.SerializableLayer) ([]gopacket.SerializableLayer, error) { + if p.SrcIP.Is6() || p.DstIP.Is6() { + icmp := &layers.ICMPv6{ + TypeCode: layers.CreateICMPv6TypeCode(p.ICMPType, p.ICMPCode), + } + if nl, ok := ipLayer.(gopacket.NetworkLayer); ok { + _ = icmp.SetNetworkLayerForChecksum(nl) + } + if p.ICMPType == layers.ICMPv6TypeEchoRequest || p.ICMPType == layers.ICMPv6TypeEchoReply { + echo := &layers.ICMPv6Echo{ + Identifier: 1, + SeqNumber: 1, + } + return []gopacket.SerializableLayer{icmp, echo}, nil + } + return []gopacket.SerializableLayer{icmp}, nil + } icmp := &layers.ICMPv4{ TypeCode: layers.CreateICMPv4TypeCode(p.ICMPType, p.ICMPCode), } @@ -204,14 +242,17 @@ func serializePacket(layers []gopacket.SerializableLayer) ([]byte, error) { return buf.Bytes(), nil } -func getIPProtocolNumber(protocol fw.Protocol) int { +func getIPProtocolNumber(protocol fw.Protocol, isV6 bool) layers.IPProtocol { switch protocol { case fw.ProtocolTCP: - return int(layers.IPProtocolTCP) + return layers.IPProtocolTCP case fw.ProtocolUDP: - return int(layers.IPProtocolUDP) + return layers.IPProtocolUDP case fw.ProtocolICMP: - return int(layers.IPProtocolICMPv4) + if isV6 { + return layers.IPProtocolICMPv6 + } + return layers.IPProtocolICMPv4 default: return 0 } @@ -234,7 +275,7 @@ func (m *Manager) TracePacket(packetData []byte, direction fw.RuleDirection) *Pa trace := &PacketTrace{Direction: direction} // Initial packet decoding - if err := d.parser.DecodeLayers(packetData, &d.decoded); err != nil { + if err := d.decodePacket(packetData); err != nil { trace.AddResult(StageReceived, fmt.Sprintf("Failed to decode packet: %v", err), false) return trace } @@ -244,6 +285,14 @@ func (m *Manager) TracePacket(packetData []byte, direction fw.RuleDirection) *Pa trace.SourceIP = srcIP trace.DestinationIP = dstIP + // A fragment or otherwise truncated packet has no transport layer. + // The inbound datapath drops these via isValidPacket; the tracer must + // guard explicitly since every downstream stage reads d.decoded[1]. + if len(d.decoded) < 2 { + trace.AddResult(StageReceived, "Packet has no transport layer (fragment or unsupported protocol)", false) + return trace + } + // Determine protocol and ports switch d.decoded[1] { case layers.LayerTypeTCP: @@ -256,6 +305,8 @@ func (m *Manager) TracePacket(packetData []byte, direction fw.RuleDirection) *Pa trace.DestinationPort = uint16(d.udp.DstPort) case layers.LayerTypeICMPv4: trace.Protocol = "ICMP" + case layers.LayerTypeICMPv6: + trace.Protocol = "ICMPv6" } trace.AddResult(StageReceived, fmt.Sprintf("Received %s packet: %s:%d -> %s:%d", @@ -319,6 +370,13 @@ func (m *Manager) buildConntrackStateMessage(d *decoder) string { flags&conntrack.TCPFin != 0) case layers.LayerTypeICMPv4: msg += fmt.Sprintf(" (ICMP ID=%d, Seq=%d)", d.icmp4.Id, d.icmp4.Seq) + case layers.LayerTypeICMPv6: + var id, seq uint16 + if len(d.icmp6.Payload) >= 4 { + id = uint16(d.icmp6.Payload[0])<<8 | uint16(d.icmp6.Payload[1]) + seq = uint16(d.icmp6.Payload[2])<<8 | uint16(d.icmp6.Payload[3]) + } + msg += fmt.Sprintf(" (ICMPv6 ID=%d, Seq=%d)", id, seq) } return msg } @@ -395,7 +453,7 @@ func (m *Manager) handleRouteACLs(trace *PacketTrace, d *decoder, srcIP, dstIP n trace.AddResult(StageRouteACL, msg, allowed) if allowed && m.forwarder.Load() != nil { - m.addForwardingResult(trace, "proxy-remote", fmt.Sprintf("%s:%d", dstIP, dstPort), true) + m.addForwardingResult(trace, "proxy-remote", net.JoinHostPort(dstIP.String(), strconv.Itoa(int(dstPort))), true) } trace.AddResult(StageCompleted, msgProcessingCompleted, allowed) @@ -415,7 +473,7 @@ func (m *Manager) traceOutbound(packetData []byte, trace *PacketTrace) *PacketTr d := m.decoders.Get().(*decoder) defer m.decoders.Put(d) - if err := d.parser.DecodeLayers(packetData, &d.decoded); err != nil { + if err := d.decodePacket(packetData); err != nil { trace.AddResult(StageCompleted, "Packet dropped - decode error", false) return trace } @@ -434,7 +492,7 @@ func (m *Manager) traceOutbound(packetData []byte, trace *PacketTrace) *PacketTr func (m *Manager) handleInboundDNAT(trace *PacketTrace, packetData []byte, d *decoder, srcIP, dstIP *netip.Addr) bool { portDNATApplied := m.traceInboundPortDNAT(trace, packetData, d) if portDNATApplied { - if err := d.parser.DecodeLayers(packetData, &d.decoded); err != nil { + if err := d.decodePacket(packetData); err != nil { trace.AddResult(StageInboundPortDNAT, "Failed to re-decode after port DNAT", false) return true } @@ -444,7 +502,7 @@ func (m *Manager) handleInboundDNAT(trace *PacketTrace, packetData []byte, d *de nat1to1Applied := m.traceInbound1to1NAT(trace, packetData, d) if nat1to1Applied { - if err := d.parser.DecodeLayers(packetData, &d.decoded); err != nil { + if err := d.decodePacket(packetData); err != nil { trace.AddResult(StageInbound1to1NAT, "Failed to re-decode after 1:1 NAT", false) return true } @@ -509,7 +567,7 @@ func (m *Manager) traceInbound1to1NAT(trace *PacketTrace, packetData []byte, d * return false } - srcIP := netip.AddrFrom4([4]byte{packetData[12], packetData[13], packetData[14], packetData[15]}) + srcIP, _ := extractPacketIPs(packetData, d) translated := m.translateInboundReverse(packetData, d) if translated { @@ -539,7 +597,7 @@ func (m *Manager) traceOutbound1to1NAT(trace *PacketTrace, packetData []byte, d return false } - dstIP := netip.AddrFrom4([4]byte{packetData[16], packetData[17], packetData[18], packetData[19]}) + _, dstIP := extractPacketIPs(packetData, d) translated := m.translateOutboundDNAT(packetData, d) if translated { diff --git a/client/firewall/uspfilter/tracer_test.go b/client/firewall/uspfilter/tracer_test.go index 657f96fc0..27b5e3f9e 100644 --- a/client/firewall/uspfilter/tracer_test.go +++ b/client/firewall/uspfilter/tracer_test.go @@ -45,7 +45,7 @@ func TestTracePacket(t *testing.T) { }, } - m, err := Create(ifaceMock, false, flowLogger, iface.DefaultMTU) + m, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(t, err) if !statefulMode { @@ -97,7 +97,7 @@ func TestTracePacket(t *testing.T) { proto := fw.ProtocolTCP port := &fw.Port{Values: []uint16{80}} action := fw.ActionAccept - _, err := m.AddPeerFiltering(nil, ip, proto, nil, port, action, "") + _, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, port, action) require.NoError(t, err) }, packetBuilder: func() *PacketBuilder { @@ -121,7 +121,7 @@ func TestTracePacket(t *testing.T) { proto := fw.ProtocolTCP port := &fw.Port{Values: []uint16{80}} action := fw.ActionDrop - _, err := m.AddPeerFiltering(nil, ip, proto, nil, port, action, "") + _, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, port, action) require.NoError(t, err) }, packetBuilder: func() *PacketBuilder { @@ -150,7 +150,7 @@ func TestTracePacket(t *testing.T) { proto := fw.ProtocolTCP port := &fw.Port{Values: []uint16{80}} action := fw.ActionAccept - _, err := m.AddPeerFiltering(nil, ip, proto, nil, port, action, "") + _, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, port, action) require.NoError(t, err) }, packetBuilder: func() *PacketBuilder { @@ -178,7 +178,7 @@ func TestTracePacket(t *testing.T) { proto := fw.ProtocolTCP port := &fw.Port{Values: []uint16{80}} action := fw.ActionAccept - _, err := m.AddPeerFiltering(nil, ip, proto, nil, port, action, "") + _, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, port, action) require.NoError(t, err) }, packetBuilder: func() *PacketBuilder { @@ -205,7 +205,7 @@ func TestTracePacket(t *testing.T) { src := netip.PrefixFrom(netip.AddrFrom4([4]byte{1, 1, 1, 1}), 32) dst := netip.PrefixFrom(netip.AddrFrom4([4]byte{192, 168, 17, 2}), 32) - _, err := m.AddRouteFiltering(nil, []netip.Prefix{src}, fw.Network{Prefix: dst}, fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionAccept) + _, err := m.AddFilterRule(nil, []netip.Prefix{src}, fw.Network{Prefix: dst}, fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionAccept) require.NoError(t, err) }, packetBuilder: func() *PacketBuilder { @@ -231,7 +231,7 @@ func TestTracePacket(t *testing.T) { src := netip.PrefixFrom(netip.AddrFrom4([4]byte{1, 1, 1, 1}), 32) dst := netip.PrefixFrom(netip.AddrFrom4([4]byte{192, 168, 17, 2}), 32) - _, err := m.AddRouteFiltering(nil, []netip.Prefix{src}, fw.Network{Prefix: dst}, fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionDrop) + _, err := m.AddFilterRule(nil, []netip.Prefix{src}, fw.Network{Prefix: dst}, fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionDrop) require.NoError(t, err) }, packetBuilder: func() *PacketBuilder { @@ -332,7 +332,7 @@ func TestTracePacket(t *testing.T) { ip := net.ParseIP("1.1.1.1") proto := fw.ProtocolICMP action := fw.ActionAccept - _, err := m.AddPeerFiltering(nil, ip, proto, nil, nil, action, "") + _, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, nil, action) require.NoError(t, err) }, packetBuilder: func() *PacketBuilder { @@ -355,7 +355,7 @@ func TestTracePacket(t *testing.T) { ip := net.ParseIP("1.1.1.1") proto := fw.ProtocolICMP action := fw.ActionDrop - _, err := m.AddPeerFiltering(nil, ip, proto, nil, nil, action, "") + _, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, nil, action) require.NoError(t, err) }, packetBuilder: func() *PacketBuilder { @@ -379,7 +379,7 @@ func TestTracePacket(t *testing.T) { proto := fw.ProtocolUDP port := &fw.Port{Values: []uint16{53}} action := fw.ActionAccept - _, err := m.AddPeerFiltering(nil, ip, proto, nil, port, action, "") + _, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, port, action) require.NoError(t, err) }, packetBuilder: func() *PacketBuilder { @@ -423,7 +423,7 @@ func TestTracePacket(t *testing.T) { proto := fw.ProtocolTCP port := &fw.Port{Values: []uint16{80}} action := fw.ActionDrop - _, err := m.AddPeerFiltering(nil, ip, proto, nil, port, action, "") + _, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, port, action) require.NoError(t, err) }, packetBuilder: func() *PacketBuilder { diff --git a/client/grpc/dialer_generic.go b/client/grpc/dialer_generic.go index 479575996..737787223 100644 --- a/client/grpc/dialer_generic.go +++ b/client/grpc/dialer_generic.go @@ -16,28 +16,52 @@ import ( "google.golang.org/grpc" nbnet "github.com/netbirdio/netbird/client/net" + "github.com/netbirdio/netbird/client/netevents/sweep" ) +// Sweeper registers in-flight dials for the network change sweep. +type Sweeper interface { + StartDial(ctx context.Context) *sweep.Dial +} + func WithCustomDialer(_ bool, _ string) grpc.DialOption { + return grpc.WithContextDialer(dialContext) +} + +// WithSweeper dials like WithCustomDialer but registers connections and +// dials with the sweeper. Append it after WithCustomDialer: gRPC applies +// dial options in order, so the later context dialer wins. +func WithSweeper(sweeper Sweeper) grpc.DialOption { return grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) { - if runtime.GOOS == "linux" { - currentUser, err := user.Current() - if err != nil { - return nil, status.Errorf(codes.FailedPrecondition, "failed to get current user: %v", err) - } + dial := sweeper.StartDial(ctx) + defer dial.Release() - // the custom dialer requires root permissions which are not required for use cases run as non-root - if currentUser.Uid != "0" { - log.Debug("Not running as root, using standard dialer") - dialer := &net.Dialer{} - return dialer.DialContext(ctx, "tcp", addr) - } - } - - conn, err := nbnet.NewDialer().DialContext(ctx, "tcp", addr) + conn, err := dialContext(dial.Ctx(), addr) if err != nil { - return nil, fmt.Errorf("nbnet.NewDialer().DialContext: %w", err) + return nil, err } - return conn, nil + return dial.WrapConn(conn) }) } + +func dialContext(ctx context.Context, addr string) (net.Conn, error) { + if runtime.GOOS == "linux" { + currentUser, err := user.Current() + if err != nil { + return nil, status.Errorf(codes.FailedPrecondition, "failed to get current user: %v", err) + } + + // the custom dialer requires root permissions which are not required for use cases run as non-root + if currentUser.Uid != "0" { + log.Debug("Not running as root, using standard dialer") + dialer := &net.Dialer{} + return dialer.DialContext(ctx, "tcp", addr) + } + } + + conn, err := nbnet.NewDialer().DialContext(ctx, "tcp", addr) + if err != nil { + return nil, fmt.Errorf("nbnet.NewDialer().DialContext: %w", err) + } + return conn, nil +} diff --git a/client/grpc/dialer_js.go b/client/grpc/dialer_js.go index b89ec3c21..4ff4ceb20 100644 --- a/client/grpc/dialer_js.go +++ b/client/grpc/dialer_js.go @@ -1,13 +1,26 @@ package grpc import ( + "context" + "google.golang.org/grpc" + "github.com/netbirdio/netbird/client/netevents/sweep" "github.com/netbirdio/netbird/util/wsproxy/client" ) +// Sweeper registers in-flight dials for the network change sweep. +type Sweeper interface { + StartDial(ctx context.Context) *sweep.Dial +} + // WithCustomDialer returns a gRPC dial option that uses WebSocket transport for WASM/JS environments. // The component parameter specifies the WebSocket proxy component path (e.g., "/management", "/signal"). func WithCustomDialer(tlsEnabled bool, component string) grpc.DialOption { return client.WithWebSocketDialer(tlsEnabled, component) } + +// WithSweeper is a no-op on WASM/JS: there is no network change signal. +func WithSweeper(_ Sweeper) grpc.DialOption { + return grpc.EmptyDialOption{} +} diff --git a/client/grpc/retry.go b/client/grpc/retry.go new file mode 100644 index 000000000..0bb6037bf --- /dev/null +++ b/client/grpc/retry.go @@ -0,0 +1,56 @@ +package grpc + +import ( + "context" + "errors" + "time" + + "github.com/cenkalti/backoff/v4" +) + +// ChangeWatcher exposes OS network availability transitions. +type ChangeWatcher interface { + Changed() <-chan struct{} +} + +// Retry mirrors backoff.Retry, but the sleep between attempts also wakes on +// OS network availability transitions: an operation cut down by a network +// change retries the moment the network settles instead of sleeping through +// the recovery. A nil watcher never fires, leaving plain backoff.Retry +// behavior. +func Retry(ctx context.Context, operation backoff.Operation, bo backoff.BackOff, watcher ChangeWatcher) error { + bo.Reset() + for { + err := operation() + if err == nil { + return nil + } + + var permanent *backoff.PermanentError + if errors.As(err, &permanent) { + return permanent.Err + } + + next := bo.NextBackOff() + if next == backoff.Stop { + if cerr := ctx.Err(); cerr != nil { + return cerr + } + return err + } + + var changed <-chan struct{} + if watcher != nil { + changed = watcher.Changed() + } + timer := time.NewTimer(next) + select { + case <-timer.C: + case <-changed: + timer.Stop() + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + } + } +} diff --git a/client/grpc/retry_test.go b/client/grpc/retry_test.go new file mode 100644 index 000000000..266bb93e5 --- /dev/null +++ b/client/grpc/retry_test.go @@ -0,0 +1,91 @@ +package grpc + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/netevents/netstate" +) + +func TestRetryWakesOnNetworkChange(t *testing.T) { + ns := netstate.New() + attempts := 0 + operation := func() error { + attempts++ + if attempts == 1 { + return errors.New("cut by network change") + } + return nil + } + + go func() { + time.Sleep(20 * time.Millisecond) + ns.Set(false) + }() + + start := time.Now() + err := Retry(context.Background(), operation, backoff.NewConstantBackOff(time.Minute), ns) + + require.NoError(t, err) + assert.Equal(t, 2, attempts, "network change must cause one immediate retry") + assert.Less(t, time.Since(start), time.Second, "the transition must cut the minute-long sleep short") +} + +func TestRetryPermanentError(t *testing.T) { + sentinel := errors.New("permission denied") + operation := func() error { + return backoff.Permanent(sentinel) + } + + err := Retry(context.Background(), operation, backoff.NewConstantBackOff(time.Millisecond), nil) + assert.ErrorIs(t, err, sentinel, "permanent errors must stop retries") +} + +func TestRetryNilNetState(t *testing.T) { + attempts := 0 + operation := func() error { + attempts++ + if attempts < 3 { + return errors.New("transient") + } + return nil + } + + err := Retry(context.Background(), operation, backoff.NewConstantBackOff(time.Millisecond), nil) + require.NoError(t, err) + assert.Equal(t, 3, attempts, "nil network state must preserve timed retries") +} + +func TestRetryStops(t *testing.T) { + failure := errors.New("still failing") + operation := func() error { + return failure + } + + err := Retry(context.Background(), operation, &backoff.StopBackOff{}, nil) + assert.ErrorIs(t, err, failure, "stop backoff must return the operation error") +} + +func TestRetryCtxCancelDuringSleep(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + operation := func() error { + return errors.New("failing") + } + + go func() { + time.Sleep(20 * time.Millisecond) + cancel() + }() + + start := time.Now() + err := Retry(ctx, operation, backoff.NewConstantBackOff(time.Minute), netstate.New()) + + assert.ErrorIs(t, err, context.Canceled, "context cancellation must stop the retry loop") + assert.Less(t, time.Since(start), time.Second, "context cancellation must interrupt backoff sleep") +} diff --git a/client/iface/bind/ice_bind.go b/client/iface/bind/ice_bind.go index bf79ecd79..cd1ed12f1 100644 --- a/client/iface/bind/ice_bind.go +++ b/client/iface/bind/ice_bind.go @@ -22,6 +22,16 @@ import ( nbnet "github.com/netbirdio/netbird/client/net" ) +const ( + // wgMsgTypeHandshakeInitiation is the lowest WireGuard message type. + wgMsgTypeHandshakeInitiation uint32 = 1 + // wgMsgTypeTransport is the highest WireGuard message type. + wgMsgTypeTransport uint32 = 4 + // wgMinMsgSize is the smallest WireGuard message: transport data with an empty + // payload, which is what a keepalive is. + wgMinMsgSize = 32 +) + type receiverCreator struct { iceBind *ICEBind } @@ -41,17 +51,19 @@ type ICEBind struct { *wgConn.StdNetBind transportNet transport.Net - filterFn udpmux.FilterFn address wgaddr.Address mtu uint16 endpoints map[netip.Addr]net.Conn endpointsMu sync.Mutex recvChan chan recvMessage - // every time when Close() is called (i.e. BindUpdate()) we need to close exit from the receiveRelayed and create a - // new closed channel. With the closedChanMu we can safely close the channel and create a new one + // Close() (i.e. BindUpdate()) closes closedChan to release receiveRelayed, + // and the following Open() installs a fresh one. closedChanMu guards both + // closedChan and closed: readers only ever hold it long enough to copy the + // channel, never across a blocking receive, so Open cannot be starved by a + // parked receiver. closedChan chan struct{} - closedChanMu sync.RWMutex // protect the closeChan recreation from reading from it. + closedChanMu sync.RWMutex closed bool activityRecorder *ActivityRecorder @@ -61,12 +73,11 @@ type ICEBind struct { ipv6Conn *net.UDPConn } -func NewICEBind(transportNet transport.Net, filterFn udpmux.FilterFn, address wgaddr.Address, mtu uint16) *ICEBind { +func NewICEBind(transportNet transport.Net, address wgaddr.Address, mtu uint16) *ICEBind { b, _ := wgConn.NewStdNetBind().(*wgConn.StdNetBind) ib := &ICEBind{ StdNetBind: b, transportNet: transportNet, - filterFn: filterFn, address: address, mtu: mtu, endpoints: make(map[netip.Addr]net.Conn), @@ -84,24 +95,41 @@ func NewICEBind(transportNet transport.Net, filterFn udpmux.FilterFn, address wg } func (s *ICEBind) Open(uport uint16) ([]wgConn.ReceiveFunc, uint16, error) { - s.closed = false s.closedChanMu.Lock() - s.closedChan = make(chan struct{}) - s.closedChanMu.Unlock() + defer s.closedChanMu.Unlock() + + // Open the underlying bind before touching any state, so a failure leaves + // the current generation exactly as it was. Publishing the new generation + // first would strand it: StdNetBind rejects an Open while it is already + // open, and a Close arriving in that window would mark the bind closed + // while this call went on to install live sockets, after which every later + // Close returns early and never shuts them down. fns, port, err := s.StdNetBind.Open(uport) if err != nil { return nil, 0, err } + + // Release whoever is parked on the outgoing generation before replacing it. + // An Open that follows an Open rather than a Close would otherwise leave + // them waiting on a channel no later Close can reach. + if !s.closed { + close(s.closedChan) + } + s.closed = false + s.closedChan = make(chan struct{}) + fns = append(fns, s.receiveRelayed) return fns, port, nil } func (s *ICEBind) Close() error { + s.closedChanMu.Lock() + defer s.closedChanMu.Unlock() + if s.closed { return nil } s.closed = true - close(s.closedChan) s.muUDPMux.Lock() @@ -113,6 +141,15 @@ func (s *ICEBind) Close() error { return s.StdNetBind.Close() } +// currentClosedChan copies the channel that signals the current Open +// generation is closing. Callers select on the copy so the lock is never held +// across a blocking receive, which would otherwise stall the next Open. +func (s *ICEBind) currentClosedChan() chan struct{} { + s.closedChanMu.RLock() + defer s.closedChanMu.RUnlock() + return s.closedChan +} + func (s *ICEBind) ActivityRecorder() *ActivityRecorder { return s.activityRecorder } @@ -142,8 +179,10 @@ func (b *ICEBind) RemoveEndpoint(fakeIP netip.Addr) { } func (b *ICEBind) ReceiveFromEndpoint(ctx context.Context, ep *Endpoint, buf []byte) { + closedChan := b.currentClosedChan() + select { - case <-b.closedChan: + case <-closedChan: return case <-ctx.Done(): return @@ -218,8 +257,15 @@ func (s *ICEBind) createReceiverFn(pc wgConn.BatchReader, conn *net.UDPConn, rxO for i := 0; i < numMsgs; i++ { msg := &(*msgs)[i] - // todo: handle err - if ok, _ := s.filterOutStunMessages(msg.Buffers, msg.N, msg.Addr); ok { + if ok, err := s.filterOutStunMessages(msg.Buffers, msg.N, msg.Addr); ok { + if err != nil { + log.Debugf("failed to handle STUN packet from %s: %v", msg.Addr, err) + } + // WireGuard reuses sizes and eps across reads and only skips a slot + // whose size is below the minimum message size. Leaving a consumed + // slot untouched makes it process this buffer again under the + // previous packet's length and endpoint. + sizes[i] = 0 continue } sizes[i] = msg.N @@ -265,7 +311,6 @@ func (s *ICEBind) createOrUpdateMux() { udpmux.UniversalUDPMuxParams{ UDPConn: muxConn, Net: s.transportNet, - FilterFn: s.filterFn, WGAddress: s.address, MTU: s.mtu, }, @@ -274,11 +319,16 @@ func (s *ICEBind) createOrUpdateMux() { func (s *ICEBind) filterOutStunMessages(buffers [][]byte, n int, addr net.Addr) (bool, error) { for i := range buffers { - if !stun.IsMessage(buffers[i]) { + if n > len(buffers[i]) { + continue + } + pkt := buffers[i][:n] + + if isWireGuardMsg(pkt) || !stun.IsMessage(pkt) { continue } - msg, err := s.parseSTUNMessage(buffers[i][:n]) + msg, err := s.parseSTUNMessage(pkt) if err != nil { buffers[i] = []byte{} return true, err @@ -314,11 +364,10 @@ func (s *ICEBind) parseSTUNMessage(raw []byte) (*stun.Message, error) { // receiveRelayed is a receive function that is used to receive packets from the relayed connection and forward to the // WireGuard. Critical part is do not block if the Closed() has been called. func (c *ICEBind) receiveRelayed(buffs [][]byte, sizes []int, eps []wgConn.Endpoint) (int, error) { - c.closedChanMu.RLock() - defer c.closedChanMu.RUnlock() + closedChan := c.currentClosedChan() select { - case <-c.closedChan: + case <-closedChan: return 0, net.ErrClosed case msg, ok := <-c.recvChan: if !ok { @@ -350,18 +399,34 @@ func putMessages(msgs *[]ipv6.Message, msgsPool *sync.Pool) { msgsPool.Put(msgs) } -func isTransportPkg(buffers [][]byte, n int) bool { - // The first buffer should contain at least 4 bytes for type - if len(buffers[0]) < 4 { - return true +// isWireGuardMsg reports whether the packet carries a WireGuard message header: a +// little-endian uint32 message type in the range 1..4, which leaves the three bytes +// after the type byte zero, in a packet long enough to hold any WireGuard message. +// +// A well formed STUN message cannot take that shape. Its length field sits in the two +// bytes the type must leave zero, and for a message of at least wgMinMsgSize bytes that +// field holds at least 12, so the two framings do not overlap. The test has to be this +// tight because stun.IsMessage only looks at the magic cookie, which in a WireGuard +// message overlaps the receiver index: a session whose index happens to equal the cookie +// would otherwise have all of its inbound data misrouted to the STUN handler until the +// next rekey. +func isWireGuardMsg(pkt []byte) bool { + if len(pkt) < wgMinMsgSize { + return false } - // WireGuard packet type is a little-endian uint32 at start - packetType := binary.LittleEndian.Uint32(buffers[0][:4]) + msgType := binary.LittleEndian.Uint32(pkt[:4]) + return msgType >= wgMsgTypeHandshakeInitiation && msgType <= wgMsgTypeTransport +} - // Check if packetType matches known WireGuard message types - if packetType == 4 && n > 32 { - return true +// isTransportPkg reports whether the packet is WireGuard transport data carrying a +// payload, which is what counts as peer activity. A keepalive holds no payload and is +// exactly wgMinMsgSize bytes. +func isTransportPkg(buffers [][]byte, n int) bool { + if n < 4 || n > len(buffers[0]) { + return false } - return false + + msgType := binary.LittleEndian.Uint32(buffers[0][:4]) + return msgType == wgMsgTypeTransport && n > wgMinMsgSize } diff --git a/client/iface/bind/ice_bind_close_test.go b/client/iface/bind/ice_bind_close_test.go new file mode 100644 index 000000000..829d27663 --- /dev/null +++ b/client/iface/bind/ice_bind_close_test.go @@ -0,0 +1,261 @@ +package bind + +import ( + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + wgConn "golang.zx2c4.com/wireguard/conn" +) + +// startReceivers runs every receive function the way wireguard-go's device +// does: one goroutine per function, all tracked by a single WaitGroup. After +// calling Bind.Close, closeBindLocked waits on exactly that WaitGroup while +// holding device.net, so a receive function that never returns wedges the +// device and every goroutine that needs the same lock. +func startReceivers(fns []wgConn.ReceiveFunc) *sync.WaitGroup { + wg, _ := startReceiversEntered(fns) + return wg +} + +// startReceiversEntered also returns a channel closed once every receive +// function has been called at least once. A receive function that has been +// entered is either inside its blocking receive or about to be, which is a +// stronger signal to synchronise on than a bare sleep. +func startReceiversEntered(fns []wgConn.ReceiveFunc) (*sync.WaitGroup, <-chan struct{}) { + var wg sync.WaitGroup + var entered sync.WaitGroup + wg.Add(len(fns)) + entered.Add(len(fns)) + + for i := range fns { + go func(fn wgConn.ReceiveFunc) { + defer wg.Done() + buffs := [][]byte{make([]byte, 1500)} + sizes := make([]int, 1) + eps := make([]wgConn.Endpoint, 1) + first := true + for { + if first { + entered.Done() + first = false + } + if _, err := fn(buffs, sizes, eps); err != nil { + return + } + } + }(fns[i]) + } + + allEntered := make(chan struct{}) + go func() { + entered.Wait() + close(allEntered) + }() + return &wg, allEntered +} + +// closeBounded runs Close off the caller's goroutine so a regression that +// wedges it fails the test instead of hanging teardown, and reports whether it +// returned in time. +func closeBounded(iceBind *ICEBind, timeout time.Duration) bool { + done := make(chan struct{}) + go func() { + _ = iceBind.Close() + close(done) + }() + select { + case <-done: + return true + case <-time.After(timeout): + return false + } +} + +// isClosed reports whether the bind's current generation channel is closed. +func isClosed(iceBind *ICEBind) bool { + iceBind.closedChanMu.RLock() + ch := iceBind.closedChan + iceBind.closedChanMu.RUnlock() + select { + case <-ch: + return true + default: + return false + } +} + +// receiversStopped reports whether every receive function returned before the +// timeout, mirroring device.net.stopping.Wait() inside closeBindLocked. +func receiversStopped(wg *sync.WaitGroup, timeout time.Duration) bool { + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + select { + case <-done: + return true + case <-time.After(timeout): + return false + } +} + +// TestICEBindCloseReleasesReceivers covers the contract closeBindLocked relies +// on: once Close returns, every receive function handed out by Open must stop. +func TestICEBindCloseReleasesReceivers(t *testing.T) { + iceBind := setupICEBind(t) + + fns, _, err := iceBind.Open(0) + require.NoError(t, err, "opening the bind must succeed") + + wg := startReceivers(fns) + require.NoError(t, iceBind.Close()) + + require.True(t, receiversStopped(wg, 5*time.Second), + "every receive function must return once Close returns") +} + +// TestICEBindOpenDoesNotBlockOnParkedReceiver reproduces the deadlock that +// wedges interface creation. +// +// receiveRelayed used to hold closedChanMu for the whole of its blocking +// select, so a parked receiver kept the read lock indefinitely. Open takes the +// same mutex for writing to install a fresh closedChan, so it could never +// acquire it while a receiver was parked. wireguard-go reaches Open from +// Device.IpcSet and Device.Up with device.net held, so the stall takes the +// device's lock with it and every other device goroutine queues behind it. +// +// Failing here means Open never returned. +func TestICEBindOpenDoesNotBlockOnParkedReceiver(t *testing.T) { + iceBind := setupICEBind(t) + + fns, _, err := iceBind.Open(0) + require.NoError(t, err, "the first Open must succeed") + + wg, entered := startReceiversEntered(fns) + t.Cleanup(func() { + // Both bounded: a regression that wedges Close must surface as the + // assertion below, not as a hung teardown. + if !closeBounded(iceBind, 5*time.Second) { + t.Error("Close did not return during teardown; the bind lifecycle is wedged even though the assertion above passed") + } + if !receiversStopped(wg, 5*time.Second) { + t.Error("receive functions were still running after teardown Close, which is what closeBindLocked blocks on") + } + }) + + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("receive functions never started") + } + // Entered is not yet parked, so still allow the blocking receive to be + // reached. Parking takes microseconds; this margin is six orders larger. + time.Sleep(500 * time.Millisecond) + + reopened := make(chan struct{}) + go func() { + // The error is irrelevant; StdNetBind rejects a second Open. What + // matters is that the call returns at all. + _, _, _ = iceBind.Open(0) + close(reopened) + }() + + select { + case <-reopened: + case <-time.After(10 * time.Second): + t.Fatal("Open blocked while a receive function was parked; wireguard-go makes this call with device.net held, which is what stalls interface creation") + } +} + +// TestICEBindConcurrentOpenClose exercises Open and Close from separate +// goroutines, the way Device.IpcSet and Device.Up reach the bind, and is meant +// to be run under -race. +// +// closed and closedChan must be updated together. When they were not, Close +// could observe a stale closed and either skip close(closedChan) and +// StdNetBind.Close entirely, leaving the receive functions running, or race a +// second Close and close the same channel twice. +// +// Receive functions are deliberately not started here: this test is about the +// shared state, and parking them would turn a race report into a hang. +func TestICEBindConcurrentOpenClose(t *testing.T) { + iceBind := setupICEBind(t) + + var wg sync.WaitGroup + wg.Add(2) + + // Release both loops together so the calls genuinely interleave rather + // than depending on goroutine start order. + start := make(chan struct{}) + + go func() { + defer wg.Done() + <-start + for i := 0; i < 200; i++ { + _, _, _ = iceBind.Open(0) + } + }() + go func() { + defer wg.Done() + <-start + for i := 0; i < 200; i++ { + _ = iceBind.Close() + } + }() + + close(start) + wg.Wait() + require.NoError(t, iceBind.Close()) + require.True(t, isClosed(iceBind), "the final Close must leave the current generation channel closed") +} + +// TestICEBindCloseReleasesReceiversUnderConcurrentClose runs full Open, receive, +// Close cycles with a second Close and an Open racing the first Close. Any +// iteration where the receive functions outlive Close, or where the surviving +// generation channel is left open, is the state closeBindLocked deadlocks on. +func TestICEBindCloseReleasesReceiversUnderConcurrentClose(t *testing.T) { + if testing.Short() { + t.Skip("stress test") + } + + for i := 0; i < 200; i++ { + iceBind := setupICEBind(t) + + fns, _, err := iceBind.Open(0) + require.NoError(t, err, "iteration %d: opening the bind must succeed", i) + wg := startReceivers(fns) + + start := make(chan struct{}) + var racers sync.WaitGroup + racers.Add(3) + for c := 0; c < 2; c++ { + go func() { + defer racers.Done() + <-start + _ = iceBind.Close() + }() + } + // An Open overlapping the Closes is what produces a generation whose + // channel outlives the flag saying the bind is closed. + go func() { + defer racers.Done() + <-start + _, _, _ = iceBind.Open(0) + }() + close(start) + racers.Wait() + + // Settle on a closed bind whatever order the racers landed in. + _ = iceBind.Close() + + if !receiversStopped(wg, 5*time.Second) { + t.Fatalf("iteration %d: receive functions still running after Close; closeBindLocked would block here on device.net.stopping.Wait", i) + } + if !isClosed(iceBind) { + t.Fatalf("iteration %d: Close returned with the current generation channel still open, so nothing will ever release its receivers", i) + } + } +} diff --git a/client/iface/bind/ice_bind_test.go b/client/iface/bind/ice_bind_test.go index f49e68508..0b8db7640 100644 --- a/client/iface/bind/ice_bind_test.go +++ b/client/iface/bind/ice_bind_test.go @@ -289,7 +289,7 @@ func setupICEBind(t *testing.T) *ICEBind { IP: netip.MustParseAddr("100.64.0.1"), Network: netip.MustParsePrefix("100.64.0.0/10"), } - return NewICEBind(transportNet, nil, address, 1280) + return NewICEBind(transportNet, address, 1280) } func createDualStackConns(t *testing.T) (*net.UDPConn, *net.UDPConn) { diff --git a/client/iface/bind/stun_filter_test.go b/client/iface/bind/stun_filter_test.go new file mode 100644 index 000000000..0e118e0fd --- /dev/null +++ b/client/iface/bind/stun_filter_test.go @@ -0,0 +1,215 @@ +//go:build !js + +package bind + +import ( + "encoding/binary" + "net" + "testing" + "time" + + "github.com/pion/stun/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/net/ipv4" + wgConn "golang.zx2c4.com/wireguard/conn" +) + +// magicCookieBytes is the STUN magic cookie as it appears on the wire. In a +// WireGuard message the same offset holds the receiver (or sender) index, which is +// a random uint32, so a session can draw exactly this value. +var magicCookieBytes = []byte{0x21, 0x12, 0xA4, 0x42} + +const testBufSize = 1500 + +// wgMsg builds a WireGuard message of the given type and size, with the index field +// at bytes 4:8 set to index. +func wgMsg(msgType uint32, size int, index []byte) []byte { + pkt := make([]byte, size) + binary.LittleEndian.PutUint32(pkt[:4], msgType) + copy(pkt[4:8], index) + return pkt +} + +// intoBuffer copies pkt into a full-size receive buffer, the way the kernel read +// does, so tests see the same buffer/length split as the hot path. +func intoBuffer(pkt []byte) [][]byte { + buf := make([]byte, testBufSize) + copy(buf, pkt) + return [][]byte{buf} +} + +func TestFilterOutStunMessages_PassesWireGuardWithCookieShapedIndex(t *testing.T) { + tests := []struct { + name string + msgType uint32 + size int + }{ + {"transport data", wgMsgTypeTransport, 128}, + {"keepalive", wgMsgTypeTransport, wgMinMsgSize}, + {"handshake initiation", wgMsgTypeHandshakeInitiation, 148}, + {"handshake response", 2, 92}, + {"cookie reply", 3, 64}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + pkt := wgMsg(tc.msgType, tc.size, magicCookieBytes) + require.True(t, stun.IsMessage(pkt), "precondition: pion sees this as STUN") + + buffers := intoBuffer(pkt) + bind := &ICEBind{} + + filtered, err := bind.filterOutStunMessages(buffers, tc.size, &net.UDPAddr{}) + assert.NoError(t, err) + assert.False(t, filtered, "WireGuard message must be handed to WireGuard, not the STUN handler") + assert.Len(t, buffers[0], testBufSize, "buffer must be left intact for WireGuard") + }) + } +} + +func TestFilterOutStunMessages_FiltersRealSTUNMessage(t *testing.T) { + msg, err := stun.Build(stun.BindingRequest, stun.TransactionID, stun.Fingerprint) + require.NoError(t, err) + + buffers := intoBuffer(msg.Raw) + bind := &ICEBind{} + + filtered, err := bind.filterOutStunMessages(buffers, len(msg.Raw), &net.UDPAddr{}) + assert.NoError(t, err) + assert.True(t, filtered, "STUN message must be consumed by the STUN handler") + assert.Empty(t, buffers[0], "consumed buffer must be emptied so WireGuard does not see it") +} + +// TestIsWireGuardMsg_DisjointFromSTUN locks the invariant the filter relies on: a +// well formed STUN message long enough to be a WireGuard message always has a +// non-zero length field, so it cannot be mistaken for a WireGuard header. +func TestIsWireGuardMsg_DisjointFromSTUN(t *testing.T) { + types := []stun.MessageType{ + stun.BindingRequest, + stun.BindingSuccess, + stun.BindingError, + {Method: stun.MethodBinding, Class: stun.ClassIndication}, + } + + for _, msgType := range types { + // Long enough that the length guard is not what makes this pass. + msg, err := stun.Build(msgType, stun.TransactionID, + stun.NewUsername("remoteUfrag:localUfrag"), stun.Fingerprint) + require.NoError(t, err) + require.GreaterOrEqual(t, len(msg.Raw), wgMinMsgSize, "precondition: %s", msgType) + assert.False(t, isWireGuardMsg(msg.Raw), + "%s must not look like a WireGuard message", msgType) + } +} + +func TestIsWireGuardMsg(t *testing.T) { + tests := []struct { + name string + pkt []byte + want bool + }{ + {"transport data", wgMsg(wgMsgTypeTransport, 128, nil), true}, + {"handshake initiation", wgMsg(wgMsgTypeHandshakeInitiation, 148, nil), true}, + {"unknown type 5", wgMsg(5, 128, nil), false}, + {"type 0", wgMsg(0, 128, nil), false}, + {"non-zero reserved byte", []byte{0x04, 0x00, 0x01, 0x00}, false}, + {"too short", []byte{0x04, 0x00, 0x00}, false}, + {"empty", nil, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, isWireGuardMsg(tc.pkt), "wrong classification for %s", tc.name) + }) + } +} + +// TestFilterOutStunMessages_IgnoresBytesBeyondPacket guards against classifying on +// buffer contents left over from an earlier, longer packet. +func TestFilterOutStunMessages_IgnoresBytesBeyondPacket(t *testing.T) { + buf := make([]byte, testBufSize) + copy(buf[4:8], magicCookieBytes) + buffers := [][]byte{buf} + bind := &ICEBind{} + + filtered, err := bind.filterOutStunMessages(buffers, 2, &net.UDPAddr{}) + assert.NoError(t, err) + assert.False(t, filtered, "a 2 byte packet must not be classified from stale buffer bytes") +} + +// TestReceiveFn_ClearsSizeOfConsumedPacket covers the accounting WireGuard relies +// on: sizes is reused across reads, so a slot whose packet was consumed as STUN must +// be reported as empty. Otherwise WireGuard reprocesses the same buffer under the +// previous packet's length, which for a WireGuard-shaped packet means it is handled +// twice. +func TestReceiveFn_ClearsSizeOfConsumedPacket(t *testing.T) { + conn := listenUDP(t, "udp4", "127.0.0.1:0") + defer conn.Close() + + recvFn := receiverCreator{setupICEBind(t)}.CreateReceiverFn( + ipv4.NewPacketConn(conn), conn, false, createMsgPool(), + ) + + msg, err := stun.Build(stun.BindingRequest, stun.TransactionID, stun.Fingerprint) + require.NoError(t, err) + + sender := listenUDP(t, "udp4", "127.0.0.1:0") + defer sender.Close() + _, err = sender.WriteTo(msg.Raw, conn.LocalAddr()) + require.NoError(t, err) + + require.NoError(t, conn.SetReadDeadline(time.Now().Add(3*time.Second))) + + bufs := [][]byte{make([]byte, 1500)} + // A leftover size from an earlier read, which is what makes the missing reset + // observable. + sizes := []int{148} + eps := make([]wgConn.Endpoint, 1) + + n, err := recvFn(bufs, sizes, eps) + require.NoError(t, err) + require.Equal(t, 1, n) + assert.Zero(t, sizes[0], "consumed STUN packet must not leave a size behind for WireGuard") +} + +func TestIsTransportPkg(t *testing.T) { + tests := []struct { + name string + pkt []byte + n int + want bool + }{ + {"transport data with payload", wgMsg(wgMsgTypeTransport, 128, nil), 128, true}, + {"keepalive", wgMsg(wgMsgTypeTransport, wgMinMsgSize, nil), wgMinMsgSize, false}, + {"handshake initiation", wgMsg(wgMsgTypeHandshakeInitiation, 148, nil), 148, false}, + {"stale type bytes beyond packet", wgMsg(wgMsgTypeTransport, 128, nil), 2, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, isTransportPkg(intoBuffer(tc.pkt), tc.n), + "wrong activity classification for %s", tc.name) + }) + } +} + +// TestFilterOutStunMessages_ConsumesSTUNWithWireGuardShapedType covers the one STUN +// encoding whose leading bytes collide with a WireGuard message type: method 0x080 as a +// request encodes to 0x0200, so the type byte reads as a handshake response and the byte +// after it is zero. Only the length check keeps such a message out of WireGuard's hands. +// pion implements no method in that range, so this is a synthetic worst case rather than +// traffic ICE produces. +func TestFilterOutStunMessages_ConsumesSTUNWithWireGuardShapedType(t *testing.T) { + msg, err := stun.Build(stun.NewType(stun.Method(0x080), stun.ClassRequest), stun.TransactionID) + require.NoError(t, err) + require.Equal(t, []byte{0x02, 0x00, 0x00, 0x00}, msg.Raw[:4], + "precondition: the leading bytes read as a WireGuard message type") + + buffers := intoBuffer(msg.Raw) + bind := &ICEBind{} + + filtered, err := bind.filterOutStunMessages(buffers, len(msg.Raw), &net.UDPAddr{}) + assert.NoError(t, err) + assert.True(t, filtered, "STUN message must be consumed despite its WireGuard-shaped type") +} diff --git a/client/iface/configurer/kernel_unix.go b/client/iface/configurer/kernel_unix.go index a29fe181a..da69c2a35 100644 --- a/client/iface/configurer/kernel_unix.go +++ b/client/iface/configurer/kernel_unix.go @@ -17,12 +17,15 @@ import ( type KernelConfigurer struct { deviceName string + statsCache *statsCache } func NewKernelConfigurer(deviceName string) *KernelConfigurer { - return &KernelConfigurer{ + c := &KernelConfigurer{ deviceName: deviceName, } + c.statsCache = newStatsCache(statsCacheTTL, c.fetchStats) + return c } func (c *KernelConfigurer) ConfigureInterface(privateKey string, port int) error { @@ -246,12 +249,6 @@ func (c *KernelConfigurer) configure(config wgtypes.Config) error { } }() - // validate if device with name exists - _, err = wg.Device(c.deviceName) - if err != nil { - return err - } - return wg.ConfigureDevice(c.deviceName, config) } @@ -300,6 +297,14 @@ func (c *KernelConfigurer) FullStats() (*Stats, error) { } func (c *KernelConfigurer) GetStats() (map[string]WGStats, error) { + return c.statsCache.get() +} + +func (c *KernelConfigurer) LastActivities() map[string]monotime.Time { + return nil +} + +func (c *KernelConfigurer) fetchStats() (map[string]WGStats, error) { stats := make(map[string]WGStats) wg, err := wgctrl.New() if err != nil { @@ -326,7 +331,3 @@ func (c *KernelConfigurer) GetStats() (map[string]WGStats, error) { } return stats, nil } - -func (c *KernelConfigurer) LastActivities() map[string]monotime.Time { - return nil -} diff --git a/client/iface/configurer/stats_cache.go b/client/iface/configurer/stats_cache.go new file mode 100644 index 000000000..71a4e88fc --- /dev/null +++ b/client/iface/configurer/stats_cache.go @@ -0,0 +1,52 @@ +package configurer + +import ( + "sync" + "time" + + "golang.org/x/sync/singleflight" +) + +const statsCacheTTL = 1 * time.Second + +type statsCache struct { + ttl time.Duration + fetch func() (map[string]WGStats, error) + + mu sync.RWMutex + value map[string]WGStats + expireAt time.Time + + sf singleflight.Group +} + +func newStatsCache(ttl time.Duration, fetch func() (map[string]WGStats, error)) *statsCache { + return &statsCache{ttl: ttl, fetch: fetch} +} + +func (c *statsCache) get() (map[string]WGStats, error) { + c.mu.RLock() + if c.value != nil && time.Now().Before(c.expireAt) { + value := c.value + c.mu.RUnlock() + return value, nil + } + c.mu.RUnlock() + + value, err, _ := c.sf.Do("stats", func() (interface{}, error) { + res, err := c.fetch() + if err != nil { + return nil, err + } + + c.mu.Lock() + c.value = res + c.expireAt = time.Now().Add(c.ttl) + c.mu.Unlock() + return res, nil + }) + if err != nil { + return nil, err + } + return value.(map[string]WGStats), nil +} diff --git a/client/iface/configurer/stats_cache_test.go b/client/iface/configurer/stats_cache_test.go new file mode 100644 index 000000000..bcee5cd52 --- /dev/null +++ b/client/iface/configurer/stats_cache_test.go @@ -0,0 +1,70 @@ +package configurer + +import ( + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestStatsCache_CachesWithinTTL(t *testing.T) { + var calls atomic.Int64 + c := newStatsCache(50*time.Millisecond, func() (map[string]WGStats, error) { + calls.Add(1) + return map[string]WGStats{"p": {}}, nil + }) + + for i := 0; i < 10; i++ { + _, err := c.get() + require.NoError(t, err) + } + require.Equal(t, int64(1), calls.Load(), "within TTL only one underlying fetch") + + time.Sleep(60 * time.Millisecond) + _, err := c.get() + require.NoError(t, err) + require.Equal(t, int64(2), calls.Load(), "after TTL expiry a fresh fetch happens") +} + +func TestStatsCache_SingleFlight(t *testing.T) { + var calls atomic.Int64 + release := make(chan struct{}) + c := newStatsCache(time.Minute, func() (map[string]WGStats, error) { + calls.Add(1) + <-release + return map[string]WGStats{}, nil + }) + + const n = 50 + var wg sync.WaitGroup + wg.Add(n) + for i := 0; i < n; i++ { + go func() { + defer wg.Done() + _, _ = c.get() + }() + } + time.Sleep(20 * time.Millisecond) + close(release) + wg.Wait() + + require.Equal(t, int64(1), calls.Load(), "concurrent misses collapse into one fetch") +} + +func TestStatsCache_ErrorNotCached(t *testing.T) { + var calls atomic.Int64 + wantErr := errors.New("dump failed") + c := newStatsCache(time.Minute, func() (map[string]WGStats, error) { + calls.Add(1) + return nil, wantErr + }) + + _, err := c.get() + require.ErrorIs(t, err, wantErr) + _, err = c.get() + require.ErrorIs(t, err, wantErr) + require.Equal(t, int64(2), calls.Load(), "errors are not cached; each call retries") +} diff --git a/client/iface/configurer/usp.go b/client/iface/configurer/usp.go index e3a96590c..2be1b861e 100644 --- a/client/iface/configurer/usp.go +++ b/client/iface/configurer/usp.go @@ -40,6 +40,7 @@ type WGUSPConfigurer struct { device *device.Device deviceName string activityRecorder *bind.ActivityRecorder + statsCache *statsCache uapiListener net.Listener } @@ -50,16 +51,19 @@ func NewUSPConfigurer(device *device.Device, deviceName string, activityRecorder deviceName: deviceName, activityRecorder: activityRecorder, } + wgCfg.statsCache = newStatsCache(statsCacheTTL, wgCfg.fetchStats) wgCfg.startUAPI() return wgCfg } func NewUSPConfigurerNoUAPI(device *device.Device, deviceName string, activityRecorder *bind.ActivityRecorder) *WGUSPConfigurer { - return &WGUSPConfigurer{ + wgCfg := &WGUSPConfigurer{ device: device, deviceName: deviceName, activityRecorder: activityRecorder, } + wgCfg.statsCache = newStatsCache(statsCacheTTL, wgCfg.fetchStats) + return wgCfg } func (c *WGUSPConfigurer) ConfigureInterface(privateKey string, port int) error { @@ -119,7 +123,7 @@ func (c *WGUSPConfigurer) UpdatePeer(peerKey string, allowedIps []netip.Prefix, if err != nil { return fmt.Errorf("failed to parse endpoint address: %w", err) } - addrPort := netip.AddrPortFrom(addr, uint16(endpoint.Port)) + addrPort := netip.AddrPortFrom(addr.Unmap(), uint16(endpoint.Port)) c.activityRecorder.UpsertAddress(peerKey, addrPort) } return nil @@ -348,6 +352,10 @@ func (t *WGUSPConfigurer) Close() { } func (t *WGUSPConfigurer) GetStats() (map[string]WGStats, error) { + return t.statsCache.get() +} + +func (t *WGUSPConfigurer) fetchStats() (map[string]WGStats, error) { ipc, err := t.device.IpcGet() if err != nil { return nil, fmt.Errorf("ipc get: %w", err) @@ -494,7 +502,7 @@ func toBytes(s string) (int64, error) { func getFwmark() int { if nbnet.AdvancedRouting() && runtime.GOOS == "linux" { - return nbnet.ControlPlaneMark + return int(nbnet.ControlPlaneMark) } return 0 } diff --git a/client/iface/device/adapter.go b/client/iface/device/adapter.go index 6ebc05390..e3caaf930 100644 --- a/client/iface/device/adapter.go +++ b/client/iface/device/adapter.go @@ -2,7 +2,7 @@ package device // TunAdapter is an interface for create tun device from external service type TunAdapter interface { - ConfigureInterface(address string, mtu int, dns string, searchDomains string, routes string) (int, error) + ConfigureInterface(address string, addressV6 string, mtu int, dns string, searchDomains string, routes string) (int, error) UpdateAddr(address string) error ProtectSocket(fd int32) bool } diff --git a/client/iface/device/device_android.go b/client/iface/device/device_android.go index 198343fbd..0ed1299ae 100644 --- a/client/iface/device/device_android.go +++ b/client/iface/device/device_android.go @@ -63,7 +63,12 @@ func (t *WGTunDevice) Create(routes []string, dns string, searchDomains []string searchDomainsToString = "" } - fd, err := t.tunAdapter.ConfigureInterface(t.address.String(), int(t.mtu), dns, searchDomainsToString, routesString) + ipv6Host := "" + if t.address.HasIPv6() { + ipv6Host = t.address.IPv6HostPrefix().String() + } + + fd, err := t.tunAdapter.ConfigureInterface(t.address.HostPrefix().String(), ipv6Host, int(t.mtu), dns, searchDomainsToString, routesString) if err != nil { log.Errorf("failed to create Android interface: %s", err) return nil, err diff --git a/client/iface/device/device_darwin.go b/client/iface/device/device_darwin.go index acd5f6f11..ac8f8a51b 100644 --- a/client/iface/device/device_darwin.go +++ b/client/iface/device/device_darwin.go @@ -131,23 +131,32 @@ func (t *TunDevice) Device() *device.Device { // assignAddr Adds IP address to the tunnel interface and network route based on the range provided func (t *TunDevice) assignAddr() error { - cmd := exec.Command("ifconfig", t.name, "inet", t.address.IP.String(), t.address.IP.String()) - if out, err := cmd.CombinedOutput(); err != nil { - log.Errorf("adding address command '%v' failed with output: %s", cmd.String(), out) - return err + if out, err := exec.Command("ifconfig", t.name, "inet", t.address.IP.String(), t.address.IP.String()).CombinedOutput(); err != nil { + return fmt.Errorf("add v4 address: %s: %w", string(out), err) } - // dummy ipv6 so routing works - cmd = exec.Command("ifconfig", t.name, "inet6", "fe80::/64") - if out, err := cmd.CombinedOutput(); err != nil { - log.Debugf("adding address command '%v' failed with output: %s", cmd.String(), out) + // Assign a dummy link-local so macOS enables IPv6 on the tun device. + // When a real overlay v6 is present, use that instead. + v6Addr := "fe80::/64" + if t.address.HasIPv6() { + v6Addr = t.address.IPv6String() + } + if out, err := exec.Command("ifconfig", t.name, "inet6", v6Addr).CombinedOutput(); err != nil { + log.Warnf("failed to assign IPv6 address %s, continuing v4-only: %s: %v", v6Addr, string(out), err) + t.address.ClearIPv6() } - routeCmd := exec.Command("route", "add", "-net", t.address.Network.String(), "-interface", t.name) - if out, err := routeCmd.CombinedOutput(); err != nil { - log.Errorf("adding route command '%v' failed with output: %s", routeCmd.String(), out) - return err + if out, err := exec.Command("route", "add", "-net", t.address.Network.String(), "-interface", t.name).CombinedOutput(); err != nil { + return fmt.Errorf("add route %s via %s: %s: %w", t.address.Network, t.name, string(out), err) } + + if t.address.HasIPv6() { + if out, err := exec.Command("route", "add", "-inet6", "-net", t.address.IPv6Net.String(), "-interface", t.name).CombinedOutput(); err != nil { + log.Warnf("failed to add route %s via %s, continuing v4-only: %s: %v", t.address.IPv6Net, t.name, string(out), err) + t.address.ClearIPv6() + } + } + return nil } diff --git a/client/iface/device/device_filter.go b/client/iface/device/device_filter.go index 4357d1916..7d7493835 100644 --- a/client/iface/device/device_filter.go +++ b/client/iface/device/device_filter.go @@ -1,9 +1,13 @@ package device import ( + "fmt" "net/netip" + "runtime/debug" "sync" + "sync/atomic" + log "github.com/sirupsen/logrus" "golang.zx2c4.com/wireguard/tun" ) @@ -28,13 +32,25 @@ type PacketFilter interface { SetTCPPacketHook(ip netip.Addr, dPort uint16, hook func(packet []byte) bool) } +// PacketCapture captures raw packets for debugging. Implementations must be +// safe for concurrent use and must not block. +type PacketCapture interface { + // Offer submits a packet for capture. outbound is true for packets + // leaving the host (Read path), false for packets arriving (Write path). + Offer(data []byte, outbound bool) +} + // FilteredDevice to override Read or Write of packets type FilteredDevice struct { tun.Device - filter PacketFilter - mutex sync.RWMutex - closeOnce sync.Once + filter PacketFilter + capture atomic.Pointer[PacketCapture] + // panicHandler is invoked after a panic in the underlying device is + // recovered in Read or Write. + panicHandler atomic.Pointer[func()] + mutex sync.RWMutex + closeOnce sync.Once } // newDeviceFilter constructor function @@ -60,23 +76,28 @@ func (d *FilteredDevice) Close() error { // 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 { + if n, err = d.deviceRead(bufs, sizes, offset); err != nil { return 0, err } + d.mutex.RLock() filter := d.filter d.mutex.RUnlock() - if filter == nil { - return + if filter != nil { + for i := 0; i < n; i++ { + if filter.FilterOutbound(bufs[i][offset:offset+sizes[i]], sizes[i]) { + bufs = append(bufs[:i], bufs[i+1:]...) + sizes = append(sizes[:i], sizes[i+1:]...) + n-- + i-- + } + } } - for i := 0; i < n; i++ { - if filter.FilterOutbound(bufs[i][offset:offset+sizes[i]], sizes[i]) { - bufs = append(bufs[:i], bufs[i+1:]...) - sizes = append(sizes[:i], sizes[i+1:]...) - n-- - i-- + if pc := d.capture.Load(); pc != nil { + for i := 0; i < n; i++ { + (*pc).Offer(bufs[i][offset:offset+sizes[i]], true) } } @@ -85,26 +106,69 @@ func (d *FilteredDevice) Read(bufs [][]byte, sizes []int, offset int) (n int, er // Write wraps write method with filtering feature func (d *FilteredDevice) Write(bufs [][]byte, offset int) (int, error) { + // Capture before filtering so dropped packets are still visible in captures. + if pc := d.capture.Load(); pc != nil { + for _, buf := range bufs { + (*pc).Offer(buf[offset:], false) + } + } + d.mutex.RLock() filter := d.filter d.mutex.RUnlock() if filter == nil { - return d.Device.Write(bufs, offset) + return d.deviceWrite(bufs, offset) } filteredBufs := make([][]byte, 0, len(bufs)) dropped := 0 for _, buf := range bufs { - if !filter.FilterInbound(buf[offset:], len(buf)) { - filteredBufs = append(filteredBufs, buf) + if filter.FilterInbound(buf[offset:], len(buf)) { dropped++ + } else { + filteredBufs = append(filteredBufs, buf) } } - n, err := d.Device.Write(filteredBufs, offset) - n += dropped - return n, err + n, err := d.deviceWrite(filteredBufs, offset) + if err != nil { + return n, err + } + return n + dropped, nil +} + +// deviceRead calls the underlying device Read, recovering from panics in the +// wintun read path and converting them into errors. +func (d *FilteredDevice) deviceRead(bufs [][]byte, sizes []int, offset int) (n int, err error) { + defer d.recoverFromPanic("read", &n, &err) + return d.Device.Read(bufs, sizes, offset) +} + +// deviceWrite calls the underlying device Write, recovering from panics in the +// wintun write path and converting them into errors. +func (d *FilteredDevice) deviceWrite(bufs [][]byte, offset int) (n int, err error) { + defer d.recoverFromPanic("write", &n, &err) + return d.Device.Write(bufs, offset) +} + +// recoverFromPanic converts a panic in the underlying device into a regular +// error and invokes the registered panic handler. The wintun read path is +// known to panic on zero-length packets that third-party filter drivers can +// place in the ring. +func (d *FilteredDevice) recoverFromPanic(op string, n *int, err *error) { + r := recover() + if r == nil { + return + } + + log.Errorf("recovered panic in tun device %s: %v\n%s", op, r, debug.Stack()) + *n = 0 + *err = fmt.Errorf("tun device %s panic: %v", op, r) + + if handler := d.panicHandler.Load(); handler != nil { + (*handler)() + } } // SetFilter sets packet filter to device @@ -113,3 +177,25 @@ func (d *FilteredDevice) SetFilter(filter PacketFilter) { d.filter = filter d.mutex.Unlock() } + +// SetPanicHandler registers a handler invoked after a recovered panic in Read +// or Write. The device is unusable after such a panic; the handler should +// trigger recreation of the interface. Pass nil to remove. +func (d *FilteredDevice) SetPanicHandler(handler func()) { + if handler == nil { + d.panicHandler.Store(nil) + return + } + d.panicHandler.Store(&handler) +} + +// SetCapture sets or clears the packet capture sink. Pass nil to disable. +// Uses atomic store so the hot path (Read/Write) is a single pointer load +// with no locking overhead when capture is off. +func (d *FilteredDevice) SetCapture(pc PacketCapture) { + if pc == nil { + d.capture.Store(nil) + return + } + d.capture.Store(&pc) +} diff --git a/client/iface/device/device_filter_test.go b/client/iface/device/device_filter_test.go index eef783542..a75ef90f9 100644 --- a/client/iface/device/device_filter_test.go +++ b/client/iface/device/device_filter_test.go @@ -4,7 +4,7 @@ import ( "net" "testing" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/google/gopacket" "github.com/google/gopacket/layers" @@ -158,7 +158,7 @@ func TestDeviceWrapperRead(t *testing.T) { t.Errorf("unexpected error: %v", err) return } - if n != 0 { + if n != 1 { t.Errorf("expected n=1, got %d", n) return } @@ -221,3 +221,60 @@ func TestDeviceWrapperRead(t *testing.T) { } }) } + +func TestDeviceWrapperReadPanic(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + tun := mocks.NewMockDevice(ctrl) + tun.EXPECT().Read(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(bufs [][]byte, sizes []int, offset int) (int, error) { + // Reproduce the wintun zero-length packet panic (index out of range). + packet := make([]byte, 0) + return int(packet[0]), nil + }) + + wrapped := newDeviceFilter(tun) + + handlerCalled := false + wrapped.SetPanicHandler(func() { handlerCalled = true }) + + n, err := wrapped.Read([][]byte{{}}, []int{0}, 0) + if err == nil { + t.Errorf("expected error from recovered panic, got nil") + } + if n != 0 { + t.Errorf("expected n=0, got %d", n) + } + if !handlerCalled { + t.Errorf("expected panic handler to be called") + } +} + +func TestDeviceWrapperWritePanic(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + tun := mocks.NewMockDevice(ctrl) + tun.EXPECT().Write(gomock.Any(), gomock.Any()). + DoAndReturn(func(bufs [][]byte, offset int) (int, error) { + packet := make([]byte, 0) + return int(packet[0]), nil + }) + + wrapped := newDeviceFilter(tun) + + handlerCalled := false + wrapped.SetPanicHandler(func() { handlerCalled = true }) + + n, err := wrapped.Write([][]byte{{0x45, 0x00}}, 0) + if err == nil { + t.Errorf("expected error from recovered panic, got nil") + } + if n != 0 { + t.Errorf("expected n=0, got %d", n) + } + if !handlerCalled { + t.Errorf("expected panic handler to be called") + } +} diff --git a/client/iface/device/device_ios.go b/client/iface/device/device_ios.go index aa77cee45..8368c8dce 100644 --- a/client/iface/device/device_ios.go +++ b/client/iface/device/device_ios.go @@ -151,8 +151,11 @@ func (t *TunDevice) MTU() uint16 { return t.mtu } -func (t *TunDevice) UpdateAddr(_ wgaddr.Address) error { - // todo implement +// UpdateAddr updates the device address. On iOS the tunnel is managed by the +// NetworkExtension, so we only store the new value. The extension picks up the +// change on the next tunnel reconfiguration. +func (t *TunDevice) UpdateAddr(addr wgaddr.Address) error { + t.address = addr return nil } diff --git a/client/iface/device/device_kernel_unix.go b/client/iface/device/device_kernel_unix.go index 2a836f846..3c429fb96 100644 --- a/client/iface/device/device_kernel_unix.go +++ b/client/iface/device/device_kernel_unix.go @@ -32,8 +32,6 @@ type TunKernelDevice struct { link *wgLink udpMuxConn net.PacketConn udpMux *udpmux.UniversalUDPMuxDefault - - filterFn udpmux.FilterFn } func NewKernelDevice(name string, address wgaddr.Address, wgPort int, key string, mtu uint16, transportNet transport.Net) *TunKernelDevice { @@ -104,7 +102,6 @@ func (t *TunKernelDevice) Up() (*udpmux.UniversalUDPMuxDefault, error) { bindParams := udpmux.UniversalUDPMuxParams{ UDPConn: nbnet.WrapPacketConn(rawSock), Net: t.transportNet, - FilterFn: t.filterFn, WGAddress: t.address, MTU: t.mtu, } @@ -173,7 +170,7 @@ func (t *TunKernelDevice) FilteredDevice() *FilteredDevice { // assignAddr Adds IP address to the tunnel interface func (t *TunKernelDevice) assignAddr() error { - return t.link.assignAddr(t.address) + return t.link.assignAddr(&t.address) } func (t *TunKernelDevice) GetNet() *netstack.Net { diff --git a/client/iface/device/device_netstack.go b/client/iface/device/device_netstack.go index 1a92b148f..b3bce3925 100644 --- a/client/iface/device/device_netstack.go +++ b/client/iface/device/device_netstack.go @@ -3,6 +3,7 @@ package device import ( "errors" "fmt" + "net/netip" log "github.com/sirupsen/logrus" "golang.zx2c4.com/wireguard/conn" @@ -63,8 +64,12 @@ func (t *TunNetstackDevice) create() (WGConfigurer, error) { return nil, fmt.Errorf("last ip: %w", err) } - log.Debugf("netstack using address: %s", t.address.IP) - t.nsTun = nbnetstack.NewNetStackTun(t.listenAddress, t.address.IP, dnsAddr, int(t.mtu)) + addresses := []netip.Addr{t.address.IP} + if t.address.HasIPv6() { + addresses = append(addresses, t.address.IPv6) + } + log.Debugf("netstack using addresses: %v", addresses) + t.nsTun = nbnetstack.NewNetStackTun(t.listenAddress, addresses, dnsAddr, int(t.mtu)) log.Debugf("netstack using dns address: %s", dnsAddr) tunIface, net, err := t.nsTun.Create() if err != nil { diff --git a/client/iface/device/device_usp_unix.go b/client/iface/device/device_usp_unix.go index 24654fc03..04c265c49 100644 --- a/client/iface/device/device_usp_unix.go +++ b/client/iface/device/device_usp_unix.go @@ -16,7 +16,7 @@ import ( "github.com/netbirdio/netbird/client/iface/wgaddr" ) -type USPDevice struct { +type TunDevice struct { name string address wgaddr.Address port int @@ -30,10 +30,10 @@ type USPDevice struct { configurer WGConfigurer } -func NewUSPDevice(name string, address wgaddr.Address, port int, key string, mtu uint16, iceBind *bind.ICEBind) *USPDevice { +func NewTunDevice(name string, address wgaddr.Address, port int, key string, mtu uint16, iceBind *bind.ICEBind) *TunDevice { log.Infof("using userspace bind mode") - return &USPDevice{ + return &TunDevice{ name: name, address: address, port: port, @@ -43,7 +43,7 @@ func NewUSPDevice(name string, address wgaddr.Address, port int, key string, mtu } } -func (t *USPDevice) Create() (WGConfigurer, error) { +func (t *TunDevice) Create() (WGConfigurer, error) { log.Info("create tun interface") tunIface, err := tun.CreateTUN(t.name, int(t.mtu)) if err != nil { @@ -75,7 +75,7 @@ func (t *USPDevice) Create() (WGConfigurer, error) { return t.configurer, nil } -func (t *USPDevice) Up() (*udpmux.UniversalUDPMuxDefault, error) { +func (t *TunDevice) Up() (*udpmux.UniversalUDPMuxDefault, error) { if t.device == nil { return nil, fmt.Errorf("device is not ready yet") } @@ -95,12 +95,12 @@ func (t *USPDevice) Up() (*udpmux.UniversalUDPMuxDefault, error) { return udpMux, nil } -func (t *USPDevice) UpdateAddr(address wgaddr.Address) error { +func (t *TunDevice) UpdateAddr(address wgaddr.Address) error { t.address = address return t.assignAddr() } -func (t *USPDevice) Close() error { +func (t *TunDevice) Close() error { if t.configurer != nil { t.configurer.Close() } @@ -115,39 +115,39 @@ func (t *USPDevice) Close() error { return nil } -func (t *USPDevice) WgAddress() wgaddr.Address { +func (t *TunDevice) WgAddress() wgaddr.Address { return t.address } -func (t *USPDevice) MTU() uint16 { +func (t *TunDevice) MTU() uint16 { return t.mtu } -func (t *USPDevice) DeviceName() string { +func (t *TunDevice) DeviceName() string { return t.name } -func (t *USPDevice) FilteredDevice() *FilteredDevice { +func (t *TunDevice) FilteredDevice() *FilteredDevice { return t.filteredDevice } // Device returns the wireguard device -func (t *USPDevice) Device() *device.Device { +func (t *TunDevice) Device() *device.Device { return t.device } // assignAddr Adds IP address to the tunnel interface -func (t *USPDevice) assignAddr() error { +func (t *TunDevice) assignAddr() error { link := newWGLink(t.name) - return link.assignAddr(t.address) + return link.assignAddr(&t.address) } -func (t *USPDevice) GetNet() *netstack.Net { +func (t *TunDevice) GetNet() *netstack.Net { return nil } // GetICEBind returns the ICEBind instance -func (t *USPDevice) GetICEBind() EndpointManager { +func (t *TunDevice) GetICEBind() EndpointManager { return t.iceBind } diff --git a/client/iface/device/device_windows.go b/client/iface/device/device_windows.go index 96350df8a..f52392fa2 100644 --- a/client/iface/device/device_windows.go +++ b/client/iface/device/device_windows.go @@ -87,7 +87,21 @@ func (t *TunDevice) Create() (WGConfigurer, error) { err = nbiface.Set() if err != nil { t.device.Close() - return nil, fmt.Errorf("got error when getting setting the interface mtu: %s", err) + return nil, fmt.Errorf("set IPv4 interface MTU: %s", err) + } + + if t.address.HasIPv6() { + nbiface6, err := luid.IPInterface(windows.AF_INET6) + if err != nil { + log.Warnf("failed to get IPv6 interface for MTU, continuing v4-only: %v", err) + t.address.ClearIPv6() + } else { + nbiface6.NLMTU = uint32(t.mtu) + if err := nbiface6.Set(); err != nil { + log.Warnf("failed to set IPv6 interface MTU, continuing v4-only: %v", err) + t.address.ClearIPv6() + } + } } err = t.assignAddr() if err != nil { @@ -178,8 +192,21 @@ func (t *TunDevice) GetInterfaceGUIDString() (string, error) { // assignAddr Adds IP address to the tunnel interface and network route based on the range provided func (t *TunDevice) assignAddr() error { luid := winipcfg.LUID(t.nativeTunDevice.LUID()) - log.Debugf("adding address %s to interface: %s", t.address.IP, t.name) - return luid.SetIPAddresses([]netip.Prefix{netip.MustParsePrefix(t.address.String())}) + + v4Prefix := t.address.Prefix() + if t.address.HasIPv6() { + v6Prefix := t.address.IPv6Prefix() + log.Debugf("adding addresses %s, %s to interface: %s", v4Prefix, v6Prefix, t.name) + if err := luid.SetIPAddresses([]netip.Prefix{v4Prefix, v6Prefix}); err != nil { + log.Warnf("failed to assign dual-stack addresses, retrying v4-only: %v", err) + t.address.ClearIPv6() + return luid.SetIPAddresses([]netip.Prefix{v4Prefix}) + } + return nil + } + + log.Debugf("adding address %s to interface: %s", v4Prefix, t.name) + return luid.SetIPAddresses([]netip.Prefix{v4Prefix}) } func (t *TunDevice) GetNet() *netstack.Net { diff --git a/client/iface/device/kernel_module.go b/client/iface/device/kernel_module.go deleted file mode 100644 index 1bdd6f7c6..000000000 --- a/client/iface/device/kernel_module.go +++ /dev/null @@ -1,8 +0,0 @@ -//go:build (!linux && !freebsd) || android - -package device - -// WireGuardModuleIsLoaded check if we can load WireGuard mod (linux only) -func WireGuardModuleIsLoaded() bool { - return false -} diff --git a/client/iface/device/kernel_module_freebsd.go b/client/iface/device/kernel_module_freebsd.go deleted file mode 100644 index dd6c8b408..000000000 --- a/client/iface/device/kernel_module_freebsd.go +++ /dev/null @@ -1,18 +0,0 @@ -package device - -// WireGuardModuleIsLoaded check if kernel support wireguard -func WireGuardModuleIsLoaded() bool { - // Despite the fact FreeBSD natively support Wireguard (https://github.com/WireGuard/wireguard-freebsd) - // we are currently do not use it, since it is required to add wireguard kernel support to - // - https://github.com/netbirdio/netbird/tree/main/sharedsock - // - https://github.com/mdlayher/socket - // TODO: implement kernel space - return false -} - -// ModuleTunIsLoaded check if tun module exist, if is not attempt to load it -func ModuleTunIsLoaded() bool { - // Assume tun supported by freebsd kernel by default - // TODO: implement check for module loaded in kernel or build-it - return true -} diff --git a/client/iface/device/kernel_module_nonlinux.go b/client/iface/device/kernel_module_nonlinux.go new file mode 100644 index 000000000..58d97080b --- /dev/null +++ b/client/iface/device/kernel_module_nonlinux.go @@ -0,0 +1,13 @@ +//go:build !linux || android + +package device + +// WireGuardModuleIsLoaded reports whether the kernel WireGuard module is available. +func WireGuardModuleIsLoaded() bool { + return false +} + +// ModuleTunIsLoaded reports whether the tun device is available. +func ModuleTunIsLoaded() bool { + return true +} diff --git a/client/iface/device/wg_link_freebsd.go b/client/iface/device/wg_link_freebsd.go index 1b06e0e15..87df89183 100644 --- a/client/iface/device/wg_link_freebsd.go +++ b/client/iface/device/wg_link_freebsd.go @@ -2,6 +2,7 @@ package device import ( "fmt" + "os/exec" log "github.com/sirupsen/logrus" @@ -57,32 +58,32 @@ func (l *wgLink) up() error { return nil } -func (l *wgLink) assignAddr(address wgaddr.Address) error { +func (l *wgLink) assignAddr(address *wgaddr.Address) error { link, err := freebsd.LinkByName(l.name) if err != nil { return fmt.Errorf("link by name: %w", err) } - ip := address.IP.String() - - // Convert prefix length to hex netmask prefixLen := address.Network.Bits() - if !address.IP.Is4() { - return fmt.Errorf("IPv6 not supported for interface assignment") - } - maskBits := uint32(0xffffffff) << (32 - prefixLen) mask := fmt.Sprintf("0x%08x", maskBits) - log.Infof("assign addr %s mask %s to %s interface", ip, mask, l.name) + log.Infof("assign addr %s mask %s to %s interface", address.IP, mask, l.name) - err = link.AssignAddr(ip, mask) - if err != nil { + if err := link.AssignAddr(address.IP.String(), mask); err != nil { return fmt.Errorf("assign addr: %w", err) } - err = link.Up() - if err != nil { + if address.HasIPv6() { + log.Infof("assign IPv6 addr %s to %s interface", address.IPv6String(), l.name) + cmd := exec.Command("ifconfig", l.name, "inet6", address.IPv6String()) + if out, err := cmd.CombinedOutput(); err != nil { + log.Warnf("failed to assign IPv6 address %s to %s, continuing v4-only: %s: %v", address.IPv6String(), l.name, string(out), err) + address.ClearIPv6() + } + } + + if err := link.Up(); err != nil { return fmt.Errorf("up: %w", err) } diff --git a/client/iface/device/wg_link_linux.go b/client/iface/device/wg_link_linux.go index d941cd022..6a02cb356 100644 --- a/client/iface/device/wg_link_linux.go +++ b/client/iface/device/wg_link_linux.go @@ -4,6 +4,8 @@ package device import ( "fmt" + "net" + "net/netip" "os" log "github.com/sirupsen/logrus" @@ -92,7 +94,7 @@ func (l *wgLink) up() error { return nil } -func (l *wgLink) assignAddr(address wgaddr.Address) error { +func (l *wgLink) assignAddr(address *wgaddr.Address) error { //delete existing addresses list, err := netlink.AddrList(l, 0) if err != nil { @@ -110,20 +112,16 @@ func (l *wgLink) assignAddr(address wgaddr.Address) error { } name := l.attrs.Name - addrStr := address.String() - log.Debugf("adding address %s to interface: %s", addrStr, name) - - addr, err := netlink.ParseAddr(addrStr) - if err != nil { - return fmt.Errorf("parse addr: %w", err) + if err := l.addAddr(name, address.Prefix()); err != nil { + return err } - err = netlink.AddrAdd(l, addr) - if os.IsExist(err) { - log.Infof("interface %s already has the address: %s", name, addrStr) - } else if err != nil { - return fmt.Errorf("add addr: %w", err) + if address.HasIPv6() { + if err := l.addAddr(name, address.IPv6Prefix()); err != nil { + log.Warnf("failed to assign IPv6 address %s to %s, continuing v4-only: %v", address.IPv6Prefix(), name, err) + address.ClearIPv6() + } } // On linux, the link must be brought up @@ -133,3 +131,22 @@ func (l *wgLink) assignAddr(address wgaddr.Address) error { return nil } + +func (l *wgLink) addAddr(ifaceName string, prefix netip.Prefix) error { + log.Debugf("adding address %s to interface: %s", prefix, ifaceName) + + addr := &netlink.Addr{ + IPNet: &net.IPNet{ + IP: prefix.Addr().AsSlice(), + Mask: net.CIDRMask(prefix.Bits(), prefix.Addr().BitLen()), + }, + } + + if err := netlink.AddrAdd(l, addr); os.IsExist(err) { + log.Infof("interface %s already has the address: %s", ifaceName, prefix) + } else if err != nil { + return fmt.Errorf("add addr %s: %w", prefix, err) + } + + return nil +} diff --git a/client/iface/iface.go b/client/iface/iface.go index 655dd1682..247f421a2 100644 --- a/client/iface/iface.go +++ b/client/iface/iface.go @@ -57,13 +57,12 @@ type wgProxyFactory interface { type WGIFaceOpts struct { IFaceName string - Address string + Address wgaddr.Address WGPort int WGPrivKey string MTU uint16 MobileArgs *device.MobileIFaceArguments TransportNet transport.Net - FilterFn udpmux.FilterFn DisableDNS bool } @@ -141,16 +140,11 @@ func (w *WGIface) Up() (*udpmux.UniversalUDPMuxDefault, error) { } // UpdateAddr updates address of the interface -func (w *WGIface) UpdateAddr(newAddr string) error { +func (w *WGIface) UpdateAddr(newAddr wgaddr.Address) error { w.mu.Lock() defer w.mu.Unlock() - addr, err := wgaddr.ParseWGAddress(newAddr) - if err != nil { - return err - } - - return w.tun.UpdateAddr(addr) + return w.tun.UpdateAddr(newAddr) } // UpdatePeer updates existing Wireguard Peer or creates a new one if doesn't exist diff --git a/client/iface/iface_new_windows.go b/client/iface/iface_new.go similarity index 50% rename from client/iface/iface_new_windows.go rename to client/iface/iface_new.go index dfd9028e7..96a0e670f 100644 --- a/client/iface/iface_new_windows.go +++ b/client/iface/iface_new.go @@ -1,33 +1,28 @@ +//go:build !linux && !ios && !android && !js + package iface import ( "github.com/netbirdio/netbird/client/iface/bind" "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/netstack" - wgaddr "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/iface/wgproxy" ) // NewWGIFace Creates a new WireGuard interface instance func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) { - wgAddress, err := wgaddr.ParseWGAddress(opts.Address) - if err != nil { - return nil, err - } - iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, wgAddress, opts.MTU) + iceBind := bind.NewICEBind(opts.TransportNet, opts.Address, opts.MTU) var tun WGTunDevice if netstack.IsEnabled() { - tun = device.NewNetstackDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, netstack.ListenAddr()) + tun = device.NewNetstackDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, netstack.ListenAddr()) } else { - tun = device.NewTunDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind) + tun = device.NewTunDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind) } - wgIFace := &WGIface{ + return &WGIface{ userspaceBind: true, tun: tun, wgProxyFactory: wgproxy.NewUSPFactory(iceBind, opts.MTU), - } - return wgIFace, nil - + }, nil } diff --git a/client/iface/iface_new_android.go b/client/iface/iface_new_android.go index 3b68f63f2..ce8b4da23 100644 --- a/client/iface/iface_new_android.go +++ b/client/iface/iface_new_android.go @@ -4,23 +4,17 @@ import ( "github.com/netbirdio/netbird/client/iface/bind" "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/netstack" - "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/iface/wgproxy" ) // NewWGIFace Creates a new WireGuard interface instance func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) { - wgAddress, err := wgaddr.ParseWGAddress(opts.Address) - if err != nil { - return nil, err - } - - iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, wgAddress, opts.MTU) + iceBind := bind.NewICEBind(opts.TransportNet, opts.Address, opts.MTU) if netstack.IsEnabled() { wgIFace := &WGIface{ userspaceBind: true, - tun: device.NewNetstackDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, netstack.ListenAddr()), + tun: device.NewNetstackDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, netstack.ListenAddr()), wgProxyFactory: wgproxy.NewUSPFactory(iceBind, opts.MTU), } return wgIFace, nil @@ -28,7 +22,7 @@ func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) { wgIFace := &WGIface{ userspaceBind: true, - tun: device.NewTunDevice(wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, opts.MobileArgs.TunAdapter, opts.DisableDNS), + tun: device.NewTunDevice(opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, opts.MobileArgs.TunAdapter, opts.DisableDNS), wgProxyFactory: wgproxy.NewUSPFactory(iceBind, opts.MTU), } return wgIFace, nil diff --git a/client/iface/iface_new_darwin.go b/client/iface/iface_new_darwin.go deleted file mode 100644 index 9f21ec950..000000000 --- a/client/iface/iface_new_darwin.go +++ /dev/null @@ -1,35 +0,0 @@ -//go:build !ios - -package iface - -import ( - "github.com/netbirdio/netbird/client/iface/bind" - "github.com/netbirdio/netbird/client/iface/device" - "github.com/netbirdio/netbird/client/iface/netstack" - "github.com/netbirdio/netbird/client/iface/wgaddr" - "github.com/netbirdio/netbird/client/iface/wgproxy" -) - -// NewWGIFace Creates a new WireGuard interface instance -func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) { - wgAddress, err := wgaddr.ParseWGAddress(opts.Address) - if err != nil { - return nil, err - } - - iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, wgAddress, opts.MTU) - - var tun WGTunDevice - if netstack.IsEnabled() { - tun = device.NewNetstackDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, netstack.ListenAddr()) - } else { - tun = device.NewTunDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind) - } - - wgIFace := &WGIface{ - userspaceBind: true, - tun: tun, - wgProxyFactory: wgproxy.NewUSPFactory(iceBind, opts.MTU), - } - return wgIFace, nil -} diff --git a/client/iface/iface_new_freebsd.go b/client/iface/iface_new_freebsd.go deleted file mode 100644 index a342bd579..000000000 --- a/client/iface/iface_new_freebsd.go +++ /dev/null @@ -1,41 +0,0 @@ -//go:build freebsd - -package iface - -import ( - "fmt" - - "github.com/netbirdio/netbird/client/iface/bind" - "github.com/netbirdio/netbird/client/iface/device" - "github.com/netbirdio/netbird/client/iface/netstack" - "github.com/netbirdio/netbird/client/iface/wgaddr" - "github.com/netbirdio/netbird/client/iface/wgproxy" -) - -// NewWGIFace Creates a new WireGuard interface instance -func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) { - wgAddress, err := wgaddr.ParseWGAddress(opts.Address) - if err != nil { - return nil, err - } - - wgIFace := &WGIface{} - - if netstack.IsEnabled() { - iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, wgAddress, opts.MTU) - wgIFace.tun = device.NewNetstackDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, netstack.ListenAddr()) - wgIFace.userspaceBind = true - wgIFace.wgProxyFactory = wgproxy.NewUSPFactory(iceBind, opts.MTU) - return wgIFace, nil - } - - if device.ModuleTunIsLoaded() { - iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, wgAddress, opts.MTU) - wgIFace.tun = device.NewUSPDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind) - wgIFace.userspaceBind = true - wgIFace.wgProxyFactory = wgproxy.NewUSPFactory(iceBind, opts.MTU) - return wgIFace, nil - } - - return nil, fmt.Errorf("couldn't check or load tun module") -} diff --git a/client/iface/iface_new_ios.go b/client/iface/iface_new_ios.go index 5d6a32e39..cedd55ce2 100644 --- a/client/iface/iface_new_ios.go +++ b/client/iface/iface_new_ios.go @@ -5,21 +5,15 @@ package iface import ( "github.com/netbirdio/netbird/client/iface/bind" "github.com/netbirdio/netbird/client/iface/device" - "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/iface/wgproxy" ) // NewWGIFace Creates a new WireGuard interface instance func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) { - wgAddress, err := wgaddr.ParseWGAddress(opts.Address) - if err != nil { - return nil, err - } - - iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, wgAddress, opts.MTU) + iceBind := bind.NewICEBind(opts.TransportNet, opts.Address, opts.MTU) wgIFace := &WGIface{ - tun: device.NewTunDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, opts.MobileArgs.TunFd), + tun: device.NewTunDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, opts.MobileArgs.TunFd), userspaceBind: true, wgProxyFactory: wgproxy.NewUSPFactory(iceBind, opts.MTU), } diff --git a/client/iface/iface_new_js.go b/client/iface/iface_new_js.go index ad913ab04..9f7a3ba62 100644 --- a/client/iface/iface_new_js.go +++ b/client/iface/iface_new_js.go @@ -4,21 +4,15 @@ import ( "github.com/netbirdio/netbird/client/iface/bind" "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/netstack" - "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/iface/wgproxy" ) // NewWGIFace creates a new WireGuard interface for WASM (always uses netstack mode) func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) { - wgAddress, err := wgaddr.ParseWGAddress(opts.Address) - if err != nil { - return nil, err - } - relayBind := bind.NewRelayBindJS() wgIface := &WGIface{ - tun: device.NewNetstackDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, relayBind, netstack.ListenAddr()), + tun: device.NewNetstackDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, relayBind, netstack.ListenAddr()), userspaceBind: true, wgProxyFactory: wgproxy.NewUSPFactory(relayBind, opts.MTU), } diff --git a/client/iface/iface_new_linux.go b/client/iface/iface_new_linux.go index d84035403..2465130e6 100644 --- a/client/iface/iface_new_linux.go +++ b/client/iface/iface_new_linux.go @@ -3,44 +3,40 @@ package iface import ( - "fmt" + "errors" "github.com/netbirdio/netbird/client/iface/bind" "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/netstack" - "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/iface/wgproxy" ) // NewWGIFace Creates a new WireGuard interface instance func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) { - wgAddress, err := wgaddr.ParseWGAddress(opts.Address) - if err != nil { - return nil, err - } - - wgIFace := &WGIface{} - if netstack.IsEnabled() { - iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, wgAddress, opts.MTU) - wgIFace.tun = device.NewNetstackDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, netstack.ListenAddr()) - wgIFace.userspaceBind = true - wgIFace.wgProxyFactory = wgproxy.NewUSPFactory(iceBind, opts.MTU) - return wgIFace, nil + iceBind := bind.NewICEBind(opts.TransportNet, opts.Address, opts.MTU) + return &WGIface{ + tun: device.NewNetstackDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, netstack.ListenAddr()), + userspaceBind: true, + wgProxyFactory: wgproxy.NewUSPFactory(iceBind, opts.MTU), + }, nil } if device.WireGuardModuleIsLoaded() { - wgIFace.tun = device.NewKernelDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, opts.TransportNet) - wgIFace.wgProxyFactory = wgproxy.NewKernelFactory(opts.WGPort, opts.MTU) - return wgIFace, nil - } - if device.ModuleTunIsLoaded() { - iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, wgAddress, opts.MTU) - wgIFace.tun = device.NewUSPDevice(opts.IFaceName, wgAddress, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind) - wgIFace.userspaceBind = true - wgIFace.wgProxyFactory = wgproxy.NewUSPFactory(iceBind, opts.MTU) - return wgIFace, nil + return &WGIface{ + tun: device.NewKernelDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, opts.TransportNet), + wgProxyFactory: wgproxy.NewKernelFactory(opts.WGPort, opts.MTU), + }, nil } - return nil, fmt.Errorf("couldn't check or load tun module") + if device.ModuleTunIsLoaded() { + iceBind := bind.NewICEBind(opts.TransportNet, opts.Address, opts.MTU) + return &WGIface{ + tun: device.NewTunDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind), + userspaceBind: true, + wgProxyFactory: wgproxy.NewUSPFactory(iceBind, opts.MTU), + }, nil + } + + return nil, errors.New("tun module not available") } diff --git a/client/iface/iface_test.go b/client/iface/iface_test.go index 6bbfeaa63..89c8cd16e 100644 --- a/client/iface/iface_test.go +++ b/client/iface/iface_test.go @@ -1,3 +1,5 @@ +//go:build privileged + package iface import ( @@ -16,6 +18,7 @@ import ( "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "github.com/netbirdio/netbird/client/iface/device" + "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/internal/stdnet" ) @@ -48,7 +51,7 @@ func TestWGIface_UpdateAddr(t *testing.T) { opts := WGIFaceOpts{ IFaceName: ifaceName, - Address: addr, + Address: wgaddr.MustParseWGAddress(addr), WGPort: wgPort, WGPrivKey: key, MTU: DefaultMTU, @@ -84,7 +87,7 @@ func TestWGIface_UpdateAddr(t *testing.T) { //update WireGuard address addr = "100.64.0.2/8" - err = iface.UpdateAddr(addr) + err = iface.UpdateAddr(wgaddr.MustParseWGAddress(addr)) if err != nil { t.Fatal(err) } @@ -130,7 +133,7 @@ func Test_CreateInterface(t *testing.T) { } opts := WGIFaceOpts{ IFaceName: ifaceName, - Address: wgIP, + Address: wgaddr.MustParseWGAddress(wgIP), WGPort: 33100, WGPrivKey: key, MTU: DefaultMTU, @@ -174,7 +177,7 @@ func Test_Close(t *testing.T) { opts := WGIFaceOpts{ IFaceName: ifaceName, - Address: wgIP, + Address: wgaddr.MustParseWGAddress(wgIP), WGPort: wgPort, WGPrivKey: key, MTU: DefaultMTU, @@ -219,7 +222,7 @@ func TestRecreation(t *testing.T) { opts := WGIFaceOpts{ IFaceName: ifaceName, - Address: wgIP, + Address: wgaddr.MustParseWGAddress(wgIP), WGPort: wgPort, WGPrivKey: key, MTU: DefaultMTU, @@ -291,7 +294,7 @@ func Test_ConfigureInterface(t *testing.T) { } opts := WGIFaceOpts{ IFaceName: ifaceName, - Address: wgIP, + Address: wgaddr.MustParseWGAddress(wgIP), WGPort: wgPort, WGPrivKey: key, MTU: DefaultMTU, @@ -347,7 +350,7 @@ func Test_UpdatePeer(t *testing.T) { opts := WGIFaceOpts{ IFaceName: ifaceName, - Address: wgIP, + Address: wgaddr.MustParseWGAddress(wgIP), WGPort: 33100, WGPrivKey: key, MTU: DefaultMTU, @@ -417,7 +420,7 @@ func Test_RemovePeer(t *testing.T) { opts := WGIFaceOpts{ IFaceName: ifaceName, - Address: wgIP, + Address: wgaddr.MustParseWGAddress(wgIP), WGPort: 33100, WGPrivKey: key, MTU: DefaultMTU, @@ -461,6 +464,8 @@ func Test_RemovePeer(t *testing.T) { } func Test_ConnectPeers(t *testing.T) { + t.Setenv("NB_DISABLE_EBPF_WG_PROXY", "true") + peer1ifaceName := fmt.Sprintf("utun%d", WgIntNumber+400) peer1wgIP := netip.MustParsePrefix("10.99.99.17/30") peer1Key, _ := wgtypes.GeneratePrivateKey() @@ -482,7 +487,7 @@ func Test_ConnectPeers(t *testing.T) { optsPeer1 := WGIFaceOpts{ IFaceName: peer1ifaceName, - Address: peer1wgIP.String(), + Address: wgaddr.MustParseWGAddress(peer1wgIP.String()), WGPort: peer1wgPort, WGPrivKey: peer1Key.String(), MTU: DefaultMTU, @@ -502,12 +507,8 @@ func Test_ConnectPeers(t *testing.T) { t.Fatal(err) } - localIP, err := getLocalIP() - if err != nil { - t.Fatal(err) - } - - peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer1wgPort)) + localIP1 := "127.0.0.1" + peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP1, peer1wgPort)) if err != nil { t.Fatal(err) } @@ -522,7 +523,7 @@ func Test_ConnectPeers(t *testing.T) { optsPeer2 := WGIFaceOpts{ IFaceName: peer2ifaceName, - Address: peer2wgIP.String(), + Address: wgaddr.MustParseWGAddress(peer2wgIP.String()), WGPort: peer2wgPort, WGPrivKey: peer2Key.String(), MTU: DefaultMTU, @@ -543,7 +544,8 @@ func Test_ConnectPeers(t *testing.T) { t.Fatal(err) } - peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer2wgPort)) + localIP2 := "127.0.0.1" + peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP2, peer2wgPort)) if err != nil { t.Fatal(err) } @@ -566,17 +568,17 @@ func Test_ConnectPeers(t *testing.T) { if err != nil { t.Fatal(err) } - // todo: investigate why in some tests execution we need 30s + // The peers use userspace WireGuard (stdnet transport). A tight busy-loop + // here starves the wireguard-go goroutines that process the handshake, so + // poll on a ticker instead and yield the CPU between checks. WireGuard also + // only retries a lost handshake initiation every REKEY_TIMEOUT (5s), which + // is why the overall wait can occasionally stretch to tens of seconds. timeout := 30 * time.Second timeoutChannel := time.After(timeout) + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() for { - select { - case <-timeoutChannel: - t.Fatalf("waiting for peer handshake timeout after %s", timeout.String()) - default: - } - peer, gpErr := getPeer(peer1ifaceName, peer2Key.PublicKey().String()) if gpErr != nil { t.Fatal(gpErr) @@ -585,6 +587,12 @@ func Test_ConnectPeers(t *testing.T) { t.Log("peers successfully handshake") break } + + select { + case <-timeoutChannel: + t.Fatalf("waiting for peer handshake timeout after %s", timeout.String()) + case <-ticker.C: + } } } @@ -612,28 +620,3 @@ func getPeer(ifaceName, peerPubKey string) (wgtypes.Peer, error) { } return wgtypes.Peer{}, fmt.Errorf("peer not found") } - -func getLocalIP() (string, error) { - // Get all interfaces - addrs, err := net.InterfaceAddrs() - if err != nil { - return "", err - } - - for _, addr := range addrs { - ipNet, ok := addr.(*net.IPNet) - if !ok { - continue - } - if ipNet.IP.IsLoopback() { - continue - } - - if ipNet.IP.To4() == nil { - continue - } - return ipNet.IP.String(), nil - } - - return "", fmt.Errorf("no local IP found") -} diff --git a/client/iface/mocks/filter.go b/client/iface/mocks/filter.go index 5ae98039c..ff3dd0c8a 100644 --- a/client/iface/mocks/filter.go +++ b/client/iface/mocks/filter.go @@ -8,7 +8,7 @@ import ( "net/netip" reflect "reflect" - gomock "github.com/golang/mock/gomock" + gomock "go.uber.org/mock/gomock" ) // MockPacketFilter is a mock of PacketFilter interface. diff --git a/client/iface/mocks/tun.go b/client/iface/mocks/tun.go index 677c82b0b..519ee6005 100644 --- a/client/iface/mocks/tun.go +++ b/client/iface/mocks/tun.go @@ -8,7 +8,7 @@ import ( os "os" reflect "reflect" - gomock "github.com/golang/mock/gomock" + gomock "go.uber.org/mock/gomock" tun "golang.zx2c4.com/wireguard/tun" ) diff --git a/client/iface/netstack/env.go b/client/iface/netstack/env.go index dd8cf29a3..b069301c1 100644 --- a/client/iface/netstack/env.go +++ b/client/iface/netstack/env.go @@ -3,14 +3,31 @@ package netstack import ( - "fmt" + "net" "os" "strconv" log "github.com/sirupsen/logrus" ) -const EnvUseNetstackMode = "NB_USE_NETSTACK_MODE" +const ( + EnvUseNetstackMode = "NB_USE_NETSTACK_MODE" + + // EnvSocks5ListenerPort overrides the port the SOCKS5 proxy listens on. + EnvSocks5ListenerPort = "NB_SOCKS5_LISTENER_PORT" + + // EnvSocks5ListenerAddress overrides the host/IP the SOCKS5 proxy binds to. + // The proxy is a bridge for local host applications into the userspace + // WireGuard netstack, so it binds to loopback by default. Override this only + // when the proxy must be reachable from other hosts (e.g. a container + // gateway); doing so exposes an unauthenticated SOCKS5 proxy on that + // address. + EnvSocks5ListenerAddress = "NB_SOCKS5_LISTENER_ADDRESS" + + // defaultSocks5Host is the loopback address the SOCKS5 proxy binds to unless + // overridden via EnvSocks5ListenerAddress. + defaultSocks5Host = "127.0.0.1" +) // IsEnabled todo: move these function to cmd layer func IsEnabled() bool { @@ -18,24 +35,40 @@ func IsEnabled() bool { } func ListenAddr() string { - sPort := os.Getenv("NB_SOCKS5_LISTENER_PORT") + return net.JoinHostPort(listenHost(), strconv.Itoa(listenPort())) +} + +// listenHost returns the host/IP the SOCKS5 proxy binds to. It defaults to +// loopback and only honors EnvSocks5ListenerAddress when it holds a valid IP. +func listenHost() string { + addr := os.Getenv(EnvSocks5ListenerAddress) + if addr == "" { + return defaultSocks5Host + } + if net.ParseIP(addr) == nil { + log.Warnf("invalid socks5 listener address %q, falling back to default: %s", addr, defaultSocks5Host) + return defaultSocks5Host + } + return addr +} + +// listenPort returns the port the SOCKS5 proxy binds to, defaulting to +// DefaultSocks5Port when EnvSocks5ListenerPort is unset or invalid. +func listenPort() int { + sPort := os.Getenv(EnvSocks5ListenerPort) if sPort == "" { - return listenAddr(DefaultSocks5Port) + return DefaultSocks5Port } port, err := strconv.Atoi(sPort) if err != nil { log.Warnf("invalid socks5 listener port, unable to convert it to int, falling back to default: %d", DefaultSocks5Port) - return listenAddr(DefaultSocks5Port) + return DefaultSocks5Port } if port < 1 || port > 65535 { log.Warnf("invalid socks5 listener port, it should be in the range 1-65535, falling back to default: %d", DefaultSocks5Port) - return listenAddr(DefaultSocks5Port) + return DefaultSocks5Port } - return listenAddr(port) -} - -func listenAddr(port int) string { - return fmt.Sprintf("0.0.0.0:%d", port) + return port } diff --git a/client/iface/netstack/env_test.go b/client/iface/netstack/env_test.go new file mode 100644 index 000000000..1083435a4 --- /dev/null +++ b/client/iface/netstack/env_test.go @@ -0,0 +1,63 @@ +//go:build !js + +package netstack + +import ( + "net" + "strconv" + "testing" +) + +func TestListenAddr_DefaultsToLoopback(t *testing.T) { + // No env overrides: must bind loopback, never all interfaces. + got := ListenAddr() + want := net.JoinHostPort("127.0.0.1", strconv.Itoa(DefaultSocks5Port)) + if got != want { + t.Fatalf("ListenAddr() = %q, want %q", got, want) + } +} + +func TestListenAddr_AddressOverride(t *testing.T) { + tests := []struct { + name string + env string + want string + }{ + {name: "valid override honored", env: "0.0.0.0", want: "0.0.0.0"}, + {name: "valid specific ip honored", env: "10.0.0.5", want: "10.0.0.5"}, + {name: "ipv6 loopback bracketed", env: "::1", want: "::1"}, + {name: "invalid falls back to loopback", env: "not-an-ip", want: "127.0.0.1"}, + {name: "empty falls back to loopback", env: "", want: "127.0.0.1"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(EnvSocks5ListenerAddress, tc.env) + want := net.JoinHostPort(tc.want, strconv.Itoa(DefaultSocks5Port)) + if got := ListenAddr(); got != want { + t.Fatalf("ListenAddr() = %q, want %q", got, want) + } + }) + } +} + +func TestListenAddr_PortOverride(t *testing.T) { + tests := []struct { + name string + env string + want int + }{ + {name: "valid port honored", env: "1081", want: 1081}, + {name: "non-numeric falls back", env: "abc", want: DefaultSocks5Port}, + {name: "out of range falls back", env: "70000", want: DefaultSocks5Port}, + {name: "zero falls back", env: "0", want: DefaultSocks5Port}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(EnvSocks5ListenerPort, tc.env) + want := net.JoinHostPort("127.0.0.1", strconv.Itoa(tc.want)) + if got := ListenAddr(); got != want { + t.Fatalf("ListenAddr() = %q, want %q", got, want) + } + }) + } +} diff --git a/client/iface/netstack/tun.go b/client/iface/netstack/tun.go index 346ae29ec..8c7526bbb 100644 --- a/client/iface/netstack/tun.go +++ b/client/iface/netstack/tun.go @@ -13,7 +13,7 @@ import ( const EnvSkipProxy = "NB_NETSTACK_SKIP_PROXY" type NetStackTun struct { //nolint:revive - address netip.Addr + addresses []netip.Addr dnsAddress netip.Addr mtu int listenAddress string @@ -22,9 +22,9 @@ type NetStackTun struct { //nolint:revive tundev tun.Device } -func NewNetStackTun(listenAddress string, address netip.Addr, dnsAddress netip.Addr, mtu int) *NetStackTun { +func NewNetStackTun(listenAddress string, addresses []netip.Addr, dnsAddress netip.Addr, mtu int) *NetStackTun { return &NetStackTun{ - address: address, + addresses: addresses, dnsAddress: dnsAddress, mtu: mtu, listenAddress: listenAddress, @@ -33,7 +33,7 @@ func NewNetStackTun(listenAddress string, address netip.Addr, dnsAddress netip.A func (t *NetStackTun) Create() (tun.Device, *netstack.Net, error) { nsTunDev, tunNet, err := netstack.CreateNetTUN( - []netip.Addr{t.address}, + t.addresses, []netip.Addr{t.dnsAddress}, t.mtu) if err != nil { diff --git a/client/iface/udpmux/universal.go b/client/iface/udpmux/universal.go index 89a7eefb9..77e1b1b35 100644 --- a/client/iface/udpmux/universal.go +++ b/client/iface/udpmux/universal.go @@ -8,8 +8,6 @@ import ( "context" "fmt" "net" - "net/netip" - "sync" "time" log "github.com/sirupsen/logrus" @@ -22,10 +20,6 @@ import ( "github.com/netbirdio/netbird/client/iface/wgaddr" ) -// FilterFn is a function that filters out candidates based on the address. -// If it returns true, the address is to be filtered. It also returns the prefix of matching route. -type FilterFn func(address netip.Addr) (bool, netip.Prefix, error) - // UniversalUDPMuxDefault handles STUN and TURN servers packets by wrapping the original UDPConn // It then passes packets to the UDPMux that does the actual connection muxing. type UniversalUDPMuxDefault struct { @@ -43,7 +37,6 @@ type UniversalUDPMuxParams struct { UDPConn net.PacketConn XORMappedAddrCacheTTL time.Duration Net transport.Net - FilterFn FilterFn WGAddress wgaddr.Address MTU uint16 } @@ -68,7 +61,6 @@ func NewUniversalUDPMuxDefault(params UniversalUDPMuxParams) *UniversalUDPMuxDef PacketConn: params.UDPConn, mux: m, logger: params.Logger, - filterFn: params.FilterFn, address: params.WGAddress, } @@ -115,15 +107,12 @@ func (m *UniversalUDPMuxDefault) ReadFromConn(ctx context.Context) { } } -// UDPConn is a wrapper around UDPMux conn that overrides ReadFrom and handles STUN/TURN packets +// UDPConn is a wrapper around UDPMux conn that overrides WriteTo to drop packets destined for the overlay subnet. type UDPConn struct { net.PacketConn - mux *UniversalUDPMuxDefault - logger logging.LeveledLogger - filterFn FilterFn - // TODO: reset cache on route changes - addrCache sync.Map - address wgaddr.Address + mux *UniversalUDPMuxDefault + logger logging.LeveledLogger + address wgaddr.Address } // GetPacketConn returns the underlying PacketConn @@ -132,65 +121,16 @@ func (u *UDPConn) GetPacketConn() net.PacketConn { } func (u *UDPConn) WriteTo(b []byte, addr net.Addr) (int, error) { - if u.filterFn == nil { + udpAddr, ok := addr.(*net.UDPAddr) + if !ok { return u.PacketConn.WriteTo(b, addr) } - - if isRouted, found := u.addrCache.Load(addr.String()); found { - return u.handleCachedAddress(isRouted.(bool), b, addr) - } - - return u.handleUncachedAddress(b, addr) -} - -func (u *UDPConn) handleCachedAddress(isRouted bool, b []byte, addr net.Addr) (int, error) { - if isRouted { - return 0, fmt.Errorf("address %s is part of a routed network, refusing to write", addr) - } - return u.PacketConn.WriteTo(b, addr) -} - -func (u *UDPConn) handleUncachedAddress(b []byte, addr net.Addr) (int, error) { - if err := u.performFilterCheck(addr); err != nil { - return 0, err - } - return u.PacketConn.WriteTo(b, addr) -} - -func (u *UDPConn) performFilterCheck(addr net.Addr) error { - host, err := getHostFromAddr(addr) - if err != nil { - log.Errorf("Failed to get host from address %s: %v", addr, err) - return nil - } - - a, err := netip.ParseAddr(host) - if err != nil { - log.Errorf("Failed to parse address %s: %v", addr, err) - return nil - } - - if u.address.Network.Contains(a) { + dst := udpAddr.AddrPort().Addr().Unmap() + if (u.address.Network.IsValid() && u.address.Network.Contains(dst)) || (u.address.IPv6Net.IsValid() && u.address.IPv6Net.Contains(dst)) { log.Warnf("address %s is part of the NetBird network %s, refusing to write", addr, u.address) - return fmt.Errorf("address %s is part of the NetBird network %s, refusing to write", addr, u.address) + return 0, fmt.Errorf("address %s is part of the NetBird network %s, refusing to write", addr, u.address) } - - if isRouted, prefix, err := u.filterFn(a); err != nil { - log.Errorf("Failed to check if address %s is routed: %v", addr, err) - } else { - u.addrCache.Store(addr.String(), isRouted) - if isRouted { - // Extra log, as the error only shows up with ICE logging enabled - log.Infof("address %s is part of routed network %s, refusing to write", addr, prefix) - return fmt.Errorf("address %s is part of routed network %s, refusing to write", addr, prefix) - } - } - return nil -} - -func getHostFromAddr(addr net.Addr) (string, error) { - host, _, err := net.SplitHostPort(addr.String()) - return host, err + return u.PacketConn.WriteTo(b, addr) } // GetSharedConn returns the shared udp conn @@ -225,6 +165,13 @@ func (m *UniversalUDPMuxDefault) HandleSTUNMessage(msg *stun.Message, addr net.A return nil } + src := udpAddr.AddrPort().Addr().Unmap() + wg := m.params.WGAddress + if (wg.Network.IsValid() && wg.Network.Contains(src)) || (wg.IPv6Net.IsValid() && wg.IPv6Net.Contains(src)) { + log.Debugf("dropping STUN message from overlay source %s", udpAddr) + return nil + } + if m.isXORMappedResponse(msg, udpAddr.String()) { err := m.handleXORMappedResponse(udpAddr, msg) if err != nil { diff --git a/client/iface/wgaddr/address.go b/client/iface/wgaddr/address.go index 078f8be95..148e724f4 100644 --- a/client/iface/wgaddr/address.go +++ b/client/iface/wgaddr/address.go @@ -3,12 +3,18 @@ package wgaddr import ( "fmt" "net/netip" + + "github.com/netbirdio/netbird/shared/netiputil" ) // Address WireGuard parsed address type Address struct { IP netip.Addr Network netip.Prefix + + // IPv6 overlay address, if assigned. + IPv6 netip.Addr + IPv6Net netip.Prefix } // ParseWGAddress parse a string ("1.2.3.4/24") address to WG Address @@ -23,6 +29,73 @@ func ParseWGAddress(address string) (Address, error) { }, nil } +// HasIPv6 reports whether a v6 overlay address is assigned. +func (addr Address) HasIPv6() bool { + return addr.IPv6.IsValid() +} + func (addr Address) String() string { - return fmt.Sprintf("%s/%d", addr.IP.String(), addr.Network.Bits()) + return addr.Prefix().String() +} + +// IPv6String returns the v6 address in CIDR notation, or empty string if none. +func (addr Address) IPv6String() string { + if !addr.HasIPv6() { + return "" + } + return addr.IPv6Prefix().String() +} + +// Prefix returns the v4 host address with its network prefix length (e.g. 100.64.0.1/16). +func (addr Address) Prefix() netip.Prefix { + return netip.PrefixFrom(addr.IP, addr.Network.Bits()) +} + +// IPv6Prefix returns the v6 host address with its network prefix length, or a zero prefix if none. +func (addr Address) IPv6Prefix() netip.Prefix { + if !addr.HasIPv6() { + return netip.Prefix{} + } + return netip.PrefixFrom(addr.IPv6, addr.IPv6Net.Bits()) +} + +// HostPrefix returns the v4 address as a single-host prefix. +func (addr Address) HostPrefix() netip.Prefix { + return netip.PrefixFrom(addr.IP, addr.IP.BitLen()) +} + +// IPv6HostPrefix returns the v6 address as a single-host prefix, or an invalid prefix when no v6 overlay address is assigned. +func (addr Address) IPv6HostPrefix() netip.Prefix { + if !addr.HasIPv6() { + return netip.Prefix{} + } + return netip.PrefixFrom(addr.IPv6, addr.IPv6.BitLen()) +} + +// SetIPv6FromCompact decodes a compact prefix (5 or 17 bytes) and sets the IPv6 fields. +// Returns an error if the bytes are invalid. A nil or empty input is a no-op. +// +//nolint:recvcheck +func (addr *Address) SetIPv6FromCompact(raw []byte) error { + if len(raw) == 0 { + return nil + } + prefix, err := netiputil.DecodePrefix(raw) + if err != nil { + return fmt.Errorf("decode v6 overlay address: %w", err) + } + if !prefix.Addr().Is6() { + return fmt.Errorf("expected IPv6 address, got %s", prefix.Addr()) + } + addr.IPv6 = prefix.Addr() + addr.IPv6Net = prefix.Masked() + return nil +} + +// ClearIPv6 removes the IPv6 overlay address, leaving only v4. +// +//nolint:recvcheck +func (addr *Address) ClearIPv6() { + addr.IPv6 = netip.Addr{} + addr.IPv6Net = netip.Prefix{} } diff --git a/client/iface/wgaddr/address_test.go b/client/iface/wgaddr/address_test.go new file mode 100644 index 000000000..61478b24c --- /dev/null +++ b/client/iface/wgaddr/address_test.go @@ -0,0 +1,24 @@ +package wgaddr + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAddress_HostPrefix(t *testing.T) { + addr := MustParseWGAddress("100.91.96.107/16") + + assert.Equal(t, netip.MustParsePrefix("100.91.96.107/32"), addr.HostPrefix(), "v4 host prefix must be a single host") + assert.Equal(t, netip.MustParsePrefix("100.91.0.0/16"), addr.Network, "network must keep the overlay prefix length") + assert.False(t, addr.IPv6HostPrefix().IsValid(), "no v6 overlay means no v6 host prefix") +} + +func TestAddress_IPv6HostPrefix(t *testing.T) { + addr := MustParseWGAddress("100.91.96.107/16") + addr.IPv6 = netip.MustParseAddr("fd00:1234::1") + addr.IPv6Net = netip.MustParsePrefix("fd00:1234::/64") + + assert.Equal(t, netip.MustParsePrefix("fd00:1234::1/128"), addr.IPv6HostPrefix(), "v6 host prefix must be a single host") +} diff --git a/client/iface/wgaddr/address_test_helpers.go b/client/iface/wgaddr/address_test_helpers.go new file mode 100644 index 000000000..87403e789 --- /dev/null +++ b/client/iface/wgaddr/address_test_helpers.go @@ -0,0 +1,10 @@ +package wgaddr + +// MustParseWGAddress parses and returns a WG Address, panicking on error. +func MustParseWGAddress(address string) Address { + a, err := ParseWGAddress(address) + if err != nil { + panic(err) + } + return a +} diff --git a/client/iface/wgproxy/bind/proxy.go b/client/iface/wgproxy/bind/proxy.go index 9ac3ea6df..fcaee15c7 100644 --- a/client/iface/wgproxy/bind/proxy.go +++ b/client/iface/wgproxy/bind/proxy.go @@ -6,7 +6,7 @@ import ( "fmt" "net" "net/netip" - "strings" + "sync" log "github.com/sirupsen/logrus" @@ -53,15 +53,15 @@ func NewProxyBind(bind Bind, mtu uint16) *ProxyBind { return p } -// AddTurnConn adds a new connection to the bind. +// AddRelayedConn adds a new connection to the bind. // endpoint is the NetBird address of the remote peer. The SetEndpoint return with the address what will be used in the // WireGuard configuration. // // Parameters: // - ctx: Context is used for proxyToLocal to avoid unnecessary error messages // - nbAddr: The NetBird UDP address of the remote peer, it required to generate fake address -// - remoteConn: The established TURN connection to the remote peer -func (p *ProxyBind) AddTurnConn(ctx context.Context, nbAddr *net.UDPAddr, remoteConn net.Conn) error { +// - remoteConn: The established relayed connection to the remote peer +func (p *ProxyBind) AddRelayedConn(ctx context.Context, nbAddr *net.UDPAddr, remoteConn net.Conn) error { fakeNetIP, err := fakeAddress(nbAddr) if err != nil { return err @@ -136,6 +136,11 @@ func (p *ProxyBind) CloseConn() error { return p.close() } +// InjectPacket is a no-op for the userspace proxy: first-packet reinjection is kernel-only. +func (p *ProxyBind) InjectPacket(_ []byte) error { + return nil +} + func (p *ProxyBind) close() error { if p.remoteConn == nil { return nil @@ -196,18 +201,25 @@ func (p *ProxyBind) proxyToLocal(ctx context.Context) { } } -// fakeAddress returns a fake address that is used to as an identifier for the peer. -// The fake address is in the format of 127.1.x.x where x.x is the last two octets of the peer address. +// fakeAddress returns a fake address that is used as an identifier for the peer. +// The fake address is in the format of 127.1.x.x where x.x is derived from the +// last two bytes of the peer address (works for both IPv4 and IPv6). func fakeAddress(peerAddress *net.UDPAddr) (*netip.AddrPort, error) { - octets := strings.Split(peerAddress.IP.String(), ".") - if len(octets) != 4 { - return nil, fmt.Errorf("invalid IP format") + if peerAddress == nil { + return nil, fmt.Errorf("nil peer address") + } + if peerAddress.Port < 0 || peerAddress.Port > 65535 { + return nil, fmt.Errorf("invalid UDP port: %d", peerAddress.Port) } - fakeIP, err := netip.ParseAddr(fmt.Sprintf("127.1.%s.%s", octets[2], octets[3])) - if err != nil { - return nil, fmt.Errorf("parse new IP: %w", err) + addr, ok := netip.AddrFromSlice(peerAddress.IP) + if !ok { + return nil, fmt.Errorf("invalid IP format") } + addr = addr.Unmap() + + raw := addr.As16() + fakeIP := netip.AddrFrom4([4]byte{127, 1, raw[14], raw[15]}) netipAddr := netip.AddrPortFrom(fakeIP, uint16(peerAddress.Port)) return &netipAddr, nil diff --git a/client/iface/wgproxy/ebpf/proxy.go b/client/iface/wgproxy/ebpf/proxy.go index 1b1a8ce1c..91c741c0d 100644 --- a/client/iface/wgproxy/ebpf/proxy.go +++ b/client/iface/wgproxy/ebpf/proxy.go @@ -30,9 +30,9 @@ type WGEBPFProxy struct { proxyPort int mtu uint16 - ebpfManager ebpfMgr.Manager - turnConnStore map[uint16]net.Conn - turnConnMutex sync.Mutex + ebpfManager ebpfMgr.Manager + relayedConnStore map[uint16]net.Conn + relayedConnMutex sync.Mutex lastUsedPort uint16 rawConnIPv4 net.PacketConn @@ -50,7 +50,7 @@ func NewWGEBPFProxy(wgPort int, mtu uint16) *WGEBPFProxy { localWGListenPort: wgPort, mtu: mtu, ebpfManager: ebpf.GetEbpfManagerInstance(), - turnConnStore: make(map[uint16]net.Conn), + relayedConnStore: make(map[uint16]net.Conn), } return wgProxy } @@ -110,14 +110,14 @@ func (p *WGEBPFProxy) Listen() error { return nil } -// AddTurnConn add new turn connection for the proxy -func (p *WGEBPFProxy) AddTurnConn(turnConn net.Conn) (*net.UDPAddr, error) { - wgEndpointPort, err := p.storeTurnConn(turnConn) +// AddRelayedConn add new relayed connection for the proxy +func (p *WGEBPFProxy) AddRelayedConn(relayedConn net.Conn) (*net.UDPAddr, error) { + wgEndpointPort, err := p.storeRelayedConn(relayedConn) if err != nil { return nil, err } - log.Infof("turn conn added to wg proxy store: %s, endpoint port: :%d", turnConn.RemoteAddr(), wgEndpointPort) + log.Infof("relayed conn added to wg proxy store: %s, endpoint port: :%d", relayedConn.RemoteAddr(), wgEndpointPort) wgEndpoint := &net.UDPAddr{ IP: net.ParseIP(loopbackAddr), @@ -186,48 +186,48 @@ func (p *WGEBPFProxy) readAndForwardPacket(buf []byte) error { return fmt.Errorf("failed to read UDP packet from WG: %w", err) } - p.turnConnMutex.Lock() - conn, ok := p.turnConnStore[uint16(addr.Port)] - p.turnConnMutex.Unlock() + p.relayedConnMutex.Lock() + conn, ok := p.relayedConnStore[uint16(addr.Port)] + p.relayedConnMutex.Unlock() if !ok { if p.ctx.Err() == nil { - log.Debugf("turn conn not found by port because conn already has been closed: %d", addr.Port) + log.Debugf("relayed conn not found by port because conn already has been closed: %d", addr.Port) } return nil } if _, err := conn.Write(buf[:n]); err != nil { - return fmt.Errorf("failed to forward local WG packet (%d) to remote turn conn: %w", addr.Port, err) + return fmt.Errorf("forward local WG packet (%d) to remote relayed conn: %w", addr.Port, err) } return nil } -func (p *WGEBPFProxy) storeTurnConn(turnConn net.Conn) (uint16, error) { - p.turnConnMutex.Lock() - defer p.turnConnMutex.Unlock() +func (p *WGEBPFProxy) storeRelayedConn(relayedConn net.Conn) (uint16, error) { + p.relayedConnMutex.Lock() + defer p.relayedConnMutex.Unlock() np, err := p.nextFreePort() if err != nil { return np, err } - p.turnConnStore[np] = turnConn + p.relayedConnStore[np] = relayedConn return np, nil } -func (p *WGEBPFProxy) removeTurnConn(turnConnID uint16) { - p.turnConnMutex.Lock() - defer p.turnConnMutex.Unlock() +func (p *WGEBPFProxy) removeRelayedConn(relayedConnID uint16) { + p.relayedConnMutex.Lock() + defer p.relayedConnMutex.Unlock() - _, ok := p.turnConnStore[turnConnID] + _, ok := p.relayedConnStore[relayedConnID] if ok { - log.Debugf("remove turn conn from store by port: %d", turnConnID) + log.Debugf("remove relayed conn from store by port: %d", relayedConnID) } - delete(p.turnConnStore, turnConnID) + delete(p.relayedConnStore, relayedConnID) } func (p *WGEBPFProxy) nextFreePort() (uint16, error) { - if len(p.turnConnStore) == 65535 { - return 0, fmt.Errorf("reached maximum turn connection numbers") + if len(p.relayedConnStore) == 65535 { + return 0, fmt.Errorf("reached maximum relayed connection numbers") } generatePort: if p.lastUsedPort == 65535 { @@ -236,7 +236,7 @@ generatePort: p.lastUsedPort++ } - if _, ok := p.turnConnStore[p.lastUsedPort]; ok { + if _, ok := p.relayedConnStore[p.lastUsedPort]; ok { goto generatePort } return p.lastUsedPort, nil diff --git a/client/iface/wgproxy/ebpf/proxy_test.go b/client/iface/wgproxy/ebpf/proxy_test.go index 3ec4f0eba..228c06c9b 100644 --- a/client/iface/wgproxy/ebpf/proxy_test.go +++ b/client/iface/wgproxy/ebpf/proxy_test.go @@ -9,32 +9,32 @@ import ( func TestWGEBPFProxy_connStore(t *testing.T) { wgProxy := NewWGEBPFProxy(1, 1280) - p, _ := wgProxy.storeTurnConn(nil) + p, _ := wgProxy.storeRelayedConn(nil) if p != 1 { t.Errorf("invalid initial port: %d", wgProxy.lastUsedPort) } numOfConns := 10 for i := 0; i < numOfConns; i++ { - p, _ = wgProxy.storeTurnConn(nil) + p, _ = wgProxy.storeRelayedConn(nil) } if p != uint16(numOfConns)+1 { t.Errorf("invalid last used port: %d, expected: %d", p, numOfConns+1) } - if len(wgProxy.turnConnStore) != numOfConns+1 { - t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.turnConnStore), numOfConns+1) + if len(wgProxy.relayedConnStore) != numOfConns+1 { + t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.relayedConnStore), numOfConns+1) } } func TestWGEBPFProxy_portCalculation_overflow(t *testing.T) { wgProxy := NewWGEBPFProxy(1, 1280) - _, _ = wgProxy.storeTurnConn(nil) + _, _ = wgProxy.storeRelayedConn(nil) wgProxy.lastUsedPort = 65535 - p, _ := wgProxy.storeTurnConn(nil) + p, _ := wgProxy.storeRelayedConn(nil) - if len(wgProxy.turnConnStore) != 2 { - t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.turnConnStore), 2) + if len(wgProxy.relayedConnStore) != 2 { + t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.relayedConnStore), 2) } if p != 2 { @@ -46,11 +46,11 @@ func TestWGEBPFProxy_portCalculation_maxConn(t *testing.T) { wgProxy := NewWGEBPFProxy(1, 1280) for i := 0; i < 65535; i++ { - _, _ = wgProxy.storeTurnConn(nil) + _, _ = wgProxy.storeRelayedConn(nil) } - _, err := wgProxy.storeTurnConn(nil) + _, err := wgProxy.storeRelayedConn(nil) if err == nil { - t.Errorf("invalid turn conn store calculation") + t.Errorf("invalid relayed conn store calculation") } } diff --git a/client/iface/wgproxy/ebpf/wrapper.go b/client/iface/wgproxy/ebpf/wrapper.go index 6e80945c4..f75e21aa6 100644 --- a/client/iface/wgproxy/ebpf/wrapper.go +++ b/client/iface/wgproxy/ebpf/wrapper.go @@ -121,10 +121,10 @@ func NewProxyWrapper(proxy *WGEBPFProxy) *ProxyWrapper { } } -func (p *ProxyWrapper) AddTurnConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error { - addr, err := p.wgeBPFProxy.AddTurnConn(remoteConn) +func (p *ProxyWrapper) AddRelayedConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error { + addr, err := p.wgeBPFProxy.AddRelayedConn(remoteConn) if err != nil { - return fmt.Errorf("add turn conn: %w", err) + return fmt.Errorf("add relayed conn: %w", err) } headers, err := NewPacketHeaders(p.wgeBPFProxy.localWGListenPort, addr) @@ -219,6 +219,17 @@ func (p *ProxyWrapper) RedirectAs(endpoint *net.UDPAddr) { p.pausedCond.L.Unlock() } +// InjectPacket writes b to the remote peer over the underlying transport. +func (p *ProxyWrapper) InjectPacket(b []byte) error { + if p.remoteConn == nil { + return errors.New("proxy not started") + } + if _, err := p.remoteConn.Write(b); err != nil { + return err + } + return nil +} + // CloseConn close the remoteConn and automatically remove the conn instance from the map func (p *ProxyWrapper) CloseConn() error { if p.cancel == nil { @@ -241,7 +252,7 @@ func (p *ProxyWrapper) CloseConn() error { } func (p *ProxyWrapper) proxyToLocal(ctx context.Context) { - defer p.wgeBPFProxy.removeTurnConn(uint16(p.wgRelayedEndpointAddr.Port)) + defer p.wgeBPFProxy.removeRelayedConn(uint16(p.wgRelayedEndpointAddr.Port)) buf := make([]byte, p.wgeBPFProxy.mtu+bufsize.WGBufferOverhead) for { @@ -262,7 +273,7 @@ func (p *ProxyWrapper) proxyToLocal(ctx context.Context) { if ctx.Err() != nil { return } - log.Errorf("failed to write out turn pkg to local conn: %v", err) + log.Errorf("failed to write out relayed pkg to local conn: %v", err) } } } @@ -275,7 +286,7 @@ func (p *ProxyWrapper) readFromRemote(ctx context.Context, buf []byte) (int, err } p.closeListener.Notify() if !errors.Is(err, io.EOF) { - log.Errorf("failed to read from turn conn (endpoint: :%d): %s", p.wgRelayedEndpointAddr.Port, err) + log.Errorf("failed to read from relayed conn (endpoint: :%d): %s", p.wgRelayedEndpointAddr.Port, err) } return 0, err } diff --git a/client/iface/wgproxy/proxy.go b/client/iface/wgproxy/proxy.go index 3c8dfd30e..b0033bffa 100644 --- a/client/iface/wgproxy/proxy.go +++ b/client/iface/wgproxy/proxy.go @@ -7,7 +7,7 @@ import ( // Proxy is a transfer layer between the relayed connection and the WireGuard type Proxy interface { - AddTurnConn(ctx context.Context, endpoint *net.UDPAddr, remoteConn net.Conn) error + AddRelayedConn(ctx context.Context, endpoint *net.UDPAddr, remoteConn net.Conn) error EndpointAddr() *net.UDPAddr // EndpointAddr returns the address of the WireGuard peer endpoint Work() // Work start or resume the proxy Pause() // Pause to forward the packages from remote connection to WireGuard. The opposite way still works. @@ -18,4 +18,9 @@ type Proxy interface { RedirectAs(endpoint *net.UDPAddr) CloseConn() error SetDisconnectListener(disconnected func()) + + // InjectPacket writes a raw packet directly to the remote peer over the underlying transport, + // bypassing WireGuard. Used to replay the captured lazyconn handshake initiation. Only the + // kernel-mode proxies act on it; the userspace proxy is a no-op since reinjection is kernel-only. + InjectPacket(b []byte) error } diff --git a/client/iface/wgproxy/proxy_linux_test.go b/client/iface/wgproxy/proxy_linux_test.go index dd24d1cdc..e34dd3b6b 100644 --- a/client/iface/wgproxy/proxy_linux_test.go +++ b/client/iface/wgproxy/proxy_linux_test.go @@ -1,4 +1,4 @@ -//go:build linux && !android +//go:build linux && !android && privileged package wgproxy @@ -66,7 +66,7 @@ func seedProxyForProxyCloseByRemoteConn() ([]proxyInstance, error) { if err != nil { return nil, err } - iceBind := bind.NewICEBind(nil, nil, wgAddress, 1280) + iceBind := bind.NewICEBind(nil, wgAddress, 1280) endpointAddress := &net.UDPAddr{ IP: net.IPv4(10, 0, 0, 1), Port: 1234, diff --git a/client/iface/wgproxy/proxy_seed_test.go b/client/iface/wgproxy/proxy_seed_test.go index ad375ccde..4fb9ed77a 100644 --- a/client/iface/wgproxy/proxy_seed_test.go +++ b/client/iface/wgproxy/proxy_seed_test.go @@ -1,4 +1,4 @@ -//go:build !linux +//go:build !linux || !privileged package wgproxy @@ -22,7 +22,7 @@ func seedProxyForProxyCloseByRemoteConn() ([]proxyInstance, error) { if err != nil { return nil, err } - iceBind := bind.NewICEBind(nil, nil, wgAddress, 1280) + iceBind := bind.NewICEBind(nil, wgAddress, 1280) endpointAddress := &net.UDPAddr{ IP: net.IPv4(10, 0, 0, 1), Port: 1234, diff --git a/client/iface/wgproxy/proxy_test.go b/client/iface/wgproxy/proxy_test.go index 1aeab66b7..d86cdbe80 100644 --- a/client/iface/wgproxy/proxy_test.go +++ b/client/iface/wgproxy/proxy_test.go @@ -95,7 +95,7 @@ func TestProxyCloseByRemoteConn(t *testing.T) { t.Run(tt.name, func(t *testing.T) { addr, _ := net.ResolveUDPAddr("udp", "100.108.135.221:51892") relayedConn := newMockConn() - err := tt.proxy.AddTurnConn(ctx, addr, relayedConn) + err := tt.proxy.AddRelayedConn(ctx, addr, relayedConn) if err != nil { t.Errorf("error: %v", err) } @@ -157,7 +157,7 @@ func redirectTraffic(t *testing.T, proxy Proxy, wgPort int, endPointAddr *net.UD _ = relayedServer.Close() }() - if err := proxy.AddTurnConn(context.Background(), endPointAddr, relayedConn); err != nil { + if err := proxy.AddRelayedConn(context.Background(), endPointAddr, relayedConn); err != nil { t.Errorf("error: %v", err) } defer func() { diff --git a/client/iface/wgproxy/rawsocket/rawsocket.go b/client/iface/wgproxy/rawsocket/rawsocket.go index bc785b43a..37aaa160f 100644 --- a/client/iface/wgproxy/rawsocket/rawsocket.go +++ b/client/iface/wgproxy/rawsocket/rawsocket.go @@ -10,8 +10,6 @@ import ( log "github.com/sirupsen/logrus" "golang.org/x/sys/unix" - - nbnet "github.com/netbirdio/netbird/client/net" ) // PrepareSenderRawSocketIPv4 creates and configures a raw socket for sending IPv4 packets @@ -60,14 +58,12 @@ func prepareSenderRawSocket(family int, isIPv4 bool) (net.PacketConn, error) { return nil, fmt.Errorf("binding to lo interface failed: %w", err) } - // Set the fwmark on the socket. - err = nbnet.SetSocketOpt(fd) - if err != nil { - if closeErr := syscall.Close(fd); closeErr != nil { - log.Warnf("failed to close raw socket fd: %v", closeErr) - } - return nil, fmt.Errorf("setting fwmark failed: %w", err) - } + // The socket is bound to lo and only ever sends to the local WireGuard + // instance, a destination the local routing table resolves without help, so + // it carries no fwmark. Staying unmarked also keeps these packets out of + // third-party NAT rules that match on marks: such a rule rewriting the + // source would make WireGuard adopt the rewritten address as the peer + // endpoint. // Convert the file descriptor to a PacketConn. file := os.NewFile(uintptr(fd), fmt.Sprintf("fd %d", fd)) diff --git a/client/iface/wgproxy/rawsocket/rawsocket_privileged_test.go b/client/iface/wgproxy/rawsocket/rawsocket_privileged_test.go new file mode 100644 index 000000000..03748c6f9 --- /dev/null +++ b/client/iface/wgproxy/rawsocket/rawsocket_privileged_test.go @@ -0,0 +1,77 @@ +//go:build linux && !android && privileged + +package rawsocket + +import ( + "net" + "syscall" + "testing" + + "golang.org/x/sys/unix" + + nbnet "github.com/netbirdio/netbird/client/net" +) + +// The sender sockets must stay unmarked: a NAT rule matching on fwmark that +// rewrites the source of an injected packet makes WireGuard adopt the rewritten +// address as the peer endpoint. +func TestSenderRawSocketsCarryNoFwmark(t *testing.T) { + // the mark is only ever applied when advanced routing is available, so + // without it the assertion below would hold for the wrong reason + nbnet.Init() + if !nbnet.AdvancedRouting() { + t.Skip("advanced routing unsupported, the sockets carry no mark either way") + } + + tests := []struct { + name string + prepare func() (net.PacketConn, error) + // the proxy treats the IPv6 socket as optional, so a host without IPv6 + // is a reason to skip rather than to fail + optional bool + }{ + {name: "IPv4", prepare: PrepareSenderRawSocketIPv4}, + {name: "IPv6", prepare: PrepareSenderRawSocketIPv6, optional: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + conn, err := tc.prepare() + if err != nil { + if tc.optional { + t.Skipf("prepare raw socket: %v", err) + } + t.Fatalf("prepare raw socket: %v", err) + } + defer func() { + if err := conn.Close(); err != nil { + t.Logf("close raw socket: %v", err) + } + }() + + syscallConn, ok := conn.(syscall.Conn) + if !ok { + t.Fatalf("raw socket %T does not expose a syscall conn", conn) + } + raw, err := syscallConn.SyscallConn() + if err != nil { + t.Fatalf("syscall conn: %v", err) + } + + var mark int + var markErr error + if err := raw.Control(func(fd uintptr) { + mark, markErr = unix.GetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_MARK) + }); err != nil { + t.Fatalf("control: %v", err) + } + if markErr != nil { + t.Fatalf("get SO_MARK: %v", markErr) + } + + if mark != 0 { + t.Errorf("SO_MARK = %#x, want 0", mark) + } + }) + } +} diff --git a/client/iface/wgproxy/redirect_test.go b/client/iface/wgproxy/redirect_test.go index b52eead25..f0d59cc64 100644 --- a/client/iface/wgproxy/redirect_test.go +++ b/client/iface/wgproxy/redirect_test.go @@ -1,4 +1,4 @@ -//go:build linux && !android +//go:build linux && !android && privileged package wgproxy @@ -26,64 +26,6 @@ func compareUDPAddr(addr1, addr2 net.Addr) bool { return udpAddr1.IP.Equal(udpAddr2.IP) && udpAddr1.Port == udpAddr2.Port } -// TestRedirectAs_eBPF_IPv4 tests RedirectAs with eBPF proxy using IPv4 addresses -func TestRedirectAs_eBPF_IPv4(t *testing.T) { - wgPort := 51850 - ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280) - if err := ebpfProxy.Listen(); err != nil { - t.Fatalf("failed to initialize ebpf proxy: %v", err) - } - defer func() { - if err := ebpfProxy.Free(); err != nil { - t.Errorf("failed to free ebpf proxy: %v", err) - } - }() - - proxy := ebpf.NewProxyWrapper(ebpfProxy) - - // NetBird UDP address of the remote peer - nbAddr := &net.UDPAddr{ - IP: net.ParseIP("100.108.111.177"), - Port: 38746, - } - - p2pEndpoint := &net.UDPAddr{ - IP: net.ParseIP("192.168.0.56"), - Port: 51820, - } - - testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint) -} - -// TestRedirectAs_eBPF_IPv6 tests RedirectAs with eBPF proxy using IPv6 addresses -func TestRedirectAs_eBPF_IPv6(t *testing.T) { - wgPort := 51851 - ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280) - if err := ebpfProxy.Listen(); err != nil { - t.Fatalf("failed to initialize ebpf proxy: %v", err) - } - defer func() { - if err := ebpfProxy.Free(); err != nil { - t.Errorf("failed to free ebpf proxy: %v", err) - } - }() - - proxy := ebpf.NewProxyWrapper(ebpfProxy) - - // NetBird UDP address of the remote peer - nbAddr := &net.UDPAddr{ - IP: net.ParseIP("100.108.111.177"), - Port: 38746, - } - - p2pEndpoint := &net.UDPAddr{ - IP: net.ParseIP("fe80::56"), - Port: 51820, - } - - testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint) -} - // TestRedirectAs_UDP_IPv4 tests RedirectAs with UDP proxy using IPv4 addresses func TestRedirectAs_UDP_IPv4(t *testing.T) { wgPort := 51852 @@ -177,9 +119,9 @@ func testRedirectAs(t *testing.T, proxy Proxy, wgPort int, nbAddr, p2pEndpoint * } defer relayConn.Close() - // Add TURN connection to proxy - if err := proxy.AddTurnConn(ctx, nbAddr, relayConn); err != nil { - t.Fatalf("failed to add TURN connection: %v", err) + // Add relayed connection to proxy + if err := proxy.AddRelayedConn(ctx, nbAddr, relayConn); err != nil { + t.Fatalf("failed to add relayed connection: %v", err) } defer func() { if err := proxy.CloseConn(); err != nil { @@ -256,6 +198,64 @@ func testRedirectAs(t *testing.T, proxy Proxy, wgPort int, nbAddr, p2pEndpoint * } } +// TestRedirectAs_eBPF_IPv4 tests RedirectAs with eBPF proxy using IPv4 addresses +func TestRedirectAs_eBPF_IPv4(t *testing.T) { + wgPort := 51850 + ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280) + if err := ebpfProxy.Listen(); err != nil { + t.Fatalf("failed to initialize ebpf proxy: %v", err) + } + defer func() { + if err := ebpfProxy.Free(); err != nil { + t.Errorf("failed to free ebpf proxy: %v", err) + } + }() + + proxy := ebpf.NewProxyWrapper(ebpfProxy) + + // NetBird UDP address of the remote peer + nbAddr := &net.UDPAddr{ + IP: net.ParseIP("100.108.111.177"), + Port: 38746, + } + + p2pEndpoint := &net.UDPAddr{ + IP: net.ParseIP("192.168.0.56"), + Port: 51820, + } + + testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint) +} + +// TestRedirectAs_eBPF_IPv6 tests RedirectAs with eBPF proxy using IPv6 addresses +func TestRedirectAs_eBPF_IPv6(t *testing.T) { + wgPort := 51851 + ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280) + if err := ebpfProxy.Listen(); err != nil { + t.Fatalf("failed to initialize ebpf proxy: %v", err) + } + defer func() { + if err := ebpfProxy.Free(); err != nil { + t.Errorf("failed to free ebpf proxy: %v", err) + } + }() + + proxy := ebpf.NewProxyWrapper(ebpfProxy) + + // NetBird UDP address of the remote peer + nbAddr := &net.UDPAddr{ + IP: net.ParseIP("100.108.111.177"), + Port: 38746, + } + + p2pEndpoint := &net.UDPAddr{ + IP: net.ParseIP("fe80::56"), + Port: 51820, + } + + testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint) +} + // TestRedirectAs_Multiple_Switches tests switching between multiple endpoints func TestRedirectAs_Multiple_Switches(t *testing.T) { wgPort := 51856 @@ -304,8 +304,8 @@ func TestRedirectAs_Multiple_Switches(t *testing.T) { Port: 38746, } - if err := proxy.AddTurnConn(ctx, nbAddr, relayConn); err != nil { - t.Fatalf("failed to add TURN connection: %v", err) + if err := proxy.AddRelayedConn(ctx, nbAddr, relayConn); err != nil { + t.Fatalf("failed to add relayed connection: %v", err) } defer func() { if err := proxy.CloseConn(); err != nil { diff --git a/client/iface/wgproxy/udp/proxy.go b/client/iface/wgproxy/udp/proxy.go index 6069d1960..a0895c8c7 100644 --- a/client/iface/wgproxy/udp/proxy.go +++ b/client/iface/wgproxy/udp/proxy.go @@ -51,12 +51,12 @@ func NewWGUDPProxy(wgPort int, mtu uint16) *WGUDPProxy { return p } -// AddTurnConn +// AddRelayedConn dials the local WireGuard port and stores the relayed connection. // The provided Context must be non-nil. If the context expires before // the connection is complete, an error is returned. Once successfully // connected, any expiration of the context will not affect the // connection. -func (p *WGUDPProxy) AddTurnConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error { +func (p *WGUDPProxy) AddRelayedConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error { dialer := net.Dialer{} localConn, err := dialer.DialContext(ctx, "udp", fmt.Sprintf(":%d", p.localWGListenPort)) if err != nil { @@ -147,6 +147,17 @@ func (p *WGUDPProxy) RedirectAs(endpoint *net.UDPAddr) { p.sendPkg = p.srcFakerConn.SendPkg } +// InjectPacket writes b to the remote peer over the underlying transport. +func (p *WGUDPProxy) InjectPacket(b []byte) error { + if p.remoteConn == nil { + return errors.New("proxy not started") + } + if _, err := p.remoteConn.Write(b); err != nil { + return err + } + return nil +} + // CloseConn close the localConn func (p *WGUDPProxy) CloseConn() error { if p.cancel == nil { diff --git a/client/installer.nsis b/client/installer.nsis index 96d60a785..eb2d7d5bd 100644 --- a/client/installer.nsis +++ b/client/installer.nsis @@ -6,7 +6,7 @@ !define DESCRIPTION "Connect your devices into a secure WireGuard-based overlay network with SSO, MFA, and granular access controls." !define INSTALLER_NAME "netbird-installer.exe" !define MAIN_APP_EXE "Netbird" -!define ICON "ui\\assets\\netbird.ico" +!define ICON "ui\\build\\windows\\icon.ico" !define BANNER "ui\\build\\banner.bmp" !define LICENSE_DATA "..\\LICENSE" @@ -22,8 +22,6 @@ !define UI_REG_APP_PATH "Software\Microsoft\Windows\CurrentVersion\App Paths\${UI_APP_EXE}" !define UI_UNINSTALL_PATH "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UI_APP_NAME}" -!define AUTOSTART_REG_KEY "Software\Microsoft\Windows\CurrentVersion\Run" - !define NETBIRD_DATA_DIR "$COMMONPROGRAMDATA\Netbird" Unicode True @@ -79,8 +77,6 @@ ShowInstDetails Show !insertmacro MUI_PAGE_DIRECTORY -Page custom AutostartPage AutostartPageLeave - !insertmacro MUI_PAGE_INSTFILES !insertmacro MUI_PAGE_FINISH @@ -97,40 +93,12 @@ UninstPage custom un.DeleteDataPage un.DeleteDataPageLeave !insertmacro MUI_LANGUAGE "English" -; Variables for autostart option -Var AutostartCheckbox -Var AutostartEnabled - ; Variables for uninstall data deletion option Var DeleteDataCheckbox Var DeleteDataEnabled ###################################################################### -; Function to create the autostart options page -Function AutostartPage - !insertmacro MUI_HEADER_TEXT "Startup Options" "Configure how ${APP_NAME} launches with Windows." - - nsDialogs::Create 1018 - Pop $0 - - ${If} $0 == error - Abort - ${EndIf} - - ${NSD_CreateCheckbox} 0 20u 100% 10u "Start ${APP_NAME} UI automatically when Windows starts" - Pop $AutostartCheckbox - ${NSD_Check} $AutostartCheckbox - StrCpy $AutostartEnabled "1" - - nsDialogs::Show -FunctionEnd - -; Function to handle leaving the autostart page -Function AutostartPageLeave - ${NSD_GetState} $AutostartCheckbox $AutostartEnabled -FunctionEnd - ; Function to create the uninstall data deletion page Function un.DeleteDataPage !insertmacro MUI_HEADER_TEXT "Uninstall Options" "Choose whether to delete ${APP_NAME} data." @@ -201,7 +169,16 @@ Pop $0 Function .onInit StrCpy $INSTDIR "${INSTALL_DIR}" + +; Pre-0.70.1 installers ran without SetRegView, so their uninstall keys live +; in the 32-bit view. Fall back to it so upgrades still find them. +SetRegView 64 ReadRegStr $R0 HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\$(^NAME)" "UninstallString" +${If} $R0 == "" + SetRegView 32 + ReadRegStr $R0 HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\$(^NAME)" "UninstallString" + SetRegView 64 +${EndIf} ${If} $R0 != "" # if silent install jump to uninstall step IfSilent uninstall @@ -214,6 +191,10 @@ ${If} $R0 != "" ${EndIf} FunctionEnd + +Function un.onInit +SetRegView 64 +FunctionEnd ###################################################################### Section -MainProgram ${INSTALL_TYPE} @@ -228,6 +209,7 @@ Section -MainProgram !else File /r "..\\dist\\netbird_windows_amd64\\" !endif + File "..\\client\\ui\\assets\\netbird.png" SectionEnd ###################################################################### @@ -244,16 +226,6 @@ WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "Publisher" "${COMP_NAME}" WriteRegStr ${REG_ROOT} "${UI_REG_APP_PATH}" "" "$INSTDIR\${UI_APP_EXE}" -; Create autostart registry entry based on checkbox -DetailPrint "Autostart enabled: $AutostartEnabled" -${If} $AutostartEnabled == "1" - WriteRegStr HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}" "$INSTDIR\${UI_APP_EXE}.exe" - DetailPrint "Added autostart registry entry: $INSTDIR\${UI_APP_EXE}.exe" -${Else} - DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}" - DetailPrint "Autostart not enabled by user" -${EndIf} - EnVar::SetHKLM EnVar::AddValueEx "path" "$INSTDIR" @@ -262,6 +234,43 @@ CreateShortCut "$SMPROGRAMS\${APP_NAME}.lnk" "$INSTDIR\${UI_APP_EXE}" CreateShortCut "$DESKTOP\${APP_NAME}.lnk" "$INSTDIR\${UI_APP_EXE}" SectionEnd +# Install the Microsoft Edge WebView2 runtime if it isn't already present. +# Macro adapted from Wails3's NSIS template (wails_tools.nsh): a registry +# probe followed by a silent install of the embedded evergreen bootstrapper. +# The MicrosoftEdgeWebview2Setup.exe payload is staged next to this script +# by the sign-pipelines build step (`wails3 generate webview2bootstrapper`). +!macro nb.webview2runtime + SetRegView 64 + # Per-machine install marker — populated when the runtime ships with + # Edge or has been installed by an admin previously. + ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + ${If} $0 != "" + Goto webview2_ok + ${EndIf} + # Per-user fallback for HKCU installs. + ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + ${If} $0 != "" + Goto webview2_ok + ${EndIf} + + SetDetailsPrint both + DetailPrint "Installing: WebView2 Runtime" + SetDetailsPrint listonly + + InitPluginsDir + CreateDirectory "$pluginsdir\webview2bootstrapper" + SetOutPath "$pluginsdir\webview2bootstrapper" + File "MicrosoftEdgeWebview2Setup.exe" + ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install' + + SetDetailsPrint both + webview2_ok: +!macroend + +Section -WebView2 + !insertmacro nb.webview2runtime +SectionEnd + Section -Post ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service install' ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service start' @@ -281,10 +290,6 @@ ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service uninstall' DetailPrint "Terminating Netbird UI process..." ExecWait `taskkill /im ${UI_APP_EXE}.exe /f` -; Remove autostart registry entry -DetailPrint "Removing autostart registry entry if exists..." -DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}" - ; Handle data deletion based on checkbox DetailPrint "Checking if user requested data deletion..." ${If} $DeleteDataEnabled == "1" @@ -306,9 +311,9 @@ DetailPrint "Deleting application files..." Delete "$INSTDIR\${UI_APP_EXE}" Delete "$INSTDIR\${MAIN_APP_EXE}" Delete "$INSTDIR\wintun.dll" -!if ${ARCH} == "amd64" +# Legacy: pre-Wails installs shipped opengl32.dll (Mesa3D for Fyne); remove +# any leftover copy on uninstall so old upgrades don't leave it behind. Delete "$INSTDIR\opengl32.dll" -!endif DetailPrint "Removing application directory..." RmDir /r "$INSTDIR" @@ -321,6 +326,7 @@ DetailPrint "Removing registry keys..." DeleteRegKey ${REG_ROOT} "${REG_APP_PATH}" DeleteRegKey ${REG_ROOT} "${UNINSTALL_PATH}" DeleteRegKey ${REG_ROOT} "${UI_REG_APP_PATH}" +DeleteRegKey HKCU "Software\Classes\AppUserModelId\${APP_NAME}" DetailPrint "Removing application directory from PATH..." EnVar::SetHKLM diff --git a/client/internal/acl/dispatch_test.go b/client/internal/acl/dispatch_test.go new file mode 100644 index 000000000..be82e414f --- /dev/null +++ b/client/internal/acl/dispatch_test.go @@ -0,0 +1,190 @@ +package acl + +import ( + "net/netip" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/netbirdio/netbird/client/firewall" + fwmgr "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/internal/acl/mocks" + mgmProto "github.com/netbirdio/netbird/shared/management/proto" +) + +// TestNetworkZeroPrefixIsRoute guards the route-vs-peer dispatch +// invariant: the backends classify a rule as a peer rule purely by the +// absence of a destination (neither prefix nor set). A default route +// (0.0.0.0/0 or ::/0) is a valid prefix and must therefore classify as +// a route, not collapse into the peer path. +func TestNetworkZeroPrefixIsRoute(t *testing.T) { + for _, p := range []string{"0.0.0.0/0", "::/0", "10.0.0.0/8"} { + n := fwmgr.Network{Prefix: netip.MustParsePrefix(p)} + assert.True(t, n.IsPrefix(), "%s must report IsPrefix", p) + assert.True(t, n.IsPrefix() || n.IsSet(), "%s must classify as a route", p) + } + + // A zero-value Network is the only peer-rule shape. + var empty fwmgr.Network + assert.False(t, empty.IsPrefix(), "zero Network must not be a prefix") + assert.False(t, empty.IsSet(), "zero Network must not be a set") +} + +// TestDetermineDestinationAlwaysRoute verifies determineDestination +// never yields an empty Network for a valid route rule: every branch +// (static prefix, default route, dynamic with/without domains, with and +// without a local resolver) produces a destination that classifies as a +// route. If this regresses, a route rule would be dispatched down the +// peer path, which matches on source only. +func TestDetermineDestinationAlwaysRoute(t *testing.T) { + v4 := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")} + v6 := []netip.Prefix{netip.MustParsePrefix("2001:db8::/48")} + + cases := []struct { + name string + rule *mgmProto.RouteFirewallRule + resolver bool + sources []netip.Prefix + }{ + {"static prefix", &mgmProto.RouteFirewallRule{Destination: "192.168.0.0/16"}, false, v4}, + {"static default route", &mgmProto.RouteFirewallRule{Destination: "0.0.0.0/0"}, false, v4}, + {"dynamic with domains + resolver", &mgmProto.RouteFirewallRule{IsDynamic: true, Domains: []string{"example.com"}}, true, v4}, + {"dynamic no domains + resolver (v4)", &mgmProto.RouteFirewallRule{IsDynamic: true}, true, v4}, + {"dynamic no domains + resolver (v6)", &mgmProto.RouteFirewallRule{IsDynamic: true}, true, v6}, + {"dynamic + no local resolver (v4)", &mgmProto.RouteFirewallRule{IsDynamic: true}, false, v4}, + {"dynamic + no local resolver (v6)", &mgmProto.RouteFirewallRule{IsDynamic: true}, false, v6}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dest, err := determineDestination(tc.rule, tc.resolver, tc.sources) + require.NoError(t, err) + assert.True(t, dest.IsPrefix() || dest.IsSet(), + "destination must classify as a route, got empty Network") + }) + } +} + +// countingFirewall wraps a real firewall.Manager and counts filter-rule +// add/delete calls so a test can assert how many backing rules the acl +// manager actually creates and tears down. +type countingFirewall struct { + fwmgr.Manager + mu sync.Mutex + addCalls int + dels int + ruleIDs map[fwmgr.RuleID]struct{} +} + +// distinctRules returns the number of distinct backing rules the +// backend produced. Because the backend dedups identical content, +// repeated AddFilterRule calls for the same rule resolve to one id. +func (f *countingFirewall) distinctRules() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.ruleIDs) +} + +func (f *countingFirewall) AddFilterRule(id []byte, sources []netip.Prefix, destination fwmgr.Network, proto fwmgr.Protocol, sPort, dPort *fwmgr.Port, action fwmgr.Action) (fwmgr.Rule, error) { + rule, err := f.Manager.AddFilterRule(id, sources, destination, proto, sPort, dPort, action) + if err == nil { + f.mu.Lock() + f.addCalls++ + if f.ruleIDs == nil { + f.ruleIDs = make(map[fwmgr.RuleID]struct{}) + } + if rule != nil { + f.ruleIDs[rule.ID()] = struct{}{} + } + f.mu.Unlock() + } + return rule, err +} + +func (f *countingFirewall) DeleteFilterRule(r fwmgr.Rule) error { + err := f.Manager.DeleteFilterRule(r) + if err == nil { + f.mu.Lock() + f.dels++ + delete(f.ruleIDs, r.ID()) + f.mu.Unlock() + } + return err +} + +func newCountingACL(t *testing.T) (*DefaultManager, *countingFirewall, func()) { + t.Helper() + t.Setenv("NB_WG_KERNEL_DISABLED", "true") + t.Setenv(firewall.EnvForceUserspaceFirewall, "true") + + ctrl := gomock.NewController(t) + ifaceMock := mocks.NewMockIFaceMapper(ctrl) + ifaceMock.EXPECT().IsUserspaceBind().Return(true).AnyTimes() + ifaceMock.EXPECT().SetFilter(gomock.Any()) + network := netip.MustParsePrefix("172.0.0.1/32") + ifaceMock.EXPECT().Name().Return("lo").AnyTimes() + ifaceMock.EXPECT().Address().Return(wgaddr.Address{IP: network.Addr(), Network: network}).AnyTimes() + ifaceMock.EXPECT().GetWGDevice().Return(nil).AnyTimes() + + realFW, err := firewall.NewFirewall(ifaceMock, nil, flowLogger, false, iface.DefaultMTU) + require.NoError(t, err) + + fw := &countingFirewall{Manager: realFW} + cleanup := func() { + require.NoError(t, realFW.Close(nil)) + ctrl.Finish() + } + return NewDefaultManager(fw), fw, cleanup +} + +// TestDuplicateContentPoliciesShareOneRule verifies the dedup contract +// the backends rely on: two policies that authorize an identical flow +// (same selector and sources) collapse to a single backing firewall +// rule, and that rule survives until BOTH policies are gone. This is +// why the backend can dedup on add without refcounting on delete: the +// acl manager's pair key matches the backend's content key, so add and +// delete stay balanced per content key across full-state reapplies. +func TestDuplicateContentPoliciesShareOneRule(t *testing.T) { + acl, fw, cleanup := newCountingACL(t) + defer cleanup() + + ruleA := &mgmProto.FirewallRule{ + PolicyID: []byte("policy-A"), + PeerIP: "10.0.0.1", //nolint:staticcheck + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "443", + } + ruleB := &mgmProto.FirewallRule{ + PolicyID: []byte("policy-B"), + PeerIP: "10.0.0.1", //nolint:staticcheck + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "443", + } + + // Both policies present: identical content collapses to one rule. + acl.ApplyFiltering(&mgmProto.NetworkMap{FirewallRules: []*mgmProto.FirewallRule{ruleA, ruleB}, FirewallRulesIsEmpty: false}, false) + assert.Equal(t, 1, fw.distinctRules(), "identical-content policies must produce one backing rule") + assert.Equal(t, 1, len(acl.peerRulesPairs), "one content key, one pair") + + // Drop policy A only: the shared rule is still authorized by B, so + // nothing is deleted. + acl.ApplyFiltering(&mgmProto.NetworkMap{FirewallRules: []*mgmProto.FirewallRule{ruleB}, FirewallRulesIsEmpty: false}, false) + assert.Equal(t, 1, fw.distinctRules(), "no new backing rule on reapply") + assert.Equal(t, 0, fw.dels, "rule must survive while any policy still authorizes it") + assert.Equal(t, 1, len(acl.peerRulesPairs)) + + // Drop policy B too: now the content key has no authorizer and the + // single backing rule is removed exactly once. + acl.ApplyFiltering(&mgmProto.NetworkMap{FirewallRules: nil, FirewallRulesIsEmpty: true}, false) + assert.Equal(t, 1, fw.dels, "rule removed once when last policy is gone") + assert.Equal(t, 0, len(acl.peerRulesPairs)) +} diff --git a/client/internal/acl/grouping_test.go b/client/internal/acl/grouping_test.go new file mode 100644 index 000000000..d6cf29b59 --- /dev/null +++ b/client/internal/acl/grouping_test.go @@ -0,0 +1,318 @@ +package acl + +import ( + "errors" + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/netbirdio/netbird/client/firewall" + fwmgr "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/internal/acl/mocks" + mgmProto "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/shared/netiputil" +) + +// TestGroupPeerRulesPolicyIDSeparates verifies that two FirewallRules +// with identical selectors but different PolicyIDs do NOT get merged +// into one group, so each policy's sources merge under its own +// attribution id. (Identical-content groups may still dedup to one +// backing rule at the backend; see TestDuplicateContentPoliciesShareOneRule.) +func TestGroupPeerRulesPolicyIDSeparates(t *testing.T) { + rules := []*mgmProto.FirewallRule{ + { + PolicyID: []byte("policy-A"), + PeerIP: "10.0.0.1", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "443", + }, + { + PolicyID: []byte("policy-B"), + PeerIP: "10.0.0.1", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "443", + }, + } + + groups, denyErr, err := groupPeerRules(rules) + require.NoError(t, denyErr) + require.NoError(t, err) + require.Len(t, groups, 2, "rules with different PolicyIDs must produce separate groups") +} + +// TestGroupPeerRulesFamilySeparates verifies that v4 and v6 rules +// belonging to the same policy don't merge. +func TestGroupPeerRulesFamilySeparates(t *testing.T) { + rules := []*mgmProto.FirewallRule{ + { + PolicyID: []byte("policy-A"), + PeerIP: "10.0.0.1", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "443", + }, + { + PolicyID: []byte("policy-A"), + PeerIP: "2001:db8::1", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "443", + }, + } + + groups, denyErr, err := groupPeerRules(rules) + require.NoError(t, denyErr) + require.NoError(t, err) + require.Len(t, groups, 2, "rules of different families must produce separate groups") + + var sawV4, sawV6 bool + for _, g := range groups { + require.Len(t, g.sources, 1) + if g.sources[0].Addr().Is4() { + sawV4 = true + } + if g.sources[0].Addr().Is6() { + sawV6 = true + } + } + assert.True(t, sawV4 && sawV6) +} + +// TestGroupPeerRulesSplitsMixedFamilySingleRule verifies that a single +// FirewallRule carrying both v4 and v6 source prefixes is split into one +// group per family. Each backend keys a rule to a single family, so a +// group whose sources span families would mismatch the other family's +// sources. mgmt normally emits one rule per family; this guards against +// a mixed-family rule slipping through. +func TestGroupPeerRulesSplitsMixedFamilySingleRule(t *testing.T) { + srcs := [][]byte{ + netiputil.EncodeAddr(netip.MustParseAddr("10.0.0.1")), + netiputil.EncodeAddr(netip.MustParseAddr("2001:db8::1")), + netiputil.EncodeAddr(netip.MustParseAddr("10.0.0.2")), + netiputil.EncodeAddr(netip.MustParseAddr("2001:db8::2")), + } + rules := []*mgmProto.FirewallRule{ + { + PolicyID: []byte("policy-A"), + SourcePrefixes: srcs, + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "443", + }, + } + + groups, denyErr, err := groupPeerRules(rules) + require.NoError(t, denyErr) + require.NoError(t, err) + require.Len(t, groups, 2, "mixed-family sources in one rule must split into two groups") + + for _, g := range groups { + require.Len(t, g.sources, 2) + v6 := prefixIsV6(g.sources[0]) + for _, s := range g.sources { + assert.Equal(t, v6, prefixIsV6(s), "every source in a group must share one family") + } + } +} + +// TestGroupPeerRulesMergesSameSelector verifies that rules sharing +// every distinguishing field (policy, family, direction, action, +// proto, port) collapse into a single multi-source group. +func TestGroupPeerRulesMergesSameSelector(t *testing.T) { + mk := func(peerIP string) *mgmProto.FirewallRule { + return &mgmProto.FirewallRule{ + PolicyID: []byte("policy-A"), + PeerIP: peerIP, //nolint:staticcheck + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "443", + } + } + rules := []*mgmProto.FirewallRule{mk("10.0.0.1"), mk("10.0.0.2"), mk("10.0.0.3")} + + groups, denyErr, err := groupPeerRules(rules) + require.NoError(t, denyErr) + require.NoError(t, err) + require.Len(t, groups, 1) + require.Len(t, groups[0].sources, 3) +} + +// TestGroupPeerRulesPortSeparates verifies that PortInfo is part of the +// selector key: rules differing only in port must not merge, and a +// single port must not merge with a range. A regression dropping the +// port from the key would collapse rules for different ports into one. +func TestGroupPeerRulesPortSeparates(t *testing.T) { + mkPort := func(peerIP string, port uint32) *mgmProto.FirewallRule { + return &mgmProto.FirewallRule{ + PolicyID: []byte("policy-A"), + PeerIP: peerIP, //nolint:staticcheck + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + PortInfo: &mgmProto.PortInfo{PortSelection: &mgmProto.PortInfo_Port{Port: port}}, + } + } + + groups, denyErr, err := groupPeerRules([]*mgmProto.FirewallRule{ + mkPort("10.0.0.1", 80), mkPort("10.0.0.2", 80), mkPort("10.0.0.3", 443), + }) + require.NoError(t, denyErr) + require.NoError(t, err) + require.Len(t, groups, 2, "rules on different ports must not merge") + + rangeRule := &mgmProto.FirewallRule{ + PolicyID: []byte("policy-A"), + PeerIP: "10.0.0.4", //nolint:staticcheck + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + PortInfo: &mgmProto.PortInfo{PortSelection: &mgmProto.PortInfo_Range_{Range: &mgmProto.PortInfo_Range{Start: 80, End: 90}}}, + } + groups, denyErr, err = groupPeerRules([]*mgmProto.FirewallRule{mkPort("10.0.0.1", 80), rangeRule}) + require.NoError(t, denyErr) + require.NoError(t, err) + require.Len(t, groups, 2, "a single port and a range must not merge") +} + +// TestGroupPeerRulesUsesSourcePrefixesWhenPresent verifies that the +// new sourcePrefixes wire field is consumed and produces a +// multi-source group in one shot (no client-side merging needed). +func TestGroupPeerRulesUsesSourcePrefixesWhenPresent(t *testing.T) { + srcs := [][]byte{ + netiputil.EncodeAddr(netip.MustParseAddr("10.0.0.1")), + netiputil.EncodeAddr(netip.MustParseAddr("10.0.0.2")), + netiputil.EncodeAddr(netip.MustParseAddr("10.0.0.3")), + } + rules := []*mgmProto.FirewallRule{ + { + PolicyID: []byte("policy-A"), + SourcePrefixes: srcs, + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "443", + }, + } + + groups, denyErr, err := groupPeerRules(rules) + require.NoError(t, denyErr) + require.NoError(t, err) + require.Len(t, groups, 1) + require.Len(t, groups[0].sources, 3) +} + +// TestGroupPeerRulesActionSeparates verifies the obvious: accept +// and drop rules with the same selector don't merge. +func TestGroupPeerRulesActionSeparates(t *testing.T) { + rules := []*mgmProto.FirewallRule{ + { + PolicyID: []byte("policy-A"), + PeerIP: "10.0.0.1", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "443", + }, + { + PolicyID: []byte("policy-A"), + PeerIP: "10.0.0.1", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_DROP, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "443", + }, + } + + groups, denyErr, err := groupPeerRules(rules) + require.NoError(t, denyErr) + require.NoError(t, err) + require.Len(t, groups, 2) +} + +// failingDeleteFirewall wraps a real firewall.Manager and forces the +// next N DeleteFilterRule calls to fail. Used to verify that the acl +// manager retains rules whose deletion was rejected by the backend, +// so they get retried on the next ApplyFiltering pass instead of +// becoming orphans. +type failingDeleteFirewall struct { + fwmgr.Manager + failCount int +} + +func (f *failingDeleteFirewall) DeleteFilterRule(r fwmgr.Rule) error { + if f.failCount > 0 { + f.failCount-- + return errors.New("simulated delete failure") + } + return f.Manager.DeleteFilterRule(r) +} + +// TestApplyFilteringRetainsRulesOnDeleteFailure verifies that a +// transient DeleteFilterRule error doesn't make the acl manager +// forget about a rule. The rule must remain in peerRulesPairs so the +// next ApplyFiltering pass attempts the delete again. +func TestApplyFilteringRetainsRulesOnDeleteFailure(t *testing.T) { + t.Setenv("NB_WG_KERNEL_DISABLED", "true") + t.Setenv(firewall.EnvForceUserspaceFirewall, "true") + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ifaceMock := mocks.NewMockIFaceMapper(ctrl) + ifaceMock.EXPECT().IsUserspaceBind().Return(true).AnyTimes() + ifaceMock.EXPECT().SetFilter(gomock.Any()) + network := netip.MustParsePrefix("172.0.0.1/32") + ifaceMock.EXPECT().Name().Return("lo").AnyTimes() + ifaceMock.EXPECT().Address().Return(wgaddr.Address{IP: network.Addr(), Network: network}).AnyTimes() + ifaceMock.EXPECT().GetWGDevice().Return(nil).AnyTimes() + + realFW, err := firewall.NewFirewall(ifaceMock, nil, flowLogger, false, iface.DefaultMTU) + require.NoError(t, err) + defer func() { require.NoError(t, realFW.Close(nil)) }() + + fw := &failingDeleteFirewall{Manager: realFW} + acl := NewDefaultManager(fw) + + // First pass: install a rule. + netmap1 := &mgmProto.NetworkMap{ + FirewallRules: []*mgmProto.FirewallRule{ + { + PolicyID: []byte("policy-A"), + PeerIP: "10.0.0.1", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_DROP, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "22", + }, + }, + FirewallRulesIsEmpty: false, + } + acl.ApplyFiltering(netmap1, false) + require.Equal(t, 1, len(acl.peerRulesPairs), "rule should be installed") + + // Second pass: remove the rule from the map. The backend will + // fail the delete; the acl manager must retain the rule. + fw.failCount = 1 + netmap2 := &mgmProto.NetworkMap{FirewallRules: nil, FirewallRulesIsEmpty: true} + acl.ApplyFiltering(netmap2, false) + require.Equal(t, 1, len(acl.peerRulesPairs), + "rule must be retained when DeleteFilterRule fails so it gets retried") + + // Third pass: same map, backend no longer fails. The rule + // should now succeed in being removed. + acl.ApplyFiltering(netmap2, false) + require.Equal(t, 0, len(acl.peerRulesPairs), "retry should succeed") +} diff --git a/client/internal/acl/id/id.go b/client/internal/acl/id/id.go index 23451453e..952403bbd 100644 --- a/client/internal/acl/id/id.go +++ b/client/internal/acl/id/id.go @@ -5,18 +5,18 @@ import ( "encoding/hex" "fmt" "net/netip" + "slices" "strconv" "github.com/netbirdio/netbird/client/firewall/manager" ) -type RuleID string +// RuleID aliases manager.RuleID so existing nbid.RuleID references +// keep working while the canonical type lives in the firewall package. +type RuleID = manager.RuleID -func (r RuleID) ID() string { - return string(r) -} - -func GenerateRouteRuleKey( +// GenerateRuleID returns a deterministic content hash identifying a filter rule. +func GenerateRuleID( sources []netip.Prefix, destination manager.Network, proto manager.Protocol, @@ -24,6 +24,7 @@ func GenerateRouteRuleKey( dPort *manager.Port, action manager.Action, ) RuleID { + sources = slices.Clone(sources) manager.SortPrefixes(sources) h := sha256.New() diff --git a/client/internal/acl/legacy_fallback_test.go b/client/internal/acl/legacy_fallback_test.go new file mode 100644 index 000000000..00ca013f3 --- /dev/null +++ b/client/internal/acl/legacy_fallback_test.go @@ -0,0 +1,75 @@ +package acl + +import ( + "net/netip" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/netbirdio/netbird/client/firewall" + fwmgr "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/internal/acl/mocks" + mgmProto "github.com/netbirdio/netbird/shared/management/proto" +) + +// sourcesRecordingFirewall wraps a real firewall.Manager and records +// the source prefixes of every AddFilterRule call. +type sourcesRecordingFirewall struct { + fwmgr.Manager + mu sync.Mutex + sources [][]netip.Prefix +} + +func (f *sourcesRecordingFirewall) AddFilterRule(id []byte, sources []netip.Prefix, destination fwmgr.Network, proto fwmgr.Protocol, sPort, dPort *fwmgr.Port, action fwmgr.Action) (fwmgr.Rule, error) { + f.mu.Lock() + f.sources = append(f.sources, sources) + f.mu.Unlock() + return f.Manager.AddFilterRule(id, sources, destination, proto, sPort, dPort, action) +} + +// TestLegacyManagementFallbackUsesMatchAnySources verifies the +// allow-all fallback for old management servers (empty FirewallRules +// without the FirewallRulesIsEmpty flag) reaches the firewall as /0 +// match-any sources. The fallback rule carries PeerIP 0.0.0.0; if that +// were converted to a host prefix (0.0.0.0/32) it would match nothing +// and all peer traffic would be dropped. +func TestLegacyManagementFallbackUsesMatchAnySources(t *testing.T) { + t.Setenv("NB_WG_KERNEL_DISABLED", "true") + t.Setenv(firewall.EnvForceUserspaceFirewall, "true") + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ifaceMock := mocks.NewMockIFaceMapper(ctrl) + ifaceMock.EXPECT().IsUserspaceBind().Return(true).AnyTimes() + ifaceMock.EXPECT().SetFilter(gomock.Any()) + network := netip.MustParsePrefix("172.0.0.1/32") + ifaceMock.EXPECT().Name().Return("lo").AnyTimes() + ifaceMock.EXPECT().Address().Return(wgaddr.Address{IP: network.Addr(), Network: network}).AnyTimes() + ifaceMock.EXPECT().GetWGDevice().Return(nil).AnyTimes() + + realFW, err := firewall.NewFirewall(ifaceMock, nil, flowLogger, false, iface.DefaultMTU) + require.NoError(t, err) + defer func() { require.NoError(t, realFW.Close(nil)) }() + + fw := &sourcesRecordingFirewall{Manager: realFW} + acl := NewDefaultManager(fw) + + // Old management: no rules and no FirewallRulesIsEmpty flag. + acl.ApplyFiltering(&mgmProto.NetworkMap{FirewallRules: nil, FirewallRulesIsEmpty: false}, false) + + fw.mu.Lock() + defer fw.mu.Unlock() + require.NotEmpty(t, fw.sources, "legacy fallback must install at least one allow-all rule") + for _, sources := range fw.sources { + require.NotEmpty(t, sources) + for _, p := range sources { + assert.Equal(t, 0, p.Bits(), "legacy fallback source %s must be a /0 match-any prefix", p) + } + } +} diff --git a/client/internal/acl/manager.go b/client/internal/acl/manager.go index dd6f9479a..6c544b345 100644 --- a/client/internal/acl/manager.go +++ b/client/internal/acl/manager.go @@ -1,17 +1,15 @@ package acl import ( - "crypto/md5" - "encoding/hex" "errors" "fmt" - "net" "net/netip" "strconv" "sync" "time" "github.com/hashicorp/go-multierror" + "github.com/mitchellh/hashstructure/v2" log "github.com/sirupsen/logrus" nberrors "github.com/netbirdio/netbird/client/errors" @@ -19,10 +17,15 @@ import ( "github.com/netbirdio/netbird/client/internal/acl/id" "github.com/netbirdio/netbird/shared/management/domain" mgmProto "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/shared/netiputil" ) var ErrSourceRangesEmpty = errors.New("sources range is empty") +// ErrNoRuleReturned is returned when the firewall backend reports success +// from AddFilterRule but yields no rule to track. +var ErrNoRuleReturned = errors.New("backend returned no rule") + // Manager is a ACL rules manager type Manager interface { ApplyFiltering(networkMap *mgmProto.NetworkMap, dnsRouteFeatureFlag bool) @@ -30,18 +33,49 @@ type Manager interface { // DefaultManager uses firewall manager to handle type DefaultManager struct { - firewall firewall.Manager - ipsetCounter int - peerRulesPairs map[id.RuleID][]firewall.Rule - routeRules map[id.RuleID]struct{} - mutex sync.Mutex + firewall firewall.Manager + peerRulesPairs map[id.RuleID][]firewall.Rule + routeRules map[id.RuleID]firewall.Rule + previousConfigHash uint64 + hasAppliedConfig bool + mutex sync.Mutex +} + +// peerRuleGroup collapses a set of single-source FirewallRules sharing +// the same selector into one multi-source rule to push to the backend. +type peerRuleGroup struct { + direction mgmProto.RuleDirection + action mgmProto.RuleAction + protocol mgmProto.RuleProtocol + port *mgmProto.PortInfo + // legacyPort is used only when PortInfo is empty (old management). + legacyPort string + policyID []byte + sources []netip.Prefix +} + +// peerRuleKey is the comparable selector that decides which single-source +// rules merge into one group. Rules with an equal key collapse into one +// multi-source backend rule. PortInfo is flattened into its scalar fields +// so the key compares by value; policyID keeps policies separate so two +// policies authorizing different peers don't merge under one attribution. +type peerRuleKey struct { + v6 bool + policyID string + direction mgmProto.RuleDirection + action mgmProto.RuleAction + protocol mgmProto.RuleProtocol + legacyPort string + port uint16 + rangeStart uint16 + rangeEnd uint16 } func NewDefaultManager(fm firewall.Manager) *DefaultManager { return &DefaultManager{ firewall: fm, peerRulesPairs: make(map[id.RuleID][]firewall.Rule), - routeRules: make(map[id.RuleID]struct{}), + routeRules: make(map[id.RuleID]firewall.Rule), } } @@ -57,6 +91,23 @@ func (d *DefaultManager) ApplyFiltering(networkMap *mgmProto.NetworkMap, dnsRout return } + // Skip the full rebuild + flush when the inputs that drive the firewall + // state are byte-for-byte identical to the last successfully applied + // update. Management re-sends the same network map far more often than it + // actually changes (account-wide updates, peer meta churn), and rebuilding + // every peer/route ACL and flushing the firewall on every such sync is the + // dominant client-side cost when nothing changed. Mirrors the same guard the + // DNS server already uses (previousConfigHash). Only the fields ApplyFiltering + // consumes participate in the hash, so an unrelated map change cannot mask a + // real ACL change. + hash, err := d.firewallConfigHash(networkMap, dnsRouteFeatureFlag) + if err != nil { + log.Errorf("unable to hash firewall configuration, applying unconditionally: %v", err) + } else if d.hasAppliedConfig && d.previousConfigHash == hash { + log.Debugf("not applying the firewall configuration update as there is nothing new (hash: %d)", hash) + return + } + start := time.Now() defer func() { total := 0 @@ -68,18 +119,57 @@ func (d *DefaultManager) ApplyFiltering(networkMap *mgmProto.NetworkMap, dnsRout time.Since(start), total) }() - d.applyPeerACLs(networkMap) - - if err := d.applyRouteACLs(networkMap.RoutesFirewallRules, dnsRouteFeatureFlag); err != nil { - log.Errorf("Failed to apply route ACLs: %v", err) + peerErr := d.applyPeerACLs(networkMap) + if peerErr != nil { + log.Errorf("apply peer ACLs: %v", peerErr) } - if err := d.firewall.Flush(); err != nil { - log.Error("failed to flush firewall rules: ", err) + routeErr := d.applyRouteACLs(networkMap.RoutesFirewallRules, dnsRouteFeatureFlag) + if routeErr != nil { + log.Errorf("apply route ACLs: %v", routeErr) + } + + flushErr := d.firewall.Flush() + if flushErr != nil { + log.Error("failed to flush firewall rules: ", flushErr) + } + + // Only remember the hash once the firewall actually reflects this config. + // If applying or flushing failed, leave the previous hash untouched so the + // next (possibly identical) update is not skipped and gets a chance to + // reconcile the firewall state. + if err == nil && peerErr == nil && routeErr == nil && flushErr == nil { + d.previousConfigHash = hash + d.hasAppliedConfig = true + } else { + d.hasAppliedConfig = false } } -func (d *DefaultManager) applyPeerACLs(networkMap *mgmProto.NetworkMap) { +// firewallConfigHash hashes exactly the inputs ApplyFiltering uses to build the +// firewall state, so an identical hash means an identical resulting ruleset. +func (d *DefaultManager) firewallConfigHash(networkMap *mgmProto.NetworkMap, dnsRouteFeatureFlag bool) (uint64, error) { + return hashstructure.Hash(struct { + PeerRules []*mgmProto.FirewallRule + PeerRulesIsEmpty bool + RouteRules []*mgmProto.RouteFirewallRule + RouteRulesIsEmpty bool + DNSRouteFeatureFlag bool + }{ + PeerRules: networkMap.GetFirewallRules(), + PeerRulesIsEmpty: networkMap.GetFirewallRulesIsEmpty(), + RouteRules: networkMap.GetRoutesFirewallRules(), + RouteRulesIsEmpty: networkMap.GetRoutesFirewallRulesIsEmpty(), + DNSRouteFeatureFlag: dnsRouteFeatureFlag, + }, hashstructure.FormatV2, &hashstructure.HashOptions{ + ZeroNil: true, + IgnoreZeroValue: true, + SlicesAsSets: true, + UseStringer: true, + }) +} + +func (d *DefaultManager) applyPeerACLs(networkMap *mgmProto.NetworkMap) error { rules := networkMap.FirewallRules // if we got empty rules list but management not set networkMap.FirewallRulesIsEmpty flag @@ -88,13 +178,13 @@ func (d *DefaultManager) applyPeerACLs(networkMap *mgmProto.NetworkMap) { log.Warn("this peer is connected to a NetBird Management service with an older version. Allowing all traffic from connected peers") rules = append(rules, &mgmProto.FirewallRule{ - PeerIP: "0.0.0.0", + PeerIP: "0.0.0.0", //nolint:staticcheck Direction: mgmProto.RuleDirection_IN, Action: mgmProto.RuleAction_ACCEPT, Protocol: mgmProto.RuleProtocol_ALL, }, &mgmProto.FirewallRule{ - PeerIP: "0.0.0.0", + PeerIP: "0.0.0.0", //nolint:staticcheck Direction: mgmProto.RuleDirection_OUT, Action: mgmProto.RuleAction_ACCEPT, Protocol: mgmProto.RuleProtocol_ALL, @@ -102,52 +192,167 @@ func (d *DefaultManager) applyPeerACLs(networkMap *mgmProto.NetworkMap) { ) } - newRulePairs := make(map[id.RuleID][]firewall.Rule) - ipsetByRuleSelectors := make(map[string]string) - - for _, r := range rules { - // if this rule is member of rule selection with more than DefaultIPsCountForSet - // it's IP address can be used in the ipset for firewall manager which supports it - selector := d.getRuleGroupingSelector(r) - ipsetName, ok := ipsetByRuleSelectors[selector] - if !ok { - d.ipsetCounter++ - ipsetName = fmt.Sprintf("nb%07d", d.ipsetCounter) - ipsetByRuleSelectors[selector] = ipsetName - } - pairID, rulePair, err := d.protoRuleToFirewallRule(r, ipsetName) - if err != nil { - log.Errorf("failed to apply firewall rule: %+v, %v", r, err) - d.rollBack(newRulePairs) - break - } - if len(rulePair) > 0 { - d.peerRulesPairs[pairID] = rulePair - newRulePairs[pairID] = rulePair - } + // Group incoming single-source rules from management by their + // (direction, action, proto, port) selector and merge sources. + // One call to the firewall backend per merged rule. + // A deny we cannot decode would leave its traffic unblocked, so skip + // the whole pass and keep existing rules until the next sync. + groups, denyErr, err := groupPeerRules(rules) + if denyErr != nil { + return fmt.Errorf("decode deny rule sources: %w", denyErr) } + newRulePairs := make(map[id.RuleID][]firewall.Rule) + var merr *multierror.Error + if err != nil { + merr = multierror.Append(merr, err) + } + + // Apply denies first. A deny that fails to install is a security + // failure (fail-open), so if any deny errors we roll back the + // denies we already installed in this pass and bail out without + // installing any accept. Pre-existing rules stay untouched until + // the next successful pass clears them. + denies, accepts := splitDenyAccept(groups) + if err := d.installPeerGroups(denies, newRulePairs, true); err != nil { + return fmt.Errorf("install deny rules: %w", err) + } + + if err := d.installPeerGroups(accepts, newRulePairs, false); err != nil { + merr = multierror.Append(merr, err) + } + + // Tear down rules that disappeared from the networkmap. Any rule + // the backend refuses to delete stays in our tracking so it gets + // retried on the next ApplyFiltering. Otherwise a transient + // delete failure would leak the rule in the firewall until the + // process exits. for pairID, rules := range d.peerRulesPairs { - if _, ok := newRulePairs[pairID]; !ok { - for _, rule := range rules { - if err := d.firewall.DeletePeerRule(rule); err != nil { - log.Errorf("failed to delete peer firewall rule: %v", err) - continue - } + if _, ok := newRulePairs[pairID]; ok { + continue + } + var remaining []firewall.Rule + for _, rule := range rules { + if err := d.firewall.DeleteFilterRule(rule); err != nil { + merr = multierror.Append(merr, fmt.Errorf("delete peer rule, will retry: %w", err)) + remaining = append(remaining, rule) } - delete(d.peerRulesPairs, pairID) + } + if len(remaining) > 0 { + newRulePairs[pairID] = remaining } } d.peerRulesPairs = newRulePairs + + return nberrors.FormatErrorOrNil(merr) +} + +// installPeerGroups applies each group and records the resulting rule +// pairs in newRulePairs. With atomic set (deny rules), a single failure +// rolls back every rule installed in this call and returns, leaving the +// firewall exactly as before: denies are fail-closed and must be applied +// all-or-nothing. With atomic unset (accept rules), failures are +// accumulated and the remaining groups still install, so one malformed +// allow cannot drop every other legitimate allow in the pass. +func (d *DefaultManager) installPeerGroups(groups []*peerRuleGroup, newRulePairs map[id.RuleID][]firewall.Rule, atomic bool) error { + var freshlyInstalled []id.RuleID + var merr *multierror.Error + for _, g := range groups { + pairID, rulePair, err := d.applyPeerGroup(g) + if err != nil { + if atomic { + d.rollbackInstalled(freshlyInstalled) + return fmt.Errorf("apply firewall rule: %w", err) + } + merr = multierror.Append(merr, fmt.Errorf("apply firewall rule: %w", err)) + continue + } + if len(rulePair) == 0 { + continue + } + if _, existed := d.peerRulesPairs[pairID]; !existed { + freshlyInstalled = append(freshlyInstalled, pairID) + } + d.peerRulesPairs[pairID] = rulePair + newRulePairs[pairID] = rulePair + } + return nberrors.FormatErrorOrNil(merr) +} + +func (d *DefaultManager) rollbackInstalled(pairIDs []id.RuleID) { + var merr *multierror.Error + for _, pairID := range pairIDs { + // Keep any rule the backend refuses to delete tracked so it is + // retried on the next ApplyFiltering instead of leaking in the + // firewall with no tracking left to remove it. + var remaining []firewall.Rule + for _, rule := range d.peerRulesPairs[pairID] { + if err := d.firewall.DeleteFilterRule(rule); err != nil { + merr = multierror.Append(merr, fmt.Errorf("rule %s: %w", pairID, err)) + remaining = append(remaining, rule) + } + } + if len(remaining) > 0 { + d.peerRulesPairs[pairID] = remaining + } else { + delete(d.peerRulesPairs, pairID) + } + } + if err := nberrors.FormatErrorOrNil(merr); err != nil { + log.Errorf("rollback peer rules: %v", err) + } +} + +func (d *DefaultManager) applyPeerGroup(g *peerRuleGroup) (id.RuleID, []firewall.Rule, error) { + protocol, err := ConvertToFirewallProtocol(g.protocol) + if err != nil { + return "", nil, fmt.Errorf("skipping firewall rule: %w", err) + } + action, err := convertFirewallAction(g.action) + if err != nil { + return "", nil, fmt.Errorf("skipping firewall rule: %w", err) + } + port, err := resolveGroupPort(g) + if err != nil { + return "", nil, err + } + + var fwRule firewall.Rule + switch g.direction { + case mgmProto.RuleDirection_IN: + fwRule, err = d.firewall.AddFilterRule(g.policyID, g.sources, firewall.Network{}, protocol, nil, port, action) + case mgmProto.RuleDirection_OUT: + if d.firewall.IsStateful() { + return "", nil, nil + } + if shouldSkipInvertedRule(protocol, port) { + return "", nil, nil + } + fwRule, err = d.firewall.AddFilterRule(g.policyID, g.sources, firewall.Network{}, protocol, port, nil, action) + default: + return "", nil, errors.New("invalid direction") + } + + if err != nil { + return "", nil, fmt.Errorf("add firewall rule: %w", err) + } + if fwRule == nil { + return "", nil, fmt.Errorf("add firewall rule: %w", ErrNoRuleReturned) + } + + // Derive the pair id from the backend rule, like the route path: + // the backend dedups identical content, so two policies authorizing + // the same flow resolve to the same id and a single backing rule. + return fwRule.ID(), []firewall.Rule{fwRule}, nil } func (d *DefaultManager) applyRouteACLs(rules []*mgmProto.RouteFirewallRule, dynamicResolver bool) error { - newRouteRules := make(map[id.RuleID]struct{}, len(rules)) + newRouteRules := make(map[id.RuleID]firewall.Rule, len(rules)) var merr *multierror.Error - // Apply new rules - firewall manager will return existing rule ID if already present + // Apply new rules - firewall manager will return the existing rule if already present for _, rule := range rules { - id, err := d.applyRouteACL(rule, dynamicResolver) + addedRule, err := d.applyRouteACL(rule, dynamicResolver) if err != nil { if errors.Is(err, ErrSourceRangesEmpty) { log.Debugf("skipping empty sources rule with destination %s: %v", rule.Destination, err) @@ -156,16 +361,18 @@ func (d *DefaultManager) applyRouteACLs(rules []*mgmProto.RouteFirewallRule, dyn } continue } - newRouteRules[id] = struct{}{} + newRouteRules[addedRule.ID()] = addedRule } - // Clean up old firewall rules - for id := range d.routeRules { - if _, exists := newRouteRules[id]; !exists { - if err := d.firewall.DeleteRouteRule(id); err != nil { - merr = multierror.Append(merr, fmt.Errorf("delete route rule: %w", err)) - } - // implicitly deleted from the map + // Tear down old route rules; retain ones the backend refused so a + // transient failure doesn't leave orphaned rules in the firewall. + for ruleID, rule := range d.routeRules { + if _, exists := newRouteRules[ruleID]; exists { + continue + } + if err := d.firewall.DeleteFilterRule(rule); err != nil { + merr = multierror.Append(merr, fmt.Errorf("delete route rule, will retry: %w", err)) + newRouteRules[ruleID] = rule } } @@ -173,102 +380,202 @@ func (d *DefaultManager) applyRouteACLs(rules []*mgmProto.RouteFirewallRule, dyn return nberrors.FormatErrorOrNil(merr) } -func (d *DefaultManager) applyRouteACL(rule *mgmProto.RouteFirewallRule, dynamicResolver bool) (id.RuleID, error) { +func (d *DefaultManager) applyRouteACL(rule *mgmProto.RouteFirewallRule, dynamicResolver bool) (firewall.Rule, error) { if len(rule.SourceRanges) == 0 { - return "", ErrSourceRangesEmpty + return nil, ErrSourceRangesEmpty } var sources []netip.Prefix for _, sourceRange := range rule.SourceRanges { source, err := netip.ParsePrefix(sourceRange) if err != nil { - return "", fmt.Errorf("parse source range: %w", err) + return nil, fmt.Errorf("parse source range: %w", err) } - sources = append(sources, source) + sources = append(sources, firewall.UnmapPrefix(source)) } destination, err := determineDestination(rule, dynamicResolver, sources) if err != nil { - return "", fmt.Errorf("determine destination: %w", err) + return nil, fmt.Errorf("determine destination: %w", err) } - protocol, err := convertToFirewallProtocol(rule.Protocol) + protocol, err := ConvertToFirewallProtocol(rule.Protocol) if err != nil { - return "", fmt.Errorf("invalid protocol: %w", err) + return nil, fmt.Errorf("invalid protocol: %w", err) } action, err := convertFirewallAction(rule.Action) if err != nil { - return "", fmt.Errorf("invalid action: %w", err) + return nil, fmt.Errorf("invalid action: %w", err) } dPorts := convertPortInfo(rule.PortInfo) - addedRule, err := d.firewall.AddRouteFiltering(rule.PolicyID, sources, destination, protocol, nil, dPorts, action) + addedRule, err := d.firewall.AddFilterRule(rule.PolicyID, sources, destination, protocol, nil, dPorts, action) if err != nil { - return "", fmt.Errorf("add route rule: %w", err) + return nil, fmt.Errorf("add route rule: %w", err) + } + if addedRule == nil { + return nil, fmt.Errorf("add route rule: %w", ErrNoRuleReturned) } - return id.RuleID(addedRule.ID()), nil + return addedRule, nil } -func (d *DefaultManager) protoRuleToFirewallRule( - r *mgmProto.FirewallRule, - ipsetName string, -) (id.RuleID, []firewall.Rule, error) { - ip := net.ParseIP(r.PeerIP) - if ip == nil { - return "", nil, fmt.Errorf("invalid IP address, skipping firewall rule") +// splitDenyAccept partitions groups by action so denies can be +// applied before accepts. Order within each bucket is preserved. +func splitDenyAccept(groups []*peerRuleGroup) (denies, accepts []*peerRuleGroup) { + for _, g := range groups { + if g.action == mgmProto.RuleAction_DROP { + denies = append(denies, g) + } else { + accepts = append(accepts, g) + } + } + return denies, accepts +} + +// groupPeerRules merges single-source rules sharing a selector into +// multi-source groups. It splits source-decode failures by action: +// denyErr is non-nil when a deny rule could not be decoded, which is a +// fail-open risk the caller must treat as fatal for the pass; err +// carries the tolerable accept-rule failures the caller can log and +// continue past. +func groupPeerRules(rules []*mgmProto.FirewallRule) (groups []*peerRuleGroup, denyErr error, err error) { + var denyMerr, acceptMerr *multierror.Error + byKey := make(map[peerRuleKey]*peerRuleGroup) + order := make([]peerRuleKey, 0) + + for _, r := range rules { + srcs, decErr := extractRuleSources(r) + if decErr != nil { + if r.Action == mgmProto.RuleAction_DROP { + denyMerr = multierror.Append(denyMerr, decErr) + } else { + acceptMerr = multierror.Append(acceptMerr, decErr) + } + continue + } + // A single FirewallRule normally carries one address family, but + // split by family defensively: each backend keys a rule to one + // family and would mismatch sources of the other, so a group's + // sources must never span families. + v4, v6 := splitPrefixesByFamily(srcs) + for _, sub := range []struct { + isV6 bool + sources []netip.Prefix + }{{false, v4}, {true, v6}} { + if len(sub.sources) == 0 { + continue + } + key := ruleGroupKey(r, sub.isV6) + g, ok := byKey[key] + if !ok { + g = &peerRuleGroup{ + direction: r.Direction, + action: r.Action, + protocol: r.Protocol, + port: r.PortInfo, + legacyPort: r.Port, + policyID: r.PolicyID, + } + byKey[key] = g + order = append(order, key) + } + g.sources = append(g.sources, sub.sources...) + } } - protocol, err := convertToFirewallProtocol(r.Protocol) + out := make([]*peerRuleGroup, 0, len(order)) + for _, k := range order { + out = append(out, byKey[k]) + } + return out, nberrors.FormatErrorOrNil(denyMerr), nberrors.FormatErrorOrNil(acceptMerr) +} + +func prefixIsV6(p netip.Prefix) bool { + return p.Addr().Is6() && !p.Addr().Is4In6() +} + +// splitPrefixesByFamily partitions prefixes into IPv4 and IPv6 groups. +func splitPrefixesByFamily(prefixes []netip.Prefix) (v4, v6 []netip.Prefix) { + for _, p := range prefixes { + if prefixIsV6(p) { + v6 = append(v6, p) + } else { + v4 = append(v4, p) + } + } + return v4, v6 +} + +// ruleGroupKey builds the selector key for a rule. v6 must reflect the +// rule's source family: mgmt emits one rule per family and mixing them +// would break ICMP-variant selection in uspfilter. +func ruleGroupKey(r *mgmProto.FirewallRule, v6 bool) peerRuleKey { + k := peerRuleKey{ + v6: v6, + policyID: string(r.PolicyID), + direction: r.Direction, + action: r.Action, + protocol: r.Protocol, + legacyPort: r.Port, + } + if pi := r.PortInfo; pi != nil { + k.port = uint16(pi.GetPort()) + if rng := pi.GetRange(); rng != nil { + k.rangeStart = uint16(rng.GetStart()) + k.rangeEnd = uint16(rng.GetEnd()) + } + } + return k +} + +// extractRuleSources returns all source prefixes the rule applies to. +// New management populates sourcePrefixes; older management sets PeerIP. +func extractRuleSources(r *mgmProto.FirewallRule) ([]netip.Prefix, error) { + if len(r.SourcePrefixes) > 0 { + out := make([]netip.Prefix, 0, len(r.SourcePrefixes)) + for _, raw := range r.SourcePrefixes { + addr, err := netiputil.DecodeAddr(raw) + if err != nil { + return nil, fmt.Errorf("decode source prefix: %w", err) + } + out = append(out, netip.PrefixFrom(addr.Unmap(), addr.Unmap().BitLen())) + } + return out, nil + } + + peerIP := r.PeerIP //nolint:staticcheck // PeerIP is the legacy source field for old management servers + addr, err := netip.ParseAddr(peerIP) if err != nil { - return "", nil, fmt.Errorf("skipping firewall rule: %s", err) + return nil, fmt.Errorf("parse peer IP %q: %w", peerIP, err) } - - action, err := convertFirewallAction(r.Action) - if err != nil { - return "", nil, fmt.Errorf("skipping firewall rule: %s", err) + addr = addr.Unmap() + // An unspecified PeerIP means "any peer" (legacy management + // allow-all fallback); only a /0 prefix matches any source in the + // backends, a full-length prefix would match nothing. + if addr.IsUnspecified() { + return []netip.Prefix{netip.PrefixFrom(addr, 0)}, nil } + return []netip.Prefix{netip.PrefixFrom(addr, addr.BitLen())}, nil +} - var port *firewall.Port - if !portInfoEmpty(r.PortInfo) { - port = convertPortInfo(r.PortInfo) - } else if r.Port != "" { - // old version of management, single port - value, err := strconv.Atoi(r.Port) +func resolveGroupPort(g *peerRuleGroup) (*firewall.Port, error) { + if !portInfoEmpty(g.port) { + return convertPortInfo(g.port), nil + } + if g.legacyPort != "" { + value, err := strconv.ParseUint(g.legacyPort, 10, 16) if err != nil { - return "", nil, fmt.Errorf("invalid port: %w", err) + return nil, fmt.Errorf("invalid port: %w", err) } - port = &firewall.Port{ + return &firewall.Port{ Values: []uint16{uint16(value)}, - } + }, nil } - - ruleID := d.getPeerRuleID(ip, protocol, int(r.Direction), port, action) - if rulesPair, ok := d.peerRulesPairs[ruleID]; ok { - return ruleID, rulesPair, nil - } - - var rules []firewall.Rule - switch r.Direction { - case mgmProto.RuleDirection_IN: - rules, err = d.addInRules(r.PolicyID, ip, protocol, port, action, ipsetName) - case mgmProto.RuleDirection_OUT: - if d.firewall.IsStateful() { - return "", nil, nil - } - // return traffic for outbound connections if firewall is stateless - rules, err = d.addOutRules(r.PolicyID, ip, protocol, port, action, ipsetName) - default: - return "", nil, fmt.Errorf("invalid direction, skipping firewall rule") - } - - if err != nil { - return "", nil, err - } - - return ruleID, rules, nil + // nolint:nilnil // a nil port legitimately means "no port restriction" + return nil, nil } func portInfoEmpty(portInfo *mgmProto.PortInfo) bool { @@ -287,75 +594,9 @@ func portInfoEmpty(portInfo *mgmProto.PortInfo) bool { } } -func (d *DefaultManager) addInRules( - id []byte, - ip net.IP, - protocol firewall.Protocol, - port *firewall.Port, - action firewall.Action, - ipsetName string, -) ([]firewall.Rule, error) { - rule, err := d.firewall.AddPeerFiltering(id, ip, protocol, nil, port, action, ipsetName) - if err != nil { - return nil, fmt.Errorf("add firewall rule: %w", err) - } - - return rule, nil -} - -func (d *DefaultManager) addOutRules( - id []byte, - ip net.IP, - protocol firewall.Protocol, - port *firewall.Port, - action firewall.Action, - ipsetName string, -) ([]firewall.Rule, error) { - if shouldSkipInvertedRule(protocol, port) { - return nil, nil - } - - rule, err := d.firewall.AddPeerFiltering(id, ip, protocol, port, nil, action, ipsetName) - if err != nil { - return nil, fmt.Errorf("add firewall rule: %w", err) - } - - return rule, nil -} - -// getPeerRuleID() returns unique ID for the rule based on its parameters. -func (d *DefaultManager) getPeerRuleID( - ip net.IP, - proto firewall.Protocol, - direction int, - port *firewall.Port, - action firewall.Action, -) id.RuleID { - idStr := ip.String() + string(proto) + strconv.Itoa(direction) + strconv.Itoa(int(action)) - if port != nil { - idStr += port.String() - } - - return id.RuleID(hex.EncodeToString(md5.New().Sum([]byte(idStr)))) -} - -// getRuleGroupingSelector takes all rule properties except IP address to build selector -func (d *DefaultManager) getRuleGroupingSelector(rule *mgmProto.FirewallRule) string { - return fmt.Sprintf("%v:%v:%v:%s:%v", strconv.Itoa(int(rule.Direction)), rule.Action, rule.Protocol, rule.Port, rule.PortInfo) -} - -func (d *DefaultManager) rollBack(newRulePairs map[id.RuleID][]firewall.Rule) { - log.Debugf("rollback ACL to previous state") - for _, rules := range newRulePairs { - for _, rule := range rules { - if err := d.firewall.DeletePeerRule(rule); err != nil { - log.Errorf("failed to delete new firewall rule (id: %v) during rollback: %v", rule.ID(), err) - } - } - } -} - -func convertToFirewallProtocol(protocol mgmProto.RuleProtocol) (firewall.Protocol, error) { +// ConvertToFirewallProtocol maps a management rule protocol to the +// firewall protocol type. +func ConvertToFirewallProtocol(protocol mgmProto.RuleProtocol) (firewall.Protocol, error) { switch protocol { case mgmProto.RuleProtocol_TCP: return firewall.ProtocolTCP, nil diff --git a/client/internal/acl/manager_test.go b/client/internal/acl/manager_test.go index 408ed992f..19bef9058 100644 --- a/client/internal/acl/manager_test.go +++ b/client/internal/acl/manager_test.go @@ -1,14 +1,16 @@ package acl import ( + "fmt" "net/netip" "testing" - "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "github.com/netbirdio/netbird/client/firewall" + fwmanager "github.com/netbirdio/netbird/client/firewall/manager" "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/internal/acl/mocks" @@ -76,9 +78,9 @@ func TestDefaultManager(t *testing.T) { }) t.Run("add extra rules", func(t *testing.T) { - existedPairs := map[string]struct{}{} + existedPairs := map[fwmanager.RuleID]struct{}{} for id := range acl.peerRulesPairs { - existedPairs[id.ID()] = struct{}{} + existedPairs[id] = struct{}{} } // remove first rule @@ -86,7 +88,7 @@ func TestDefaultManager(t *testing.T) { networkMap.FirewallRules = append( networkMap.FirewallRules, &mgmProto.FirewallRule{ - PeerIP: "10.93.0.3", + PeerIP: "10.93.0.3", //nolint:staticcheck Direction: mgmProto.RuleDirection_IN, Action: mgmProto.RuleAction_DROP, Protocol: mgmProto.RuleProtocol_ICMP, @@ -105,7 +107,7 @@ func TestDefaultManager(t *testing.T) { // check that old rule was removed previousCount := 0 for id := range acl.peerRulesPairs { - if _, ok := existedPairs[id.ID()]; ok { + if _, ok := existedPairs[id]; ok { previousCount++ } } @@ -485,3 +487,149 @@ func TestPortInfoEmpty(t *testing.T) { }) } } + +// TestApplyFilteringSkipsUnchangedConfig verifies that an identical network map +// re-applied is recognized as a no-op (hash unchanged), while a real change to +// any firewall-relevant input forces a re-apply (hash changes). This is the +// guard that prevents a full ruleset rebuild + flush on every redundant sync. +func TestApplyFilteringSkipsUnchangedConfig(t *testing.T) { + t.Setenv("NB_WG_KERNEL_DISABLED", "true") + t.Setenv(firewall.EnvForceUserspaceFirewall, "true") + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ifaceMock := mocks.NewMockIFaceMapper(ctrl) + ifaceMock.EXPECT().IsUserspaceBind().Return(true).AnyTimes() + ifaceMock.EXPECT().SetFilter(gomock.Any()) + network := netip.MustParsePrefix("172.0.0.1/32") + ifaceMock.EXPECT().Name().Return("lo").AnyTimes() + ifaceMock.EXPECT().Address().Return(wgaddr.Address{ + IP: network.Addr(), + Network: network, + }).AnyTimes() + ifaceMock.EXPECT().GetWGDevice().Return(nil).AnyTimes() + + 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) + + 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) + require.True(t, acl.hasAppliedConfig, "config should be marked applied after first apply") + firstHash := acl.previousConfigHash + require.NotZero(t, firstHash) + + // Re-applying the identical map must not change the recorded hash: the + // expensive rebuild path was skipped. + acl.ApplyFiltering(networkMap, false) + assert.Equal(t, firstHash, acl.previousConfigHash, + "identical re-apply must be a no-op (hash unchanged)") + + // A real change must produce a different hash and re-apply. + networkMap.FirewallRules[0].Action = mgmProto.RuleAction_DROP + acl.ApplyFiltering(networkMap, false) + assert.NotEqual(t, firstHash, acl.previousConfigHash, + "changing a rule's action must force a re-apply (hash changed)") + + // The dnsRouteFeatureFlag also participates in the hash. + changedHash := acl.previousConfigHash + acl.ApplyFiltering(networkMap, true) + assert.NotEqual(t, changedHash, acl.previousConfigHash, + "flipping dnsRouteFeatureFlag must force a re-apply (hash changed)") +} + +func buildNetworkMap(peerRules, routeRules int) *mgmProto.NetworkMap { + nm := &mgmProto.NetworkMap{ + FirewallRulesIsEmpty: peerRules == 0, + RoutesFirewallRulesIsEmpty: routeRules == 0, + } + for i := range peerRules { + nm.FirewallRules = append(nm.FirewallRules, &mgmProto.FirewallRule{ + PeerIP: fmt.Sprintf("10.%d.%d.%d", i>>16&0xff, i>>8&0xff, i&0xff), //nolint:staticcheck + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: fmt.Sprintf("%d", 1024+i%64511), + }) + } + for i := range routeRules { + nm.RoutesFirewallRules = append(nm.RoutesFirewallRules, &mgmProto.RouteFirewallRule{ + Destination: fmt.Sprintf("192.168.%d.0/24", i%256), + SourceRanges: []string{fmt.Sprintf("10.0.%d.0/24", i%256)}, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_ALL, + }) + } + return nm +} + +func BenchmarkFirewallConfigHash_Small(b *testing.B) { + d := &DefaultManager{} + nm := buildNetworkMap(10, 5) + b.ResetTimer() + for b.Loop() { + _, _ = d.firewallConfigHash(nm, false) + } +} + +func BenchmarkFirewallConfigHash_Medium(b *testing.B) { + d := &DefaultManager{} + nm := buildNetworkMap(100, 50) + b.ResetTimer() + for b.Loop() { + _, _ = d.firewallConfigHash(nm, false) + } +} + +func BenchmarkFirewallConfigHash_Large(b *testing.B) { + d := &DefaultManager{} + nm := buildNetworkMap(1000, 200) + b.ResetTimer() + for b.Loop() { + _, _ = d.firewallConfigHash(nm, false) + } +} + +// TestFirewallConfigHashDeterministic verifies the hash is stable for equal +// inputs and order-independent for the rule slices (management does not +// guarantee rule order). +func TestFirewallConfigHashDeterministic(t *testing.T) { + d := &DefaultManager{} + + nm1 := &mgmProto.NetworkMap{ + FirewallRules: []*mgmProto.FirewallRule{ + {PeerIP: "10.0.0.1", Direction: mgmProto.RuleDirection_IN, Action: mgmProto.RuleAction_ACCEPT, Protocol: mgmProto.RuleProtocol_TCP, Port: "22"}, + {PeerIP: "10.0.0.2", Direction: mgmProto.RuleDirection_IN, Action: mgmProto.RuleAction_DROP, Protocol: mgmProto.RuleProtocol_TCP, Port: "80"}, + }, + } + // Same rules, reversed order. + nm2 := &mgmProto.NetworkMap{ + FirewallRules: []*mgmProto.FirewallRule{ + nm1.FirewallRules[1], + nm1.FirewallRules[0], + }, + } + + h1, err := d.firewallConfigHash(nm1, false) + require.NoError(t, err) + h2, err := d.firewallConfigHash(nm2, false) + require.NoError(t, err) + assert.Equal(t, h1, h2, "hash must be order-independent for rule slices") +} diff --git a/client/internal/acl/mocks/iface_mapper.go b/client/internal/acl/mocks/iface_mapper.go index 95d5a2c58..f8cca1c2d 100644 --- a/client/internal/acl/mocks/iface_mapper.go +++ b/client/internal/acl/mocks/iface_mapper.go @@ -7,7 +7,7 @@ package mocks import ( reflect "reflect" - gomock "github.com/golang/mock/gomock" + gomock "go.uber.org/mock/gomock" wgdevice "golang.zx2c4.com/wireguard/device" "github.com/netbirdio/netbird/client/iface/device" diff --git a/client/internal/auth/auth.go b/client/internal/auth/auth.go index bdfd07430..939df3a21 100644 --- a/client/internal/auth/auth.go +++ b/client/internal/auth/auth.go @@ -3,6 +3,7 @@ package auth import ( "context" "net/url" + "strings" "sync" "time" @@ -21,6 +22,25 @@ import ( mgmProto "github.com/netbirdio/netbird/shared/management/proto" ) +// peerLoginExpiredMsg is the exact phrase the management server returns +// when a previously SSO-enrolled peer's login has expired. Sourced from +// shared/management/status/error.go (NewPeerLoginExpiredError). Matched +// by substring so a future server-side rewording that keeps the phrase +// still triggers the friendly fallback in Login(). +const peerLoginExpiredMsg = "peer login has expired" + +// errSetupKeyOnSSOExpiredPeer replaces the raw management error when the +// user runs `netbird login -k ` against a peer that was +// originally enrolled via SSO. Wrapped in a PermissionDenied gRPC status +// so callers' existing isPermissionDenied / isAuthError checks still +// classify it correctly (early-exit from retry backoff, StatusNeedsLogin +// in the server state machine). +var errSetupKeyOnSSOExpiredPeer = status.Error( + codes.PermissionDenied, + "this peer was originally enrolled via SSO and its session has expired. "+ + "Setup keys can only enrol new peers — run `netbird up` (interactive SSO) to re-login.", +) + // Auth manages authentication operations with the management server // It maintains a long-lived connection and automatically handles reconnection with backoff type Auth struct { @@ -118,26 +138,37 @@ func (a *Auth) IsSSOSupported(ctx context.Context) (bool, error) { // GetOAuthFlow returns an OAuth flow (PKCE or Device) using the existing management connection // This avoids creating a new connection to the management server -func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth bool) (OAuthFlow, error) { +func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth bool, hint string) (OAuthFlow, error) { var flow OAuthFlow - var err error - err = a.withRetry(ctx, func(client *mgm.GrpcClient) error { + err := a.withRetry(ctx, func(client *mgm.GrpcClient) error { if forceDeviceAuth { - flow, err = a.getDeviceFlow(client) - return err + deviceFlow, err := a.getDeviceFlow(client) + if err != nil { + return err + } + deviceFlow.SetLoginHint(hint) + flow = deviceFlow + return nil } // Try PKCE flow first - flow, err = a.getPKCEFlow(client) + pkceFlow, err := a.getPKCEFlow(client) if err != nil { // If PKCE not supported, try Device flow if s, ok := status.FromError(err); ok && (s.Code() == codes.NotFound || s.Code() == codes.Unimplemented) { - flow, err = a.getDeviceFlow(client) - return err + deviceFlow, err := a.getDeviceFlow(client) + if err != nil { + return err + } + deviceFlow.SetLoginHint(hint) + flow = deviceFlow + return nil } return err } + pkceFlow.SetLoginHint(hint) + flow = pkceFlow return nil }) @@ -184,6 +215,15 @@ func (a *Auth) Login(ctx context.Context, setupKey string, jwtToken string) (err log.Debugf("peer registration required") _, err = a.registerPeer(client, ctx, setupKey, jwtToken, pubSSHKey) if err != nil { + // The peer pub-key is already on file with the management + // server (originally enrolled via SSO) and the session has + // expired. The setup-key path can only enrol new peers, so + // retrying with -k will keep failing. Replace the raw mgm + // message with an actionable hint that tells the user to + // re-authenticate via SSO instead. + if setupKey != "" && jwtToken == "" && isPeerLoginExpired(err) { + err = errSetupKeyOnSSOExpiredPeer + } isAuthError = isPermissionDenied(err) return err } @@ -321,12 +361,14 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) { a.config.DisableFirewall, a.config.BlockLANAccess, a.config.BlockInbound, - a.config.LazyConnectionEnabled, + a.config.DisableIPv6, + a.config.SyncMessageVersion, a.config.EnableSSHRoot, a.config.EnableSSHSFTP, a.config.EnableSSHLocalPortForwarding, a.config.EnableSSHRemotePortForwarding, a.config.DisableSSHAuth, + a.config.RemoteJobsAllowed, ) } @@ -473,3 +515,16 @@ func isLoginNeeded(err error) bool { func isRegistrationNeeded(err error) bool { return isPermissionDenied(err) } + +// isPeerLoginExpired reports whether err is the management server's +// "peer login has expired" PermissionDenied response. Used by Login to +// detect the case where the caller passed a setup-key but the peer is +// actually an SSO-enrolled record whose session needs refreshing — the +// setup-key path cannot help there. +func isPeerLoginExpired(err error) bool { + if !isPermissionDenied(err) { + return false + } + s, _ := status.FromError(err) + return strings.Contains(s.Message(), peerLoginExpiredMsg) +} diff --git a/client/internal/auth/auth_test.go b/client/internal/auth/auth_test.go new file mode 100644 index 000000000..e393beccb --- /dev/null +++ b/client/internal/auth/auth_test.go @@ -0,0 +1,80 @@ +package auth + +import ( + "errors" + "strings" + "testing" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestIsPeerLoginExpired(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + { + name: "nil", + err: nil, + want: false, + }, + { + name: "plain error (not a gRPC status)", + err: errors.New("network read: connection reset"), + want: false, + }, + { + name: "PermissionDenied with different message", + err: status.Error(codes.PermissionDenied, "user is blocked"), + want: false, + }, + { + name: "Unauthenticated with the expected phrase", + // Wrong status code — must still return false. + err: status.Error(codes.Unauthenticated, "peer login has expired, please log in once more"), + want: false, + }, + { + name: "exact server message", + err: status.Error(codes.PermissionDenied, "peer login has expired, please log in once more"), + want: true, + }, + { + name: "phrase as substring", + // Future-proofing: if mgm reworords but keeps the phrase, + // the friendly fallback must still kick in. + err: status.Error(codes.PermissionDenied, "session refused: peer login has expired (account=foo)"), + want: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isPeerLoginExpired(tc.err); got != tc.want { + t.Fatalf("isPeerLoginExpired(%v) = %v, want %v", tc.err, got, tc.want) + } + }) + } +} + +func TestErrSetupKeyOnSSOExpiredPeer(t *testing.T) { + // Sentinel must surface as PermissionDenied so the upstream + // isPermissionDenied / isAuthError checks classify it correctly + // (short-circuit retry backoff, set StatusNeedsLogin). + if !isPermissionDenied(errSetupKeyOnSSOExpiredPeer) { + t.Fatalf("errSetupKeyOnSSOExpiredPeer must be a PermissionDenied gRPC error") + } + + // Message must actually mention SSO and `netbird up` so it is + // actionable for the end user. Loose substring checks keep the + // test resilient to copy edits. + s, _ := status.FromError(errSetupKeyOnSSOExpiredPeer) + msg := strings.ToLower(s.Message()) + for _, want := range []string{"sso", "netbird up"} { + if !strings.Contains(msg, want) { + t.Errorf("sentinel message should contain %q, got %q", want, s.Message()) + } + } +} diff --git a/client/internal/auth/device_flow.go b/client/internal/auth/device_flow.go index e33765300..3592e589d 100644 --- a/client/internal/auth/device_flow.go +++ b/client/internal/auth/device_flow.go @@ -259,12 +259,18 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn ticker := time.NewTicker(interval) defer ticker.Stop() + log.Infof("device flow: waiting for user authorization, polling token endpoint every %s, code expires in %s", interval, timeout) + + start := time.Now() + polls := 0 + for { select { case <-waitCtx.Done(): return TokenInfo{}, waitCtx.Err() case <-ticker.C: + polls++ tokenResponse, err := d.requestToken(info) if err != nil { return TokenInfo{}, fmt.Errorf("parsing token response failed with error: %v", err) @@ -272,10 +278,12 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn if tokenResponse.Error != "" { if tokenResponse.Error == "authorization_pending" { + log.Tracef("device flow: authorization still pending after poll %d", polls) continue } else if tokenResponse.Error == "slow_down" { interval += (3 * time.Second) ticker.Reset(interval) + log.Infof("device flow: IdP requested slow_down, polling interval increased to %s", interval) continue } @@ -291,11 +299,22 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn UseIDToken: d.providerConfig.UseIDToken, } - err = isValidAccessToken(tokenInfo.GetTokenToUse(), d.providerConfig.Audience) + err = validateTokenAudience(tokenInfo.GetTokenToUse(), d.providerConfig.Audience) if err != nil { return TokenInfo{}, fmt.Errorf("validate access token failed with error: %v", err) } + // Same as the PKCE flow: the account the token belongs to is what + // callers store to send back as the login_hint. Without it a client + // driven through the device flow — Android TV and tvOS — never binds + // an account to its profile and every later login goes out blind. + if email, err := parseEmailFromIDToken(tokenInfo.IDToken); err != nil { + log.Warnf("failed to parse email from ID token: %v", err) + } else { + tokenInfo.Email = email + } + + log.Infof("device flow: user authorization confirmed after %d polls in %s", polls, time.Since(start).Round(time.Second)) return tokenInfo, err } } diff --git a/client/internal/auth/oauth.go b/client/internal/auth/oauth.go index a50a2ce6f..91329c98b 100644 --- a/client/internal/auth/oauth.go +++ b/client/internal/auth/oauth.go @@ -97,9 +97,7 @@ func authenticateWithPKCEFlow(ctx context.Context, config *profilemanager.Config return nil, fmt.Errorf("getting pkce authorization flow info failed with error: %v", err) } - if hint != "" { - pkceFlowInfo.SetLoginHint(hint) - } + pkceFlowInfo.SetLoginHint(hint) return pkceFlowInfo, nil } @@ -127,9 +125,7 @@ func authenticateWithDeviceCodeFlow(ctx context.Context, config *profilemanager. } } - if hint != "" { - deviceFlowInfo.SetLoginHint(hint) - } + deviceFlowInfo.SetLoginHint(hint) return deviceFlowInfo, nil } diff --git a/client/internal/auth/pending_flow.go b/client/internal/auth/pending_flow.go new file mode 100644 index 000000000..daeb18bc2 --- /dev/null +++ b/client/internal/auth/pending_flow.go @@ -0,0 +1,89 @@ +package auth + +import ( + "context" + "sync" + "time" +) + +// PendingFlow stores an in-progress OAuth flow between the RPC that +// initiates it (returns the verification URI to the UI) and the RPC +// that waits for the user to complete it. The flow handle, the +// device-code info, and the absolute expiry are kept together so the +// waiting RPC can validate the device code and reuse the same flow. +// +// PendingFlow is safe for concurrent use; callers must not access the +// stored fields directly. +type PendingFlow struct { + mu sync.Mutex + flow OAuthFlow + info AuthFlowInfo + expiresAt time.Time + waitCancel context.CancelFunc +} + +// NewPendingFlow returns an empty PendingFlow ready to be populated by Set. +func NewPendingFlow() *PendingFlow { + return &PendingFlow{} +} + +// Set stores the flow and its authorization info, computing the absolute +// expiry from info.ExpiresIn (seconds, as returned by the IdP). +func (p *PendingFlow) Set(flow OAuthFlow, info AuthFlowInfo) { + p.mu.Lock() + defer p.mu.Unlock() + p.flow = flow + p.info = info + p.expiresAt = time.Now().Add(time.Duration(info.ExpiresIn) * time.Second) +} + +// Get returns the stored flow, info, and whether a flow is currently +// pending. Returns (nil, zero, false) after Clear or before Set. +func (p *PendingFlow) Get() (OAuthFlow, AuthFlowInfo, bool) { + p.mu.Lock() + defer p.mu.Unlock() + if p.flow == nil { + return nil, AuthFlowInfo{}, false + } + return p.flow, p.info, true +} + +// ExpiresAt returns the absolute expiry of the pending flow. Returns +// the zero time when no flow is pending. +func (p *PendingFlow) ExpiresAt() time.Time { + p.mu.Lock() + defer p.mu.Unlock() + return p.expiresAt +} + +// SetWaitCancel records the cancel function for the goroutine currently +// blocked in WaitToken so a new RequestAuth can preempt it. +func (p *PendingFlow) SetWaitCancel(cancel context.CancelFunc) { + p.mu.Lock() + defer p.mu.Unlock() + p.waitCancel = cancel +} + +// CancelWait invokes and clears the stored wait-cancel, if any. Safe to +// call when no wait is in progress. +func (p *PendingFlow) CancelWait() { + p.mu.Lock() + cancel := p.waitCancel + p.waitCancel = nil + p.mu.Unlock() + if cancel != nil { + cancel() + } +} + +// Clear resets the pending flow to empty. Any stored wait-cancel is +// dropped without being invoked — call CancelWait first if the waiting +// goroutine must be stopped. +func (p *PendingFlow) Clear() { + p.mu.Lock() + defer p.mu.Unlock() + p.flow = nil + p.info = AuthFlowInfo{} + p.expiresAt = time.Time{} + p.waitCancel = nil +} diff --git a/client/internal/auth/pkce_flow.go b/client/internal/auth/pkce_flow.go index 2e16836d8..be64cc6a8 100644 --- a/client/internal/auth/pkce_flow.go +++ b/client/internal/auth/pkce_flow.go @@ -188,6 +188,8 @@ func (p *PKCEAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowInfo waitCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() + log.Infof("pkce flow: waiting for authorization callback on %s, timeout %s", p.oAuthConfig.RedirectURL, timeout) + tokenChan := make(chan *oauth2.Token, 1) errChan := make(chan error, 1) @@ -221,6 +223,7 @@ func (p *PKCEAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowInfo func (p *PKCEAuthorizationFlow) startServer(server *http.Server, tokenChan chan<- *oauth2.Token, errChan chan<- error) { mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { + log.Infof("pkce flow: received authorization callback from IdP") cert := p.providerConfig.ClientCertPair if cert != nil { tr := &http.Transport{ @@ -271,11 +274,18 @@ func (p *PKCEAuthorizationFlow) handleRequest(req *http.Request) (*oauth2.Token, return nil, fmt.Errorf("authentication failed: missing code") } - return p.oAuthConfig.Exchange( + exchangeStart := time.Now() + token, err := p.oAuthConfig.Exchange( req.Context(), code, oauth2.SetAuthURLParam("code_verifier", p.codeVerifier), ) + if err != nil { + return nil, err + } + + log.Infof("pkce flow: authorization code exchanged for token in %s", time.Since(exchangeStart).Round(time.Millisecond)) + return token, nil } func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo, error) { @@ -296,7 +306,7 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo, audience = p.providerConfig.ClientID } - if err := isValidAccessToken(tokenInfo.GetTokenToUse(), audience); err != nil { + if err := validateTokenAudience(tokenInfo.GetTokenToUse(), audience); err != nil { return TokenInfo{}, fmt.Errorf("authentication failed: invalid access token - %w", err) } @@ -310,6 +320,11 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo, return tokenInfo, nil } +// parseEmailFromIDToken extracts the email (or name) claim from an ID token +// without verifying its signature. The value is best-effort and used only as a +// UX convenience (login hint prefill and display); it never drives an +// authorization decision. The authoritative identity is established server-side +// from the signature-verified token. func parseEmailFromIDToken(token string) (string, error) { parts := strings.Split(token, ".") if len(parts) < 2 { @@ -360,7 +375,13 @@ func isRedirectURLPortUsed(redirectURL string, excludedRanges []excludedPortRang return true } - addr := fmt.Sprintf(":%s", port) + // FreeBSD 15 disables connecting to INADDR_ANY (0.0.0.0) as a localhost + // alias by default, ensure explicit ip for localhost. + host := parsedURL.Hostname() + if host == "" { + host = "127.0.0.1" + } + addr := net.JoinHostPort(host, port) conn, err := net.DialTimeout("tcp", addr, 3*time.Second) if err != nil { return false diff --git a/client/internal/auth/sessionwatch/event.go b/client/internal/auth/sessionwatch/event.go new file mode 100644 index 000000000..3e55b26dd --- /dev/null +++ b/client/internal/auth/sessionwatch/event.go @@ -0,0 +1,82 @@ +package sessionwatch + +import ( + "strconv" + "time" +) + +// internal event kinds are no longer exposed: the watcher drives the Sink +// directly (NotifyStateChange on deadline change/clear, PublishEvent at +// each warning lead). Tests use a mock Sink to observe what the watcher +// emits. + +// Metadata keys attached by the daemon to session-warning SystemEvents. +// The UI tray reads these to build a locale-aware notification without +// relying on the daemon's locale-less UserMessage string, and to +// disambiguate the T-WarningLead notification from the T-FinalWarningLead +// fallback that auto-opens the SessionAboutToExpire dialog. +const ( + // MetaSessionWarning is set to "true" on both warning events (T-10 and + // T-2) so the UI can detect a session-warning SystemEvent without + // matching on the message text. Use MetaSessionFinal to distinguish + // the two. + MetaSessionWarning = "session_warning" + // MetaSessionFinal is set to "true" on the T-FinalWarningLead event + // only. Consumers that need to auto-open the SessionAboutToExpire + // dialog gate on this; T-WarningLead events leave the field unset. + MetaSessionFinal = "session_final_warning" + // MetaSessionExpiresAt carries the absolute UTC deadline encoded with + // FormatExpiresAt; consumers must decode with ParseExpiresAt so a + // future format change stays a single edit. + MetaSessionExpiresAt = "session_expires_at" + // MetaSessionLeadMinutes carries the lead in whole minutes (WarningLead + // for the T-10 event, FinalWarningLead for the T-2 event) so the UI + // can show "expires in ~N minutes" without hardcoding either constant. + MetaSessionLeadMinutes = "lead_minutes" + // MetaSessionDeadlineRejected is attached to the ERROR/AUTHENTICATION + // SystemEvent the daemon emits when it discards a deadline from the + // management server (pre-epoch, too far in the future, or past the + // clock-skew tolerance). The value is the rejection reason string. + // userMessage is left empty; the UI detects the event via this key + // and builds a localized notification — same pattern as the session + // warnings above. + MetaSessionDeadlineRejected = "session_deadline_rejected" +) + +// expiresAtLayout is the wire format used for MetaSessionExpiresAt. +// Producer and consumers both go through FormatExpiresAt/ParseExpiresAt +// so this layout stays a single source of truth. +const expiresAtLayout = time.RFC3339 + +// FormatExpiresAt encodes a deadline for MetaSessionExpiresAt. Always +// emits UTC so a consumer in another timezone reads the same wall-clock +// deadline. +func FormatExpiresAt(t time.Time) string { + return t.UTC().Format(expiresAtLayout) +} + +// ParseExpiresAt decodes the MetaSessionExpiresAt value back to a UTC +// time. Returns an error when the field is empty or malformed; the +// caller decides whether to fall back (zero value) or propagate. +func ParseExpiresAt(s string) (time.Time, error) { + t, err := time.Parse(expiresAtLayout, s) + if err != nil { + return time.Time{}, err + } + return t.UTC(), nil +} + +// FormatLeadMinutes encodes a lead duration for MetaSessionLeadMinutes +// as the integer count of whole minutes. Sub-minute residuals are +// truncated — the field is informational ("expires in ~N minutes") and +// fractional minutes don't change what the UI displays. +func FormatLeadMinutes(d time.Duration) string { + return strconv.Itoa(int(d / time.Minute)) +} + +// ParseLeadMinutes decodes a MetaSessionLeadMinutes value. Returns 0 +// and the parse error for malformed input; consumers that prefer a +// silent fallback can simply ignore the error. +func ParseLeadMinutes(s string) (int, error) { + return strconv.Atoi(s) +} diff --git a/client/internal/auth/sessionwatch/watcher.go b/client/internal/auth/sessionwatch/watcher.go new file mode 100644 index 000000000..e685c28d0 --- /dev/null +++ b/client/internal/auth/sessionwatch/watcher.go @@ -0,0 +1,382 @@ +// Package sessionwatch tracks the SSO session expiry deadline that the +// management server publishes via LoginResponse / SyncResponse and fires +// two warning events at fixed lead times before expiry: an interactive +// T-WarningLead notification and a dismiss-gated T-FinalWarningLead +// fallback dialog. +// +// The watcher is idempotent: Update may be called as often as the network +// map snapshots arrive. Repeating the same deadline is a no-op; a new +// deadline reschedules the timers and arms a fresh warning cycle. +// +// Warning firing is edge-detected. Each unique deadline value fires each +// warning callback at most once. +package sessionwatch + +import ( + "errors" + "fmt" + "sync" + "time" + + log "github.com/sirupsen/logrus" + + cProto "github.com/netbirdio/netbird/client/proto" +) + +const ( + maxPastHorizon = 30 * 24 * time.Hour + + // maxDeadlineHorizon caps how far in the future an accepted deadline + // can sit. A timestamp beyond this is almost certainly a protocol + // glitch, and silently arming a 100-year timer would hide the bug. + maxDeadlineHorizon = 10 * 365 * 24 * time.Hour + + // WarningLead is how far before expiry the first (interactive) + // warning fires. Drives the T-10 OS notification with + // Extend/Dismiss actions. + WarningLead = 10 * time.Minute + + // FinalWarningLead is how far before expiry the fallback final + // warning fires. Drives the auto-opened SessionAboutToExpire dialog, + // but only when the user has not dismissed the T-WarningLead warning + // for the same deadline. Must be strictly less than WarningLead. + FinalWarningLead = 2 * time.Minute +) + +var ( + // ErrDeadlineBeforeEpoch is returned by Update when the supplied + // deadline pre-dates 1970-01-01. + ErrDeadlineBeforeEpoch = errors.New("session deadline before unix epoch") + + // ErrDeadlineTooFarFuture is returned by Update when the supplied + // deadline is more than maxDeadlineHorizon in the future. + ErrDeadlineTooFarFuture = errors.New("session deadline too far in the future") + + // ErrDeadlineInPast is returned by Update when the supplied deadline + // is more than maxPastHorizon in the past. + ErrDeadlineInPast = errors.New("session deadline in the past") +) + +// StatusRecorder is the side-effect surface the watcher drives on every +// state transition. Production wires this to peer.Status (SetSessionExpiresAt +// for deadline change/clear, PublishEvent for the two warnings); tests pass +// a fake recorder so the same surface is observable without an engine. +// +// While the watcher runs, it owns the deadline propagated to the recorder: +// every set, clear and sanity-check rejection routes the value through +// SetSessionExpiresAt, so the SubscribeStatus snapshot the UI reads can +// never drift from the watcher's timer state. (SetSessionExpiresAt fans +// out its own state-change notification, so no separate notify is needed.) +// The recorder is server-scoped and outlives this engine-scoped watcher; +// Close deliberately leaves the recorder value in place so transient engine +// restarts don't blank it — the client run loop clears it on real teardown. +// +// PublishEvent's signature mirrors peer.Status.PublishEvent: the watcher +// composes the metadata internally so the wire format (MetaSession*) is +// owned by sessionwatch, not the caller. +type StatusRecorder interface { + SetSessionExpiresAt(deadline time.Time) + PublishEvent( + severity cProto.SystemEvent_Severity, + category cProto.SystemEvent_Category, + message string, + userMessage string, + metadata map[string]string, + ) +} + +// Watcher observes the latest session deadline and fires two warnings +// before it expires: the interactive T-WarningLead notification, and the +// fallback T-FinalWarningLead dialog (suppressed when the user dismissed +// the first one for the same deadline). Safe for concurrent use. +type Watcher struct { + lead time.Duration + finalLead time.Duration + + mu sync.Mutex + current time.Time + timer *time.Timer + finalTimer *time.Timer + firedAt time.Time // deadline value the T-WarningLead callback last fired against + finalFiredAt time.Time // deadline value the T-FinalWarningLead callback last fired against + dismissedAt time.Time // deadline value the user dismissed via Dismiss(); gates fireFinal + closed bool + recorder StatusRecorder +} + +// New returns a watcher with the package defaults WarningLead and +// FinalWarningLead. Pass nil for recorder to silence side effects (handy +// in unit tests that exercise sanity checks without observing the publish +// path). +func New(recorder StatusRecorder) *Watcher { + return NewWithLeads(WarningLead, FinalWarningLead, recorder) +} + +// NewWithLeads returns a watcher with custom lead times. Useful for tests. +// final must be strictly less than lead; otherwise both timers fire in the +// wrong order or simultaneously and the UI flow breaks. A zero final lead +// disables the final-warning timer entirely (see armTimerLocked) so a +// millisecond-scale deadline doesn't flush both timers in one tick. +func NewWithLeads(lead, final time.Duration, recorder StatusRecorder) *Watcher { + return &Watcher{ + lead: lead, + finalLead: final, + recorder: recorder, + } +} + +// Update sets the latest deadline. Pass the zero time to clear (e.g. when +// a Sync push from the server omits the field because login expiration +// was disabled). +// +// Same-value updates are no-ops. A different non-zero value cancels any +// pending timer, resets the "already fired" guards, and — when the +// deadline lies in the future — arms fresh warning timers. A deadline +// already in the past (within maxPastHorizon) is recorded as-is with no +// timers: the session has expired and consumers render it that way. +// +// Returns one of the sentinel Err* values when the deadline fails the +// sanity checks (pre-epoch, far future, or past beyond maxPastHorizon). +// In every error case the watcher first clears its state so it stays +// consistent with what the caller will push into its other sinks (e.g. +// applySessionDeadline forces a zero deadline into the status recorder +// after a non-nil error). +func (w *Watcher) Update(deadline time.Time) error { + w.mu.Lock() + if w.closed { + w.mu.Unlock() + return nil + } + + if deadline.IsZero() { + w.clearLocked() + return nil + } + + now := time.Now() + switch { + case deadline.Before(time.Unix(0, 0)): + w.clearLocked() + return fmt.Errorf("%w: %v", ErrDeadlineBeforeEpoch, deadline) + case deadline.After(now.Add(maxDeadlineHorizon)): + w.clearLocked() + return fmt.Errorf("%w: %v", ErrDeadlineTooFarFuture, deadline) + case deadline.Before(now.Add(-maxPastHorizon)): + w.clearLocked() + return fmt.Errorf("%w: %v (now=%v)", ErrDeadlineInPast, deadline, now) + } + + if deadline.Equal(w.current) { + w.mu.Unlock() + return nil + } + + w.stopTimerLocked() + w.current = deadline + // Reset every per-deadline guard so a refreshed deadline arms a fresh + // warning cycle: both edge triggers and the user Dismiss decision + // (the user agreed to the old deadline expiring; a new deadline + // restarts the contract). + w.firedAt = time.Time{} + w.finalFiredAt = time.Time{} + w.dismissedAt = time.Time{} + + if deadline.After(now) { + w.armTimerLocked(deadline) + } + recorder := w.recorder + w.mu.Unlock() + if recorder != nil { + recorder.SetSessionExpiresAt(deadline) + } + log.Infof("auth session deadline set to: %s (in %s)", deadline.Format(time.RFC3339), time.Until(deadline).Round(time.Second)) + return nil +} + +// Deadline returns the most recently observed deadline. Zero when no +// deadline is currently tracked. +func (w *Watcher) Deadline() time.Time { + w.mu.Lock() + defer w.mu.Unlock() + return w.current +} + +// Dismiss records the user's "Dismiss" action against the current deadline +// and suppresses the upcoming final-warning callback for that deadline. +// Idempotent: repeated calls are no-ops. A subsequent Update with a fresh +// deadline resets the dismissal so the final-warning cycle re-arms. +// +// No-op when the watcher holds no deadline or has been closed. +func (w *Watcher) Dismiss() { + w.mu.Lock() + defer w.mu.Unlock() + if w.closed || w.current.IsZero() { + return + } + if w.dismissedAt.Equal(w.current) { + return + } + w.dismissedAt = w.current + // Cancel the armed final-warning timer eagerly. fireFinal would also + // gate on dismissedAt, but stopping the timer avoids a wakeup with + // nothing to do and makes the intent visible. + if w.finalTimer != nil { + w.finalTimer.Stop() + w.finalTimer = nil + } + log.Infof("auth session final-warning dismissed for deadline %s", w.current.Format(time.RFC3339)) +} + +// Close stops any pending timer. Update calls after Close are ignored. +// The recorder keeps its deadline: the watcher is engine-scoped and closes +// on every engine restart (network change, sleep/wake, stream errors) +// while the SSO deadline stays valid across those, so clearing here would +// blank the UI's "expires in" row on every transient reconnect. The +// client run loop clears the server-scoped recorder when it exits for +// real (Down, profile switch, permanent login failure). +func (w *Watcher) Close() { + w.mu.Lock() + defer w.mu.Unlock() + if w.closed { + return + } + w.closed = true + w.stopTimerLocked() + w.current = time.Time{} + w.firedAt = time.Time{} + w.finalFiredAt = time.Time{} + w.dismissedAt = time.Time{} +} + +// clearLocked drops the tracked deadline and notifies the recorder so +// downstream consumers (SubscribeStatus stream, UI) drop their anchor. +// The caller must hold w.mu; this helper releases it before invoking +// the recorder. +func (w *Watcher) clearLocked() { + if w.current.IsZero() { + w.mu.Unlock() + return + } + w.stopTimerLocked() + w.current = time.Time{} + w.firedAt = time.Time{} + w.finalFiredAt = time.Time{} + w.dismissedAt = time.Time{} + recorder := w.recorder + w.mu.Unlock() + if recorder != nil { + recorder.SetSessionExpiresAt(time.Time{}) + } + log.Infof("auth session deadline cleared") +} + +func (w *Watcher) stopTimerLocked() { + if w.timer != nil { + w.timer.Stop() + w.timer = nil + } + if w.finalTimer != nil { + w.finalTimer.Stop() + w.finalTimer = nil + } +} + +func (w *Watcher) armTimerLocked(deadline time.Time) { + w.timer = armOneShotLocked(deadline.Add(-w.lead), func() { w.fire(deadline) }) + // finalLead <= 0 disables the final-warning timer entirely. Used by + // tests that predate the final-warning fallback so a millisecond-scale + // deadline does not flush both timers at once. + if w.finalLead > 0 { + w.finalTimer = armOneShotLocked(deadline.Add(-w.finalLead), func() { w.fireFinal(deadline) }) + } +} + +func (w *Watcher) fire(armedFor time.Time) { + w.mu.Lock() + if w.closed || !w.current.Equal(armedFor) { + // Deadline moved while we were waiting (e.g. a successful extend). + // The reschedule path armed a fresh timer; this one is stale. + w.mu.Unlock() + return + } + if !w.firedAt.IsZero() && w.firedAt.Equal(armedFor) { + w.mu.Unlock() + return + } + w.firedAt = armedFor + recorder := w.recorder + w.mu.Unlock() + if recorder == nil { + return + } + log.Infof("auth session expiry soon warning fired") + publishWarning(recorder, armedFor, false) +} + +// fireFinal mirrors fire for the T-FinalWarningLead timer with an extra +// dismiss-gate: if the user dismissed the T-WarningLead notification for +// this deadline, the final warning is suppressed entirely. +func (w *Watcher) fireFinal(armedFor time.Time) { + w.mu.Lock() + if w.closed || !w.current.Equal(armedFor) { + w.mu.Unlock() + return + } + if !w.finalFiredAt.IsZero() && w.finalFiredAt.Equal(armedFor) { + w.mu.Unlock() + return + } + if w.dismissedAt.Equal(armedFor) { + w.mu.Unlock() + log.Infof("auth session final-warning skipped (dismissed by user)") + return + } + w.finalFiredAt = armedFor + recorder := w.recorder + w.mu.Unlock() + if recorder == nil { + return + } + log.Infof("auth session final-warning fired") + publishWarning(recorder, armedFor, true) +} + +// armOneShotLocked schedules cb at fireAt. When fireAt is already in the +// past it dispatches on the next scheduler tick so a state-change recorder +// notification (invoked after w.mu is released) lands first. Caller must +// hold w.mu. +func armOneShotLocked(fireAt time.Time, cb func()) *time.Timer { + delay := time.Until(fireAt) + if delay <= 0 { + return time.AfterFunc(0, cb) + } + return time.AfterFunc(delay, cb) +} + +// publishWarning composes the SystemEvent for a watcher-fired warning and +// pushes it through the recorder. Severity is CRITICAL on both — bypassing +// the user's Notifications toggle is deliberate: missing the warning +// window forces the post-mortem SessionExpired flow (tunnel torn down, +// lock icon, manual re-login), which is the UX we are trying to avoid. +func publishWarning(recorder StatusRecorder, deadline time.Time, final bool) { + lead := WarningLead + message := "session expiry warning" + meta := map[string]string{ + MetaSessionWarning: "true", + MetaSessionExpiresAt: FormatExpiresAt(deadline), + } + if final { + lead = FinalWarningLead + message = "session expiry final warning" + meta[MetaSessionFinal] = "true" + } + meta[MetaSessionLeadMinutes] = FormatLeadMinutes(lead) + + recorder.PublishEvent( + cProto.SystemEvent_CRITICAL, + cProto.SystemEvent_AUTHENTICATION, + message, + "", + meta, + ) +} diff --git a/client/internal/auth/sessionwatch/watcher_test.go b/client/internal/auth/sessionwatch/watcher_test.go new file mode 100644 index 000000000..4b49a94b6 --- /dev/null +++ b/client/internal/auth/sessionwatch/watcher_test.go @@ -0,0 +1,529 @@ +package sessionwatch + +import ( + "errors" + "sync" + "testing" + "time" + + cProto "github.com/netbirdio/netbird/client/proto" +) + +// fakeRecorder satisfies StatusRecorder and records every call so tests +// can observe what the watcher emits. SetSessionExpiresAt and PublishEvent +// land in the same ordered events slice (with the Kind distinguishing +// them) so tests that care about ordering still work. lastDeadline holds +// the most recent value passed to SetSessionExpiresAt so tests can assert +// the recorder ended up cleared/set as expected. +type fakeRecorder struct { + mu sync.Mutex + events []event + lastDeadline time.Time +} + +type eventKind int + +const ( + stateChange eventKind = iota + publish +) + +type event struct { + kind eventKind + // Set only for publish events. + severity cProto.SystemEvent_Severity + category cProto.SystemEvent_Category + message string + meta map[string]string +} + +// SetSessionExpiresAt mirrors peer.Status: a same-value write is a no-op, +// a real change records the new value and fans out a state-change (the +// production recorder calls notifyStateChange internally). The baseline +// is the zero time, so an initial clear before any deadline is set emits +// nothing — matching the real recorder. +func (r *fakeRecorder) SetSessionExpiresAt(deadline time.Time) { + r.mu.Lock() + defer r.mu.Unlock() + if r.lastDeadline.Equal(deadline) { + return + } + r.lastDeadline = deadline + r.events = append(r.events, event{kind: stateChange}) +} + +func (r *fakeRecorder) deadline() time.Time { + r.mu.Lock() + defer r.mu.Unlock() + return r.lastDeadline +} + +func (r *fakeRecorder) PublishEvent( + severity cProto.SystemEvent_Severity, + category cProto.SystemEvent_Category, + message string, + _ string, + metadata map[string]string, +) { + r.mu.Lock() + defer r.mu.Unlock() + r.events = append(r.events, event{ + kind: publish, + severity: severity, + category: category, + message: message, + meta: metadata, + }) +} + +func (r *fakeRecorder) snapshot() []event { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]event, len(r.events)) + copy(out, r.events) + return out +} + +func (e event) isFinalWarning() bool { + return e.kind == publish && e.meta[MetaSessionFinal] == "true" +} + +func (e event) isWarning() bool { + return e.kind == publish && e.meta[MetaSessionWarning] == "true" && e.meta[MetaSessionFinal] != "true" +} + +func countWhere(events []event, pred func(event) bool) int { + n := 0 + for _, e := range events { + if pred(e) { + n++ + } + } + return n +} + +func waitForEvents(t *testing.T, r *fakeRecorder, want int) []event { + t.Helper() + deadline := time.Now().Add(500 * time.Millisecond) + for time.Now().Before(deadline) { + if got := r.snapshot(); len(got) >= want { + return got + } + time.Sleep(5 * time.Millisecond) + } + got := r.snapshot() + t.Fatalf("timed out waiting for %d events, got %d: %+v", want, len(got), got) + return nil +} + +// newWatcher builds a watcher with the final timer disabled (finalLead=0), +// matching the lead-only behaviour the pre-final-warning tests assume. +func newWatcher(lead time.Duration, r *fakeRecorder) *Watcher { + return NewWithLeads(lead, 0, r) +} + +func TestUpdateZeroBeforeAnythingIsNoop(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(50*time.Millisecond, r) + defer w.Close() + + _ = w.Update(time.Time{}) + + if got := r.snapshot(); len(got) != 0 { + t.Fatalf("expected no events on initial zero, got %+v", got) + } +} + +func TestUpdateNonZeroFiresStateChange(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(50*time.Millisecond, r) + defer w.Close() + + d := time.Now().Add(time.Hour) + _ = w.Update(d) + + events := waitForEvents(t, r, 1) + if events[0].kind != stateChange { + t.Fatalf("expected stateChange, got %+v", events[0]) + } + if !w.Deadline().Equal(d) { + t.Fatalf("deadline mismatch: %v vs %v", w.Deadline(), d) + } +} + +func TestSameDeadlineIsNoop(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(50*time.Millisecond, r) + defer w.Close() + + d := time.Now().Add(time.Hour) + _ = w.Update(d) + _ = w.Update(d) + _ = w.Update(d) + + events := waitForEvents(t, r, 1) + if len(events) != 1 { + t.Fatalf("expected exactly 1 event for repeated same deadline, got %d: %+v", len(events), events) + } +} + +func TestWarningFiresOnceWithinLeadWindow(t *testing.T) { + r := &fakeRecorder{} + lead := 50 * time.Millisecond + w := newWatcher(lead, r) + defer w.Close() + + // Deadline 80ms out — warning should fire after ~30ms. + d := time.Now().Add(80 * time.Millisecond) + _ = w.Update(d) + + events := waitForEvents(t, r, 2) + if events[0].kind != stateChange { + t.Fatalf("event[0] should be stateChange, got %+v", events[0]) + } + if !events[1].isWarning() { + t.Fatalf("event[1] should be a warning publish, got %+v", events[1]) + } +} + +func TestWarningFiresImmediatelyWhenAlreadyInsideWindow(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(time.Hour, r) // lead > delta => fire immediately + defer w.Close() + + d := time.Now().Add(10 * time.Millisecond) + _ = w.Update(d) + + events := waitForEvents(t, r, 2) + if !events[1].isWarning() { + t.Fatalf("expected immediate warning publish, got %+v", events[1]) + } +} + +func TestNewDeadlineCancelsPriorTimer(t *testing.T) { + r := &fakeRecorder{} + lead := 50 * time.Millisecond + w := newWatcher(lead, r) + defer w.Close() + + first := time.Now().Add(80 * time.Millisecond) // would fire warning ~30ms in + _ = w.Update(first) + + // Replace with a far-future deadline before the warning fires. + time.Sleep(5 * time.Millisecond) + second := time.Now().Add(time.Hour) + _ = w.Update(second) + + // Wait past when first's warning would have fired. + time.Sleep(80 * time.Millisecond) + + if n := countWhere(r.snapshot(), event.isWarning); n != 0 { + t.Fatalf("warning fired for cancelled deadline: %+v", r.snapshot()) + } +} + +func TestRefreshAfterFireArmsNewWarning(t *testing.T) { + r := &fakeRecorder{} + lead := 150 * time.Millisecond + w := newWatcher(lead, r) + defer w.Close() + + // Warning fires ~20ms in; the deadline itself stays 150ms away so the + // replacement below lands well before it. + first := time.Now().Add(170 * time.Millisecond) + _ = w.Update(first) + + // Wait for stateChange + warning of the first cycle. + waitForEvents(t, r, 2) + + // Simulate a successful extend: brand new deadline. + second := time.Now().Add(60 * time.Millisecond) + _ = w.Update(second) + + // 4 events total: stateChange, warning (first), stateChange, warning (second). + events := waitForEvents(t, r, 4) + if events[2].kind != stateChange { + t.Fatalf("event[2] should be stateChange for the new deadline, got %+v", events[2]) + } + if !events[3].isWarning() { + t.Fatalf("event[3] should be a warning publish for the new deadline, got %+v", events[3]) + } +} + +func TestUpdateZeroAfterNonZeroClearsState(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(time.Hour, r) + defer w.Close() + + d := time.Now().Add(2 * time.Hour) + _ = w.Update(d) + waitForEvents(t, r, 1) + + _ = w.Update(time.Time{}) + + events := waitForEvents(t, r, 2) + if events[1].kind != stateChange { + t.Fatalf("expected stateChange on clear, got %+v", events[1]) + } + if !w.Deadline().IsZero() { + t.Fatalf("Deadline should be zero after clear") + } +} + +func TestUpdateRejectsBeforeEpoch(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(50*time.Millisecond, r) + defer w.Close() + + good := time.Now().Add(time.Hour) + if err := w.Update(good); err != nil { + t.Fatalf("seed Update: %v", err) + } + + err := w.Update(time.Unix(-100, 0)) + if !errors.Is(err, ErrDeadlineBeforeEpoch) { + t.Fatalf("want ErrDeadlineBeforeEpoch, got %v", err) + } + if !w.Deadline().IsZero() { + t.Fatalf("rejected pre-epoch update must clear deadline; got %v", w.Deadline()) + } +} + +func TestUpdateRejectsTooFarFuture(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(50*time.Millisecond, r) + defer w.Close() + + good := time.Now().Add(time.Hour) + if err := w.Update(good); err != nil { + t.Fatalf("seed Update: %v", err) + } + + err := w.Update(time.Now().Add(50 * 365 * 24 * time.Hour)) + if !errors.Is(err, ErrDeadlineTooFarFuture) { + t.Fatalf("want ErrDeadlineTooFarFuture, got %v", err) + } + if !w.Deadline().IsZero() { + t.Fatalf("rejected far-future update must clear deadline; got %v", w.Deadline()) + } +} + +func TestUpdateRecentPastRecordedAsExpired(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(50*time.Millisecond, r) + defer w.Close() + + d := time.Now().Add(-1 * time.Hour) + if err := w.Update(d); err != nil { + t.Fatalf("recent-past Update should succeed, got %v", err) + } + if !w.Deadline().Equal(d) { + t.Fatalf("expected deadline to be recorded, got %v want %v", w.Deadline(), d) + } + if got := r.deadline(); !got.Equal(d) { + t.Fatalf("recorder deadline = %v, want %v", got, d) + } + + time.Sleep(80 * time.Millisecond) + if n := countWhere(r.snapshot(), func(e event) bool { return e.kind == publish }); n != 0 { + t.Fatalf("no warning events may fire for an already-past deadline, got %+v", r.snapshot()) + } +} + +func TestUpdateAncientPastRejected(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(50*time.Millisecond, r) + defer w.Close() + + good := time.Now().Add(time.Hour) + if err := w.Update(good); err != nil { + t.Fatalf("seed Update: %v", err) + } + // Drain the stateChange from the seed. + waitForEvents(t, r, 1) + + err := w.Update(time.Now().Add(-31 * 24 * time.Hour)) + if !errors.Is(err, ErrDeadlineInPast) { + t.Fatalf("want ErrDeadlineInPast, got %v", err) + } + if !w.Deadline().IsZero() { + t.Fatalf("rejected ancient-past update must clear the deadline, got %v", w.Deadline()) + } + events := waitForEvents(t, r, 2) + if events[1].kind != stateChange { + t.Fatalf("expected stateChange on clear, got %+v", events[1]) + } +} + +func TestCloseSilencesUpdates(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(50*time.Millisecond, r) + w.Close() + + if err := w.Update(time.Now().Add(time.Hour)); err != nil { + t.Fatalf("Update after Close: want nil, got %v", err) + } + if got := r.snapshot(); len(got) != 0 { + t.Fatalf("expected no events after Close, got %+v", got) + } +} + +// TestCloseKeepsRecorderDeadline pins the reconnect-flap fix: the watcher +// closes on every engine restart (network change, sleep/wake) while the +// SSO deadline stays valid across those, so Close must leave the +// server-scoped recorder's value in place. The client run loop clears the +// recorder when it exits for real. +func TestCloseKeepsRecorderDeadline(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(time.Hour, r) + + d := time.Now().Add(2 * time.Hour) + if err := w.Update(d); err != nil { + t.Fatalf("seed Update: %v", err) + } + if got := r.deadline(); !got.Equal(d) { + t.Fatalf("recorder deadline after Update = %v, want %v", got, d) + } + + w.Close() + + if got := r.deadline(); !got.Equal(d) { + t.Fatalf("recorder deadline after Close = %v, want %v", got, d) + } +} + +// TestCloseWithoutDeadlineLeavesRecorderUntouched guards the symmetric +// case: closing a watcher that never held a deadline must not emit a +// redundant clear (the recorder may legitimately hold a value written by +// some other path; the watcher only owns what it set). +func TestCloseWithoutDeadlineLeavesRecorderUntouched(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(time.Hour, r) + + w.Close() + + if got := r.snapshot(); len(got) != 0 { + t.Fatalf("expected no events from Close on an empty watcher, got %+v", got) + } +} + +func TestFinalWarningFiresAfterRegularWarning(t *testing.T) { + r := &fakeRecorder{} + // Warning fires at deadline-80ms, final at deadline-30ms. + w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r) + defer w.Close() + + d := time.Now().Add(100 * time.Millisecond) + _ = w.Update(d) + + // Expect stateChange + warning + final-warning. + events := waitForEvents(t, r, 3) + + if countWhere(events, func(e event) bool { return e.kind == stateChange }) != 1 { + t.Fatalf("expected exactly 1 stateChange, got %+v", events) + } + if countWhere(events, event.isWarning) != 1 { + t.Fatalf("expected exactly 1 warning publish, got %+v", events) + } + if countWhere(events, event.isFinalWarning) != 1 { + t.Fatalf("expected exactly 1 final-warning publish, got %+v", events) + } + + // Warning must precede final (same deadline, longer lead fires first). + var wIdx, fIdx int + for i, e := range events { + switch { + case e.isWarning(): + wIdx = i + case e.isFinalWarning(): + fIdx = i + } + } + if wIdx > fIdx { + t.Fatalf("warning must publish before final-warning, got order %+v", events) + } +} + +func TestDismissSuppressesFinalWarning(t *testing.T) { + r := &fakeRecorder{} + w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r) + defer w.Close() + + d := time.Now().Add(100 * time.Millisecond) + _ = w.Update(d) + + // Wait for the warning publish so we know we're inside the warning + // window, then dismiss before the final timer would fire. + deadline := time.Now().Add(500 * time.Millisecond) + for time.Now().Before(deadline) { + if countWhere(r.snapshot(), event.isWarning) >= 1 { + break + } + time.Sleep(2 * time.Millisecond) + } + if countWhere(r.snapshot(), event.isWarning) < 1 { + t.Fatalf("warning did not publish in time, events=%+v", r.snapshot()) + } + + w.Dismiss() + + // Now wait past when the final would have fired. + time.Sleep(120 * time.Millisecond) + + if n := countWhere(r.snapshot(), event.isFinalWarning); n != 0 { + t.Fatalf("final-warning published after Dismiss(), events=%+v", r.snapshot()) + } +} + +func TestDismissResetByNewDeadline(t *testing.T) { + r := &fakeRecorder{} + w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r) + defer w.Close() + + first := time.Now().Add(100 * time.Millisecond) + _ = w.Update(first) + + // Dismiss against the first deadline. + w.Dismiss() + + // Replace with a fresh deadline before the first's timers complete. + time.Sleep(10 * time.Millisecond) + second := time.Now().Add(100 * time.Millisecond) + _ = w.Update(second) + + // The second cycle must publish a final-warning (the dismiss state + // did not carry over). + deadline := time.Now().Add(500 * time.Millisecond) + for time.Now().Before(deadline) { + if countWhere(r.snapshot(), event.isFinalWarning) >= 1 { + break + } + time.Sleep(5 * time.Millisecond) + } + if countWhere(r.snapshot(), event.isFinalWarning) < 1 { + t.Fatalf("final-warning did not publish on fresh deadline after Dismiss reset, events=%+v", r.snapshot()) + } +} + +func TestDismissBeforeUpdateIsNoop(t *testing.T) { + r := &fakeRecorder{} + w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r) + defer w.Close() + + // No deadline tracked yet; Dismiss must be a no-op (no panic, no state). + w.Dismiss() + + d := time.Now().Add(100 * time.Millisecond) + _ = w.Update(d) + + // Final warning should still publish — Dismiss only acts on the current + // deadline, and there was none at the time of the call. + deadline := time.Now().Add(500 * time.Millisecond) + for time.Now().Before(deadline) { + if countWhere(r.snapshot(), event.isFinalWarning) >= 1 { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("final-warning did not publish after no-op pre-Update Dismiss, events=%+v", r.snapshot()) +} diff --git a/client/internal/auth/util.go b/client/internal/auth/util.go index 31c81d701..1800584a2 100644 --- a/client/internal/auth/util.go +++ b/client/internal/auth/util.go @@ -20,14 +20,26 @@ func randomBytesInHex(count int) (string, error) { return hex.EncodeToString(buf), nil } -// isValidAccessToken is a simple validation of the access token -func isValidAccessToken(token string, audience string) error { +// validateTokenAudience checks that the token is a well-formed JWT whose +// audience claim matches the expected audience. +// +// It does NOT verify the token's cryptographic signature and therefore must not +// be treated as an authenticity check. The token is obtained by the client +// directly from the IdP token endpoint over TLS, and its signature is verified +// server-side by the management server against the IdP's JWKS +// (see shared/auth/jwt/validator.go). This function is only a client-side +// sanity check that the returned token targets the expected audience. +func validateTokenAudience(token string, audience string) error { if token == "" { return fmt.Errorf("token received is empty") } - encodedClaims := strings.Split(token, ".")[1] - claimsString, err := base64.RawURLEncoding.DecodeString(encodedClaims) + parts := strings.Split(token, ".") + if len(parts) != 3 { + return fmt.Errorf("token is not a well-formed JWT") + } + + claimsString, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return err } diff --git a/client/internal/auth/util_test.go b/client/internal/auth/util_test.go new file mode 100644 index 000000000..7f225bb86 --- /dev/null +++ b/client/internal/auth/util_test.go @@ -0,0 +1,108 @@ +package auth + +import ( + "encoding/base64" + "encoding/json" + "testing" +) + +// makeJWT builds an unsigned JWT-shaped string (header.payload.signature) with +// the given claims payload. The signature part is arbitrary because +// validateTokenAudience intentionally does not verify it. +func makeJWT(t *testing.T, claims map[string]interface{}) string { + t.Helper() + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) + payloadBytes, err := json.Marshal(claims) + if err != nil { + t.Fatalf("marshal claims: %v", err) + } + payload := base64.RawURLEncoding.EncodeToString(payloadBytes) + return header + "." + payload + ".unverified-signature" +} + +func TestValidateTokenAudience(t *testing.T) { + tests := []struct { + name string + token string + audience string + wantErr bool + }{ + { + name: "empty token", + token: "", + audience: "netbird", + wantErr: true, + }, + { + name: "not a JWT - no dots", + token: "notajwt", + audience: "netbird", + wantErr: true, + }, + { + name: "not a JWT - two parts only", + token: "header.payload", + audience: "netbird", + wantErr: true, + }, + { + name: "matching string audience", + token: makeJWT(t, map[string]interface{}{"aud": "netbird"}), + audience: "netbird", + wantErr: false, + }, + { + name: "mismatching string audience", + token: makeJWT(t, map[string]interface{}{"aud": "other"}), + audience: "netbird", + wantErr: true, + }, + { + name: "matching audience in array", + token: makeJWT(t, map[string]interface{}{"aud": []interface{}{"other", "netbird"}}), + audience: "netbird", + wantErr: false, + }, + { + name: "mismatching audience array", + token: makeJWT(t, map[string]interface{}{"aud": []interface{}{"a", "b"}}), + audience: "netbird", + wantErr: true, + }, + { + name: "missing audience claim", + token: makeJWT(t, map[string]interface{}{"sub": "user"}), + audience: "netbird", + wantErr: true, + }, + { + name: "invalid base64 payload", + token: "header.!!!not-base64!!!.sig", + audience: "netbird", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := validateTokenAudience(tc.token, tc.audience) + if tc.wantErr && err == nil { + t.Fatalf("expected error, got nil") + } + if !tc.wantErr && err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) + } +} + +// TestValidateTokenAudienceNoPanic guards the regression where a non-empty +// token without the JWT dot structure caused an index-out-of-range panic. +func TestValidateTokenAudienceNoPanic(t *testing.T) { + inputs := []string{"a", ".", "a.", "aaaa", "no-dots-here"} + for _, in := range inputs { + if err := validateTokenAudience(in, "netbird"); err == nil { + t.Fatalf("expected error for malformed token %q, got nil", in) + } + } +} diff --git a/client/internal/conn_mgr.go b/client/internal/conn_mgr.go index 112559132..8b01eabcf 100644 --- a/client/internal/conn_mgr.go +++ b/client/internal/conn_mgr.go @@ -2,6 +2,7 @@ package internal import ( "context" + "maps" "os" "strconv" "sync" @@ -14,6 +15,17 @@ import ( "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/peerstore" "github.com/netbirdio/netbird/route" + mgmProto "github.com/netbirdio/netbird/shared/management/proto" +) + +// lazyForce is the resolved local decision for lazy connections, layered above the +// management feature flag. lazyForceNone defers to management. +type lazyForce int + +const ( + lazyForceNone lazyForce = iota + lazyForceOn + lazyForceOff ) // ConnMgr coordinates both lazy connections (established on-demand) and permanent peer connections. @@ -24,86 +36,97 @@ import ( // - Handling connection establishment based on peer signaling // // The implementation is not thread-safe; it is protected by engine.syncMsgMux. +// The only exception is ActivatePeer, which is safe for concurrent use so the +// DNS warm-up path can call it without contending on the engine mutex. type ConnMgr struct { - peerStore *peerstore.Store - statusRecorder *peer.Status - iface lazyconn.WGIface - enabledLocally bool - rosenpassEnabled bool + peerStore *peerstore.Store + statusRecorder *peer.Status + iface lazyconn.WGIface + force lazyForce + // remoteLazyEnabled caches the account-wide lazy feature flag from management. + // It is the default for peers that do not carry a per-peer lazy hint. + remoteLazyEnabled bool lazyConnMgr *manager.Manager + // lazyConnMgrMu guards the lazyConnMgr pointer for readers outside the + // engine loop (ActivatePeer). Writers hold it in addition to + // engine.syncMsgMux; all other reads stay under engine.syncMsgMux only. + lazyConnMgrMu sync.RWMutex + + // reconcileRoutedIPs re-applies a peer's routed allowed IPs after its lazy wake endpoint is + // (re)armed (Mode A at arm time). Injected by the engine; nil disables the reconcile. + reconcileRoutedIPs func(peerKey string) error + + // appliedExcludeList is the exclude set last handed to the lazy manager, kept so an + // unchanged set on the next sync skips the O(n) reconciliation. + appliedExcludeList map[string]bool wg sync.WaitGroup lazyCtx context.Context lazyCtxCancel context.CancelFunc } +// SetRoutedIPsReconciler injects the callback used to re-apply a peer's routed allowed IPs when +// its lazy wake endpoint is (re)armed. Must be called before the lazy manager starts. +func (e *ConnMgr) SetRoutedIPsReconciler(fn func(peerKey string) error) { + e.reconcileRoutedIPs = fn +} + func NewConnMgr(engineConfig *EngineConfig, statusRecorder *peer.Status, peerStore *peerstore.Store, iface lazyconn.WGIface) *ConnMgr { e := &ConnMgr{ - peerStore: peerStore, - statusRecorder: statusRecorder, - iface: iface, - rosenpassEnabled: engineConfig.RosenpassEnabled, - } - if engineConfig.LazyConnectionEnabled || lazyconn.IsLazyConnEnabledByEnv() { - e.enabledLocally = true + peerStore: peerStore, + statusRecorder: statusRecorder, + iface: iface, + force: resolveLazyForce(engineConfig.LazyConnection), } return e } -// Start initializes the connection manager and starts the lazy connection manager if enabled by env var or cmd line option. +// Start initializes the connection manager. The lazy connection manager always runs so that +// per-peer lazy defaults (e.g. proxy peers) work even when the account flag is off; the +// account flag and the local override decide the default lazy state per peer (see +// PeerLazyDefault). Rosenpass peers stay lazy-capable too: their connections just never idle +// on their own, since rosenpass rekey traffic keeps them active. func (e *ConnMgr) Start(ctx context.Context) { if e.lazyConnMgr != nil { log.Errorf("lazy connection manager is already started") return } - if !e.enabledLocally { - log.Infof("lazy connection manager is disabled") - return - } - - if e.rosenpassEnabled { - log.Warnf("rosenpass connection manager is enabled, lazy connection manager will not be started") - return - } - e.initLazyManager(ctx) - e.statusRecorder.UpdateLazyConnection(true) + e.statusRecorder.UpdateLazyConnection(e.PeerLazyDefault(mgmProto.LazyState_LazyStateDefault)) } -// UpdatedRemoteFeatureFlag is called when the remote feature flag is updated. -// If enabled, it initializes the lazy connection manager and start it. Do not need to call Start() again. -// If disabled, then it closes the lazy connection manager and open the connections to all peers. -func (e *ConnMgr) UpdatedRemoteFeatureFlag(ctx context.Context, enabled bool) error { - // do not disable lazy connection manager if it was enabled by env var - if e.enabledLocally { - return nil +// UpdatedRemoteFeatureFlag caches the account-wide lazy feature flag. The manager itself is +// not started or stopped here; the per-sync exclude-list reconciliation moves normal peers +// between the lazy and always-active sets when the flag flips. +func (e *ConnMgr) UpdatedRemoteFeatureFlag(_ context.Context, enabled bool) error { + e.remoteLazyEnabled = enabled + if e.isStartedWithLazyMgr() { + e.statusRecorder.UpdateLazyConnection(e.PeerLazyDefault(mgmProto.LazyState_LazyStateDefault)) + } + return nil +} + +// PeerLazyDefault reports whether a peer should be lazy. The local override +// (NB_LAZY_CONN/MDM) wins over everything; without a local override the +// management per-peer state applies (LazyStateLazy/Eager force the decision), +// and LazyStateDefault follows the account-wide flag. +func (e *ConnMgr) PeerLazyDefault(state mgmProto.LazyState) bool { + switch e.force { + case lazyForceOn: + return true + case lazyForceOff: + return false } - if enabled { - // if the lazy connection manager is already started, do not start it again - if e.lazyConnMgr != nil { - return nil - } - - if e.rosenpassEnabled { - log.Infof("rosenpass connection manager is enabled, lazy connection manager will not be started") - return nil - } - - log.Warnf("lazy connection manager is enabled by management feature flag") - e.initLazyManager(ctx) - e.statusRecorder.UpdateLazyConnection(true) - return e.addPeersToLazyConnManager() - } else { - if e.lazyConnMgr == nil { - return nil - } - log.Infof("lazy connection manager is disabled by management feature flag") - e.closeManager(ctx) - e.statusRecorder.UpdateLazyConnection(false) - return nil + switch state { + case mgmProto.LazyState_LazyStateLazy: + return true + case mgmProto.LazyState_LazyStateEager: + return false + default: + return e.remoteLazyEnabled } } @@ -123,6 +146,13 @@ func (e *ConnMgr) SetExcludeList(ctx context.Context, peerIDs map[string]bool) { return } + // The exclude set is recomputed every sync but rarely changes; skip the O(n) + // store lookups and reconciliation when it matches what was already applied. + if maps.Equal(peerIDs, e.appliedExcludeList) { + return + } + e.appliedExcludeList = maps.Clone(peerIDs) + excludedPeers := make([]lazyconn.PeerConfig, 0, len(peerIDs)) for peerID := range peerIDs { @@ -158,12 +188,16 @@ func (e *ConnMgr) SetExcludeList(ctx context.Context, peerIDs map[string]bool) { } } -func (e *ConnMgr) AddPeerConn(ctx context.Context, peerKey string, conn *peer.Conn) (exists bool) { +// AddPeerConn registers a peer connection. permanent requests an always-active connection +// (the peer belongs to the exclude set: a forwarder, or a peer that is not lazy by policy). +// Non-permanent peers are handed to the lazy manager. The subsequent SetExcludeList call +// reconciles membership for existing peers across flag flips. +func (e *ConnMgr) AddPeerConn(ctx context.Context, peerKey string, conn *peer.Conn, permanent bool) (exists bool) { if success := e.peerStore.AddPeerConn(peerKey, conn); !success { return true } - if !e.isStartedWithLazyMgr() { + if !e.isStartedWithLazyMgr() || permanent { if err := conn.Open(ctx); err != nil { conn.Log.Errorf("failed to open connection: %v", err) } @@ -220,12 +254,20 @@ func (e *ConnMgr) RemovePeerConn(peerKey string) { conn.Log.Infof("removed peer from lazy conn manager") } +// ActivatePeer wakes an idle lazy connection. Unlike the rest of ConnMgr it is +// safe for concurrent use: the lazy manager pointer is read under lazyConnMgrMu +// and the manager itself is internally synchronized, so callers outside the +// engine loop (DNS warm-up) do not need engine.syncMsgMux. func (e *ConnMgr) ActivatePeer(ctx context.Context, conn *peer.Conn) { - if !e.isStartedWithLazyMgr() { + e.lazyConnMgrMu.RLock() + lazyConnMgr := e.lazyConnMgr + started := lazyConnMgr != nil && e.lazyCtxCancel != nil + e.lazyConnMgrMu.RUnlock() + if !started { return } - if found := e.lazyConnMgr.ActivatePeer(conn.GetKey()); found { + if found := lazyConnMgr.ActivatePeer(conn.GetKey()); found { if err := conn.Open(ctx); err != nil { conn.Log.Errorf("failed to open connection: %v", err) } @@ -250,16 +292,26 @@ func (e *ConnMgr) Close() { e.lazyCtxCancel() e.wg.Wait() + + e.lazyConnMgrMu.Lock() e.lazyConnMgr = nil + e.lazyConnMgrMu.Unlock() + + e.appliedExcludeList = nil } func (e *ConnMgr) initLazyManager(engineCtx context.Context) { cfg := manager.Config{ InactivityThreshold: inactivityThresholdEnv(), + ReconcileAllowedIPs: e.reconcileRoutedIPs, } - e.lazyConnMgr = manager.NewManager(cfg, engineCtx, e.peerStore, e.iface) + e.lazyConnMgrMu.Lock() + e.lazyConnMgr = manager.NewManager(cfg, engineCtx, e.peerStore, e.iface) e.lazyCtx, e.lazyCtxCancel = context.WithCancel(engineCtx) + e.lazyConnMgrMu.Unlock() + + e.appliedExcludeList = nil e.wg.Add(1) go func() { @@ -268,58 +320,49 @@ func (e *ConnMgr) initLazyManager(engineCtx context.Context) { }() } -func (e *ConnMgr) addPeersToLazyConnManager() error { - peers := e.peerStore.PeersPubKey() - lazyPeerCfgs := make([]lazyconn.PeerConfig, 0, len(peers)) - for _, peerID := range peers { - var peerConn *peer.Conn - var exists bool - if peerConn, exists = e.peerStore.PeerConn(peerID); !exists { - log.Warnf("failed to find peer conn for peerID: %s", peerID) - continue - } - - lazyPeerCfg := lazyconn.PeerConfig{ - PublicKey: peerID, - AllowedIPs: peerConn.WgConfig().AllowedIps, - PeerConnID: peerConn.ConnID(), - Log: peerConn.Log, - } - lazyPeerCfgs = append(lazyPeerCfgs, lazyPeerCfg) - } - - return e.lazyConnMgr.AddActivePeers(lazyPeerCfgs) -} - -func (e *ConnMgr) closeManager(ctx context.Context) { - if e.lazyConnMgr == nil { - return - } - - e.lazyCtxCancel() - e.wg.Wait() - e.lazyConnMgr = nil - - for _, peerID := range e.peerStore.PeersPubKey() { - e.peerStore.PeerConnOpen(ctx, peerID) - } -} - func (e *ConnMgr) isStartedWithLazyMgr() bool { return e.lazyConnMgr != nil && e.lazyCtxCancel != nil } +// resolveLazyForce determines the local override. NB_LAZY_CONN takes precedence; when it +// is unset the MDM policy override (mdmState) applies. Either wins in both directions over +// the management feature flag; StateUnset for both defers to management. +func resolveLazyForce(mdmState lazyconn.State) lazyForce { + state := lazyconn.EnvState() + if state == lazyconn.StateUnset { + state = mdmState + } + + switch state { + case lazyconn.StateOn: + return lazyForceOn + case lazyconn.StateOff: + return lazyForceOff + default: + return lazyForceNone + } +} + func inactivityThresholdEnv() *time.Duration { envValue := os.Getenv(lazyconn.EnvInactivityThreshold) if envValue == "" { return nil } - parsedMinutes, err := strconv.Atoi(envValue) - if err != nil || parsedMinutes <= 0 { - return nil + // Documented format: a Go duration such as "30m" or "1h". + if d, err := time.ParseDuration(envValue); err == nil { + if d <= 0 { + return nil + } + return &d } - d := time.Duration(parsedMinutes) * time.Minute - return &d + // Backwards compatibility: a bare integer used to be interpreted as minutes. + if parsedMinutes, err := strconv.Atoi(envValue); err == nil && parsedMinutes > 0 { + d := time.Duration(parsedMinutes) * time.Minute + return &d + } + + log.Warnf("invalid %s value %q: expected a Go duration such as 30m or 1h", lazyconn.EnvInactivityThreshold, envValue) + return nil } diff --git a/client/internal/conn_mgr_test.go b/client/internal/conn_mgr_test.go new file mode 100644 index 000000000..e3723b5ff --- /dev/null +++ b/client/internal/conn_mgr_test.go @@ -0,0 +1,229 @@ +package internal + +import ( + "context" + "net" + "net/netip" + "os" + "sync" + "testing" + "time" + + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/internal/lazyconn" + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/peerstore" + "github.com/netbirdio/netbird/monotime" + mgmProto "github.com/netbirdio/netbird/shared/management/proto" +) + +func TestResolveLazyForce(t *testing.T) { + tests := []struct { + name string + env string + envSet bool + mdm lazyconn.State + want lazyForce + }{ + {name: "env unset, mdm unset -> defer to management", mdm: lazyconn.StateUnset, want: lazyForceNone}, + {name: "env on -> force on", env: "on", envSet: true, mdm: lazyconn.StateUnset, want: lazyForceOn}, + {name: "env off -> force off", env: "off", envSet: true, mdm: lazyconn.StateUnset, want: lazyForceOff}, + {name: "env unset, mdm on -> force on", mdm: lazyconn.StateOn, want: lazyForceOn}, + {name: "env unset, mdm off -> force off", mdm: lazyconn.StateOff, want: lazyForceOff}, + {name: "env on beats mdm off", env: "on", envSet: true, mdm: lazyconn.StateOff, want: lazyForceOn}, + {name: "env off beats mdm on", env: "off", envSet: true, mdm: lazyconn.StateOn, want: lazyForceOff}, + {name: "unrecognized env, mdm on -> mdm wins", env: "auto", envSet: true, mdm: lazyconn.StateOn, want: lazyForceOn}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(lazyconn.EnvLazyConn, tt.env) + if !tt.envSet { + os.Unsetenv(lazyconn.EnvLazyConn) + } + + if got := resolveLazyForce(tt.mdm); got != tt.want { + t.Fatalf("resolveLazyForce(%v) = %v, want %v", tt.mdm, got, tt.want) + } + }) + } +} + +type mockLazyWGIface struct{} + +func (mockLazyWGIface) RemovePeer(string) error { return nil } +func (mockLazyWGIface) UpdatePeer(string, []netip.Prefix, time.Duration, *net.UDPAddr, *wgtypes.Key) error { + return nil +} +func (mockLazyWGIface) IsUserspaceBind() bool { return false } +func (mockLazyWGIface) Address() wgaddr.Address { return wgaddr.Address{} } +func (mockLazyWGIface) LastActivities() map[string]monotime.Time { return nil } +func (mockLazyWGIface) MTU() uint16 { return 1280 } + +// TestConnMgr_ActivatePeerConcurrentWithLifecycle exercises ActivatePeer from +// non-engine goroutines (the DNS warm-up path) racing the manager lifecycle, +// which stays on the engine loop. Run with -race: it fails if ActivatePeer +// still requires engine.syncMsgMux for safety. +func TestConnMgr_ActivatePeerConcurrentWithLifecycle(t *testing.T) { + t.Setenv(lazyconn.EnvLazyConn, "on") + + status := peer.NewRecorder("https://mgm") + store := peerstore.NewConnStore() + connMgr := NewConnMgr(&EngineConfig{}, status, store, mockLazyWGIface{}) + + conn := newTestPeerConn(t, "peerA") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + connMgr.Start(ctx) + + done := make(chan struct{}) + var wg sync.WaitGroup + for range 4 { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-done: + return + default: + connMgr.ActivatePeer(ctx, conn) + } + } + }() + } + + // Let the activators spin against the started manager, then tear it down + // underneath them and let them spin against the stopped manager. + time.Sleep(100 * time.Millisecond) + connMgr.Close() + time.Sleep(50 * time.Millisecond) + + close(done) + wg.Wait() +} + +func TestInactivityThresholdEnv(t *testing.T) { + tests := []struct { + name string + val string + want *time.Duration + }{ + {name: "unset", val: "", want: nil}, + {name: "go duration minutes", val: "30m", want: durPtr(30 * time.Minute)}, + {name: "go duration hours", val: "1h", want: durPtr(time.Hour)}, + {name: "go duration seconds", val: "90s", want: durPtr(90 * time.Second)}, + {name: "bare integer is minutes (backwards compat)", val: "5", want: durPtr(5 * time.Minute)}, + {name: "zero duration", val: "0s", want: nil}, + {name: "zero integer", val: "0", want: nil}, + {name: "negative duration", val: "-5m", want: nil}, + {name: "garbage", val: "abc", want: nil}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(lazyconn.EnvInactivityThreshold, tc.val) + got := inactivityThresholdEnv() + switch { + case tc.want == nil && got != nil: + t.Fatalf("want nil, got %v", *got) + case tc.want != nil && got == nil: + t.Fatalf("want %v, got nil", *tc.want) + case tc.want != nil && *got != *tc.want: + t.Fatalf("want %v, got %v", *tc.want, *got) + } + }) + } +} + +func TestPeerLazyDefault(t *testing.T) { + tests := []struct { + name string + force lazyForce + remoteEnabled bool + state mgmProto.LazyState + want bool + }{ + {name: "force on wins over eager state", force: lazyForceOn, state: mgmProto.LazyState_LazyStateEager, want: true}, + {name: "force off wins over lazy state", force: lazyForceOff, remoteEnabled: true, state: mgmProto.LazyState_LazyStateLazy, want: false}, + {name: "none, default, account off -> active", force: lazyForceNone, state: mgmProto.LazyState_LazyStateDefault, want: false}, + {name: "none, default, account on -> lazy", force: lazyForceNone, remoteEnabled: true, state: mgmProto.LazyState_LazyStateDefault, want: true}, + {name: "none, lazy state, account off -> lazy", force: lazyForceNone, state: mgmProto.LazyState_LazyStateLazy, want: true}, + {name: "none, eager state, account on -> active", force: lazyForceNone, remoteEnabled: true, state: mgmProto.LazyState_LazyStateEager, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := &ConnMgr{force: tt.force, remoteLazyEnabled: tt.remoteEnabled} + if got := e.PeerLazyDefault(tt.state); got != tt.want { + t.Fatalf("PeerLazyDefault(%v) = %v, want %v", tt.state, got, tt.want) + } + }) + } +} + +func durPtr(d time.Duration) *time.Duration { return &d } + +// TestToExcludedLazyPeers covers the per-peer lazy classification (proxy vs +// normal, across the force/account-flag matrix). Forwarder-target exclusion is +// covered by TestToExcludedLazyPeers_ForwardTarget. +func TestToExcludedLazyPeers(t *testing.T) { + const ( + normalKey = "normal" + lazyKey = "lazy-state" + eagerKey = "eager-state" + ) + + peers := []*mgmProto.RemotePeerConfig{ + {WgPubKey: normalKey, AllowedIps: []string{"100.64.0.1/32"}}, + {WgPubKey: lazyKey, AllowedIps: []string{"100.64.0.2/32"}, LazyState: mgmProto.LazyState_LazyStateLazy}, + {WgPubKey: eagerKey, AllowedIps: []string{"100.64.0.3/32"}, LazyState: mgmProto.LazyState_LazyStateEager}, + } + + tests := []struct { + name string + force lazyForce + remoteEnabled bool + want map[string]bool + }{ + { + name: "account off: lazy-state peer lazy, normal + eager active", + force: lazyForceNone, remoteEnabled: false, + want: map[string]bool{normalKey: true, eagerKey: true}, + }, + { + name: "account on: only eager-state peer active", + force: lazyForceNone, remoteEnabled: true, + want: map[string]bool{eagerKey: true}, + }, + { + name: "force off: everything active", + force: lazyForceOff, remoteEnabled: true, + want: map[string]bool{normalKey: true, lazyKey: true, eagerKey: true}, + }, + { + name: "force on: nothing active", + force: lazyForceOn, remoteEnabled: false, + want: map[string]bool{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := &Engine{connMgr: &ConnMgr{force: tt.force, remoteLazyEnabled: tt.remoteEnabled}} + got := e.toExcludedLazyPeers(peers) + + if len(got) != len(tt.want) { + t.Fatalf("toExcludedLazyPeers() = %v, want %v", got, tt.want) + } + for k := range tt.want { + if !got[k] { + t.Fatalf("expected peer %s excluded, got %v", k, got) + } + } + }) + } +} diff --git a/client/internal/connect.go b/client/internal/connect.go index ac498f719..88d829d2f 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -6,31 +6,39 @@ import ( "fmt" "net" "net/netip" + "path/filepath" "runtime" "runtime/debug" "strings" "sync" + "sync/atomic" "time" "github.com/cenkalti/backoff/v4" log "github.com/sirupsen/logrus" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "google.golang.org/grpc/codes" gstatus "google.golang.org/grpc/status" + "github.com/netbirdio/netbird/client/iface/wgaddr" + "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/lazyconn" "github.com/netbirdio/netbird/client/internal/listener" "github.com/netbirdio/netbird/client/internal/metrics" "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/tunnelnotifier" "github.com/netbirdio/netbird/client/internal/updater" "github.com/netbirdio/netbird/client/internal/updater/installer" nbnet "github.com/netbirdio/netbird/client/net" + "github.com/netbirdio/netbird/client/netevents" cProto "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/ssh" sshconfig "github.com/netbirdio/netbird/client/ssh/config" @@ -50,6 +58,10 @@ var androidRunOverride func(c *ConnectClient, runningChan chan struct{}, logPath type ConnectClient struct { ctx context.Context + runCancel context.CancelFunc + runExited chan struct{} + runOnce sync.Once + runStarted atomic.Bool config *profilemanager.Config statusRecorder *peer.Status @@ -59,19 +71,42 @@ type ConnectClient struct { updateManager *updater.Manager persistSyncResponse bool + + // netMgr gates every reconnection loop on OS-reported network + // availability and sweeps connections on network change. + netMgr *netevents.Manager +} + +// ConnectClientOption configures optional ConnectClient behavior. +type ConnectClientOption func(*ConnectClient) + +// WithNetEvents injects the OS network event handling. +func WithNetEvents(events *netevents.Manager) ConnectClientOption { + return func(c *ConnectClient) { c.netMgr = events } } func NewConnectClient( ctx context.Context, config *profilemanager.Config, statusRecorder *peer.Status, + opts ...ConnectClientOption, ) *ConnectClient { - return &ConnectClient{ - ctx: ctx, + // Derive the run context here so Stop owns the cancel that unblocks the run + // loop. runCancel is set once at construction, so Stop can call it without + // racing the run loop's startup. Callers therefore need not cancel before Stop. + runCtx, runCancel := context.WithCancel(ctx) + c := &ConnectClient{ + ctx: runCtx, + runCancel: runCancel, + runExited: make(chan struct{}), config: config, statusRecorder: statusRecorder, engineMutex: sync.Mutex{}, } + for _, opt := range opts { + opt(c) + } + return c } func (c *ConnectClient) SetUpdateManager(um *updater.Manager) { @@ -96,11 +131,14 @@ func (c *ConnectClient) RunOnAndroid( stateFilePath string, cacheDir string, ) error { + notifier := tunnelnotifier.New(networkChangeListener, nil) + defer notifier.Close() + // in case of non Android os these variables will be nil mobileDependency := MobileDependency{ TunAdapter: tunAdapter, IFaceDiscover: iFaceDiscover, - NetworkChangeListener: networkChangeListener, + NetworkChangeListener: notifier, HostDNSAddresses: dnsAddresses, DnsReadyListener: dnsReadyListener, StateFilePath: stateFilePath, @@ -113,23 +151,32 @@ func (c *ConnectClient) RunOniOS( fileDescriptor int32, networkChangeListener listener.NetworkChangeListener, dnsManager dns.IosDnsManager, - dnsAddresses []netip.AddrPort, stateFilePath string, + cacheDir string, + logFilePath string, ) error { // Set GC percent to 5% to reduce memory usage as iOS only allows 50MB of memory for the extension. debug.SetGCPercent(5) + notifier := tunnelnotifier.New(networkChangeListener, dnsManager) + defer notifier.Close() + mobileDependency := MobileDependency{ FileDescriptor: fileDescriptor, - NetworkChangeListener: networkChangeListener, - DnsManager: dnsManager, - HostDNSAddresses: dnsAddresses, + NetworkChangeListener: notifier, + DnsManager: notifier, StateFilePath: stateFilePath, + TempDir: cacheDir, } - return c.run(mobileDependency, nil, "") + return c.run(mobileDependency, nil, logFilePath) } func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan struct{}, logPath string) error { + // Mark the loop as started and signal exit on return so Stop can wait for + // the loop to finish (and skip the wait if the loop never ran). + c.runStarted.Store(true) + defer c.runOnce.Do(func() { close(c.runExited) }) + defer func() { if r := recover(); r != nil { rec := c.statusRecorder @@ -195,7 +242,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan wrapErr := state.Wrap myPrivateKey, err := wgtypes.ParseKey(c.config.PrivateKey) if err != nil { - log.Errorf("failed parsing Wireguard key %s: [%s]", c.config.PrivateKey, err.Error()) + log.Errorf("failed parsing Wireguard key: %s", err) return wrapErr(err) } @@ -235,13 +282,23 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan log.Errorf("failed to clean up temporary installer file: %v", err) } - defer c.statusRecorder.ClientStop() + defer func() { + c.statusRecorder.SetSessionExpiresAt(time.Time{}) + c.statusRecorder.ClientStop() + }() operation := func() error { // if context cancelled we not start new backoff cycle if c.ctx.Err() != nil { return nil } + // suspend connection attempts while the OS reports no usable network + if waited, err := c.netMgr.Wait(c.ctx); err != nil { + return nil + } else if waited { + backOff.Reset() + } + state.Set(StatusConnecting) engineCtx, cancel := context.WithCancel(c.ctx) @@ -253,8 +310,18 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan }() log.Debugf("connecting to the Management service %s", c.config.ManagementURL.Host) - mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled) + mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled, + mgm.WithNetEvents(c.netMgr)) if err != nil { + // On daemon shutdown / Down() the parent context is cancelled + // and the dial fails with "context canceled". Wrapping that + // into state would leave the snapshot stuck at Connecting+err + // until the backoff loop wakes up — instead let the operation + // return cleanly so the deferred state.Set(StatusIdle) takes + // effect on the next iteration. + if c.ctx.Err() != nil { + return nil + } return wrapErr(gstatus.Errorf(codes.FailedPrecondition, "failed connecting to Management Service : %s", err)) } mgmNotifier := statusRecorderToMgmConnStateNotifier(c.statusRecorder) @@ -285,7 +352,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan log.Debug(err) if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.PermissionDenied) { state.Set(StatusNeedsLogin) - _ = c.Stop() + c.runCancel() return backoff.Permanent(wrapErr(err)) // unrecoverable error } return wrapErr(err) @@ -293,6 +360,10 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan c.clientMetrics.RecordLoginDuration(engineCtx, time.Since(loginStarted), true) c.statusRecorder.MarkManagementConnected() + if metricsConfig := loginResp.GetNetbirdConfig().GetMetrics(); metricsConfig != nil { + c.clientMetrics.UpdatePushFromMgm(c.ctx, metricsConfig.GetEnabled()) + } + localPeerState := peer.LocalPeerState{ IP: loginResp.GetPeerConfig().GetAddress(), PubKey: myPrivateKey.PublicKey().String(), @@ -315,7 +386,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan }() // with the global Netbird config in hand connect (just a connection, no stream yet) Signal - signalClient, err := connectToSignal(engineCtx, loginResp.GetNetbirdConfig(), myPrivateKey) + signalClient, err := connectToSignal(engineCtx, loginResp.GetNetbirdConfig(), myPrivateKey, c.netMgr) if err != nil { log.Error(err) return wrapErr(err) @@ -333,6 +404,10 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan c.statusRecorder.MarkSignalConnected() relayURLs, token := parseRelayInfo(loginResp) + if override, ok := peer.OverrideRelayURLs(); ok { + log.Infof("overriding relay URLs from %s: %v", peer.EnvKeyNBHomeRelayServers, override) + relayURLs = override + } peerConfig := loginResp.GetPeerConfig() engineConfig, err := createEngineConfig(myPrivateKey, c.config, peerConfig, logPath) @@ -341,8 +416,14 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan return wrapErr(err) } engineConfig.TempDir = mobileDependency.TempDir + // Leave StateDir empty when there is no state path so a disk-backed + // syncstore falls back to os.TempDir() instead of filepath.Dir("") == ".". + if path != "" { + engineConfig.StateDir = filepath.Dir(path) + } - relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU) + relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU, + relayClient.WithNetEvents(c.netMgr)) c.statusRecorder.SetRelayMgr(relayManager) if len(relayURLs) > 0 { if token != nil { @@ -369,6 +450,8 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan StateManager: stateManager, UpdateManager: c.updateManager, ClientMetrics: c.clientMetrics, + MetricsCtx: c.ctx, + NetMgr: c.netMgr, }, mobileDependency) engine.SetSyncResponsePersistence(c.persistSyncResponse) c.engine = engine @@ -379,6 +462,10 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan return wrapErr(err) } + // Seed the session-expiry deadline from the LoginResponse. Subsequent + // changes flow in through SyncResponse and are applied in handleSync. + engine.ApplySessionDeadline(loginResp.GetSessionExpiresAt()) + log.Infof("Netbird engine started, the IP is: %s", peerConfig.GetAddress()) state.Set(StatusConnected) @@ -396,14 +483,10 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan c.engine = nil c.engineMutex.Unlock() - // todo: consider to remove this condition. Is not thread safe. - // We should always call Stop(), but we need to verify that it is idempotent - if engine.wgInterface != nil { - log.Infof("ensuring %s is removed, Netbird engine context cancelled", engine.wgInterface.Name()) + log.Infof("ensuring wg interface is removed, Netbird engine context cancelled") - if err := engine.Stop(); err != nil { - log.Errorf("Failed to stop engine: %v", err) - } + if err := engine.Stop(); err != nil { + log.Errorf("Failed to stop engine: %v", err) } c.statusRecorder.ClientTeardown() @@ -419,12 +502,26 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan } c.statusRecorder.ClientStart() - err = backoff.Retry(operation, backOff) + // Wrap the backoff with c.ctx so Down()/actCancel propagates into the + // inter-attempt sleep — otherwise a 15s MaxInterval can keep the retry + // loop alive long after the caller asked to give up, leaving the + // status stream stuck at Connecting. + err = backoff.Retry(operation, backoff.WithContext(backOff, c.ctx)) if err != nil { + // Once the client context is cancelled backoff.WithContext surfaces the + // bare context error, and any attempt torn down mid-flight reports the + // same. That cancellation is the caller asking us to stop (Stop, Down or + // an engine restart), so exit cleanly instead of handing back a failure + // the caller would have to distinguish from a real one. + if c.ctx.Err() != nil && errors.Is(err, context.Canceled) { + log.Info("exiting client retry loop, context cancelled") + return nil + } + log.Debugf("exiting client retry loop due to unrecoverable error: %s", err) if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.PermissionDenied) { state.Set(StatusNeedsLogin) - _ = c.Stop() + c.runCancel() } return err } @@ -502,11 +599,9 @@ func (c *ConnectClient) Status() StatusType { } func (c *ConnectClient) Stop() error { - engine := c.Engine() - if engine != nil { - if err := engine.Stop(); err != nil { - return fmt.Errorf("stop engine: %w", err) - } + c.runCancel() + if c.runStarted.Load() { + <-c.runExited } return nil } @@ -532,9 +627,20 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf if config.NetworkMonitor != nil { nm = *config.NetworkMonitor } + wgAddr, err := wgaddr.ParseWGAddress(peerConfig.Address) + if err != nil { + return nil, fmt.Errorf("parse overlay address %q: %w", peerConfig.Address, err) + } + + if !config.DisableIPv6 { + if err := wgAddr.SetIPv6FromCompact(peerConfig.GetAddressV6()); err != nil { + log.Warn(err) + } + } + engineConf := &EngineConfig{ WgIfaceName: config.WgIface, - WgAddr: peerConfig.Address, + WgAddr: wgAddr, IFaceBlackList: config.IFaceBlackList, DisableIPv6Discovery: config.DisableIPv6Discovery, WgPrivateKey: key, @@ -546,6 +652,7 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf RosenpassEnabled: config.RosenpassEnabled, RosenpassPermissive: config.RosenpassPermissive, ServerSSHAllowed: util.ReturnBoolWithDefaultTrue(config.ServerSSHAllowed), + RemoteJobsAllowed: util.ReturnBoolWithDefaultFalse(config.RemoteJobsAllowed), EnableSSHRoot: config.EnableSSHRoot, EnableSSHSFTP: config.EnableSSHSFTP, EnableSSHLocalPortForwarding: config.EnableSSHLocalPortForwarding, @@ -559,8 +666,10 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf DisableFirewall: config.DisableFirewall, BlockLANAccess: config.BlockLANAccess, BlockInbound: config.BlockInbound, + DisableIPv6: config.DisableIPv6, + SyncMessageVersion: config.SyncMessageVersion, - LazyConnectionEnabled: config.LazyConnectionEnabled, + LazyConnection: lazyconn.ParseState(config.LazyConnection), MTU: selectMTU(config.MTU, peerConfig.Mtu), LogPath: logPath, @@ -603,7 +712,7 @@ func selectMTU(localMTU uint16, peerMTU int32) uint16 { } // connectToSignal creates Signal Service client and established a connection -func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourPrivateKey wgtypes.Key) (*signal.GrpcClient, error) { +func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourPrivateKey wgtypes.Key, netMgr *netevents.Manager) (*signal.GrpcClient, error) { var sigTLSEnabled bool if wtConfig.Signal.Protocol == mgmProto.HostConfig_HTTPS { sigTLSEnabled = true @@ -611,7 +720,8 @@ func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourP sigTLSEnabled = false } - signalClient, err := signal.NewClient(ctx, wtConfig.Signal.Uri, ourPrivateKey, sigTLSEnabled) + signalClient, err := signal.NewClient(ctx, wtConfig.Signal.Uri, ourPrivateKey, sigTLSEnabled, + signal.WithNetEvents(netMgr)) if err != nil { log.Errorf("error while connecting to the Signal Exchange Service %s: %s", wtConfig.Signal.Uri, err) return nil, gstatus.Errorf(codes.FailedPrecondition, "failed connecting to Signal Service : %s", err) @@ -633,12 +743,14 @@ func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte, config.DisableFirewall, config.BlockLANAccess, config.BlockInbound, - config.LazyConnectionEnabled, + config.DisableIPv6, + config.SyncMessageVersion, config.EnableSSHRoot, config.EnableSSHSFTP, config.EnableSSHLocalPortForwarding, config.EnableSSHRemotePortForwarding, config.DisableSSHAuth, + config.RemoteJobsAllowed, ) return client.Login(sysInfo, pubSSHKey, config.DNSLabels) } diff --git a/client/internal/connect_android_default.go b/client/internal/connect_android_default.go index 190341c4a..b05e91fec 100644 --- a/client/internal/connect_android_default.go +++ b/client/internal/connect_android_default.go @@ -40,6 +40,10 @@ func (noopNetworkChangeListener) SetInterfaceIP(string) { // network stack, not by OS-level interface configuration. } +func (noopNetworkChangeListener) SetInterfaceIPv6(string) { + // No-op: same as SetInterfaceIP, IPv6 overlay is managed by userspace stack. +} + // noopDnsReadyListener is a stub for embed.Client on Android. // DNS readiness notifications are not needed in netstack/embed mode // since system DNS is disabled and DNS resolution happens externally. diff --git a/client/internal/connect_test.go b/client/internal/connect_test.go index c317c88d8..3212e6abf 100644 --- a/client/internal/connect_test.go +++ b/client/internal/connect_test.go @@ -5,65 +5,78 @@ import ( "testing" ) -func Test_freePort(t *testing.T) { - tests := []struct { - name string - port int - want int - shouldMatch bool - }{ - { - name: "when port is 0 use random port", - port: 0, - want: 0, - shouldMatch: false, - }, - { - name: "provided and available", - port: 51821, - want: 51821, - shouldMatch: true, - }, - { - name: "provided and not available", - port: 51830, - want: 51830, - shouldMatch: false, - }, - } - c1, err := net.ListenUDP("udp", &net.UDPAddr{Port: 0}) +// probeFreePort asks the OS for a free UDP port and immediately releases it. +// The returned number is only a hint: nothing stops another process from +// grabbing the same port before the caller gets a chance to bind it. +// +// A hardcoded port number is not an option here: any fixed number can fall +// inside the ephemeral range and be held by an unrelated process on the test +// runner. +func probeFreePort(t *testing.T) int { + t.Helper() + + conn, err := net.ListenUDP("udp", &net.UDPAddr{Port: 0}) if err != nil { - t.Errorf("freePort error = %v", err) + t.Fatalf("failed to bind probe port: %v", err) } - defer func(c1 *net.UDPConn) { - _ = c1.Close() - }(c1) - - if tests[1].port == c1.LocalAddr().(*net.UDPAddr).Port { - tests[1].port++ - tests[1].want++ - } - - tests[2].port = c1.LocalAddr().(*net.UDPAddr).Port - tests[2].want = c1.LocalAddr().(*net.UDPAddr).Port - - for _, tt := range tests { - - t.Run(tt.name, func(t *testing.T) { - got, err := freePort(tt.port) - - if err != nil { - t.Errorf("got an error while getting free port: %v", err) - } - - if tt.shouldMatch && got != tt.want { - t.Errorf("got a different port %v, want %v", got, tt.want) - } - - if !tt.shouldMatch && got == tt.want { - t.Errorf("got the same port %v, want a different port", tt.want) - } - }) - + port := conn.LocalAddr().(*net.UDPAddr).Port + if err := conn.Close(); err != nil { + t.Fatalf("failed to close probe port: %v", err) } + return port +} + +func Test_freePort(t *testing.T) { + t.Run("when port is 0 use random port", func(t *testing.T) { + got, err := freePort(0) + if err != nil { + t.Fatalf("got an error while getting free port: %v", err) + } + if got == 0 { + t.Errorf("got port 0, want a non-zero random port") + } + }) + + t.Run("provided and available", func(t *testing.T) { + const maxAttempts = 5 + + // The probed port is released before freePort binds it, so an + // unrelated process on the test runner can grab it in between, + // making freePort fall back to a different port. Retry with a + // freshly probed port instead of failing on a lost race. + for attempt := 1; attempt <= maxAttempts; attempt++ { + candidate := probeFreePort(t) + + got, err := freePort(candidate) + if err != nil { + t.Fatalf("got an error while getting free port: %v", err) + } + + if got == candidate { + return + } + t.Logf("attempt %d: freePort returned %d instead of the requested %d, retrying", attempt, got, candidate) + } + + t.Fatalf("freePort did not return the requested free port after %d attempts", maxAttempts) + }) + + t.Run("provided and not available", func(t *testing.T) { + busy, err := net.ListenUDP("udp", &net.UDPAddr{Port: 0}) + if err != nil { + t.Fatalf("failed to bind busy port: %v", err) + } + t.Cleanup(func() { + _ = busy.Close() + }) + busyPort := busy.LocalAddr().(*net.UDPAddr).Port + + got, err := freePort(busyPort) + if err != nil { + t.Fatalf("got an error while getting free port: %v", err) + } + if got == busyPort { + t.Errorf("got the same port %v, want a different port", busyPort) + } + }) } diff --git a/client/internal/daemonaddr/identity.go b/client/internal/daemonaddr/identity.go new file mode 100644 index 000000000..b6af515b7 --- /dev/null +++ b/client/internal/daemonaddr/identity.go @@ -0,0 +1,17 @@ +package daemonaddr + +import "strings" + +// CarriesIdentity reports whether the control channel at addr conveys the +// connecting process's identity to the daemon. A Unix socket carries peer +// credentials and a named pipe carries the client's token. Nothing else does, TCP +// included, and there the daemon can authorize a privileged operation for nobody +// at all: see ResolveDaemonAddr, which says as much to anyone still reaching the +// Windows daemon on the address it served before it had a pipe. +// +// A client uses this to tell whether becoming privileged would get it anywhere. +// It answers from the scheme and nothing else, so an address it does not +// recognise counts as carrying no identity. +func CarriesIdentity(addr string) bool { + return strings.HasPrefix(addr, "unix://") || strings.HasPrefix(addr, pipeScheme) +} diff --git a/client/internal/daemonaddr/identity_test.go b/client/internal/daemonaddr/identity_test.go new file mode 100644 index 000000000..2808b5017 --- /dev/null +++ b/client/internal/daemonaddr/identity_test.go @@ -0,0 +1,29 @@ +package daemonaddr + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCarriesIdentity(t *testing.T) { + tests := []struct { + addr string + want bool + }{ + {"unix:///var/run/netbird.sock", true}, + {"unix:///var/run/netbird/default.sock", true}, + {"npipe://netbird", true}, + {`npipe://\\.\pipe\ProtectedPrefix\Administrators\netbird`, true}, + {"tcp://127.0.0.1:41731", false}, + {"tcp://localhost:41731", false}, + {"", false}, + {"/var/run/netbird.sock", false}, + } + + for _, tt := range tests { + t.Run(tt.addr, func(t *testing.T) { + assert.Equal(t, tt.want, CarriesIdentity(tt.addr), "address %q", tt.addr) + }) + } +} diff --git a/client/internal/daemonaddr/owner.go b/client/internal/daemonaddr/owner.go new file mode 100644 index 000000000..c476f9ae6 --- /dev/null +++ b/client/internal/daemonaddr/owner.go @@ -0,0 +1,15 @@ +package daemonaddr + +// DaemonRunsAsSelf reports whether the daemon listening at addr runs as this very +// user. That is what makes an unprivileged daemon authorize this process for the +// changes it otherwise restricts to root or an administrator, so a client can tell +// up front whether those controls are usable instead of letting a save fail. +// +// It is answered from the ownership of the socket or pipe the daemon created, so it +// costs no round trip and needs no cooperation from the daemon. Ownership that +// cannot be read is reported as false, including for a TCP address, so a caller +// reading this as "the daemon would allow it" fails closed. The daemon remains the +// only thing that authorizes anything: this only decides what a client offers. +func DaemonRunsAsSelf(addr string) bool { + return daemonRunsAsSelf(addr) +} diff --git a/client/internal/daemonaddr/owner_unix.go b/client/internal/daemonaddr/owner_unix.go new file mode 100644 index 000000000..493e6528d --- /dev/null +++ b/client/internal/daemonaddr/owner_unix.go @@ -0,0 +1,40 @@ +//go:build !windows + +package daemonaddr + +import ( + "os" + "strings" + "syscall" + + log "github.com/sirupsen/logrus" +) + +// daemonRunsAsSelf compares the owner of the daemon's Unix socket with this +// process's uid. Root is not treated specially here: a root caller is privileged +// on its own merits, and a root-owned socket says nothing about the caller. +func daemonRunsAsSelf(addr string) bool { + path, ok := strings.CutPrefix(addr, "unix://") + if !ok { + return false + } + + info, err := os.Stat(path) + if err != nil { + log.Debugf("stat daemon socket %s: %v", path, err) + return false + } + + // Only a socket says anything about a daemon. A directory or a leftover + // regular file at that path is not one, and reading it as "the daemon runs as + // us" would offer controls the daemon then refuses. + if info.Mode()&os.ModeSocket == 0 { + return false + } + + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return false + } + return stat.Uid == uint32(os.Getuid()) +} diff --git a/client/internal/daemonaddr/owner_unix_test.go b/client/internal/daemonaddr/owner_unix_test.go new file mode 100644 index 000000000..363c7d95d --- /dev/null +++ b/client/internal/daemonaddr/owner_unix_test.go @@ -0,0 +1,62 @@ +//go:build !windows + +package daemonaddr + +import ( + "net" + "os" + "path/filepath" + "testing" +) + +// A socket this user created means the daemon runs as this user, which is the +// rootless case where the daemon delegates its authority to its own identity. +func TestDaemonRunsAsSelf_OwnSocket(t *testing.T) { + path := filepath.Join(t.TempDir(), "netbird.sock") + ln, err := net.Listen("unix", path) + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { + if err := ln.Close(); err != nil { + t.Logf("close listener: %v", err) + } + }) + + if !DaemonRunsAsSelf("unix://" + path) { + t.Error("a socket owned by this user must count as the daemon running as us") + } +} + +// Everything that is not a readable socket of ours has to answer false, because +// the caller reads a true as "the daemon would authorize me". +func TestDaemonRunsAsSelf_FailsClosed(t *testing.T) { + dir := t.TempDir() + + // A socket owned by another user, which is what a root-run daemon looks like + // to an unprivileged client. Only assertable when we are not root ourselves. + rootOwned := "unix:///var/run/netbird.sock" + if _, err := os.Stat("/var/run/netbird.sock"); err == nil && os.Getuid() != 0 { + if DaemonRunsAsSelf(rootOwned) { + t.Error("a socket owned by another user must not count as ours") + } + } + + for name, addr := range map[string]string{ + "missing socket": "unix://" + filepath.Join(dir, "absent.sock"), + "tcp address": "tcp://127.0.0.1:41731", + "named pipe": "npipe://netbird", + "empty": "", + "no scheme": filepath.Join(dir, "absent.sock"), + "directory": "unix://" + dir, + "unknown scheme": "http://localhost:8080", + "scheme only": "unix://", + "relative socket": "unix://netbird.sock", + } { + t.Run(name, func(t *testing.T) { + if DaemonRunsAsSelf(addr) { + t.Errorf("%q must not count as a daemon running as us", addr) + } + }) + } +} diff --git a/client/internal/daemonaddr/owner_windows.go b/client/internal/daemonaddr/owner_windows.go new file mode 100644 index 000000000..1cd2bba15 --- /dev/null +++ b/client/internal/daemonaddr/owner_windows.go @@ -0,0 +1,42 @@ +//go:build windows + +package daemonaddr + +import ( + "context" + "strings" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/ipcauth" +) + +// daemonRunsAsSelf reads the owner of the daemon's pipe. A daemon running as the +// service account owns its pipe as LocalSystem, and an elevated one as +// BUILTIN\Administrators, so only a daemon the user started themselves matches. +func daemonRunsAsSelf(addr string) bool { + name, ok := strings.CutPrefix(addr, pipeScheme) + if !ok { + return false + } + + for _, path := range PipePaths(name) { + // Bounded: this runs on the UI's path for deciding which controls to + // offer, so a pipe that does not answer promptly must not stall it. A + // timeout leaves the caller unprivileged, which only disables controls. + ctx, cancel := context.WithTimeout(context.Background(), probeTimeout) + conn, err := dialPipe(ctx, path) + cancel() + if err != nil { + continue + } + + owned := ipcauth.PipeOwnedBySelf(conn) + if cerr := conn.Close(); cerr != nil { + log.Debugf("close daemon pipe %s after ownership check: %v", path, cerr) + } + return owned + } + + return false +} diff --git a/client/internal/daemonaddr/pipe.go b/client/internal/daemonaddr/pipe.go new file mode 100644 index 000000000..51815ef5e --- /dev/null +++ b/client/internal/daemonaddr/pipe.go @@ -0,0 +1,103 @@ +package daemonaddr + +import ( + "context" + "net" + "runtime" + "strings" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +const ( + // WindowsPipeAddr is the default daemon address on Windows. A named pipe + // carries the connecting process's token, which loopback TCP does not, so + // it is the only Windows transport on which the daemon can tell who is + // calling it. + WindowsPipeAddr = "npipe://netbird" + + // legacyWindowsAddr is the loopback-TCP address the Windows daemon used + // before named-pipe support. + legacyWindowsAddr = "tcp://127.0.0.1:41731" + + pipeScheme = "npipe://" + + // protectedPrefix is the NPFS namespace in which only LocalSystem and + // members of BUILTIN\Administrators may create a pipe. A daemon running as + // the service account creates its pipe there so that an unprivileged process + // cannot pre-create the name, which would keep the daemon from starting and + // leave callers talking to the squatter. Opening such a pipe needs no + // privilege, so unprivileged clients still reach the daemon. + protectedPrefix = `ProtectedPrefix\Administrators\` +) + +// DialTarget returns the gRPC dial target and transport options for a daemon +// address. The npipe scheme needs a context dialer because gRPC has no +// named-pipe resolver; unix and tcp are handled by gRPC itself. +func DialTarget(addr string) (string, []grpc.DialOption) { + opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())} + + if name, ok := strings.CutPrefix(addr, pipeScheme); ok { + paths := PipePaths(name) + opts = append(opts, grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { + return dialPipePaths(ctx, paths) + })) + return "passthrough:///netbird-daemon-pipe", opts + } + + return strings.TrimPrefix(addr, "tcp://"), opts +} + +// PipePath maps an npipe address name ("netbird", from "npipe://netbird") to a +// Windows named-pipe path (\\.\pipe\netbird). A fully qualified path is left as +// is. +func PipePath(name string) string { + if strings.HasPrefix(name, `\\`) { + return name + } + return `\\.\pipe\` + name +} + +// PipePaths returns the paths a daemon control pipe may live at for an npipe +// address name, in the order both sides must try them: the protected name first, +// then the plain one. +// +// The daemon serves the first it can create, which is the protected name when it +// runs as the service account and the plain one when it runs as an ordinary user, +// as it does in netstack mode. Clients therefore have to try both, and because a +// client cannot tell from the name alone who created the pipe, the plain name is +// only usable once the server's identity has been checked: see +// verifyPipeServer. +// +// A fully qualified path is what the operator asked for and is used as is. +func PipePaths(name string) []string { + if strings.HasPrefix(name, `\\`) { + return []string{name} + } + return []string{PipePath(protectedPrefix + name), PipePath(name)} +} + +// IsProtectedPipePath reports whether a pipe path is in the namespace only an +// administrator or LocalSystem can create in, which is what lets a client trust +// such a pipe from its name alone. +func IsProtectedPipePath(path string) bool { + return strings.HasPrefix(path, `\\.\pipe\`+protectedPrefix) +} + +// MigrateLegacy upgrades the pre-named-pipe Windows daemon address to the named +// pipe, reporting whether it rewrote the address. Existing installs persist the +// daemon address, so without this an upgraded daemon would keep listening on +// loopback TCP, where callers carry no identity and privileged operations would +// have to be refused for everyone. Only the exact legacy default is rewritten: +// a deliberately chosen custom address is left alone. +func MigrateLegacy(addr string) (string, bool) { + return migrateLegacyForOS(runtime.GOOS, addr) +} + +func migrateLegacyForOS(goos, addr string) (string, bool) { + if goos == "windows" && addr == legacyWindowsAddr { + return WindowsPipeAddr, true + } + return addr, false +} diff --git a/client/internal/daemonaddr/pipe_other.go b/client/internal/daemonaddr/pipe_other.go new file mode 100644 index 000000000..04e8e7331 --- /dev/null +++ b/client/internal/daemonaddr/pipe_other.go @@ -0,0 +1,15 @@ +//go:build !windows + +package daemonaddr + +import ( + "context" + "fmt" + "net" +) + +// dialPipePaths is Windows-only: no other platform serves the daemon on a named +// pipe. +func dialPipePaths(context.Context, []string) (net.Conn, error) { + return nil, fmt.Errorf("named pipes are only supported on Windows") +} diff --git a/client/internal/daemonaddr/pipe_test.go b/client/internal/daemonaddr/pipe_test.go new file mode 100644 index 000000000..b9dfd90f1 --- /dev/null +++ b/client/internal/daemonaddr/pipe_test.go @@ -0,0 +1,30 @@ +package daemonaddr + +import ( + "slices" + "testing" +) + +// The protected name must be tried before the plain one on both sides: it is the +// one an unprivileged process cannot create, so preferring it is what keeps a +// squatter from owning the name the service daemon would otherwise use. +func TestPipePaths_PrefersTheProtectedName(t *testing.T) { + got := PipePaths("netbird") + want := []string{ + `\\.\pipe\ProtectedPrefix\Administrators\netbird`, + `\\.\pipe\netbird`, + } + if !slices.Equal(got, want) { + t.Errorf("PipePaths = %q, want %q", got, want) + } +} + +// An operator who passes a full path chose exactly one pipe, so neither side may +// look anywhere else. +func TestPipePaths_QualifiedPathIsUsedAsIs(t *testing.T) { + path := `\\.\pipe\custom-netbird` + got := PipePaths(path) + if !slices.Equal(got, []string{path}) { + t.Errorf("PipePaths = %q, want just %q", got, path) + } +} diff --git a/client/internal/daemonaddr/pipe_windows.go b/client/internal/daemonaddr/pipe_windows.go new file mode 100644 index 000000000..3cd10a6c3 --- /dev/null +++ b/client/internal/daemonaddr/pipe_windows.go @@ -0,0 +1,59 @@ +//go:build windows + +package daemonaddr + +import ( + "context" + "errors" + "fmt" + "net" + + "github.com/Microsoft/go-winio" + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" + + "github.com/netbirdio/netbird/client/internal/ipcauth" +) + +// dialPipePaths connects to the first path that answers with a pipe server this +// client may trust, and returns the last error when none does. +func dialPipePaths(ctx context.Context, paths []string) (net.Conn, error) { + var lastErr error + for _, path := range paths { + conn, err := dialPipe(ctx, path) + if err != nil { + log.Debugf("dial daemon pipe %s: %v", path, err) + lastErr = err + continue + } + + // A pipe in the protected namespace could only have been created by an + // administrator or LocalSystem, so its name is the guarantee. Any other + // name has to be checked, because any local user can create one. + if !IsProtectedPipePath(path) { + if err := ipcauth.PipeServerTrusted(conn); err != nil { + if closeErr := conn.Close(); closeErr != nil { + log.Debugf("close untrusted pipe %s: %v", path, closeErr) + } + lastErr = fmt.Errorf("%s: %w", path, err) + continue + } + } + + return conn, nil + } + + if lastErr == nil { + lastErr = errors.New("no daemon pipe to connect to") + } + return nil, lastErr +} + +// dialPipe connects to the daemon control pipe at SECURITY_IDENTIFICATION. +// winio's plain DialPipe connects at SECURITY_ANONYMOUS, under which the daemon +// cannot read the caller's token at all. Identification lets the daemon read the +// caller's SID and groups without granting it the ability to act as the caller. +func dialPipe(ctx context.Context, path string) (net.Conn, error) { + access := uint32(windows.GENERIC_READ | windows.GENERIC_WRITE) + return winio.DialPipeAccessImpLevel(ctx, path, access, winio.PipeImpLevelIdentification) +} diff --git a/client/internal/daemonaddr/resolve_pipe_other.go b/client/internal/daemonaddr/resolve_pipe_other.go new file mode 100644 index 000000000..1aede8453 --- /dev/null +++ b/client/internal/daemonaddr/resolve_pipe_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package daemonaddr + +// ResolveDaemonAddr is a no-op off Windows, where there is no named-pipe +// default to fall back from. +func ResolveDaemonAddr(addr string) string { + return addr +} diff --git a/client/internal/daemonaddr/resolve_pipe_windows.go b/client/internal/daemonaddr/resolve_pipe_windows.go new file mode 100644 index 000000000..d12ddb15d --- /dev/null +++ b/client/internal/daemonaddr/resolve_pipe_windows.go @@ -0,0 +1,82 @@ +//go:build windows + +package daemonaddr + +import ( + "net" + "strings" + "time" + + "github.com/Microsoft/go-winio" + log "github.com/sirupsen/logrus" +) + +// probeTimeout bounds each transport probe. Both are local, so a daemon that is +// listening answers immediately and one that is not fails immediately. +const probeTimeout = 300 * time.Millisecond + +// ResolveDaemonAddr keeps a client on the named pipe and never silently moves it +// off. When the pipe does not answer it checks the legacy loopback TCP address, so +// a client meeting a daemon that has not restarted since the upgrade can say what +// is wrong, but it does not connect there. +// +// Using that address automatically would be a downgrade the user never asked for: +// any local process can bind 127.0.0.1 while the daemon is not listening, and the +// transport carries no caller identity, so a client that accepted whatever answered +// would hand a setup key, a pre-shared key or an SSO prompt to a local impostor. An +// operator who needs the legacy address during the upgrade window can still pass +// --daemon-addr explicitly, which is a deliberate choice and still refuses the +// privileged operations. +// +// Only the pipe address is resolved. A custom address is left alone, though passing +// --daemon-addr npipe://netbird explicitly is indistinguishable from the default +// here, so it is treated the same way. +func ResolveDaemonAddr(addr string) string { + if addr != WindowsPipeAddr { + return addr + } + + for _, path := range PipePaths("netbird") { + if pipeAvailable(path) { + return addr + } + } + + if tcpAvailable(legacyWindowsAddr) { + log.Warnf("the daemon is not serving %s, but something is listening on the legacy %s. "+ + "Restart the NetBird service so it serves the pipe. That address is not used automatically: "+ + "any local user can bind it and it carries no caller identity, so pass --daemon-addr %s "+ + "explicitly if you accept that", + WindowsPipeAddr, legacyWindowsAddr, legacyWindowsAddr) + } + + return addr +} + +func pipeAvailable(path string) bool { + timeout := probeTimeout + conn, err := winio.DialPipe(path, &timeout) + if err != nil { + return false + } + if err := conn.Close(); err != nil { + log.Debugf("close daemon pipe probe: %v", err) + } + return true +} + +func tcpAvailable(addr string) bool { + host := addr + if _, after, ok := strings.Cut(addr, "://"); ok { + host = after + } + + conn, err := net.DialTimeout("tcp", host, probeTimeout) + if err != nil { + return false + } + if err := conn.Close(); err != nil { + log.Debugf("close daemon TCP probe: %v", err) + } + return true +} diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go index bddb9a69e..b362ae293 100644 --- a/client/internal/debug/debug.go +++ b/client/internal/debug/debug.go @@ -21,6 +21,7 @@ import ( "time" log "github.com/sirupsen/logrus" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "google.golang.org/protobuf/encoding/protojson" "github.com/netbirdio/netbird/client/anonymize" @@ -30,11 +31,11 @@ import ( "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/shared/netiputil" ) -const readmeContent = `Netbird debug bundle -This debug bundle contains the following files. -If the --anonymize flag is set, the files are anonymized to protect sensitive information. +const readmeContent = `This debug bundle contains the following files. +If anonymization is enabled (--anonymize / --anonymize-level), the files are anonymized to protect sensitive information. status.txt: Anonymized status information of the NetBird client. client.log: Most recent, anonymized client log file of the NetBird client. @@ -43,10 +44,14 @@ netbird.out: Most recent, anonymized stdout log file of the NetBird client. routes.txt: Detailed system routing table in tabular format including destination, gateway, interface, metrics, and protocol information, if --system-info flag was provided. interfaces.txt: Anonymized network interface information, if --system-info flag was provided. ip_rules.txt: Detailed IP routing rules in tabular format including priority, source, destination, interfaces, table, and action information (Linux only), if --system-info flag was provided. -iptables.txt: Anonymized iptables rules with packet counters, if --system-info flag was provided. -nftables.txt: Anonymized nftables rules with packet counters, if --system-info flag was provided. +iptables.txt: Anonymized iptables (IPv4) rules with packet counters, if --system-info flag was provided. +ip6tables.txt: Anonymized ip6tables (IPv6) rules with packet counters, if --system-info flag was provided. +ipset.txt: Anonymized ipset list output, if --system-info flag was provided. +nftables.txt: Anonymized nftables rules with packet counters across all families (ip, ip6, inet, etc.), if --system-info flag was provided. +sysctls.txt: Forwarding, reverse-path filter, source-validation, and conntrack accounting sysctl values that the NetBird client may read or modify, if --system-info flag was provided (Linux only). resolv.conf: DNS resolver configuration from /etc/resolv.conf (Unix systems only), if --system-info flag was provided. scutil_dns.txt: DNS configuration from scutil --dns (macOS only), if --system-info flag was provided. +dns_windows.txt: Anonymized NRPT rules and policy table in effect, DNS client policy, and per-interface and per-adapter DNS configuration (Windows only), if --system-info flag was provided. resolved_domains.txt: Anonymized resolved domain IP addresses from the status recorder. config.txt: Anonymized configuration information of the NetBird client. network_map.json: Anonymized sync response containing peer configurations, routes, DNS settings, and firewall rules. @@ -61,24 +66,38 @@ allocs.prof: Allocations profiling information. threadcreate.prof: Thread creation profiling information. cpu.prof: CPU profiling information. stack_trace.txt: Complete stack traces of all goroutines at the time of bundle creation. +capture.pcap: Packet capture in pcap format. Only present when capture was running during bundle collection. Omitted from anonymized bundles because it contains raw decrypted packet data. Anonymization Process -The files in this bundle have been anonymized to protect sensitive information. Here's how the anonymization was applied: +The files in this bundle have been anonymized to protect sensitive information. The level applied to this bundle is recorded at the top of this file. Here's how the anonymization was applied: IP Addresses -IPv4 addresses are replaced with addresses starting from 198.51.100.0 -IPv6 addresses are replaced with addresses starting from 100:: +Default level: +- Public IPv4 addresses are replaced with addresses starting from 198.51.100.0 +- Public IPv6 addresses are replaced with addresses starting from 2001:db8:ffff:: +- IPv6 unique local addresses (fc00::/7) are anonymized as well: their random global ID uniquely identifies the network. +- IP addresses from internal IPv4 ranges and well-known addresses are not anonymized (e.g. 8.8.8.8, 100.64.0.0/10, addresses starting with 192.168., 172.16., 10., 169.254., fe80::). + +Strict level (--anonymize-level strict), in addition to the default level: +- Private (RFC 1918), CGNAT (100.64.0.0/10), and link-local (169.254.0.0/16, fe80::/10) addresses are anonymized too. +- Internal IPv4 addresses are replaced with addresses starting from 198.18.0.0 and internal IPv6 addresses with addresses starting from 2001:db8:1::, so internal addresses remain distinguishable from public ones. +- Addresses are mapped in order of first appearance: subnet structure, allocation scheme, and gateway conventions are not preserved. Prefix lengths of networks are preserved. +- Peer names in front of NetBird domains are replaced with numbered placeholders (e.g. peer-1.netbird.cloud), and subdomain labels of other domains with host-N placeholders. +- WireGuard public keys are replaced with consistent placeholder keys. -IP addresses from non public ranges and well known addresses are not anonymized (e.g. 8.8.8.8, 100.64.0.0/10, addresses starting with 192.168., 172.16., 10., etc.). Reoccuring IP addresses are replaced with the same anonymized address. Note: The anonymized IP addresses in the status file do not match those in the log and routes files. However, the anonymized IP addresses are consistent within the status file and across the routes and log files. +MAC Addresses +MAC addresses are replaced at every anonymization level with consistent placeholders counting up from 02:00:00:00:00:01. Broadcast, multicast, and all-zero addresses are kept. At the default level a preserved IPv6 link-local address may still embed a MAC address (EUI-64); the strict level anonymizes those addresses. + Domains All domain names (except for the netbird domains) are replaced with randomly generated strings ending in ".domain". Anonymized domains are consistent across all files in the bundle. Reoccuring domain names are replaced with the same anonymized domain. +At the strict level, the peer name labels in front of netbird domains are anonymized as well. Sync Response The network_map.json file contains the following anonymized information: @@ -162,22 +181,33 @@ The config.txt file contains anonymized configuration information of the NetBird Other non-sensitive configuration options are included without anonymization. Firewall Rules (Linux only) -The bundle includes two separate firewall rule files: +The bundle includes the following firewall-related files: iptables.txt: -- Complete iptables ruleset with packet counters using 'iptables -v -n -L' +- IPv4 iptables ruleset with packet counters using 'iptables-save' and 'iptables -v -n -L' - Includes all tables (filter, nat, mangle, raw, security) - Shows packet and byte counters for each rule - All IP addresses are anonymized - Chain names, table names, and other non-sensitive information remain unchanged +ip6tables.txt: +- IPv6 ip6tables ruleset with packet counters using 'ip6tables-save' and 'ip6tables -v -n -L' +- Same table coverage and anonymization as iptables.txt +- Omitted when ip6tables is not installed or no IPv6 rules are present + +ipset.txt: +- Output of 'ipset list' (family-agnostic) +- IP addresses are anonymized; set names and types remain unchanged + nftables.txt: -- Complete nftables ruleset obtained via 'nft -a list ruleset' +- Complete nftables ruleset across all families (ip, ip6, inet, arp, bridge, netdev) via 'nft -a list ruleset' - Includes rule handle numbers and packet counters -- All tables, chains, and rules are included -- Shows packet and byte counters for each rule -- All IP addresses are anonymized -- Chain names, table names, and other non-sensitive information remain unchanged +- All IP addresses are anonymized; chain/table names remain unchanged + +sysctls.txt: +- Forwarding (IPv4 + IPv6, global and per-interface), reverse-path filter, source-validation, conntrack accounting, and TCP-related sysctls that netbird may read or modify +- Per-interface keys are enumerated from /proc/sys/net/ipv{4,6}/conf +- Interface names anonymized when --anonymize is set IP Rules (Linux only) The ip_rules.txt file contains detailed IP routing rule information: @@ -208,6 +238,13 @@ scutil_dns.txt (macOS only): - Shows DNS configuration for all network interfaces - Includes search domains, nameservers, and DNS resolver settings - All IP addresses and domain names are anonymized + +dns_windows.txt (Windows only): +- Lists the NRPT rules of both policy stores, the local one and the group policy one, marking the rules the client created +- Follows them with the policy table the resolver has loaded, which differs from the rules while a change has not been picked up yet +- Includes the DNS client group policy, the global TCP/IP and Dnscache parameters, and the DNS values of every interface that has any +- Ends with the resolver configuration in effect per adapter, from GetAdaptersAddresses +- All IP addresses and domain names are anonymized ` const ( @@ -215,6 +252,12 @@ const ( errorLogFile = "netbird.err" stdoutLogFile = "netbird.out" + // Rotated-log glob prefixes (base log name without extension) passed to + // addRotatedLogFiles. The daemon's own log and the GUI log live in the same + // dir, so the prefixes must be disjoint to keep their rotated siblings apart. + clientLogPrefix = "client" + uiLogPrefix = "gui-client" + darwinErrorLogPath = "/var/log/netbird.out.log" darwinStdoutLogPath = "/var/log/netbird.err.log" ) @@ -224,6 +267,20 @@ type MetricsExporter interface { Export(w io.Writer) error } +// LogOpener opens a log file for inclusion in the bundle. It exists so that log +// files whose path was supplied by an IPC caller can be opened under a check +// the daemon defines, instead of being opened with the daemon's privileges +// unconditionally. +type LogOpener func(path string) (*os.File, error) + +func openLogFile(path string) (*os.File, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + return f, nil +} + type BundleGenerator struct { anonymizer *anonymize.Anonymizer @@ -232,12 +289,19 @@ type BundleGenerator struct { statusRecorder *peer.Status syncResponse *mgmProto.SyncResponse logPath string + uiLogPath string + uiLogOpener LogOpener tempDir string + statePath string cpuProfile []byte + capturePath string refreshStatus func() // Optional callback to refresh status before bundle generation clientMetrics MetricsExporter + daemonVersion string + cliVersion string anonymize bool + anonymizeLevel anonymize.Level includeSystemInfo bool logFileCount uint32 @@ -245,7 +309,10 @@ type BundleGenerator struct { } type BundleConfig struct { - Anonymize bool + Anonymize bool + // AnonymizeLevel selects how much the anonymizer redacts. + // anonymize.LevelStrict implies Anonymize. + AnonymizeLevel anonymize.Level IncludeSystemInfo bool LogFileCount uint32 } @@ -255,10 +322,21 @@ type GeneratorDependencies struct { StatusRecorder *peer.Status SyncResponse *mgmProto.SyncResponse LogPath string - TempDir string // Directory for temporary bundle zip files. If empty, os.TempDir() is used. - CPUProfile []byte - RefreshStatus func() // Optional callback to refresh status before bundle generation - ClientMetrics MetricsExporter + UILogPath string // Absolute path to the desktop UI's gui-client.log, reported via RegisterUILog. Empty if no UI registered one. + // UILogOpener opens the UI log and its rotated siblings. The path comes from + // a local IPC caller, so the daemon must not open it with plain os.Open: the + // opener is where the caller's right to that file is enforced. Defaults to + // os.Open, which is only correct where the path is not caller-supplied + // (mobile). + UILogOpener LogOpener + TempDir string // Directory for temporary bundle zip files. If empty, os.TempDir() is used. + StatePath string // Path to the state file. If empty, the ServiceManager default path is used. + CPUProfile []byte + CapturePath string + RefreshStatus func() + ClientMetrics MetricsExporter + DaemonVersion string + CliVersion string } func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGenerator { @@ -268,19 +346,34 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen logFileCount = 1 } + uiLogOpener := deps.UILogOpener + if uiLogOpener == nil { + uiLogOpener = openLogFile + } + + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + anonymizer.SetLevel(cfg.AnonymizeLevel) + return &BundleGenerator{ - anonymizer: anonymize.NewAnonymizer(anonymize.DefaultAddresses()), + anonymizer: anonymizer, internalConfig: deps.InternalConfig, statusRecorder: deps.StatusRecorder, syncResponse: deps.SyncResponse, logPath: deps.LogPath, + uiLogPath: deps.UILogPath, + uiLogOpener: uiLogOpener, tempDir: deps.TempDir, + statePath: deps.StatePath, cpuProfile: deps.CPUProfile, + capturePath: deps.CapturePath, refreshStatus: deps.RefreshStatus, clientMetrics: deps.ClientMetrics, + daemonVersion: deps.DaemonVersion, + cliVersion: deps.CliVersion, - anonymize: cfg.Anonymize, + anonymize: cfg.Anonymize || cfg.AnonymizeLevel >= anonymize.LevelStrict, + anonymizeLevel: cfg.AnonymizeLevel, includeSystemInfo: cfg.IncludeSystemInfo, logFileCount: logFileCount, } @@ -346,6 +439,10 @@ func (g *BundleGenerator) createArchive() error { log.Errorf("failed to add CPU profile to debug bundle: %v", err) } + if err := g.addCaptureFile(); err != nil { + log.Errorf("failed to add capture file to debug bundle: %v", err) + } + if err := g.addStackTrace(); err != nil { log.Errorf("failed to add stack trace to debug bundle: %v", err) } @@ -378,6 +475,10 @@ func (g *BundleGenerator) createArchive() error { log.Errorf("failed to add logs to debug bundle: %v", err) } + if err := g.addUILog(); err != nil { + log.Errorf("failed to add UI log to debug bundle: %v", err) + } + if err := g.addUpdateLogs(); err != nil { log.Errorf("failed to add updater logs: %v", err) } @@ -402,13 +503,23 @@ func (g *BundleGenerator) addSystemInfo() { log.Errorf("failed to add firewall rules to debug bundle: %v", err) } + if err := g.addSysctls(); err != nil { + log.Errorf("failed to add sysctls to debug bundle: %v", err) + } + if err := g.addDNSInfo(); err != nil { log.Errorf("failed to add DNS info to debug bundle: %v", err) } } func (g *BundleGenerator) addReadme() error { - readmeReader := strings.NewReader(readmeContent) + level := "none (anonymization disabled)" + if g.anonymize { + level = g.anonymizeLevel.String() + } + header := fmt.Sprintf("Netbird debug bundle\nAnonymization level applied to this bundle: %s\n", level) + + readmeReader := strings.NewReader(header + readmeContent) if err := g.addFileToZip(readmeReader, "README.txt"); err != nil { return fmt.Errorf("add README file to zip: %w", err) } @@ -429,11 +540,13 @@ func (g *BundleGenerator) addStatus() error { fullStatus := g.statusRecorder.GetFullStatus() protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus) - protoFullStatus.Events = g.statusRecorder.GetEventHistory() overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{ - Anonymize: g.anonymize, - ProfileName: profName, + Anonymize: g.anonymize, + AnonymizeLevel: g.anonymizeLevel, + ProfileName: profName, + DaemonVersion: g.daemonVersion, }) + overview.CliVersion = g.cliVersion statusOutput := overview.FullDetailSummary() statusReader := strings.NewReader(statusOutput) @@ -480,6 +593,14 @@ func (g *BundleGenerator) addConfig() error { } } + // Surface the set of MDM-enforced keys so a support engineer reading + // the bundle can tell which field values are user-set vs MDM-overridden. + // Same semantics as the mDMManagedFields list returned by the + // GetConfig RPC consumed by `netbird debug config`. + if managed := g.internalConfig.Policy().ManagedKeys(); len(managed) > 0 { + configContent.WriteString(fmt.Sprintf("MDMManagedFields: %v\n", managed)) + } + configReader := strings.NewReader(configContent.String()) if err := g.addFileToZip(configReader, "config.txt"); err != nil { return fmt.Errorf("add config file to zip: %w", err) @@ -575,6 +696,9 @@ func isSensitiveEnvVar(key string) bool { func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder) { configContent.WriteString("NetBird Client Configuration:\n\n") + if key, err := wgtypes.ParseKey(g.internalConfig.PrivateKey); err == nil { + configContent.WriteString(fmt.Sprintf("PublicKey: %s\n", g.anonymizer.AnonymizeWGKey(key.PublicKey().String()))) + } configContent.WriteString(fmt.Sprintf("WgIface: %s\n", g.internalConfig.WgIface)) configContent.WriteString(fmt.Sprintf("WgPort: %d\n", g.internalConfig.WgPort)) if g.internalConfig.NetworkMonitor != nil { @@ -587,6 +711,9 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder) if g.internalConfig.ServerSSHAllowed != nil { configContent.WriteString(fmt.Sprintf("ServerSSHAllowed: %v\n", *g.internalConfig.ServerSSHAllowed)) } + if g.internalConfig.RemoteJobsAllowed != nil { + configContent.WriteString(fmt.Sprintf("RemoteJobsAllowed: %v\n", *g.internalConfig.RemoteJobsAllowed)) + } if g.internalConfig.EnableSSHRoot != nil { configContent.WriteString(fmt.Sprintf("EnableSSHRoot: %v\n", *g.internalConfig.EnableSSHRoot)) } @@ -599,6 +726,12 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder) if g.internalConfig.EnableSSHRemotePortForwarding != nil { configContent.WriteString(fmt.Sprintf("EnableSSHRemotePortForwarding: %v\n", *g.internalConfig.EnableSSHRemotePortForwarding)) } + if g.internalConfig.DisableSSHAuth != nil { + configContent.WriteString(fmt.Sprintf("DisableSSHAuth: %v\n", *g.internalConfig.DisableSSHAuth)) + } + if g.internalConfig.SSHJWTCacheTTL != nil { + configContent.WriteString(fmt.Sprintf("SSHJWTCacheTTL: %d\n", *g.internalConfig.SSHJWTCacheTTL)) + } configContent.WriteString(fmt.Sprintf("DisableClientRoutes: %v\n", g.internalConfig.DisableClientRoutes)) configContent.WriteString(fmt.Sprintf("DisableServerRoutes: %v\n", g.internalConfig.DisableServerRoutes)) @@ -606,6 +739,10 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder) configContent.WriteString(fmt.Sprintf("DisableFirewall: %v\n", g.internalConfig.DisableFirewall)) configContent.WriteString(fmt.Sprintf("BlockLANAccess: %v\n", g.internalConfig.BlockLANAccess)) configContent.WriteString(fmt.Sprintf("BlockInbound: %v\n", g.internalConfig.BlockInbound)) + configContent.WriteString(fmt.Sprintf("DisableIPv6: %v\n", g.internalConfig.DisableIPv6)) + configContent.WriteString(fmt.Sprintf("LocalMetricsEnabled: %v\n", g.internalConfig.LocalMetricsEnabled)) + configContent.WriteString(fmt.Sprintf("LocalMetricsAddress: %s\n", g.internalConfig.LocalMetricsAddress)) + configContent.WriteString(fmt.Sprintf("SyncMessageVersion: %v\n", g.internalConfig.SyncMessageVersion)) if g.internalConfig.DisableNotifications != nil { configContent.WriteString(fmt.Sprintf("DisableNotifications: %v\n", *g.internalConfig.DisableNotifications)) @@ -624,7 +761,8 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder) configContent.WriteString(fmt.Sprintf("ClientCertKeyPath: %s\n", g.internalConfig.ClientCertKeyPath)) } - configContent.WriteString(fmt.Sprintf("LazyConnectionEnabled: %v\n", g.internalConfig.LazyConnectionEnabled)) + configContent.WriteString(fmt.Sprintf("LazyConnection: %q\n", g.internalConfig.LazyConnection)) + configContent.WriteString(fmt.Sprintf("MTU: %d\n", g.internalConfig.MTU)) } func (g *BundleGenerator) addProf() (err error) { @@ -669,6 +807,29 @@ func (g *BundleGenerator) addCPUProfile() error { return nil } +func (g *BundleGenerator) addCaptureFile() error { + if g.capturePath == "" { + return nil + } + + if g.anonymize { + log.Info("skipping capture file in anonymized bundle (contains raw packet data)") + return nil + } + + f, err := os.Open(g.capturePath) + if err != nil { + return fmt.Errorf("open capture file: %w", err) + } + defer f.Close() + + if err := g.addFileToZip(f, "capture.pcap"); err != nil { + return fmt.Errorf("add capture file to zip: %w", err) + } + + return nil +} + func (g *BundleGenerator) addStackTrace() error { buf := make([]byte, 5242880) // 5 MB buffer n := runtime.Stack(buf, true) @@ -736,6 +897,8 @@ func (g *BundleGenerator) addSyncResponse() error { AllowPartial: true, } + g.maskSecrets() + jsonBytes, err := options.Marshal(g.syncResponse) if err != nil { return fmt.Errorf("generate json: %w", err) @@ -748,9 +911,33 @@ func (g *BundleGenerator) addSyncResponse() error { return nil } +func (g *BundleGenerator) maskSecrets() { + if g.syncResponse == nil || g.syncResponse.NetbirdConfig == nil { + return + } + + if g.syncResponse.NetbirdConfig.Flow != nil { + g.syncResponse.NetbirdConfig.Flow.TokenPayload = maskedValue + + } + + if g.syncResponse.NetbirdConfig.Relay != nil { + g.syncResponse.NetbirdConfig.Relay.TokenPayload = maskedValue + } + + for i := range g.syncResponse.NetbirdConfig.Turns { + if g.syncResponse.NetbirdConfig.Turns[i] != nil { + g.syncResponse.NetbirdConfig.Turns[i].Password = maskedValue + } + } +} + func (g *BundleGenerator) addStateFile() error { - sm := profilemanager.NewServiceManager("") - path := sm.GetStatePath() + path := g.statePath + if path == "" { + sm := profilemanager.NewServiceManager("") + path = sm.GetStatePath() + } if path == "" { return nil } @@ -805,6 +992,11 @@ func (g *BundleGenerator) addUpdateLogs() error { } baseName := filepath.Base(logFile) + data, err = g.anonymizeBytes(data) + if err != nil { + log.Warnf("skipping update log file %s: %v", baseName, err) + continue + } if err := g.addFileToZip(bytes.NewReader(data), filepath.Join("update-logs", baseName)); err != nil { return fmt.Errorf("add update log file %s to zip: %w", baseName, err) } @@ -832,6 +1024,13 @@ func (g *BundleGenerator) addCorruptedStateFiles() error { } fileName := filepath.Base(match) + // Corrupted state files usually fail structured JSON anonymization, + // so run them through the string anonymizer instead. + data, err = g.anonymizeBytes(data) + if err != nil { + log.Warnf("skipping corrupted state file %s: %v", fileName, err) + continue + } if err := g.addFileToZip(bytes.NewReader(data), "corrupted_states/"+fileName); err != nil { log.Warnf("Failed to add corrupted state file %s to zip: %v", fileName, err) continue @@ -843,6 +1042,27 @@ func (g *BundleGenerator) addCorruptedStateFiles() error { return nil } +// anonymizeBytes runs raw file content through the string anonymizer line by +// line when anonymization is enabled. It errors instead of returning partial +// content, so a caller never adds an unanonymized fallback to the bundle. +func (g *BundleGenerator) anonymizeBytes(data []byte) ([]byte, error) { + if !g.anonymize { + return data, nil + } + + var buf bytes.Buffer + scanner := bufio.NewScanner(bytes.NewReader(data)) + scanner.Buffer(make([]byte, 1024*1024), 1024*1024) + for scanner.Scan() { + buf.WriteString(g.anonymizer.AnonymizeString(scanner.Text())) + buf.WriteByte('\n') + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("anonymize content: %w", err) + } + return buf.Bytes(), nil +} + func (g *BundleGenerator) addMetrics() error { if g.clientMetrics == nil { log.Debugf("skipping metrics in debug bundle: no metrics collector") @@ -875,11 +1095,11 @@ func (g *BundleGenerator) addLogfile() error { logDir := filepath.Dir(g.logPath) - if err := g.addSingleLogfile(g.logPath, clientLogFile); err != nil { + if err := g.addSingleLogfile(openLogFile, g.logPath, clientLogFile); err != nil { return fmt.Errorf("add client log file to zip: %w", err) } - g.addRotatedLogFiles(logDir) + g.addRotatedLogFiles(openLogFile, logDir, clientLogPrefix) stdErrLogPath := filepath.Join(logDir, errorLogFile) stdoutLogPath := filepath.Join(logDir, stdoutLogFile) @@ -888,20 +1108,39 @@ func (g *BundleGenerator) addLogfile() error { stdoutLogPath = darwinStdoutLogPath } - if err := g.addSingleLogfile(stdErrLogPath, errorLogFile); err != nil { + if err := g.addSingleLogfile(openLogFile, stdErrLogPath, errorLogFile); err != nil { log.Warnf("Failed to add %s to zip: %v", errorLogFile, err) } - if err := g.addSingleLogfile(stdoutLogPath, stdoutLogFile); err != nil { + if err := g.addSingleLogfile(openLogFile, stdoutLogPath, stdoutLogFile); err != nil { log.Warnf("Failed to add %s to zip: %v", stdoutLogFile, err) } return nil } +// addUILog adds the desktop UI's gui-client.log (and its rotated siblings) to +// the bundle. The path is reported by the UI via RegisterUILog; empty when no +// UI registered one (e.g. headless / server). Missing file is non-fatal — the +// UI only writes it while the daemon is in debug, so it's often absent. +func (g *BundleGenerator) addUILog() error { + if g.uiLogPath == "" { + log.Debugf("no UI log path registered, skipping in debug bundle") + return nil + } + + if err := g.addSingleLogfile(g.uiLogOpener, g.uiLogPath, configs.UILogFile); err != nil { + return fmt.Errorf("add UI log file to zip: %w", err) + } + + g.addRotatedLogFiles(g.uiLogOpener, filepath.Dir(g.uiLogPath), uiLogPrefix) + + return nil +} + // addSingleLogfile adds a single log file to the archive -func (g *BundleGenerator) addSingleLogfile(logPath, targetName string) error { - logFile, err := os.Open(logPath) +func (g *BundleGenerator) addSingleLogfile(open LogOpener, logPath, targetName string) error { + logFile, err := open(logPath) if err != nil { return fmt.Errorf("open log file %s: %w", targetName, err) } @@ -926,8 +1165,8 @@ func (g *BundleGenerator) addSingleLogfile(logPath, targetName string) error { } // addSingleLogFileGz adds a single gzipped log file to the archive -func (g *BundleGenerator) addSingleLogFileGz(logPath, targetName string) error { - f, err := os.Open(logPath) +func (g *BundleGenerator) addSingleLogFileGz(open LogOpener, logPath, targetName string) error { + f, err := open(logPath) if err != nil { return fmt.Errorf("open gz log file %s: %w", targetName, err) } @@ -971,13 +1210,16 @@ func (g *BundleGenerator) addSingleLogFileGz(logPath, targetName string) error { return nil } -// addRotatedLogFiles adds rotated log files to the bundle based on logFileCount -func (g *BundleGenerator) addRotatedLogFiles(logDir string) { +// addRotatedLogFiles adds rotated log files to the bundle based on logFileCount. +// prefix is the base log name without extension (e.g. "client", "gui-client"); +// the glob matches both files rotated by us and by logrotate on linux. +func (g *BundleGenerator) addRotatedLogFiles(open LogOpener, logDir, prefix string) { if g.logFileCount == 0 { return } - pattern := filepath.Join(logDir, "client-*.log.gz") + // This pattern matches both logs rotated by us and logrotate on linux + pattern := filepath.Join(logDir, prefix+"*.log.*") files, err := filepath.Glob(pattern) if err != nil { log.Warnf("failed to glob rotated logs: %v", err) @@ -1010,7 +1252,12 @@ func (g *BundleGenerator) addRotatedLogFiles(logDir string) { for i := 0; i < maxFiles; i++ { name := filepath.Base(files[i]) - if err := g.addSingleLogFileGz(files[i], name); err != nil { + if strings.HasSuffix(name, ".gz") { + err = g.addSingleLogFileGz(open, files[i], name) + } else { + err = g.addSingleLogfile(open, files[i], name) + } + if err != nil { log.Warnf("failed to add rotated log %s: %v", name, err) } } @@ -1252,6 +1499,21 @@ func anonymizePeerConfig(config *mgmProto.PeerConfig, anonymizer *anonymize.Anon config.Address = anonymizer.AnonymizeIP(addr).String() } + if len(config.GetAddressV6()) > 0 { + v6Prefix, err := netiputil.DecodePrefix(config.GetAddressV6()) + if err != nil { + config.AddressV6 = nil + } else { + anonV6 := anonymizer.AnonymizeIP(v6Prefix.Addr()) + b, err := netiputil.EncodePrefix(netip.PrefixFrom(anonV6, v6Prefix.Bits())) + if err != nil { + config.AddressV6 = nil + } else { + config.AddressV6 = b + } + } + } + anonymizeSSHConfig(config.SshConfig) config.Dns = anonymizer.AnonymizeString(config.Dns) @@ -1273,6 +1535,7 @@ func anonymizeRemotePeer(peer *mgmProto.RemotePeerConfig, anonymizer *anonymize. } peer.Fqdn = anonymizer.AnonymizeDomain(peer.Fqdn) + peer.WgPubKey = anonymizer.AnonymizeWGKey(peer.WgPubKey) anonymizeSSHConfig(peer.SshConfig) } @@ -1354,8 +1617,20 @@ func anonymizeFirewallRule(rule *mgmProto.FirewallRule, anonymizer *anonymize.An return } + //nolint:staticcheck // PeerIP used for backward compatibility if addr, err := netip.ParseAddr(rule.PeerIP); err == nil { - rule.PeerIP = anonymizer.AnonymizeIP(addr).String() + rule.PeerIP = anonymizer.AnonymizeIP(addr).String() //nolint:staticcheck + } + + for i, raw := range rule.GetSourcePrefixes() { + p, err := netiputil.DecodePrefix(raw) + if err != nil { + continue + } + anonAddr := anonymizer.AnonymizeIP(p.Addr()) + if b, err := netiputil.EncodePrefix(netip.PrefixFrom(anonAddr, p.Bits())); err == nil { + rule.SourcePrefixes[i] = b + } } } diff --git a/client/internal/debug/debug_ios.go b/client/internal/debug/debug_ios.go new file mode 100644 index 000000000..001d64241 --- /dev/null +++ b/client/internal/debug/debug_ios.go @@ -0,0 +1,36 @@ +//go:build ios + +package debug + +import ( + "path/filepath" + + log "github.com/sirupsen/logrus" +) + +// swiftLogFile is the Swift app log written by the iOS app into the same log +// directory as the Go client log, so it can be collected into the bundle. +const swiftLogFile = "swift-log.log" + +// addPlatformLog collects logs for the iOS debug bundle. iOS has no logcat or +// systemd journal, so we rely on file-based logs. addLogfile handles the Go +// client log (logPath) with rotation, the stderr/stdout companions and +// anonymization. The iOS app writes its own Swift log into the same directory, +// so we add it alongside the Go log. +func (g *BundleGenerator) addPlatformLog() error { + if err := g.addLogfile(); err != nil { + return err + } + + if g.logPath == "" { + return nil + } + + swiftLogPath := filepath.Join(filepath.Dir(g.logPath), swiftLogFile) + if err := g.addSingleLogfile(openLogFile, swiftLogPath, swiftLogFile); err != nil { + // The Swift log is best-effort: the app may not have written it yet. + log.Warnf("failed to add %s to debug bundle: %v", swiftLogFile, err) + } + + return nil +} diff --git a/client/internal/debug/debug_linux.go b/client/internal/debug/debug_linux.go index aedf88b79..a36c0c0e7 100644 --- a/client/internal/debug/debug_linux.go +++ b/client/internal/debug/debug_linux.go @@ -124,15 +124,18 @@ func getSystemdLogs(serviceName string) (string, error) { // addFirewallRules collects and adds firewall rules to the archive func (g *BundleGenerator) addFirewallRules() error { log.Info("Collecting firewall rules") - iptablesRules, err := collectIPTablesRules() + g.addIPTablesRulesToBundle("iptables-save", "iptables", "iptables.txt") + g.addIPTablesRulesToBundle("ip6tables-save", "ip6tables", "ip6tables.txt") + + ipsetOutput, err := collectIPSets() if err != nil { - log.Warnf("Failed to collect iptables rules: %v", err) + log.Warnf("Failed to collect ipset information: %v", err) } else { if g.anonymize { - iptablesRules = g.anonymizer.AnonymizeString(iptablesRules) + ipsetOutput = g.anonymizer.AnonymizeString(ipsetOutput) } - if err := g.addFileToZip(strings.NewReader(iptablesRules), "iptables.txt"); err != nil { - log.Warnf("Failed to add iptables rules to bundle: %v", err) + if err := g.addFileToZip(strings.NewReader(ipsetOutput), "ipset.txt"); err != nil { + log.Warnf("Failed to add ipset output to bundle: %v", err) } } @@ -151,44 +154,65 @@ func (g *BundleGenerator) addFirewallRules() error { return nil } -// collectIPTablesRules collects rules using both iptables-save and verbose listing -func collectIPTablesRules() (string, error) { - var builder strings.Builder - - saveOutput, err := collectIPTablesSave() +// addIPTablesRulesToBundle collects iptables/ip6tables rules and writes them to the bundle. +func (g *BundleGenerator) addIPTablesRulesToBundle(saveBin, listBin, filename string) { + rules, err := collectIPTablesRules(saveBin, listBin) if err != nil { - log.Warnf("Failed to collect iptables rules using iptables-save: %v", err) - } else { - builder.WriteString("=== iptables-save output ===\n") + log.Warnf("Failed to collect %s rules: %v", listBin, err) + return + } + if g.anonymize { + rules = g.anonymizer.AnonymizeString(rules) + } + if err := g.addFileToZip(strings.NewReader(rules), filename); err != nil { + log.Warnf("Failed to add %s rules to bundle: %v", listBin, err) + } +} + +// collectIPTablesRules collects rules using both and verbose listing via . +// Returns an error when neither command produced any output (e.g. the binary is missing), +// so the caller can skip writing an empty file. +func collectIPTablesRules(saveBin, listBin string) (string, error) { + var builder strings.Builder + var collected bool + var firstErr error + + saveOutput, err := runCommand(saveBin) + switch { + case err != nil: + firstErr = err + log.Warnf("Failed to collect %s output: %v", saveBin, err) + case strings.TrimSpace(saveOutput) == "": + log.Debugf("%s produced no output, skipping", saveBin) + default: + builder.WriteString(fmt.Sprintf("=== %s output ===\n", saveBin)) builder.WriteString(saveOutput) builder.WriteString("\n") + collected = true } - ipsetOutput, err := collectIPSets() - if err != nil { - log.Warnf("Failed to collect ipset information: %v", err) - } else { - builder.WriteString("=== ipset list output ===\n") - builder.WriteString(ipsetOutput) - builder.WriteString("\n") - } - - builder.WriteString("=== iptables -v -n -L output ===\n") + listHeader := fmt.Sprintf("=== %s -v -n -L output ===\n", listBin) + builder.WriteString(listHeader) tables := []string{"filter", "nat", "mangle", "raw", "security"} - for _, table := range tables { - builder.WriteString(fmt.Sprintf("*%s\n", table)) - - stats, err := getTableStatistics(table) + stats, err := runCommand(listBin, "-v", "-n", "-L", "-t", table) if err != nil { - log.Warnf("Failed to get statistics for table %s: %v", table, err) + if firstErr == nil { + firstErr = err + } + log.Warnf("Failed to get %s statistics for table %s: %v", listBin, table, err) continue } + builder.WriteString(fmt.Sprintf("*%s\n", table)) builder.WriteString(stats) builder.WriteString("\n") + collected = true } + if !collected { + return "", fmt.Errorf("collect %s rules: %w", listBin, firstErr) + } return builder.String(), nil } @@ -214,34 +238,15 @@ func collectIPSets() (string, error) { return ipsets, nil } -// collectIPTablesSave uses iptables-save to get rule definitions -func collectIPTablesSave() (string, error) { - cmd := exec.Command("iptables-save") +// runCommand executes a command and returns its stdout, wrapping stderr in the error on failure. +func runCommand(name string, args ...string) (string, error) { + cmd := exec.Command(name, args...) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { - return "", fmt.Errorf("execute iptables-save: %w (stderr: %s)", err, stderr.String()) - } - - rules := stdout.String() - if strings.TrimSpace(rules) == "" { - return "", fmt.Errorf("no iptables rules found") - } - - return rules, nil -} - -// getTableStatistics gets verbose statistics for an entire table using iptables command -func getTableStatistics(table string) (string, error) { - cmd := exec.Command("iptables", "-v", "-n", "-L", "-t", table) - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - return "", fmt.Errorf("execute iptables -v -n -L: %w (stderr: %s)", err, stderr.String()) + return "", fmt.Errorf("execute %s: %w (stderr: %s)", name, err, stderr.String()) } return stdout.String(), nil @@ -804,3 +809,95 @@ func formatSetKeyType(keyType nftables.SetDatatype) string { return fmt.Sprintf("type-%v", keyType) } } + +// addSysctls collects forwarding and netbird-managed sysctl values and writes them to the bundle. +func (g *BundleGenerator) addSysctls() error { + log.Info("Collecting sysctls") + content := collectSysctls() + if g.anonymize { + content = g.anonymizer.AnonymizeString(content) + } + if err := g.addFileToZip(strings.NewReader(content), "sysctls.txt"); err != nil { + return fmt.Errorf("add sysctls to bundle: %w", err) + } + return nil +} + +// collectSysctls reads every sysctl that the netbird client may modify, plus +// global IPv4/IPv6 forwarding, and returns a formatted dump grouped by topic. +// Per-interface values are enumerated by listing /proc/sys/net/ipv{4,6}/conf. +func collectSysctls() string { + var builder strings.Builder + + writeSysctlGroup(&builder, "forwarding", []string{ + "net.ipv4.ip_forward", + "net.ipv6.conf.all.forwarding", + "net.ipv6.conf.default.forwarding", + }) + writeSysctlGroup(&builder, "ipv4 per-interface forwarding", listInterfaceSysctls("ipv4", "forwarding")) + writeSysctlGroup(&builder, "ipv6 per-interface forwarding", listInterfaceSysctls("ipv6", "forwarding")) + writeSysctlGroup(&builder, "rp_filter", append( + []string{"net.ipv4.conf.all.rp_filter", "net.ipv4.conf.default.rp_filter"}, + listInterfaceSysctls("ipv4", "rp_filter")..., + )) + writeSysctlGroup(&builder, "src_valid_mark", append( + []string{"net.ipv4.conf.all.src_valid_mark", "net.ipv4.conf.default.src_valid_mark"}, + listInterfaceSysctls("ipv4", "src_valid_mark")..., + )) + writeSysctlGroup(&builder, "accept_ra", append( + []string{"net.ipv6.conf.all.accept_ra", "net.ipv6.conf.default.accept_ra"}, + listInterfaceSysctls("ipv6", "accept_ra")..., + )) + writeSysctlGroup(&builder, "conntrack", []string{ + "net.netfilter.nf_conntrack_acct", + "net.netfilter.nf_conntrack_tcp_loose", + }) + writeSysctlGroup(&builder, "tcp", []string{ + "net.ipv4.tcp_tw_reuse", + }) + + return builder.String() +} + +func writeSysctlGroup(builder *strings.Builder, title string, keys []string) { + builder.WriteString(fmt.Sprintf("=== %s ===\n", title)) + for _, key := range keys { + value, err := readSysctl(key) + if err != nil { + builder.WriteString(fmt.Sprintf("%s = \n", key, err)) + continue + } + builder.WriteString(fmt.Sprintf("%s = %s\n", key, value)) + } + builder.WriteString("\n") +} + +// listInterfaceSysctls returns net.ipvX.conf.. keys for every +// interface present in /proc/sys/net/ipvX/conf, skipping "all" and "default" +// (callers add those explicitly so they appear first). +func listInterfaceSysctls(family, leaf string) []string { + dir := fmt.Sprintf("/proc/sys/net/%s/conf", family) + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + var keys []string + for _, e := range entries { + name := e.Name() + if name == "all" || name == "default" { + continue + } + keys = append(keys, fmt.Sprintf("net.%s.conf.%s.%s", family, name, leaf)) + } + sort.Strings(keys) + return keys +} + +func readSysctl(key string) (string, error) { + path := fmt.Sprintf("/proc/sys/%s", strings.ReplaceAll(key, ".", "/")) + value, err := os.ReadFile(path) + if err != nil { + return "", err + } + return strings.TrimSpace(string(value)), nil +} diff --git a/client/internal/debug/debug_logfiles_test.go b/client/internal/debug/debug_logfiles_test.go new file mode 100644 index 000000000..31420711f --- /dev/null +++ b/client/internal/debug/debug_logfiles_test.go @@ -0,0 +1,126 @@ +package debug + +import ( + "archive/zip" + "bytes" + "compress/gzip" + "io" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestAddRotatedLogFiles_PicksUpAllVariants asserts that the rotated-log +// glob picks up logs rotated by timberjack (gzipped) and by logrotate (plain +// and gzipped), and skips unrelated files. +func TestAddRotatedLogFiles_PicksUpAllVariants(t *testing.T) { + dir := t.TempDir() + + writeFile(t, filepath.Join(dir, "client.log"), "active log\n") + writeFile(t, filepath.Join(dir, "other.log"), "unrelated\n") + + timberjackRotated := "client-2026-05-21T10-30-45.000.log.gz" + writeGzFile(t, filepath.Join(dir, timberjackRotated), "timberjack rotated content\n") + + logrotatePlain := "client.log.1" + writeFile(t, filepath.Join(dir, logrotatePlain), "logrotate plain content\n") + + logrotateGz := "client.log.2.gz" + writeGzFile(t, filepath.Join(dir, logrotateGz), "logrotate gz content\n") + + names := runAddRotatedLogFiles(t, dir, 10) + + require.Contains(t, names, timberjackRotated, "timberjack rotated file should be in bundle") + require.Contains(t, names, logrotatePlain, "logrotate plain rotated file should be in bundle") + require.Contains(t, names, logrotateGz, "logrotate gzipped rotated file should be in bundle") + require.NotContains(t, names, "client.log", "active log should not be added by addRotatedLogFiles") + require.NotContains(t, names, "other.log", "unrelated files should not be in bundle") +} + +// TestAddRotatedLogFiles_GUIPrefix asserts the prefix parameter scopes the glob +// to the GUI log: gui-client.log.* rotated siblings are picked up and the +// daemon's own client.log.* are not (and vice versa, covered above). This is +// the load-bearing check for the gui-client.log bundle collection — the old +// "client*.log.*" glob would have missed gui-client rotations. +func TestAddRotatedLogFiles_GUIPrefix(t *testing.T) { + dir := t.TempDir() + + writeFile(t, filepath.Join(dir, "gui-client.log.1"), "gui rotated\n") + writeGzFile(t, filepath.Join(dir, "gui-client.log.2.gz"), "gui rotated gz\n") + writeFile(t, filepath.Join(dir, "client.log.1"), "daemon rotated\n") + + names := runAddRotatedLogFilesPrefix(t, dir, "gui-client", 10) + + require.Contains(t, names, "gui-client.log.1", "gui-client rotated file should be in bundle") + require.Contains(t, names, "gui-client.log.2.gz", "gui-client gz rotated file should be in bundle") + require.NotContains(t, names, "client.log.1", "daemon rotated file must not match the gui-client prefix") +} + +// TestAddRotatedLogFiles_RespectsLogFileCount asserts that only the newest +// logFileCount rotated files are bundled, ordered by mtime. +func TestAddRotatedLogFiles_RespectsLogFileCount(t *testing.T) { + dir := t.TempDir() + + oldest := filepath.Join(dir, "client.log.3") + middle := filepath.Join(dir, "client.log.2") + newest := filepath.Join(dir, "client.log.1") + writeFile(t, oldest, "old\n") + writeFile(t, middle, "mid\n") + writeFile(t, newest, "new\n") + + now := time.Now() + require.NoError(t, os.Chtimes(oldest, now.Add(-2*time.Hour), now.Add(-2*time.Hour))) + require.NoError(t, os.Chtimes(middle, now.Add(-1*time.Hour), now.Add(-1*time.Hour))) + require.NoError(t, os.Chtimes(newest, now, now)) + + names := runAddRotatedLogFiles(t, dir, 2) + + require.Contains(t, names, "client.log.1") + require.Contains(t, names, "client.log.2") + require.NotContains(t, names, "client.log.3", "oldest file should be dropped when logFileCount=2") +} + +// runAddRotatedLogFiles calls addRotatedLogFiles against a fresh in-memory +// zip writer and returns the set of entry names that ended up in the archive. +func runAddRotatedLogFiles(t *testing.T, dir string, logFileCount uint32) map[string]struct{} { + return runAddRotatedLogFilesPrefix(t, dir, "client", logFileCount) +} + +func runAddRotatedLogFilesPrefix(t *testing.T, dir, prefix string, logFileCount uint32) map[string]struct{} { + t.Helper() + + var buf bytes.Buffer + g := &BundleGenerator{ + archive: zip.NewWriter(&buf), + logFileCount: logFileCount, + } + g.addRotatedLogFiles(openLogFile, dir, prefix) + require.NoError(t, g.archive.Close()) + + zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) + require.NoError(t, err) + + names := make(map[string]struct{}, len(zr.File)) + for _, f := range zr.File { + names[f.Name] = struct{}{} + } + return names +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) +} + +func writeGzFile(t *testing.T, path, content string) { + t.Helper() + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + _, err := io.WriteString(gw, content) + require.NoError(t, err) + require.NoError(t, gw.Close()) + require.NoError(t, os.WriteFile(path, buf.Bytes(), 0o644)) +} diff --git a/client/internal/debug/debug_nonandroid.go b/client/internal/debug/debug_nonandroid.go index 117238dec..2dfca6ddc 100644 --- a/client/internal/debug/debug_nonandroid.go +++ b/client/internal/debug/debug_nonandroid.go @@ -1,4 +1,4 @@ -//go:build !android +//go:build !android && !ios package debug diff --git a/client/internal/debug/debug_nonlinux.go b/client/internal/debug/debug_nonlinux.go index ace53bd94..878fee40f 100644 --- a/client/internal/debug/debug_nonlinux.go +++ b/client/internal/debug/debug_nonlinux.go @@ -17,3 +17,8 @@ func (g *BundleGenerator) addIPRules() error { // IP rules are only supported on Linux return nil } + +func (g *BundleGenerator) addSysctls() error { + // Sysctl collection is only supported on Linux + return nil +} diff --git a/client/internal/debug/debug_nonunix.go b/client/internal/debug/debug_nonunix.go index 18d017050..adc9b9649 100644 --- a/client/internal/debug/debug_nonunix.go +++ b/client/internal/debug/debug_nonunix.go @@ -1,4 +1,4 @@ -//go:build !unix +//go:build !unix && !windows package debug diff --git a/client/internal/debug/debug_test.go b/client/internal/debug/debug_test.go index 6b5bb911c..17d520358 100644 --- a/client/internal/debug/debug_test.go +++ b/client/internal/debug/debug_test.go @@ -5,19 +5,33 @@ import ( "bytes" "encoding/json" "net" + "net/netip" + "net/url" "os" "path/filepath" + "reflect" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/netbirdio/netbird/client/anonymize" "github.com/netbirdio/netbird/client/configs" + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/shared/management/domain" mgmProto "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/shared/netiputil" ) +func mustEncodePrefix(t *testing.T, p netip.Prefix) []byte { + t.Helper() + b, err := netiputil.EncodePrefix(p) + require.NoError(t, err) + return b +} + func TestAnonymizeStateFile(t *testing.T) { testState := map[string]json.RawMessage{ "null_state": json.RawMessage("null"), @@ -168,7 +182,7 @@ func TestAnonymizeStateFile(t *testing.T) { assert.Equal(t, "100.64.0.1", state["protected_ip"]) // Protected IP unchanged assert.Equal(t, "8.8.8.8", state["well_known_ip"]) // Well-known IP unchanged assert.NotEqual(t, "2001:db8::1", state["ipv6_addr"]) - assert.Equal(t, "fd00::1", state["private_ipv6"]) // Private IPv6 unchanged + assert.NotEqual(t, "fd00::1", state["private_ipv6"]) // ULA IPv6 anonymized (global ID is a fingerprint) assert.NotEqual(t, "test.example.com", state["domain"]) assert.True(t, strings.HasSuffix(state["domain"].(string), ".domain")) assert.Equal(t, "device.netbird.cloud", state["netbird_domain"]) // Netbird domain unchanged @@ -272,11 +286,13 @@ func mustMarshal(v any) json.RawMessage { } func TestAnonymizeNetworkMap(t *testing.T) { + origV6Prefix := netip.MustParsePrefix("2001:db8:abcd::5/64") networkMap := &mgmProto.NetworkMap{ PeerConfig: &mgmProto.PeerConfig{ - Address: "203.0.113.5", - Dns: "1.2.3.4", - Fqdn: "peer1.corp.example.com", + Address: "203.0.113.5", + AddressV6: mustEncodePrefix(t, origV6Prefix), + Dns: "1.2.3.4", + Fqdn: "peer1.corp.example.com", SshConfig: &mgmProto.SSHConfig{ SshPubKey: []byte("ssh-rsa AAAAB3NzaC1..."), }, @@ -350,6 +366,12 @@ func TestAnonymizeNetworkMap(t *testing.T) { require.NotEqual(t, "peer1.corp.example.com", peerCfg.Fqdn) require.True(t, strings.HasSuffix(peerCfg.Fqdn, ".domain")) + // Verify AddressV6 is anonymized but preserves prefix length + anonV6Prefix, err := netiputil.DecodePrefix(peerCfg.AddressV6) + require.NoError(t, err) + assert.Equal(t, origV6Prefix.Bits(), anonV6Prefix.Bits(), "prefix length must be preserved") + assert.NotEqual(t, origV6Prefix.Addr(), anonV6Prefix.Addr(), "IPv6 address must be anonymized") + // Verify SSH key is replaced require.Equal(t, []byte("ssh-placeholder-key"), peerCfg.SshConfig.SshPubKey) @@ -471,8 +493,8 @@ func TestSanitizeServiceEnvVars(t *testing.T) { anonymize: false, input: map[string]any{ jsonKeyServiceEnv: map[string]any{ - "HOME": "/root", - "PATH": "/usr/bin", + "HOME": "/root", + "PATH": "/usr/bin", "NB_LOG_LEVEL": "debug", }, }, @@ -489,9 +511,9 @@ func TestSanitizeServiceEnvVars(t *testing.T) { anonymize: false, input: map[string]any{ jsonKeyServiceEnv: map[string]any{ - "NB_SETUP_KEY": "abc123", - "NB_API_TOKEN": "tok_xyz", - "NB_LOG_LEVEL": "info", + "NB_SETUP_KEY": "abc123", + "NB_API_TOKEN": "tok_xyz", + "NB_LOG_LEVEL": "info", }, }, check: func(t *testing.T, params map[string]any) { @@ -655,8 +677,6 @@ func isInCGNATRange(ip net.IP) bool { } func TestAnonymizeFirewallRules(t *testing.T) { - // TODO: Add ipv6 - // Example iptables-save output iptablesSave := `# Generated by iptables-save v1.8.7 on Thu Dec 19 10:00:00 2024 *filter @@ -692,17 +712,31 @@ Chain FORWARD (policy ACCEPT 0 packets, 0 bytes) Chain OUTPUT (policy ACCEPT 0 packets, 0 bytes) pkts bytes target prot opt in out source destination` - // Example nftables output + // Example ip6tables-save output + ip6tablesSave := `# Generated by ip6tables-save v1.8.7 on Thu Dec 19 10:00:00 2024 +*filter +:INPUT ACCEPT [0:0] +:FORWARD ACCEPT [0:0] +:OUTPUT ACCEPT [0:0] +-A INPUT -s fd00:1234::1/128 -j ACCEPT +-A INPUT -s 2607:f8b0:4005::1/128 -j DROP +-A FORWARD -s 2001:db8::/32 -d 2607:f8b0:4005::200e/128 -j ACCEPT +COMMIT` + + // Example nftables output with IPv6 nftablesRules := `table inet filter { chain input { type filter hook input priority filter; policy accept; ip saddr 192.168.1.1 accept ip saddr 44.192.140.1 drop + ip6 saddr 2607:f8b0:4005::1 drop + ip6 saddr fd00:1234::1 accept } chain forward { type filter hook forward priority filter; policy accept; ip saddr 10.0.0.0/8 drop ip saddr 44.192.140.0/24 ip daddr 52.84.12.34/24 accept + ip6 saddr 2001:db8::/32 ip6 daddr 2607:f8b0:4005::200e/128 accept } }` @@ -765,4 +799,173 @@ Chain OUTPUT (policy ACCEPT 0 packets, 0 bytes) assert.Contains(t, anonNftables, "table inet filter {") assert.Contains(t, anonNftables, "chain input {") assert.Contains(t, anonNftables, "type filter hook input priority filter; policy accept;") + + // IPv6 public addresses in nftables should be anonymized + assert.NotContains(t, anonNftables, "2607:f8b0:4005::1") + assert.NotContains(t, anonNftables, "2607:f8b0:4005::200e") + assert.NotContains(t, anonNftables, "2001:db8::") + assert.Contains(t, anonNftables, "2001:db8:ffff::") // Default anonymous v6 range + + // ULA addresses in nftables should be anonymized (global ID is a fingerprint) + assert.NotContains(t, anonNftables, "fd00:1234::1") + + // IPv6 nftables structure preserved + assert.Contains(t, anonNftables, "ip6 saddr") + assert.Contains(t, anonNftables, "ip6 daddr") + + // Test ip6tables-save anonymization + anonIp6tablesSave := anonymizer.AnonymizeString(ip6tablesSave) + + // ULA IPv6 should be anonymized (global ID is a fingerprint) + assert.NotContains(t, anonIp6tablesSave, "fd00:1234::1/128") + + // Public IPv6 addresses should be anonymized + assert.NotContains(t, anonIp6tablesSave, "2607:f8b0:4005::1") + assert.NotContains(t, anonIp6tablesSave, "2607:f8b0:4005::200e") + assert.NotContains(t, anonIp6tablesSave, "2001:db8::") + assert.Contains(t, anonIp6tablesSave, "2001:db8:ffff::") // Default anonymous v6 range + + // Structure should be preserved + assert.Contains(t, anonIp6tablesSave, "*filter") + assert.Contains(t, anonIp6tablesSave, "COMMIT") + assert.Contains(t, anonIp6tablesSave, "-j DROP") + assert.Contains(t, anonIp6tablesSave, "-j ACCEPT") +} + +// TestAddConfig_AllFieldsCovered uses reflection to ensure every field in +// profilemanager.Config is either rendered in the debug bundle or explicitly +// excluded. When a new field is added to Config, this test fails until the +// developer either dumps it in addConfig/addCommonConfigFields or adds it to +// the excluded set with a justification. +func TestAddConfig_AllFieldsCovered(t *testing.T) { + excluded := map[string]string{ + "PrivateKey": "sensitive: WireGuard private key", + "PreSharedKey": "sensitive: WireGuard pre-shared key", + "SSHKey": "sensitive: SSH private key", + "ClientCertKeyPair": "non-config: parsed cert pair, not serialized", + "Name": "non-config: profile name is not needed for debug purposes", + "policy": "non-config: in-memory MDM policy snapshot, surfaced via Config.Policy() / GetConfigResponse.MDMManagedFields", + "DebugBundleUploadURL": "sensitive: MDM-provided upload URL may carry credentials or query tokens; kept out of the shared bundle", + } + + mURL, _ := url.Parse("https://api.example.com:443") + aURL, _ := url.Parse("https://admin.example.com:443") + bTrue := true + iVal := 42 + cfg := &profilemanager.Config{ + PrivateKey: "priv", + PreSharedKey: "psk", + ManagementURL: mURL, + AdminURL: aURL, + WgIface: "wt0", + WgPort: 51820, + NetworkMonitor: &bTrue, + IFaceBlackList: []string{"eth0"}, + DisableIPv6Discovery: true, + RosenpassEnabled: true, + RosenpassPermissive: true, + ServerSSHAllowed: &bTrue, + RemoteJobsAllowed: &bTrue, + EnableSSHRoot: &bTrue, + EnableSSHSFTP: &bTrue, + EnableSSHLocalPortForwarding: &bTrue, + EnableSSHRemotePortForwarding: &bTrue, + DisableSSHAuth: &bTrue, + SSHJWTCacheTTL: &iVal, + DisableClientRoutes: true, + DisableServerRoutes: true, + DisableDNS: true, + DisableFirewall: true, + BlockLANAccess: true, + BlockInbound: true, + DisableNotifications: &bTrue, + DNSLabels: domain.List{}, + SSHKey: "sshkey", + NATExternalIPs: []string{"1.2.3.4"}, + CustomDNSAddress: "1.1.1.1:53", + DisableAutoConnect: true, + DNSRouteInterval: 5 * time.Second, + ClientCertPath: "/tmp/cert", + ClientCertKeyPath: "/tmp/key", + LazyConnection: "on", + DebugBundleUploadURL: "https://upload.example.test/bundle?token=secret", + MTU: 1280, + DisableIPv6: true, + SyncMessageVersion: func(v int) *int { return &v }(1), + } + + for _, anonymize := range []bool{false, true} { + t.Run("anonymize="+map[bool]string{true: "true", false: "false"}[anonymize], func(t *testing.T) { + g := &BundleGenerator{ + anonymizer: newAnonymizerForTest(), + internalConfig: cfg, + anonymize: anonymize, + } + + var sb strings.Builder + g.addCommonConfigFields(&sb) + rendered := sb.String() + renderAddConfigSpecific(g) + + // DebugBundleUploadURL is an MDM-provided value that can carry + // credentials or signed query tokens. It is deliberately excluded + // above; assert it never reaches the rendered bundle — neither the + // field name nor the token — in either anonymize mode. + assert.NotContains(t, rendered, "DebugBundleUploadURL:", "MDM upload URL field must not be serialized into the debug bundle") + assert.NotContains(t, rendered, "token=secret", "MDM upload URL value must not leak into the debug bundle") + + val := reflect.ValueOf(cfg).Elem() + typ := val.Type() + var missing []string + for i := 0; i < typ.NumField(); i++ { + name := typ.Field(i).Name + if _, ok := excluded[name]; ok { + continue + } + if !strings.Contains(rendered, name+":") { + missing = append(missing, name) + } + } + if len(missing) > 0 { + t.Fatalf("Config field(s) not present in debug bundle output: %v\n"+ + "Either render the field in addCommonConfigFields/addConfig, "+ + "or add it to the excluded map with a justification.", missing) + } + }) + } +} + +// renderAddConfigSpecific renders the fields handled by the anonymize/non-anonymize +// branches in addConfig (ManagementURL, AdminURL, NATExternalIPs, CustomDNSAddress). +// addCommonConfigFields covers the rest. Keeping this in the test mirrors the +// production shape without needing to write an actual zip. +func renderAddConfigSpecific(g *BundleGenerator) string { + var sb strings.Builder + if g.anonymize { + if g.internalConfig.ManagementURL != nil { + sb.WriteString("ManagementURL: " + g.anonymizer.AnonymizeURI(g.internalConfig.ManagementURL.String()) + "\n") + } + if g.internalConfig.AdminURL != nil { + sb.WriteString("AdminURL: " + g.anonymizer.AnonymizeURI(g.internalConfig.AdminURL.String()) + "\n") + } + sb.WriteString("NATExternalIPs: x\n") + if g.internalConfig.CustomDNSAddress != "" { + sb.WriteString("CustomDNSAddress: " + g.anonymizer.AnonymizeString(g.internalConfig.CustomDNSAddress) + "\n") + } + } else { + if g.internalConfig.ManagementURL != nil { + sb.WriteString("ManagementURL: " + g.internalConfig.ManagementURL.String() + "\n") + } + if g.internalConfig.AdminURL != nil { + sb.WriteString("AdminURL: " + g.internalConfig.AdminURL.String() + "\n") + } + sb.WriteString("NATExternalIPs: x\n") + if g.internalConfig.CustomDNSAddress != "" { + sb.WriteString("CustomDNSAddress: " + g.internalConfig.CustomDNSAddress + "\n") + } + } + return sb.String() +} + +func newAnonymizerForTest() *anonymize.Anonymizer { + return anonymize.NewAnonymizer(anonymize.DefaultAddresses()) } diff --git a/client/internal/debug/debug_windows.go b/client/internal/debug/debug_windows.go new file mode 100644 index 000000000..e88940fd3 --- /dev/null +++ b/client/internal/debug/debug_windows.go @@ -0,0 +1,443 @@ +//go:build windows + +package debug + +import ( + "encoding/hex" + "errors" + "fmt" + "net/netip" + "strings" + "unsafe" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" + + nbdns "github.com/netbirdio/netbird/client/internal/dns" +) + +const dnsInfoFileName = "dns_windows.txt" + +const ( + gpoDNSClientRoot = `SOFTWARE\Policies\Microsoft\Windows NT\DNSClient` + tcpipParamsPath = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters` + dnscacheParams = `SYSTEM\CurrentControlSet\Services\Dnscache\Parameters` +) + +// interfaceDNSValues are the per-interface values that decide how a name is +// resolved and registered. Everything the DNS host manager writes is in here, +// so a bundle shows both what we set and what it replaced. +var interfaceDNSValues = []string{ + "NameServer", + "DhcpNameServer", + "Domain", + "DhcpDomain", + "SearchList", + "RegistrationEnabled", + "DisableDynamicUpdate", + "MaxNumberOfAddressesToRegister", + "EnableDHCP", +} + +// addDNSInfo collects and adds DNS configuration information to the archive +func (g *BundleGenerator) addDNSInfo() error { + if err := g.addFileToZip(strings.NewReader(g.collectDNSInfo()), dnsInfoFileName); err != nil { + return fmt.Errorf("add DNS info to zip: %w", err) + } + + return nil +} + +// collectDNSInfo renders the report. Everything below it reaches the platform +// through COM and through lazily resolved procedures, which panic when a +// procedure is missing rather than returning an error, and a debug bundle is not +// allowed to take the daemon down. The panic is contained here, and whatever was +// collected before it is kept and reported with it. +func (g *BundleGenerator) collectDNSInfo() (content string) { + var sb strings.Builder + + defer func() { + if r := recover(); r != nil { + log.Errorf("collecting Windows DNS configuration panicked: %v", r) + fmt.Fprintf(&sb, "\nerror: collection stopped: %v\n", r) + } + content = sb.String() + }() + + sb.WriteString("Windows DNS configuration\n") + sb.WriteString("=========================\n") + + adapters, adaptersErr := adapterAddresses() + + g.writeNRPTRules(&sb, "NRPT rules, local policy store", nbdns.DNSPolicyConfigRoot) + g.writeNRPTRules(&sb, "NRPT rules, group policy store", nbdns.GPODNSPolicyConfigRoot) + g.writeEffectiveNRPTPolicies(&sb) + g.writeRegistryKey(&sb, "DNS client group policy", gpoDNSClientRoot) + g.writeRegistryKey(&sb, "Global TCP/IP parameters", tcpipParamsPath) + g.writeRegistryKey(&sb, "Dnscache parameters", dnscacheParams) + g.writeInterfaceDNS(&sb, "Per-interface DNS, IPv4", nbdns.InterfaceConfigPath, adapterNames(adapters)) + g.writeInterfaceDNS(&sb, "Per-interface DNS, IPv6", nbdns.InterfaceConfigPathV6, adapterNames(adapters)) + g.writeAdapterDNS(&sb, adapters, adaptersErr) + + return sb.String() +} + +// writeNRPTRules lists every rule in a policy store, ours and any other +// product's, since a foreign rule for the same namespace decides resolution +// just as ours does. Rules the client wrote are marked. +func (g *BundleGenerator) writeNRPTRules(sb *strings.Builder, title, root string) { + writeSection(sb, title, root) + + names, err := subKeyNames(root) + if err != nil { + fmt.Fprintf(sb, "error: %v\n", err) + return + } + + if len(names) == 0 { + sb.WriteString("no rules\n") + return + } + + for _, name := range names { + owner := "" + if strings.HasPrefix(strings.ToLower(name), strings.ToLower(nbdns.NRPTKeyPrefix)) { + owner = " (netbird)" + } + fmt.Fprintf(sb, "%s%s\n", name, owner) + g.writeValues(sb, root+`\`+name, nil, " ") + } +} + +// writeEffectiveNRPTPolicies reports the table the resolver answers from, which +// the registry cannot show: a rule is written before it is loaded, and it keeps +// being enforced after its key is gone until the resolver reloads its policy. +func (g *BundleGenerator) writeEffectiveNRPTPolicies(sb *strings.Builder) { + writeSection(sb, "NRPT policy table in effect", nrptPolicyClass+"."+nrptPolicyMethod+" in "+nrptPolicyNamespace) + + entries, err := effectiveNRPTPolicies() + if err != nil { + fmt.Fprintf(sb, "error: %v\n", err) + return + } + + if len(entries) == 0 { + sb.WriteString("no policies\n") + return + } + + for _, entry := range entries { + fmt.Fprintf(sb, "%s\n", g.anonymizeValue("Namespace", entry.namespace)) + for _, value := range entry.values { + fmt.Fprintf(sb, " %s: %s\n", value.name, g.anonymizeValue(value.name, value.value)) + } + } +} + +// writeInterfaceDNS reports the DNS values of every interface that has any, so +// the netbird interface can be compared against the physical ones. The registry +// keys the values by GUID, so each is named from the adapter list; a GUID with +// no adapter is a leftover key of an interface that no longer exists. +func (g *BundleGenerator) writeInterfaceDNS(sb *strings.Builder, title, root string, names map[string]string) { + writeSection(sb, title, root) + + guids, err := subKeyNames(root) + if err != nil { + fmt.Fprintf(sb, "error: %v\n", err) + return + } + + var reported int + for _, guid := range guids { + var iface strings.Builder + g.writeValues(&iface, root+`\`+guid, interfaceDNSValues, " ") + if iface.Len() == 0 { + continue + } + + name, ok := names[strings.ToLower(guid)] + if !ok { + name = "no adapter with this GUID" + } + + reported++ + fmt.Fprintf(sb, "%s (%s)\n%s", guid, name, iface.String()) + } + + if reported == 0 { + sb.WriteString("no interface holds DNS values\n") + } +} + +// writeRegistryKey reports the values of a single key, without its subkeys. +func (g *BundleGenerator) writeRegistryKey(sb *strings.Builder, title, path string) { + writeSection(sb, title, path) + + var values strings.Builder + g.writeValues(&values, path, nil, "") + if values.Len() == 0 { + sb.WriteString("no values\n") + return + } + + sb.WriteString(values.String()) +} + +// writeValues renders the values of a key. A nil names list reports every +// value, otherwise only those named and present. +func (g *BundleGenerator) writeValues(sb *strings.Builder, path string, names []string, indent string) { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.QUERY_VALUE) + switch { + case errors.Is(err, registry.ErrNotExist), errors.Is(err, windows.ERROR_PATH_NOT_FOUND): + // an absent key is the normal state for the GPO store and for + // interfaces without DNS settings + log.Debugf("HKEY_LOCAL_MACHINE\\%s does not exist", path) + return + case err != nil: + fmt.Fprintf(sb, "%serror: open HKEY_LOCAL_MACHINE\\%s: %v\n", indent, path, err) + return + } + defer closeKey(k) + + if names == nil { + names, err = k.ReadValueNames(-1) + if err != nil { + fmt.Fprintf(sb, "%serror: read value names: %v\n", indent, err) + return + } + } + + for _, name := range names { + value, err := readRegistryValue(k, name) + switch { + case errors.Is(err, registry.ErrNotExist): + // the caller asks for a fixed set of values, most of which a + // given interface does not carry + continue + case err != nil: + // report rather than omit: a value that is there but cannot be + // read reads as unset otherwise + fmt.Fprintf(sb, "%s%s: error: %v\n", indent, name, err) + continue + } + + fmt.Fprintf(sb, "%s%s: %s\n", indent, name, g.anonymizeValue(name, value)) + } +} + +// anonymizeValue redacts a registry value according to what its name says it +// holds. Domains and addresses are handled per entry rather than by the string +// pass: the pass only replaces domains something else in the bundle already +// seeded, and its address regex would eat the digit labels of a reverse zone. +func (g *BundleGenerator) anonymizeValue(name, value string) string { + if !g.anonymize || value == "" { + return value + } + + switch { + case holdsDomains(name): + return joinValueEntries(splitValueEntries(value), g.anonymizeDomain) + case holdsAddresses(name): + return joinValueEntries(splitValueEntries(value), g.anonymizer.AnonymizeIPString) + default: + return g.anonymizer.AnonymizeString(value) + } +} + +// holdsDomains reports whether a value name holds domains: the domain list of +// an NRPT rule (Name) or of the policy table (Namespace), a search list, the +// DNS suffix values of the TCP/IP and policy keys, which all end in "Domain" +// (Domain, DhcpDomain, NV Domain, ICSDomain), and a proxy host name. +func holdsDomains(name string) bool { + lower := strings.ToLower(name) + return lower == "name" || lower == "namespace" || lower == "searchlist" || + strings.HasSuffix(lower, "domain") || strings.HasSuffix(lower, "proxyname") +} + +// holdsAddresses reports whether a value name holds DNS server addresses +// (NameServer, DhcpNameServer, GenericDNSServers, NameServers). +func holdsAddresses(name string) bool { + lower := strings.ToLower(name) + return strings.Contains(lower, "nameserver") || strings.Contains(lower, "dnsserver") +} + +// adapterNames maps adapter GUIDs, as the registry keys the interfaces, to the +// names an operator sees. +func adapterNames(adapters []*windows.IpAdapterAddresses) map[string]string { + names := make(map[string]string, len(adapters)) + for _, adapter := range adapters { + guid := windows.BytePtrToString(adapter.AdapterName) + names[strings.ToLower(guid)] = windows.UTF16PtrToString(adapter.FriendlyName) + } + return names +} + +// writeAdapterDNS reports the resolver configuration in effect per adapter, +// which is what the resolver uses for a name no NRPT rule matches. +func (g *BundleGenerator) writeAdapterDNS(sb *strings.Builder, adapters []*windows.IpAdapterAddresses, err error) { + writeSection(sb, "Adapter DNS configuration", "GetAdaptersAddresses") + + if err != nil { + fmt.Fprintf(sb, "error: %v\n", err) + return + } + + for _, adapter := range adapters { + name := windows.UTF16PtrToString(adapter.FriendlyName) + suffix := g.anonymizeDomain(windows.UTF16PtrToString(adapter.DnsSuffix)) + + fmt.Fprintf(sb, "%s (index %d, oper status %d)\n", name, adapter.IfIndex, adapter.OperStatus) + fmt.Fprintf(sb, " DNS suffix: %s\n", suffix) + + var servers []string + for server := adapter.FirstDnsServerAddress; server != nil; server = server.Next { + addr, ok := netip.AddrFromSlice(server.Address.IP()) + if !ok { + continue + } + + addr = addr.Unmap() + if g.anonymize { + addr = g.anonymizer.AnonymizeIP(addr) + } + servers = append(servers, addr.String()) + } + + fmt.Fprintf(sb, " DNS servers: %s\n", strings.Join(servers, ", ")) + } +} + +// anonymizeDomain anonymizes a single domain, keeping the leading dot an NRPT +// match domain carries. +func (g *BundleGenerator) anonymizeDomain(entry string) string { + if !g.anonymize { + return entry + } + + domain, dot := strings.CutPrefix(entry, ".") + if domain == "" { + return entry + } + + anonymized := g.anonymizer.AnonymizeDomain(domain) + if dot { + anonymized = "." + anonymized + } + return anonymized +} + +// splitValueEntries splits a registry value that holds a list. The separator +// differs per value: a REG_MULTI_SZ arrives joined with ", ", a SearchList is +// comma separated and a NameServer may use commas or spaces. +func splitValueEntries(value string) []string { + return strings.FieldsFunc(value, func(r rune) bool { + return r == ',' || r == ';' || r == ' ' || r == '\t' + }) +} + +func joinValueEntries(entries []string, anonymize func(string) string) string { + for i, entry := range entries { + entries[i] = anonymize(entry) + } + return strings.Join(entries, ", ") +} + +func writeSection(sb *strings.Builder, title, source string) { + fmt.Fprintf(sb, "\n%s\n%s\n%s\n", title, strings.Repeat("-", len(title)), source) +} + +func subKeyNames(root string) ([]string, error) { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, root, registry.ENUMERATE_SUB_KEYS) + if err != nil { + return nil, fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", root, err) + } + defer closeKey(k) + + names, err := k.ReadSubKeyNames(-1) + if err != nil { + return nil, fmt.Errorf("read subkey names: %w", err) + } + + return names, nil +} + +// readRegistryValue renders a value as text regardless of its type, so an +// unexpected type in a policy key still shows up instead of being dropped. +func readRegistryValue(k registry.Key, name string) (string, error) { + _, valueType, err := k.GetValue(name, nil) + if err != nil { + return "", fmt.Errorf("get value %s: %w", name, err) + } + + switch valueType { + case registry.SZ, registry.EXPAND_SZ: + value, _, err := k.GetStringValue(name) + if err != nil { + return "", fmt.Errorf("get string value %s: %w", name, err) + } + return value, nil + case registry.MULTI_SZ: + values, _, err := k.GetStringsValue(name) + if err != nil { + return "", fmt.Errorf("get strings value %s: %w", name, err) + } + return strings.Join(values, ", "), nil + case registry.DWORD, registry.QWORD: + value, _, err := k.GetIntegerValue(name) + if err != nil { + return "", fmt.Errorf("get integer value %s: %w", name, err) + } + return fmt.Sprintf("%d (0x%x)", value, value), nil + case registry.BINARY: + value, _, err := k.GetBinaryValue(name) + if err != nil { + return "", fmt.Errorf("get binary value %s: %w", name, err) + } + return hex.EncodeToString(value), nil + default: + return fmt.Sprintf("", valueType), nil + } +} + +// adapterAddresses returns the adapter list including DNS servers. The call +// reports the size it needs, so grow the buffer and retry until it fits. +func adapterAddresses() (adapters []*windows.IpAdapterAddresses, err error) { + // GetAdaptersAddresses is resolved on first use and panics when it is + // missing, so this reports it as an error and leaves the rest of the + // report intact. + defer func() { + if r := recover(); r != nil { + adapters, err = nil, fmt.Errorf("GetAdaptersAddresses: %v", r) + } + }() + + const flags = windows.GAA_FLAG_SKIP_ANYCAST | windows.GAA_FLAG_SKIP_MULTICAST + + size := uint32(15000) + for range 3 { + buf := make([]byte, size) + first := (*windows.IpAdapterAddresses)(unsafe.Pointer(&buf[0])) + + err := windows.GetAdaptersAddresses(windows.AF_UNSPEC, flags, 0, first, &size) + if errors.Is(err, windows.ERROR_BUFFER_OVERFLOW) { + continue + } + if err != nil { + return nil, fmt.Errorf("GetAdaptersAddresses: %w", err) + } + + for adapter := first; adapter != nil; adapter = adapter.Next { + adapters = append(adapters, adapter) + } + return adapters, nil + } + + return nil, fmt.Errorf("GetAdaptersAddresses: buffer kept growing") +} + +func closeKey(k registry.Key) { + if err := k.Close(); err != nil { + log.Debugf("close registry key: %v", err) + } +} diff --git a/client/internal/debug/debug_windows_test.go b/client/internal/debug/debug_windows_test.go new file mode 100644 index 000000000..47df3f6f9 --- /dev/null +++ b/client/internal/debug/debug_windows_test.go @@ -0,0 +1,146 @@ +//go:build windows + +package debug + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/anonymize" +) + +func newDNSValueGenerator(level anonymize.Level) *BundleGenerator { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + anonymizer.SetLevel(level) + + return &BundleGenerator{ + anonymize: true, + anonymizeLevel: level, + anonymizer: anonymizer, + } +} + +// TestAnonymizeValueByName covers the value kinds of the DNS registry keys. The +// names decide the treatment, because the string pass alone replaces only +// domains another part of the bundle already seeded. +func TestAnonymizeValueByName(t *testing.T) { + tests := []struct { + name string + valueName string + value string + assert func(t *testing.T, got string) + }{ + { + name: "NRPT match domains keep the leading dot", + valueName: "Name", + value: ".internal.example.com, .corp.example.org", + assert: func(t *testing.T, got string) { + t.Helper() + for _, entry := range strings.Split(got, ", ") { + assert.True(t, strings.HasPrefix(entry, "."), "entry %q should keep its leading dot", entry) + assert.NotContains(t, entry, "example", "entry %q should not keep the original domain", entry) + } + }, + }, + { + name: "any value name ending in Domain is treated as a domain", + valueName: "ICSDomain", + value: "mshome.net", + assert: func(t *testing.T, got string) { + t.Helper() + assert.NotContains(t, got, "mshome", "should anonymize a domain suffix value") + }, + }, + { + name: "search list is a comma separated domain list", + valueName: "SearchList", + value: "corp.example.com,branch.example.com", + assert: func(t *testing.T, got string) { + t.Helper() + assert.NotContains(t, got, "example", "should anonymize every search domain") + assert.Len(t, strings.Split(got, ", "), 2, "should keep both search domains") + }, + }, + { + name: "name servers are anonymized as addresses", + valueName: "DhcpNameServer", + value: "203.0.113.10 8.8.8.8", + assert: func(t *testing.T, got string) { + t.Helper() + assert.NotContains(t, got, "203.0.113.10", "should anonymize a public resolver address") + // well-known resolvers stay readable at every level + assert.Contains(t, got, "8.8.8.8", "should keep a well-known resolver address") + }, + }, + { + name: "opaque values are left to the string pass", + valueName: "DataBasePath", + value: `%SystemRoot%\System32\drivers\etc`, + assert: func(t *testing.T, got string) { + t.Helper() + assert.Equal(t, `%SystemRoot%\System32\drivers\etc`, got, "should not alter a path") + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + g := newDNSValueGenerator(anonymize.LevelDefault) + tc.assert(t, g.anonymizeValue(tc.valueName, tc.value)) + }) + } +} + +// TestParseNRPTPolicyTable parses the MOF text of the policy table out +// parameters, as the provider on a client with one NRPT rule renders it. +func TestParseNRPTPolicyTable(t *testing.T) { + const text = `[abstract] +class __PARAMETERS +{ + [Out, EmbeddedInstance("DnsClientPolicyConfiguration"): ToSubClass, ID(2): DisableOverride ToInstance] DnsClientPolicyConfiguration cmdletOutput[] = { +instance of DnsClientPolicyConfiguration +{ + DirectAccessProxyType = "NoProxy"; + DirectAccessQueryIPsecRequired = FALSE; + NameEncoding = "Utf8WithoutMapping"; + Namespace = ".0.100.in-addr.arpa"; +}, +instance of DnsClientPolicyConfiguration +{ + DirectAccessProxyType = "NoProxy"; + NameEncoding = "Utf8WithoutMapping"; + NameServers = {"100.0.255.254", "100.0.255.253"}; + Namespace = ".nb.internal"; +}}; + [in] boolean Effective; + [out] uint32 ReturnValue = 0; +}; +` + + entries := parseNRPTPolicyTable(text) + require.Len(t, entries, 2, "should parse both embedded instances") + + assert.Equal(t, ".0.100.in-addr.arpa", entries[0].namespace, "should read the namespace of the first instance") + assert.Equal(t, ".nb.internal", entries[1].namespace, "should read the namespace of the second instance") + + assert.Equal(t, []registryValue{ + {name: "DirectAccessProxyType", value: "NoProxy"}, + {name: "DirectAccessQueryIPsecRequired", value: "FALSE"}, + {name: "NameEncoding", value: "Utf8WithoutMapping"}, + }, entries[0].values, "should keep the remaining values in order") + + assert.Contains(t, entries[1].values, registryValue{name: "NameServers", value: "100.0.255.254, 100.0.255.253"}, + "should flatten a MOF array") + + for _, value := range entries[1].values { + assert.NotContains(t, value.name, "ReturnValue", "should not read the class level parameters as values") + } +} + +func TestParseNRPTPolicyTableEmpty(t *testing.T) { + assert.Empty(t, parseNRPTPolicyTable(""), "should parse no entries from empty text") + assert.Empty(t, parseNRPTPolicyTable("class __PARAMETERS\n{\n};\n"), "should parse no entries from a table with no instances") +} diff --git a/client/internal/debug/nrpt_windows.go b/client/internal/debug/nrpt_windows.go new file mode 100644 index 000000000..6b6e0e29a --- /dev/null +++ b/client/internal/debug/nrpt_windows.go @@ -0,0 +1,317 @@ +//go:build windows + +package debug + +import ( + "errors" + "fmt" + "runtime" + "strings" + "time" + + "github.com/go-ole/go-ole" + "github.com/go-ole/go-ole/oleutil" + log "github.com/sirupsen/logrus" +) + +const ( + // The NRPT policy table is reachable through the CIM class that backs + // Get-DnsClientNrptPolicy. Unlike the rules in the registry, the table is + // what the resolver currently has loaded, which is the only way to tell an + // applied rule from one that is merely written, in either direction. + nrptPolicyNamespace = `root\Microsoft\Windows\DNS` + nrptPolicyClass = "PS_DnsClientNrptPolicy" + nrptPolicyMethod = "Get" + + // The class has no instances, so the table comes from the out parameters + // of a static method call, rendered as MOF text: the embedded instances + // arrive as a safe array of objects, which cannot be read back through the + // COM bindings, and the text form carries all of them. + nrptPolicyInstanceKeyword = "instance of DnsClientPolicyConfiguration" + + nrptPolicyTimeout = 15 * time.Second +) + +// COM initialization results that leave the calling thread usable: S_FALSE for +// a thread this process already initialized, RPC_E_CHANGED_MODE for one that +// belongs to another apartment. +const ( + sFalse = 0x00000001 + rpcEChangedMode = 0x80010106 +) + +// nrptQueryInFlight admits one read of the policy table at a time. A provider +// that stops answering keeps its goroutine and the OS thread that goroutine +// pinned, so a later bundle reports that instead of pinning another one. +var nrptQueryInFlight = make(chan struct{}, 1) + +// nrptPolicyEntry is one namespace of the effective policy table, holding the +// values of an embedded DnsClientPolicyConfiguration instance in the order the +// provider reported them. +type nrptPolicyEntry struct { + namespace string + values []registryValue +} + +// registryValue is a name and its rendered value, shared by the registry and +// policy table readers so both anonymize by value name the same way. +type registryValue struct { + name string + value string +} + +// effectiveNRPTPolicies reads the effective NRPT table. The call is bounded +// because a WMI provider can block indefinitely and a debug bundle must not. +func effectiveNRPTPolicies() ([]nrptPolicyEntry, error) { + type result struct { + text string + err error + } + + select { + case nrptQueryInFlight <- struct{}{}: + default: + return nil, errors.New("an earlier read of the policy table has not returned") + } + + done := make(chan result, 1) + go func() { + // the slot is released here rather than by the caller, so a read that + // outlives the timeout holds it until the provider answers + defer func() { <-nrptQueryInFlight }() + + text, err := nrptPolicyTableText() + done <- result{text: text, err: err} + }() + + select { + case res := <-done: + if res.err != nil { + return nil, res.err + } + return parseNRPTPolicyTable(res.text), nil + case <-time.After(nrptPolicyTimeout): + return nil, errors.New("read of the policy table timed out") + } +} + +// nrptPolicyTableText calls the policy table method and returns the MOF text of +// its out parameters. +func nrptPolicyTableText() (text string, err error) { + // COM is per thread, and the collection is short lived, so the thread is + // pinned for the duration rather than initialized for the process. + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + defer func() { + // The COM call chain is dynamically typed, so a provider that answers + // with an unexpected shape must not take the daemon down with it. + if r := recover(); r != nil { + err = fmt.Errorf("read NRPT policy table: %v", r) + } + }() + + owns, err := coInitialize() + if err != nil { + return "", err + } + if owns { + defer ole.CoUninitialize() + } + + locator, err := oleutil.CreateObject("WbemScripting.SWbemLocator") + if err != nil { + return "", fmt.Errorf("create WMI locator: %w", err) + } + defer locator.Release() + + dispatch, err := locator.QueryInterface(ole.IID_IDispatch) + if err != nil { + return "", fmt.Errorf("query WMI locator interface: %w", err) + } + defer dispatch.Release() + + service, err := dispatchCall(dispatch, "ConnectServer", nil, nrptPolicyNamespace) + if err != nil { + return "", fmt.Errorf("connect to %s: %w", nrptPolicyNamespace, err) + } + defer service.Release() + + inParams, err := spawnMethodInParams(service) + if err != nil { + return "", err + } + defer inParams.Release() + + // The effective table is the merge of the local and the group policy + // store, which is what the resolver answers from. + if _, err := oleutil.PutProperty(inParams, "Effective", true); err != nil { + return "", fmt.Errorf("set Effective parameter: %w", err) + } + + outParams, err := dispatchCall(service, "ExecMethod", nrptPolicyClass, nrptPolicyMethod, inParams) + if err != nil { + return "", fmt.Errorf("call %s.%s: %w", nrptPolicyClass, nrptPolicyMethod, err) + } + defer outParams.Release() + + textVariant, err := oleutil.CallMethod(outParams, "GetObjectText_") + if err != nil { + return "", fmt.Errorf("render policy table: %w", err) + } + defer func() { + if err := textVariant.Clear(); err != nil { + log.Debugf("clear policy table variant: %v", err) + } + }() + + return textVariant.ToString(), nil +} + +// spawnMethodInParams builds the in parameters instance the method needs. The +// provider rejects the call without one, even when every parameter is optional. +func spawnMethodInParams(service *ole.IDispatch) (*ole.IDispatch, error) { + class, err := dispatchCall(service, "Get", nrptPolicyClass) + if err != nil { + return nil, fmt.Errorf("get class %s: %w", nrptPolicyClass, err) + } + defer class.Release() + + methods, err := dispatchProperty(class, "Methods_") + if err != nil { + return nil, fmt.Errorf("get class methods: %w", err) + } + defer methods.Release() + + method, err := dispatchCall(methods, "Item", nrptPolicyMethod) + if err != nil { + return nil, fmt.Errorf("get method %s: %w", nrptPolicyMethod, err) + } + defer method.Release() + + params, err := dispatchProperty(method, "InParameters") + if err != nil { + return nil, fmt.Errorf("get method parameters: %w", err) + } + defer params.Release() + + inParams, err := dispatchCall(params, "SpawnInstance_") + if err != nil { + return nil, fmt.Errorf("spawn parameter instance: %w", err) + } + + return inParams, nil +} + +// parseNRPTPolicyTable pulls the embedded instances out of the MOF text. Each +// instance is a namespace of the table, with one name and value per line. +func parseNRPTPolicyTable(text string) []nrptPolicyEntry { + var entries []nrptPolicyEntry + var current *nrptPolicyEntry + + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(line), ";")) + + switch { + case strings.HasPrefix(line, nrptPolicyInstanceKeyword): + entries = append(entries, nrptPolicyEntry{}) + current = &entries[len(entries)-1] + continue + case strings.HasPrefix(line, "}"): + // closes an instance, and the array with the last one, so the + // class level parameters that follow are not read as values + current = nil + continue + case current == nil, line == "{": + continue + } + + name, value, ok := strings.Cut(line, " = ") + if !ok { + continue + } + + value = unquoteMOFValue(value) + if name == "Namespace" { + current.namespace = value + continue + } + + current.values = append(current.values, registryValue{name: name, value: value}) + } + + return entries +} + +// unquoteMOFValue renders a MOF scalar or array as plain text: "a" becomes a, +// and {"a", "b"} becomes a, b. +func unquoteMOFValue(value string) string { + value = strings.TrimSpace(value) + + if inner, ok := strings.CutPrefix(value, "{"); ok { + value = strings.TrimSuffix(inner, "}") + + entries := strings.Split(value, ",") + for i, entry := range entries { + entries[i] = strings.Trim(strings.TrimSpace(entry), `"`) + } + return strings.Join(entries, ", ") + } + + return strings.Trim(value, `"`) +} + +// coInitialize prepares the calling thread for COM and reports whether this +// call owns the initialization, which decides whether it may be balanced with +// CoUninitialize. S_FALSE took a reference on a thread this process had already +// initialized and so has to be released, while RPC_E_CHANGED_MODE took none: +// the thread belongs to another apartment, which is usable but is not ours to +// uninitialize. +func coInitialize() (bool, error) { + err := ole.CoInitializeEx(0, ole.COINIT_MULTITHREADED) + if err == nil { + return true, nil + } + + var oleErr *ole.OleError + if errors.As(err, &oleErr) { + switch oleErr.Code() { + case sFalse: + return true, nil + case rpcEChangedMode: + return false, nil + } + } + + return false, fmt.Errorf("initialize COM: %w", err) +} + +// dispatchCall calls a COM method that returns an object. +func dispatchCall(dispatch *ole.IDispatch, method string, params ...any) (*ole.IDispatch, error) { + variant, err := oleutil.CallMethod(dispatch, method, params...) + if err != nil { + return nil, err + } + + object := variant.ToIDispatch() + if object == nil { + return nil, fmt.Errorf("%s returned no object", method) + } + + return object, nil +} + +// dispatchProperty reads a COM property that holds an object. +func dispatchProperty(dispatch *ole.IDispatch, property string) (*ole.IDispatch, error) { + variant, err := oleutil.GetProperty(dispatch, property) + if err != nil { + return nil, err + } + + object := variant.ToIDispatch() + if object == nil { + return nil, fmt.Errorf("property %s holds no object", property) + } + + return object, nil +} diff --git a/client/internal/debug/uilog_test.go b/client/internal/debug/uilog_test.go new file mode 100644 index 000000000..103e98c6f --- /dev/null +++ b/client/internal/debug/uilog_test.go @@ -0,0 +1,64 @@ +package debug + +import ( + "archive/zip" + "bytes" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/configs" +) + +// bundleEntries generates a bundle with the given generator and returns the +// set of entry names in the resulting archive. +func bundleEntries(t *testing.T, g *BundleGenerator) map[string]struct{} { + t.Helper() + + path, err := g.Generate() + require.NoError(t, err) + t.Cleanup(func() { _ = os.Remove(path) }) + + data, err := os.ReadFile(path) + require.NoError(t, err) + + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + require.NoError(t, err) + + names := make(map[string]struct{}, len(zr.File)) + for _, f := range zr.File { + names[f.Name] = struct{}{} + } + return names +} + +func TestBundleIncludesUILogWhenOpenerAllows(t *testing.T) { + path := filepath.Join(t.TempDir(), configs.UILogFile) + require.NoError(t, os.WriteFile(path, []byte("gui log"), 0600)) + + g := NewBundleGenerator(GeneratorDependencies{ + UILogPath: path, + UILogOpener: openLogFile, + }, BundleConfig{}) + + require.Contains(t, bundleEntries(t, g), configs.UILogFile) +} + +// A UILogOpener that refuses (as the ownership check does for a foreign file) +// keeps the UI log out of the bundle without failing bundle generation. +func TestBundleExcludesUILogWhenOpenerRefuses(t *testing.T) { + path := filepath.Join(t.TempDir(), configs.UILogFile) + require.NoError(t, os.WriteFile(path, []byte("secret"), 0600)) + + g := NewBundleGenerator(GeneratorDependencies{ + UILogPath: path, + UILogOpener: func(string) (*os.File, error) { + return nil, fmt.Errorf("not owned by the caller") + }, + }, BundleConfig{}) + + require.NotContains(t, bundleEntries(t, g), configs.UILogFile) +} diff --git a/client/internal/debug/upload.go b/client/internal/debug/upload.go index cdf52409d..88fde6d6f 100644 --- a/client/internal/debug/upload.go +++ b/client/internal/debug/upload.go @@ -3,10 +3,12 @@ package debug import ( "context" "crypto/sha256" + "crypto/tls" "encoding/json" "fmt" "io" "net/http" + neturl "net/url" "os" "github.com/netbirdio/netbird/upload-server/types" @@ -14,20 +16,80 @@ import ( const maxBundleUploadSize = 50 * 1024 * 1024 -func UploadDebugBundle(ctx context.Context, url, managementURL, filePath string) (key string, err error) { - response, err := getUploadURL(ctx, url, managementURL) +// requireHTTPS refuses any URL the daemon would fetch or upload to that is not +// https. The daemon runs as root and the bundle carries its logs and state, so a +// plaintext hop is a place to intercept the bundle or the presigned redirect. +// The server-side gate already enforces this for the desktop path; this also +// covers the mobile and job-runner callers that reach this package directly. +// Skipped when the caller opted into an insecure upload (self-hosted server). +func requireHTTPS(what, rawURL string) error { + parsed, err := neturl.Parse(rawURL) + if err != nil { + return fmt.Errorf("parse %s: %w", what, err) + } + if parsed.Scheme != "https" { + return fmt.Errorf("%s must use https, got scheme %q", what, parsed.Scheme) + } + return nil +} + +// uploadClient returns the HTTP client for the upload requests. The default +// client verifies TLS and refuses a redirect that would downgrade to a non-https +// hop, so a bundle can never leave over http after an https start. The insecure +// variant accepts http and untrusted certificates, and is only reachable for a +// privileged caller that passed --upload-bundle-insecure (see +// requirePrivilegeForUploadURL). +func uploadClient(insecure bool) *http.Client { + if !insecure { + return &http.Client{CheckRedirect: rejectInsecureRedirect} + } + return &http.Client{ + Transport: &http.Transport{ + //nolint:gosec // opt-in, privileged, self-hosted upload servers + TLSClientConfig: &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12}, + }, + } +} + +// rejectInsecureRedirect refuses a redirect to a non-https target and keeps the +// standard library's 10-hop limit that a custom CheckRedirect would otherwise +// disable. +func rejectInsecureRedirect(req *http.Request, via []*http.Request) error { + if req.URL.Scheme != "https" { + return fmt.Errorf("refusing redirect to non-https URL %s", req.URL.Redacted()) + } + if len(via) >= 10 { + return fmt.Errorf("stopped after 10 redirects") + } + return nil +} + +func UploadDebugBundle(ctx context.Context, url, managementURL, filePath string, insecure bool) (key string, err error) { + if !insecure { + if err := requireHTTPS("upload service URL", url); err != nil { + return "", err + } + } + + response, err := getUploadURL(ctx, url, managementURL, insecure) if err != nil { return "", err } - err = upload(ctx, filePath, response) + if !insecure { + if err := requireHTTPS("upload URL from service", response.URL); err != nil { + return "", err + } + } + + err = upload(ctx, filePath, response, insecure) if err != nil { return "", err } return response.Key, nil } -func upload(ctx context.Context, filePath string, response *types.GetURLResponse) error { +func upload(ctx context.Context, filePath string, response *types.GetURLResponse, insecure bool) error { fileData, err := os.Open(filePath) if err != nil { return fmt.Errorf("open file: %w", err) @@ -52,7 +114,7 @@ func upload(ctx context.Context, filePath string, response *types.GetURLResponse req.ContentLength = stat.Size() req.Header.Set("Content-Type", "application/octet-stream") - putResp, err := http.DefaultClient.Do(req) + putResp, err := uploadClient(insecure).Do(req) if err != nil { return fmt.Errorf("upload failed: %v", err) } @@ -65,16 +127,23 @@ func upload(ctx context.Context, filePath string, response *types.GetURLResponse return nil } -func getUploadURL(ctx context.Context, url string, managementURL string) (*types.GetURLResponse, error) { - id := getURLHash(managementURL) - getReq, err := http.NewRequestWithContext(ctx, "GET", url+"?id="+id, nil) +func getUploadURL(ctx context.Context, serviceURL string, managementURL string, insecure bool) (*types.GetURLResponse, error) { + parsed, err := neturl.Parse(serviceURL) + if err != nil { + return nil, fmt.Errorf("parse upload service URL: %w", err) + } + q := parsed.Query() + q.Set("id", getURLHash(managementURL)) + parsed.RawQuery = q.Encode() + + getReq, err := http.NewRequestWithContext(ctx, "GET", parsed.String(), nil) if err != nil { return nil, fmt.Errorf("create GET request: %w", err) } getReq.Header.Set(types.ClientHeader, types.ClientHeaderValue) - resp, err := http.DefaultClient.Do(getReq) + resp, err := uploadClient(insecure).Do(getReq) if err != nil { return nil, fmt.Errorf("get presigned URL: %w", err) } diff --git a/client/internal/debug/upload_test.go b/client/internal/debug/upload_test.go index f224b8d3f..f3927cb81 100644 --- a/client/internal/debug/upload_test.go +++ b/client/internal/debug/upload_test.go @@ -5,6 +5,7 @@ import ( "errors" "net" "net/http" + "net/http/httptest" "os" "path/filepath" "testing" @@ -43,7 +44,7 @@ func TestUpload(t *testing.T) { fileContent := []byte("test file content") err := os.WriteFile(file, fileContent, 0640) require.NoError(t, err) - key, err := UploadDebugBundle(context.Background(), testURL+types.GetURLPath, testURL, file) + key, err := UploadDebugBundle(context.Background(), testURL+types.GetURLPath, testURL, file, true) require.NoError(t, err) id := getURLHash(testURL) require.Contains(t, key, id+"/") @@ -79,3 +80,47 @@ func waitForServer(t *testing.T, addr string) { } t.Fatalf("server did not start listening on %s in time", addr) } + +func TestRequireHTTPS(t *testing.T) { + require.NoError(t, requireHTTPS("upload URL", "https://upload.example/path")) + require.Error(t, requireHTTPS("upload URL", "http://upload.example/path")) + require.Error(t, requireHTTPS("upload URL", "ftp://upload.example/path")) + require.Error(t, requireHTTPS("upload URL", "://malformed")) +} + +func TestRejectInsecureRedirect(t *testing.T) { + httpsReq, err := http.NewRequest(http.MethodGet, "https://a.example/", nil) + require.NoError(t, err) + require.NoError(t, rejectInsecureRedirect(httpsReq, nil), "https redirect target must be allowed") + + httpReq, err := http.NewRequest(http.MethodGet, "http://a.example/", nil) + require.NoError(t, err) + require.Error(t, rejectInsecureRedirect(httpReq, nil), "http redirect target must be refused") + + require.Error(t, rejectInsecureRedirect(httpsReq, make([]*http.Request, 10)), "the 10-redirect limit must be enforced") +} + +// The secure client refuses to follow an https response that redirects to http, +// so a bundle can't be downgraded onto plaintext mid-flight. +func TestUploadClientRefusesHTTPSToHTTPRedirect(t *testing.T) { + plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(plain.Close) + + secure := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, plain.URL, http.StatusFound) + })) + t.Cleanup(secure.Close) + + client := uploadClient(false) + // Trust the test server's cert without disabling verification globally. + client.Transport = secure.Client().Transport + + resp, err := client.Get(secure.URL) + if resp != nil { + _ = resp.Body.Close() + } + require.Error(t, err, "redirect from https to http must be refused") + require.Contains(t, err.Error(), "non-https") +} diff --git a/client/internal/debug/wgshow.go b/client/internal/debug/wgshow.go index 1e8a8a6cc..ee24902e6 100644 --- a/client/internal/debug/wgshow.go +++ b/client/internal/debug/wgshow.go @@ -35,14 +35,14 @@ func (g *BundleGenerator) toWGShowFormat(s *configurer.Stats) string { var sb strings.Builder sb.WriteString(fmt.Sprintf("interface: %s\n", s.DeviceName)) - sb.WriteString(fmt.Sprintf(" public key: %s\n", s.PublicKey)) + sb.WriteString(fmt.Sprintf(" public key: %s\n", g.anonymizer.AnonymizeWGKey(s.PublicKey))) sb.WriteString(fmt.Sprintf(" listen port: %d\n", s.ListenPort)) if s.FWMark != 0 { sb.WriteString(fmt.Sprintf(" fwmark: %#x\n", s.FWMark)) } for _, peer := range s.Peers { - sb.WriteString(fmt.Sprintf("\npeer: %s\n", peer.PublicKey)) + sb.WriteString(fmt.Sprintf("\npeer: %s\n", g.anonymizer.AnonymizeWGKey(peer.PublicKey))) if peer.Endpoint.IP != nil { if g.anonymize { anonEndpoint := g.anonymizer.AnonymizeUDPAddr(peer.Endpoint) @@ -54,7 +54,11 @@ func (g *BundleGenerator) toWGShowFormat(s *configurer.Stats) string { if len(peer.AllowedIPs) > 0 { var ipStrings []string for _, ipnet := range peer.AllowedIPs { - ipStrings = append(ipStrings, ipnet.String()) + ipStr := ipnet.String() + if g.anonymize { + ipStr = g.anonymizer.AnonymizeIPString(ipStr) + } + ipStrings = append(ipStrings, ipStr) } sb.WriteString(fmt.Sprintf(" allowed ips: %s\n", strings.Join(ipStrings, ", "))) } diff --git a/client/internal/dns.go b/client/internal/dns.go index f5040ee49..a6604810f 100644 --- a/client/internal/dns.go +++ b/client/internal/dns.go @@ -12,52 +12,83 @@ import ( nbdns "github.com/netbirdio/netbird/dns" ) -func createPTRRecord(aRecord nbdns.SimpleRecord, prefix netip.Prefix) (nbdns.SimpleRecord, bool) { - ip, err := netip.ParseAddr(aRecord.RData) +func createPTRRecord(record nbdns.SimpleRecord, prefix netip.Prefix) (nbdns.SimpleRecord, bool) { + ip, err := netip.ParseAddr(record.RData) if err != nil { - log.Warnf("failed to parse IP address %s: %v", aRecord.RData, err) + log.Warnf("failed to parse IP address %s: %v", record.RData, err) return nbdns.SimpleRecord{}, false } + ip = ip.Unmap() if !prefix.Contains(ip) { return nbdns.SimpleRecord{}, false } - ipOctets := strings.Split(ip.String(), ".") - slices.Reverse(ipOctets) - rdnsName := dns.Fqdn(strings.Join(ipOctets, ".") + ".in-addr.arpa") + var rdnsName string + if ip.Is4() { + octets := strings.Split(ip.String(), ".") + slices.Reverse(octets) + rdnsName = dns.Fqdn(strings.Join(octets, ".") + ".in-addr.arpa") + } else { + // Expand to full 32 nibbles in reverse order (LSB first) per RFC 3596. + raw := ip.As16() + nibbles := make([]string, 32) + for i := 0; i < 16; i++ { + nibbles[31-i*2] = fmt.Sprintf("%x", raw[i]>>4) + nibbles[31-i*2-1] = fmt.Sprintf("%x", raw[i]&0x0f) + } + rdnsName = dns.Fqdn(strings.Join(nibbles, ".") + ".ip6.arpa") + } return nbdns.SimpleRecord{ Name: rdnsName, Type: int(dns.TypePTR), - Class: aRecord.Class, - TTL: aRecord.TTL, - RData: dns.Fqdn(aRecord.Name), + Class: record.Class, + TTL: record.TTL, + RData: dns.Fqdn(record.Name), }, true } -// generateReverseZoneName creates the reverse DNS zone name for a given network +// generateReverseZoneName creates the reverse DNS zone name for a given network. +// For IPv4 it produces an in-addr.arpa name, for IPv6 an ip6.arpa name. func generateReverseZoneName(network netip.Prefix) (string, error) { - networkIP := network.Masked().Addr() + networkIP := network.Masked().Addr().Unmap() + bits := network.Bits() - if !networkIP.Is4() { - return "", fmt.Errorf("reverse DNS is only supported for IPv4 networks, got: %s", networkIP) + if networkIP.Is4() { + // Round up to nearest byte. + octetsToUse := (bits + 7) / 8 + + octets := strings.Split(networkIP.String(), ".") + if octetsToUse > len(octets) { + return "", fmt.Errorf("invalid network mask size for reverse DNS: %d", bits) + } + + reverseOctets := make([]string, octetsToUse) + for i := 0; i < octetsToUse; i++ { + reverseOctets[octetsToUse-1-i] = octets[i] + } + + return dns.Fqdn(strings.Join(reverseOctets, ".") + ".in-addr.arpa"), nil } - // round up to nearest byte - octetsToUse := (network.Bits() + 7) / 8 + // IPv6: round up to nearest nibble (4-bit boundary). + nibblesToUse := (bits + 3) / 4 - octets := strings.Split(networkIP.String(), ".") - if octetsToUse > len(octets) { - return "", fmt.Errorf("invalid network mask size for reverse DNS: %d", network.Bits()) + raw := networkIP.As16() + allNibbles := make([]string, 32) + for i := 0; i < 16; i++ { + allNibbles[i*2] = fmt.Sprintf("%x", raw[i]>>4) + allNibbles[i*2+1] = fmt.Sprintf("%x", raw[i]&0x0f) } - reverseOctets := make([]string, octetsToUse) - for i := 0; i < octetsToUse; i++ { - reverseOctets[octetsToUse-1-i] = octets[i] + // Take the first nibblesToUse nibbles (network portion), reverse them. + used := make([]string, nibblesToUse) + for i := 0; i < nibblesToUse; i++ { + used[nibblesToUse-1-i] = allNibbles[i] } - return dns.Fqdn(strings.Join(reverseOctets, ".") + ".in-addr.arpa"), nil + return dns.Fqdn(strings.Join(used, ".") + ".ip6.arpa"), nil } // zoneExists checks if a zone with the given name already exists in the configuration @@ -71,7 +102,7 @@ func zoneExists(config *nbdns.Config, zoneName string) bool { return false } -// collectPTRRecords gathers all PTR records for the given network from A records +// collectPTRRecords gathers all PTR records for the given network from A and AAAA records. func collectPTRRecords(config *nbdns.Config, prefix netip.Prefix) []nbdns.SimpleRecord { var records []nbdns.SimpleRecord @@ -80,7 +111,7 @@ func collectPTRRecords(config *nbdns.Config, prefix netip.Prefix) []nbdns.Simple continue } for _, record := range zone.Records { - if record.Type != int(dns.TypeA) { + if record.Type != int(dns.TypeA) && record.Type != int(dns.TypeAAAA) { continue } diff --git a/client/internal/dns/handler_chain.go b/client/internal/dns/handler_chain.go index 57e7722d4..dc20146eb 100644 --- a/client/internal/dns/handler_chain.go +++ b/client/internal/dns/handler_chain.go @@ -339,8 +339,7 @@ func (c *HandlerChain) isHandlerMatch(qname string, entry HandlerEntry) bool { case entry.Pattern == ".": return true case entry.IsWildcard: - parts := strings.Split(strings.TrimSuffix(qname, entry.Pattern), ".") - return len(parts) >= 2 && strings.HasSuffix(qname, entry.Pattern) + return strings.HasSuffix(qname, "."+entry.Pattern) default: // For non-wildcard patterns: // If handler wants subdomain matching, allow suffix match diff --git a/client/internal/dns/handler_chain_test.go b/client/internal/dns/handler_chain_test.go index 034a760dc..b3db97ba3 100644 --- a/client/internal/dns/handler_chain_test.go +++ b/client/internal/dns/handler_chain_test.go @@ -164,6 +164,54 @@ func TestHandlerChain_ServeDNS_DomainMatching(t *testing.T) { matchSubdomains: true, shouldMatch: true, }, + { + name: "wildcard label-boundary mismatch (suffix overlap)", + handlerDomain: "*.b.test.", + queryDomain: "x.ab.test.", + isWildcard: true, + matchSubdomains: false, + shouldMatch: false, + }, + { + name: "wildcard label-boundary match", + handlerDomain: "*.b.test.", + queryDomain: "x.b.test.", + isWildcard: true, + matchSubdomains: false, + shouldMatch: true, + }, + { + name: "wildcard multi-label match", + handlerDomain: "*.b.test.", + queryDomain: "x.y.b.test.", + isWildcard: true, + matchSubdomains: false, + shouldMatch: true, + }, + { + name: "wildcard no match on multi-label apex", + handlerDomain: "*.b.test.", + queryDomain: "b.test.", + isWildcard: true, + matchSubdomains: false, + shouldMatch: false, + }, + { + name: "wildcard no match on unrelated suffix containment", + handlerDomain: "*.example.com.", + queryDomain: "notexample.com.", + isWildcard: true, + matchSubdomains: false, + shouldMatch: false, + }, + { + name: "wildcard accepts pattern registered without trailing dot", + handlerDomain: "*.b.test", + queryDomain: "x.b.test.", + isWildcard: true, + matchSubdomains: false, + shouldMatch: true, + }, } for _, tt := range tests { @@ -273,6 +321,19 @@ func TestHandlerChain_ServeDNS_OverlappingDomains(t *testing.T) { expectedCalls: 1, expectedHandler: 2, // highest priority matching handler should be called }, + { + name: "overlapping wildcard suffixes route to correct handler", + handlers: []struct { + pattern string + priority int + }{ + {pattern: "*.b.test.", priority: nbdns.PriorityDNSRoute}, + {pattern: "*.ab.test.", priority: nbdns.PriorityDNSRoute}, + }, + queryDomain: "app.ab.test.", + expectedCalls: 1, + expectedHandler: 1, + }, { name: "root zone with specific domain", handlers: []struct { diff --git a/client/internal/dns/host.go b/client/internal/dns/host.go index f7dc46a6b..48eacef29 100644 --- a/client/internal/dns/host.go +++ b/client/internal/dns/host.go @@ -16,6 +16,10 @@ type hostManager interface { restoreHostDNS() error supportCustomPort() bool string() string + // getOriginalNameservers returns the OS-side resolvers used as PriorityFallback + // upstreams: pre-takeover snapshots on desktop, the OS-pushed list on Android, + // hardcoded Quad9 on iOS, nil for noop / mock. + getOriginalNameservers() []netip.Addr } type SystemDNSSettings struct { @@ -131,3 +135,11 @@ func (n noopHostConfigurator) supportCustomPort() bool { func (n noopHostConfigurator) string() string { return "noop" } + +func (n noopHostConfigurator) getOriginalNameservers() []netip.Addr { + return nil +} + +func (m *mockHostConfigurator) getOriginalNameservers() []netip.Addr { + return nil +} diff --git a/client/internal/dns/host_android.go b/client/internal/dns/host_android.go index dfa3e5712..48b3e0301 100644 --- a/client/internal/dns/host_android.go +++ b/client/internal/dns/host_android.go @@ -1,14 +1,20 @@ package dns import ( + "net/netip" + "github.com/netbirdio/netbird/client/internal/statemanager" ) +// androidHostManager is a noop on the OS side (Android's VPN service handles +// DNS for us) but tracks the OS-reported resolver list pushed via +// OnUpdatedHostDNSServer so it can serve as the fallback nameserver source. type androidHostManager struct { + holder *hostsDNSHolder } -func newHostManager() (*androidHostManager, error) { - return &androidHostManager{}, nil +func newHostManager(holder *hostsDNSHolder) (*androidHostManager, error) { + return &androidHostManager{holder: holder}, nil } func (a androidHostManager) applyDNSConfig(HostDNSConfig, *statemanager.Manager) error { @@ -26,3 +32,12 @@ func (a androidHostManager) supportCustomPort() bool { func (a androidHostManager) string() string { return "none" } + +func (a androidHostManager) getOriginalNameservers() []netip.Addr { + hosts := a.holder.get() + out := make([]netip.Addr, 0, len(hosts)) + for ap := range hosts { + out = append(out, ap.Addr()) + } + return out +} diff --git a/client/internal/dns/host_darwin.go b/client/internal/dns/host_darwin.go index b3908f163..81029752e 100644 --- a/client/internal/dns/host_darwin.go +++ b/client/internal/dns/host_darwin.go @@ -267,18 +267,38 @@ func (s *systemConfigurator) getSystemDNSSettings() (SystemDNSSettings, error) { return SystemDNSSettings{}, fmt.Errorf("sending the command: %w", err) } - var dnsSettings SystemDNSSettings + dnsSettings, serverAddresses, err := parseSystemDNSSettings(b) + if err != nil { + return dnsSettings, err + } + + s.mu.Lock() + s.origNameservers = serverAddresses + s.mu.Unlock() + + return dnsSettings, nil +} + +// parseSystemDNSSettings parses the output of `scutil show State:/Network/Service//DNS`. +// Lines that don't match the expected "index : value" shape are skipped: hosts with unusual +// network services (e.g. orphaned hardware ports) can produce entries without a value. +func parseSystemDNSSettings(out []byte) (SystemDNSSettings, []netip.Addr, error) { + // port is not exposed by scutil, default to 53 + dnsSettings := SystemDNSSettings{ServerPort: DefaultPort} var serverAddresses []netip.Addr inSearchDomainsArray := false inServerAddressesArray := false - scanner := bufio.NewScanner(bytes.NewReader(b)) + scanner := bufio.NewScanner(bytes.NewReader(out)) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) switch { case strings.HasPrefix(line, "DomainName :"): - domainName := strings.TrimSpace(strings.Split(line, ":")[1]) - dnsSettings.Domains = append(dnsSettings.Domains, domainName) + domainName := strings.TrimSpace(strings.TrimPrefix(line, "DomainName :")) + if domainName != "" { + dnsSettings.Domains = append(dnsSettings.Domains, domainName) + } + continue case line == "SearchDomains : {": inSearchDomainsArray = true continue @@ -288,35 +308,45 @@ func (s *systemConfigurator) getSystemDNSSettings() (SystemDNSSettings, error) { case line == "}": inSearchDomainsArray = false inServerAddressesArray = false + continue + } + + if !inSearchDomainsArray && !inServerAddressesArray { + continue + } + + parts := strings.SplitN(line, " : ", 2) + if len(parts) != 2 { + log.Debugf("skipping unexpected scutil DNS line %q", line) + continue + } + value := strings.TrimSpace(parts[1]) + if value == "" { + continue } if inSearchDomainsArray { - searchDomain := strings.Split(line, " : ")[1] - dnsSettings.Domains = append(dnsSettings.Domains, searchDomain) - } else if inServerAddressesArray { - address := strings.Split(line, " : ")[1] - if ip, err := netip.ParseAddr(address); err == nil && !ip.IsUnspecified() { - ip = ip.Unmap() - serverAddresses = append(serverAddresses, ip) - if !dnsSettings.ServerIP.IsValid() && ip.Is4() { - dnsSettings.ServerIP = ip - } - } + dnsSettings.Domains = append(dnsSettings.Domains, value) + continue + } + + ip, err := netip.ParseAddr(value) + if err != nil || ip.IsUnspecified() { + continue + } + ip = ip.Unmap() + serverAddresses = append(serverAddresses, ip) + // Prefer the first IPv4 server as ServerIP since our DNS listener is IPv4. + if !dnsSettings.ServerIP.IsValid() && ip.Is4() { + dnsSettings.ServerIP = ip } } if err := scanner.Err(); err != nil { - return dnsSettings, err + return dnsSettings, serverAddresses, err } - // default to 53 port - dnsSettings.ServerPort = DefaultPort - - s.mu.Lock() - s.origNameservers = serverAddresses - s.mu.Unlock() - - return dnsSettings, nil + return dnsSettings, serverAddresses, nil } func (s *systemConfigurator) getOriginalNameservers() []netip.Addr { @@ -434,11 +464,15 @@ func (s *systemConfigurator) getPrimaryService() (string, string, error) { router := "" for scanner.Scan() { text := scanner.Text() + parts := strings.SplitN(text, ":", 2) + if len(parts) != 2 { + continue + } if strings.Contains(text, "PrimaryService") { - primaryService = strings.TrimSpace(strings.Split(text, ":")[1]) + primaryService = strings.TrimSpace(parts[1]) } if strings.Contains(text, "Router") { - router = strings.TrimSpace(strings.Split(text, ":")[1]) + router = strings.TrimSpace(parts[1]) } } if err := scanner.Err(); err != nil && err != io.EOF { diff --git a/client/internal/dns/host_darwin_test.go b/client/internal/dns/host_darwin_test.go index 94d020c39..bee691c71 100644 --- a/client/internal/dns/host_darwin_test.go +++ b/client/internal/dns/host_darwin_test.go @@ -328,6 +328,120 @@ func removeTestDNSKey(key string) error { return err } +func TestParseSystemDNSSettings(t *testing.T) { + tests := []struct { + name string + output string + expectedDomains []string + expectedServers []netip.Addr + expectedIP netip.Addr + }{ + { + name: "well_formed", + output: ` { + DomainName : example.com + SearchDomains : { + 0 : example.com + 1 : corp.example.com + } + ServerAddresses : { + 0 : 192.168.1.1 + 1 : fd00::53 + } +} +`, + expectedDomains: []string{"example.com", "example.com", "corp.example.com"}, + expectedServers: []netip.Addr{netip.MustParseAddr("192.168.1.1"), netip.MustParseAddr("fd00::53")}, + expectedIP: netip.MustParseAddr("192.168.1.1"), + }, + { + // entries without a value after the separator used to panic with + // "index out of range [1] with length 1" + name: "malformed_array_entries_skipped", + output: ` { + SearchDomains : { + 0 : + (null) + + 1 : corp.example.com + } + ServerAddresses : { + 0 : + 1 : 192.168.1.1 + } +} +`, + expectedDomains: []string{"corp.example.com"}, + expectedServers: []netip.Addr{netip.MustParseAddr("192.168.1.1")}, + expectedIP: netip.MustParseAddr("192.168.1.1"), + }, + { + name: "domain_name_without_value_skipped", + output: ` { + DomainName : + ServerAddresses : { + 0 : 192.168.1.1 + } +} +`, + expectedServers: []netip.Addr{netip.MustParseAddr("192.168.1.1")}, + expectedIP: netip.MustParseAddr("192.168.1.1"), + }, + { + name: "ipv6_first_prefers_ipv4_server_ip", + output: ` { + ServerAddresses : { + 0 : fd00::53 + 1 : 192.168.1.1 + } +} +`, + expectedServers: []netip.Addr{netip.MustParseAddr("fd00::53"), netip.MustParseAddr("192.168.1.1")}, + expectedIP: netip.MustParseAddr("192.168.1.1"), + }, + { + name: "invalid_and_unspecified_addresses_skipped", + output: ` { + ServerAddresses : { + 0 : (null) + 1 : 0.0.0.0 + 2 : 192.168.1.1 + } +} +`, + expectedServers: []netip.Addr{netip.MustParseAddr("192.168.1.1")}, + expectedIP: netip.MustParseAddr("192.168.1.1"), + }, + { + name: "v4_mapped_address_unmapped", + output: ` { + ServerAddresses : { + 0 : ::ffff:192.168.1.1 + } +} +`, + expectedServers: []netip.Addr{netip.MustParseAddr("192.168.1.1")}, + expectedIP: netip.MustParseAddr("192.168.1.1"), + }, + { + name: "empty_output", + output: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + settings, servers, err := parseSystemDNSSettings([]byte(tc.output)) + require.NoError(t, err, "parsing should not fail") + + assert.Equal(t, tc.expectedDomains, settings.Domains, "domains should match") + assert.Equal(t, tc.expectedServers, servers, "server addresses should match") + assert.Equal(t, tc.expectedIP, settings.ServerIP, "server IP should match") + assert.Equal(t, DefaultPort, settings.ServerPort, "server port should default to 53") + }) + } +} + func TestGetOriginalNameservers(t *testing.T) { configurator := &systemConfigurator{ createdKeys: make(map[string]struct{}), diff --git a/client/internal/dns/host_ios.go b/client/internal/dns/host_ios.go index 1c0ac63e9..860bb8b50 100644 --- a/client/internal/dns/host_ios.go +++ b/client/internal/dns/host_ios.go @@ -3,6 +3,7 @@ package dns import ( "encoding/json" "fmt" + "net/netip" log "github.com/sirupsen/logrus" @@ -20,6 +21,14 @@ func newHostManager(dnsManager IosDnsManager) (*iosHostManager, error) { }, nil } +func (a iosHostManager) getOriginalNameservers() []netip.Addr { + // Quad9 v4+v6: 9.9.9.9, 2620:fe::fe. + return []netip.Addr{ + netip.AddrFrom4([4]byte{9, 9, 9, 9}), + netip.AddrFrom16([16]byte{0x26, 0x20, 0x00, 0xfe, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xfe}), + } +} + func (a iosHostManager) applyDNSConfig(config HostDNSConfig, _ *statemanager.Manager) error { jsonData, err := json.Marshal(config) if err != nil { diff --git a/client/internal/dns/host_windows.go b/client/internal/dns/host_windows.go index 4a8cf8cec..948000a3d 100644 --- a/client/internal/dns/host_windows.go +++ b/client/internal/dns/host_windows.go @@ -6,7 +6,10 @@ import ( "fmt" "io" "net/netip" + "os" "os/exec" + "slices" + "strconv" "strings" "syscall" "time" @@ -30,10 +33,52 @@ var ( dnsFlushResolverCacheFn = dnsapi.NewProc("DnsFlushResolverCache") ) +// Registry locations of the host DNS configuration this package programs, +// exported so a diagnostic reader reports the same locations that are written. const ( - dnsPolicyConfigMatchPath = `SYSTEM\CurrentControlSet\Services\Dnscache\Parameters\DnsPolicyConfig\NetBird-Match` - gpoDnsPolicyRoot = `SOFTWARE\Policies\Microsoft\Windows NT\DNSClient\DnsPolicyConfig` - gpoDnsPolicyConfigMatchPath = gpoDnsPolicyRoot + `\NetBird-Match` + // NRPTKeyPrefix starts the name of every NRPT rule key this client creates: + // the match rules, the catch-all, and the .local exemption. Cleanup + // enumerates by this prefix, so a new kind of rule is removed by existing + // code as long as its key starts here. + NRPTKeyPrefix = "NetBird-" + + // nrptMatchKeyName names the match-domain rules. Older versions used + // different layouts under the same name: a single unsuffixed key, then one + // key per domain, now one key per batch of domains. + nrptMatchKeyName = NRPTKeyPrefix + "Match" + + // DNSPolicyConfigRoot holds the NRPT rules of the local policy store. + DNSPolicyConfigRoot = `SYSTEM\CurrentControlSet\Services\Dnscache\Parameters\DnsPolicyConfig` + + // GPODNSPolicyConfigRoot holds the NRPT rules of the group policy store, + // which takes precedence over the local one when it is present. + GPODNSPolicyConfigRoot = `SOFTWARE\Policies\Microsoft\Windows NT\DNSClient\DnsPolicyConfig` + + // InterfaceConfigPath and InterfaceConfigPathV6 hold the per-interface DNS + // settings, keyed by interface GUID, in separate hives per address family. + InterfaceConfigPath = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces` + InterfaceConfigPathV6 = `SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters\Interfaces` +) + +const ( + dnsPolicyConfigMatchPath = DNSPolicyConfigRoot + `\` + nrptMatchKeyName + gpoDnsPolicyConfigMatchPath = GPODNSPolicyConfigRoot + `\` + nrptMatchKeyName + + dnsPolicyConfigExemptLocalPath = DNSPolicyConfigRoot + `\` + NRPTKeyPrefix + `ExemptLocal` + gpoDnsPolicyConfigExemptLocalPath = GPODNSPolicyConfigRoot + `\` + NRPTKeyPrefix + `ExemptLocal` + + nrptCatchAllNamespace = "." + // nrptLocalNamespace is reserved for multicast DNS by RFC 6762: a unicast + // resolver must not answer for it. The catch-all rule would hand it to us + // anyway, so it gets an exemption rule of its own. + nrptLocalNamespace = ".local" + + // envLegacyDNSResolution restores the pre-catch-all behaviour: the adapter's + // NameServer alone, leaving the OS free to query other adapters' resolvers in + // parallel. An escape hatch for setups that depend on a resolver of theirs + // still being reachable while connected, at the cost of the leak and of the + // race the catch-all rule exists to close. + envLegacyDNSResolution = "NB_USE_LEGACY_DNS_RESOLUTION" dnsPolicyConfigVersionKey = "Version" dnsPolicyConfigVersionValue = 2 @@ -44,9 +89,9 @@ const ( nrptMaxDomainsPerRule = 50 - interfaceConfigPath = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces` - interfaceConfigNameServerKey = "NameServer" - interfaceConfigSearchListKey = "SearchList" + interfaceConfigNameServerKey = "NameServer" + interfaceConfigDhcpNameSrvKey = "DhcpNameServer" + interfaceConfigSearchListKey = "SearchList" // Network interface DNS registration settings disableDynamicUpdateKey = "DisableDynamicUpdate" @@ -67,10 +112,10 @@ const ( ) type registryConfigurator struct { - guid string - routingAll bool - gpo bool - nrptEntryCount int + guid string + routingAll bool + gpo bool + origNameservers []netip.Addr } func newHostManager(wgInterface WGIface) (*registryConfigurator, error) { @@ -80,7 +125,7 @@ func newHostManager(wgInterface WGIface) (*registryConfigurator, error) { } var useGPO bool - k, err := registry.OpenKey(registry.LOCAL_MACHINE, gpoDnsPolicyRoot, registry.QUERY_VALUE) + k, err := registry.OpenKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot, registry.QUERY_VALUE) if err != nil { log.Debugf("failed to open GPO DNS policy root: %v", err) } else { @@ -94,6 +139,17 @@ func newHostManager(wgInterface WGIface) (*registryConfigurator, error) { gpo: useGPO, } + origNameservers, err := configurator.captureOriginalNameservers() + switch { + case err != nil: + log.Warnf("capture original nameservers from non-WG adapters: %v", err) + case len(origNameservers) == 0: + log.Warnf("no original nameservers captured from non-WG adapters; DNS fallback will be empty") + default: + log.Debugf("captured %d original nameservers from non-WG adapters: %v", len(origNameservers), origNameservers) + } + configurator.origNameservers = origNameservers + if err := configurator.configureInterface(); err != nil { log.Errorf("failed to configure interface settings: %v", err) } @@ -101,6 +157,98 @@ func newHostManager(wgInterface WGIface) (*registryConfigurator, error) { return configurator, nil } +// captureOriginalNameservers reads DNS addresses from every Tcpip(6) interface +// registry key except the WG adapter. v4 and v6 servers live in separate +// hives (Tcpip vs Tcpip6) keyed by the same interface GUID. +func (r *registryConfigurator) captureOriginalNameservers() ([]netip.Addr, error) { + seen := make(map[netip.Addr]struct{}) + var out []netip.Addr + var merr *multierror.Error + for _, root := range []string{InterfaceConfigPath, InterfaceConfigPathV6} { + addrs, err := r.captureFromTcpipRoot(root) + if err != nil { + merr = multierror.Append(merr, fmt.Errorf("%s: %w", root, err)) + continue + } + for _, addr := range addrs { + if _, dup := seen[addr]; dup { + continue + } + seen[addr] = struct{}{} + out = append(out, addr) + } + } + return out, nberrors.FormatErrorOrNil(merr) +} + +func (r *registryConfigurator) captureFromTcpipRoot(rootPath string) ([]netip.Addr, error) { + root, err := registry.OpenKey(registry.LOCAL_MACHINE, rootPath, registry.READ) + if err != nil { + return nil, fmt.Errorf("open key: %w", err) + } + defer closer(root) + + guids, err := root.ReadSubKeyNames(-1) + if err != nil { + return nil, fmt.Errorf("read subkeys: %w", err) + } + + var out []netip.Addr + for _, guid := range guids { + if strings.EqualFold(guid, r.guid) { + continue + } + out = append(out, readInterfaceNameservers(rootPath, guid)...) + } + return out, nil +} + +func readInterfaceNameservers(rootPath, guid string) []netip.Addr { + keyPath := rootPath + "\\" + guid + k, err := registry.OpenKey(registry.LOCAL_MACHINE, keyPath, registry.QUERY_VALUE) + if err != nil { + return nil + } + defer closer(k) + + // Static NameServer wins over DhcpNameServer for actual resolution. + for _, name := range []string{interfaceConfigNameServerKey, interfaceConfigDhcpNameSrvKey} { + raw, _, err := k.GetStringValue(name) + if err != nil || raw == "" { + continue + } + if out := parseRegistryNameservers(raw); len(out) > 0 { + return out + } + } + return nil +} + +func parseRegistryNameservers(raw string) []netip.Addr { + var out []netip.Addr + for _, field := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' }) { + addr, err := netip.ParseAddr(strings.TrimSpace(field)) + if err != nil { + continue + } + addr = addr.Unmap() + if !addr.IsValid() || addr.IsUnspecified() { + continue + } + // Drop unzoned link-local: not routable without a scope id. If + // the user wrote "fe80::1%eth0" ParseAddr preserves the zone. + if addr.IsLinkLocalUnicast() && addr.Zone() == "" { + continue + } + out = append(out, addr) + } + return out +} + +func (r *registryConfigurator) getOriginalNameservers() []netip.Addr { + return slices.Clone(r.origNameservers) +} + func (r *registryConfigurator) supportCustomPort() bool { return false } @@ -169,6 +317,13 @@ func (r *registryConfigurator) disableWINSForInterface() error { } func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager *statemanager.Manager) error { + // Clear every rule the previous apply installed before installing any new + // one, including a leftover catch-all: removal is unconditional so a rule + // from an earlier run cannot survive into a config that no longer wants it. + if err := r.removeDNSMatchPolicies(); err != nil { + log.Errorf("cleanup old dns match policies: %s", err) + } + if config.RouteAll { if err := r.addDNSSetupForAll(config.ServerIP); err != nil { return fmt.Errorf("add dns setup: %w", err) @@ -194,19 +349,28 @@ func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager matchDomains = append(matchDomains, "."+strings.TrimSuffix(dConf.Domain, ".")) } - if err := r.removeDNSMatchPolicies(); err != nil { - log.Errorf("cleanup old dns match policies: %s", err) + // The root namespace is a match domain like any other: it just happens to + // match every name. Without it the adapter's NameServer only adds one more + // resolver to the set Windows queries in parallel, keeping whichever answer + // comes back first — which leaks every query to the local network and lets a + // resolver other than ours answer for a name we are authoritative for. + if config.RouteAll { + if parseBoolEnv(envLegacyDNSResolution) { + log.Infof("%s is set, leaving DNS resolution shared with the other adapters' resolvers instead of forcing it through %s", envLegacyDNSResolution, config.ServerIP) + } else { + matchDomains = append(matchDomains, nrptCatchAllNamespace) + log.Infof("routing every namespace through %s: DNS resolution is now exclusive to NetBird", config.ServerIP) + + if err := r.addDNSExemptLocalPolicy(); err != nil { + return fmt.Errorf("add dns exempt policy: %w", err) + } + } } if len(matchDomains) != 0 { - count, err := r.addDNSMatchPolicy(matchDomains, config.ServerIP) - // Update count even on error to ensure cleanup covers partially created rules - r.nrptEntryCount = count - if err != nil { + if err := r.addDNSMatchPolicy(matchDomains, config.ServerIP); err != nil { return fmt.Errorf("add dns match policy: %w", err) } - } else { - r.nrptEntryCount = 0 } r.updateState(stateManager) @@ -222,9 +386,8 @@ func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager func (r *registryConfigurator) updateState(stateManager *statemanager.Manager) { if err := stateManager.UpdateState(&ShutdownState{ - Guid: r.guid, - GPO: r.gpo, - NRPTEntryCount: r.nrptEntryCount, + Guid: r.guid, + GPO: r.gpo, }); err != nil { log.Errorf("failed to update shutdown state: %s", err) } @@ -239,7 +402,7 @@ func (r *registryConfigurator) addDNSSetupForAll(ip netip.Addr) error { return nil } -func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr) (int, error) { +func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr) error { // if the gpo key is present, we need to put our DNS settings there, otherwise our config might be ignored // see https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-gpnrpt/8cc31cb9-20cb-4140-9e85-3e08703b4745 @@ -256,19 +419,17 @@ func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr gpoPath := fmt.Sprintf("%s-%d", gpoDnsPolicyConfigMatchPath, ruleIndex) if err := r.configureDNSPolicy(localPath, batchDomains, ip); err != nil { - return ruleIndex, fmt.Errorf("configure DNS Local policy for rule %d: %w", ruleIndex, err) + return fmt.Errorf("configure DNS Local policy for rule %d: %w", ruleIndex, err) } - // Increment immediately so the caller's cleanup path knows about this rule - ruleIndex++ - if r.gpo { if err := r.configureDNSPolicy(gpoPath, batchDomains, ip); err != nil { - return ruleIndex, fmt.Errorf("configure gpo DNS policy for rule %d: %w", ruleIndex-1, err) + return fmt.Errorf("configure gpo DNS policy for rule %d: %w", ruleIndex, err) } } - log.Debugf("added NRPT rule %d with %d domains", ruleIndex-1, len(batchDomains)) + log.Debugf("added NRPT rule %d with %d domains", ruleIndex, len(batchDomains)) + ruleIndex++ } if r.gpo { @@ -278,9 +439,45 @@ func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr } log.Infof("added %d NRPT rules for %d domains", ruleIndex, len(domains)) - return ruleIndex, nil + return nil } +// addDNSExemptLocalPolicy carves .local back out of the catch-all. RFC 6762 +// reserves it for multicast DNS, so forwarding those names to a unicast +// upstream answers NXDOMAIN for hosts that do exist - printers, NAS boxes, and +// anything else announcing itself on the link - and the answer is authoritative +// enough that Windows stops looking. A rule naming the namespace with no +// servers hands it back to the DNS client untouched. A more specific rule still +// wins, so a match domain under .local keeps going through us. +func (r *registryConfigurator) addDNSExemptLocalPolicy() error { + var noServers netip.Addr + + if err := r.configureDNSPolicy(dnsPolicyConfigExemptLocalPath, []string{nrptLocalNamespace}, noServers); err != nil { + return fmt.Errorf("configure exempt policy for %s: %w", nrptLocalNamespace, err) + } + + if r.gpo { + if err := r.configureDNSPolicy(gpoDnsPolicyConfigExemptLocalPath, []string{nrptLocalNamespace}, noServers); err != nil { + return fmt.Errorf("configure gpo exempt policy for %s: %w", nrptLocalNamespace, err) + } + if err := refreshGroupPolicy(); err != nil { + log.Warnf("failed to refresh group policy: %v", err) + } + } + + log.Infof("added NRPT exemption for %s, leaving it to the OS resolver", nrptLocalNamespace) + return nil +} + +// configureDNSPolicy writes one NRPT rule. An invalid ip writes an exemption +// rule: the namespace with an empty server list, which tells the DNS client to +// resolve those names the way it would without any rule at all. +// +// The empty string is the whole difference, and it has to be written: dropping +// the value and clearing ConfigOptions instead produces a rule Windows treats +// as a no-op, keeps out of Get-DnsClientNrptPolicy -Effective, and ignores in +// favour of the catch-all. 0x8 says the server list is the meaningful part of +// the rule, and an empty list then means "no server, resolve normally". func (r *registryConfigurator) configureDNSPolicy(policyPath string, domains []string, ip netip.Addr) error { if err := removeRegistryKeyFromDNSPolicyConfig(policyPath); err != nil { return fmt.Errorf("remove existing dns policy: %w", err) @@ -300,7 +497,11 @@ func (r *registryConfigurator) configureDNSPolicy(policyPath string, domains []s return fmt.Errorf("set %s: %w", dnsPolicyConfigNameKey, err) } - if err := regKey.SetStringValue(dnsPolicyConfigGenericDNSServersKey, ip.String()); err != nil { + var servers string + if ip.IsValid() { + servers = ip.String() + } + if err := regKey.SetStringValue(dnsPolicyConfigGenericDNSServersKey, servers); err != nil { return fmt.Errorf("set %s: %w", dnsPolicyConfigGenericDNSServersKey, err) } @@ -343,7 +544,7 @@ func (r *registryConfigurator) flushDNSCache() { ret, _, err := dnsFlushResolverCacheFn.Call() if ret == 0 { - if err != nil && !errors.Is(err, syscall.Errno(0)) { + if !errors.Is(err, syscall.Errno(0)) { log.Errorf("DnsFlushResolverCache failed: %v", err) return } @@ -389,7 +590,7 @@ func (r *registryConfigurator) deleteInterfaceRegistryKeyProperty(propertyKey st } func (r *registryConfigurator) getInterfaceRegistryKey() (registry.Key, error) { - regKeyPath := interfaceConfigPath + "\\" + r.guid + regKeyPath := InterfaceConfigPath + "\\" + r.guid regKey, err := registry.OpenKey(registry.LOCAL_MACHINE, regKeyPath, registry.SET_VALUE) if err != nil { return regKey, fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", regKeyPath, err) @@ -398,8 +599,11 @@ func (r *registryConfigurator) getInterfaceRegistryKey() (registry.Key, error) { } func (r *registryConfigurator) restoreHostDNS() error { + // Propagated, unlike in applyDNSConfig: there we are about to write fresh + // rules over whatever survived, here we are leaving, and a rule left behind + // keeps sending every query to an address that is about to disappear. if err := r.removeDNSMatchPolicies(); err != nil { - log.Errorf("remove dns match policies: %s", err) + return fmt.Errorf("remove dns match policies: %w", err) } if err := r.deleteInterfaceRegistryKeyProperty(interfaceConfigSearchListKey); err != nil { @@ -411,28 +615,28 @@ func (r *registryConfigurator) restoreHostDNS() error { return nil } +// removeDNSMatchPolicies deletes every NRPT rule this client may have created, +// from the local and the GPO policy store. The rules are found by enumerating +// the registry, the only authoritative record of what was written. Cleanup must +// not depend on a rule count: the in-memory one is scoped to a single +// registryConfigurator and the persisted one is deleted on every clean +// disconnect, and a rule left behind keeps resolving names over an interface +// that is gone, until reboot discards the volatile key. func (r *registryConfigurator) removeDNSMatchPolicies() error { var merr *multierror.Error - // Try to remove the base entries (for backward compatibility) - if err := removeRegistryKeyFromDNSPolicyConfig(dnsPolicyConfigMatchPath); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove local base entry: %w", err)) - } - - if err := removeRegistryKeyFromDNSPolicyConfig(gpoDnsPolicyConfigMatchPath); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove GPO base entry: %w", err)) - } - - for i := 0; i < r.nrptEntryCount; i++ { - localPath := fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i) - gpoPath := fmt.Sprintf("%s-%d", gpoDnsPolicyConfigMatchPath, i) - - if err := removeRegistryKeyFromDNSPolicyConfig(localPath); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove local entry %d: %w", i, err)) + for _, root := range []string{DNSPolicyConfigRoot, GPODNSPolicyConfigRoot} { + names, err := listNRPTRuleKeys(root) + if err != nil { + merr = multierror.Append(merr, fmt.Errorf("list rule keys under %s: %w", root, err)) + continue } - if err := removeRegistryKeyFromDNSPolicyConfig(gpoPath); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove GPO entry %d: %w", i, err)) + for _, name := range names { + path := root + `\` + name + if err := removeRegistryKeyFromDNSPolicyConfig(path); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove entry %s: %w", path, err)) + } } } @@ -447,11 +651,52 @@ func (r *registryConfigurator) restoreUncleanShutdownDNS() error { return r.restoreHostDNS() } +// listNRPTRuleKeys returns the names of our NRPT rule keys under a policy store +// root. An absent root holds nothing to clean up, which is the normal state of +// the GPO store on a machine without DNS Client policy. +func listNRPTRuleKeys(root string) ([]string, error) { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, root, registry.ENUMERATE_SUB_KEYS) + switch { + case errors.Is(err, registry.ErrNotExist), errors.Is(err, syscall.ERROR_PATH_NOT_FOUND): + // the GPO store is absent on a machine without DNS client policy + log.Debugf("HKEY_LOCAL_MACHINE\\%s does not exist", root) + return nil, nil + case err != nil: + // any other failure has to reach the caller: reporting no rules would + // report a successful cleanup while leaving the rules in place + return nil, fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", root, err) + } + defer closer(k) + + names, err := k.ReadSubKeyNames(-1) + if err != nil { + return nil, fmt.Errorf("read subkey names: %w", err) + } + + var ruleKeys []string + for _, name := range names { + // registry key names are case insensitive + if strings.HasPrefix(strings.ToLower(name), strings.ToLower(NRPTKeyPrefix)) { + ruleKeys = append(ruleKeys, name) + } + } + + return ruleKeys, nil +} + func removeRegistryKeyFromDNSPolicyConfig(regKeyPath string) error { k, err := registry.OpenKey(registry.LOCAL_MACHINE, regKeyPath, registry.QUERY_VALUE) - if err != nil { - log.Debugf("failed to open HKEY_LOCAL_MACHINE\\%s: %v", regKeyPath, err) + switch { + case errors.Is(err, registry.ErrNotExist), errors.Is(err, syscall.ERROR_PATH_NOT_FOUND): + // nothing to remove, which is the normal case for a rule this config + // never installed + log.Debugf("HKEY_LOCAL_MACHINE\\%s does not exist", regKeyPath) return nil + case err != nil: + // anything else has to reach the caller: reporting success here would + // leave the rule in force while claiming it was removed, which is how a + // stale rule outlives the interface it points at + return fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", regKeyPath, err) } closer(k) @@ -478,7 +723,7 @@ func refreshGroupPolicy() error { ) if ret == 0 { - if err != nil && !errors.Is(err, syscall.Errno(0)) { + if !errors.Is(err, syscall.Errno(0)) { return fmt.Errorf("RefreshPolicyEx failed: %w", err) } return fmt.Errorf("RefreshPolicyEx failed") @@ -487,6 +732,20 @@ func refreshGroupPolicy() error { return nil } +func parseBoolEnv(key string) bool { + val := os.Getenv(key) + if val == "" { + return false + } + + parsed, err := strconv.ParseBool(val) + if err != nil { + log.Warnf("failed to parse %s=%q: %v", key, val, err) + return false + } + return parsed +} + func closer(closer io.Closer) { if err := closer.Close(); err != nil { log.Errorf("failed to close: %s", err) diff --git a/client/internal/dns/host_windows_test.go b/client/internal/dns/host_windows_test.go index 3cd2b1bd5..7aef64590 100644 --- a/client/internal/dns/host_windows_test.go +++ b/client/internal/dns/host_windows_test.go @@ -25,7 +25,7 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) { // Create a test interface registry key so updateSearchDomains doesn't fail testGUID := "{12345678-1234-1234-1234-123456789ABC}" - interfacePath := `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\` + testGUID + interfacePath := InterfaceConfigPath + `\` + testGUID testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE) require.NoError(t, err, "Should create test interface registry key") testKey.Close() @@ -56,7 +56,7 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) { require.NoError(t, err) // Verify 3 NRPT rules exist - assert.Equal(t, 3, cfg.nrptEntryCount, "Should create 3 NRPT rules for 125 domains") + assert.Equal(t, 3, countNRPTRuleKeys(t), "Should create 3 NRPT rules for 125 domains") for i := 0; i < 3; i++ { exists, err := registryKeyExists(fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i)) require.NoError(t, err) @@ -81,7 +81,7 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) { require.NoError(t, err) // Verify first 2 NRPT rules exist - assert.Equal(t, 2, cfg.nrptEntryCount, "Should create 2 NRPT rules for 75 domains") + assert.Equal(t, 2, countNRPTRuleKeys(t), "Should create 2 NRPT rules for 75 domains") for i := 0; i < 2; i++ { exists, err := registryKeyExists(fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i)) require.NoError(t, err) @@ -94,6 +94,145 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) { assert.False(t, exists, "NRPT rule 2 should NOT exist after reducing to 75 domains") } +// TestNRPTCatchAllRule verifies that RouteAll adds the root namespace to the +// match rule instead of a rule of its own, that .local is carved back out with +// an empty server list, and that both go away when RouteAll is cleared or the +// host DNS is restored. +func TestNRPTCatchAllRule(t *testing.T) { + if testing.Short() { + t.Skip("skipping registry integration test in short mode") + } + + defer cleanupRegistryKeys(t) + cleanupRegistryKeys(t) + + testIP := netip.MustParseAddr("100.64.0.1") + testGUID := "{12345678-1234-1234-1234-123456789ABC}" + interfacePath := InterfaceConfigPath + `\` + testGUID + testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE) + require.NoError(t, err, "Should create test interface registry key") + require.NoError(t, testKey.Close(), "close test interface registry key") + defer func() { + assert.NoError(t, registry.DeleteKey(registry.LOCAL_MACHINE, interfacePath), "delete test interface registry key") + }() + + cfg := ®istryConfigurator{guid: testGUID} + + matchOnly := HostDNSConfig{ + ServerIP: testIP, + Domains: []DomainConfig{{Domain: "example.com", MatchOnly: true}}, + } + primary := HostDNSConfig{ + ServerIP: testIP, + RouteAll: true, + Domains: []DomainConfig{{Domain: "example.com", MatchOnly: true}}, + } + firstRule := fmt.Sprintf("%s-0", dnsPolicyConfigMatchPath) + + // The root namespace is not a rule of its own: it rides in the match rule, + // which is the point of it not being a special case. + require.NoError(t, cfg.applyDNSConfig(matchOnly, nil)) + names := ruleNamespaces(t, firstRule) + assert.Contains(t, names, ".example.com") + assert.NotContains(t, names, nrptCatchAllNamespace, "a match-only config must not claim every namespace") + + require.NoError(t, cfg.applyDNSConfig(primary, nil)) + names = ruleNamespaces(t, firstRule) + assert.Contains(t, names, ".example.com") + assert.Contains(t, names, nrptCatchAllNamespace, "RouteAll should add the root namespace to the match rule") + + k, err := registry.OpenKey(registry.LOCAL_MACHINE, firstRule, registry.QUERY_VALUE) + require.NoError(t, err) + servers, _, err := k.GetStringValue(dnsPolicyConfigGenericDNSServersKey) + require.NoError(t, err) + assert.Equal(t, testIP.String(), servers, "every namespace in the rule resolves through our resolver") + require.NoError(t, k.Close(), "close match rule key") + + // .local is carved back out: RFC 6762 reserves it for mDNS, so it needs a + // rule of its own — it is the one rule with a different server list. + ek, err := registry.OpenKey(registry.LOCAL_MACHINE, dnsPolicyConfigExemptLocalPath, registry.QUERY_VALUE) + require.NoError(t, err, "exemption rule should exist once the root namespace is claimed") + + exemptNames, _, err := ek.GetStringsValue(dnsPolicyConfigNameKey) + require.NoError(t, err) + assert.Equal(t, []string{nrptLocalNamespace}, exemptNames, "the exemption should name only the mDNS namespace") + + exemptServers, _, err := ek.GetStringValue(dnsPolicyConfigGenericDNSServersKey) + require.NoError(t, err, "the value has to be present, empty: without it Windows drops the rule") + assert.Empty(t, exemptServers, "an exemption rule lists no servers") + + exemptOpts, _, err := ek.GetIntegerValue(dnsPolicyConfigConfigOptionsKey) + require.NoError(t, err) + assert.EqualValues(t, dnsPolicyConfigConfigOptionsValue, exemptOpts, "same options as a normal rule; the empty server list is what makes it an exemption") + require.NoError(t, ek.Close(), "close exemption rule key") + + require.NoError(t, cfg.applyDNSConfig(matchOnly, nil)) + names = ruleNamespaces(t, firstRule) + assert.NotContains(t, names, nrptCatchAllNamespace, "clearing RouteAll should drop the root namespace") + + exists, err := registryKeyExists(dnsPolicyConfigExemptLocalPath) + require.NoError(t, err) + assert.False(t, exists, "exemption rule should go with the namespace it carves out of") + + require.NoError(t, cfg.applyDNSConfig(primary, nil)) + require.NoError(t, cfg.restoreHostDNS()) + exists, err = registryKeyExists(firstRule) + require.NoError(t, err) + assert.False(t, exists, "restore should leave no rule behind") +} + +// ruleNamespaces returns the namespaces an NRPT rule key claims. +func ruleNamespaces(t *testing.T, path string) []string { + t.Helper() + k, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.QUERY_VALUE) + require.NoError(t, err, "rule key %s should exist", path) + defer k.Close() + + names, _, err := k.GetStringsValue(dnsPolicyConfigNameKey) + require.NoError(t, err) + return names +} + +// TestNRPTCatchAllRuleLegacyEnv verifies that NB_USE_LEGACY_DNS_RESOLUTION +// leaves the root namespace unclaimed, so no rule is written for a RouteAll +// config that carries no match domains. +func TestNRPTCatchAllRuleLegacyEnv(t *testing.T) { + if testing.Short() { + t.Skip("skipping registry integration test in short mode") + } + + defer cleanupRegistryKeys(t) + cleanupRegistryKeys(t) + + t.Setenv(envLegacyDNSResolution, "true") + + testGUID := "{12345678-1234-1234-1234-123456789ABC}" + interfacePath := InterfaceConfigPath + `\` + testGUID + testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE) + require.NoError(t, err, "Should create test interface registry key") + require.NoError(t, testKey.Close(), "close test interface registry key") + defer func() { + assert.NoError(t, registry.DeleteKey(registry.LOCAL_MACHINE, interfacePath), "delete test interface registry key") + }() + + cfg := ®istryConfigurator{guid: testGUID} + config := HostDNSConfig{ + ServerIP: netip.MustParseAddr("100.64.0.1"), + RouteAll: true, + } + + require.NoError(t, cfg.applyDNSConfig(config, nil)) + + // RouteAll with no match domains and the switch set leaves nothing to write. + exists, err := registryKeyExists(fmt.Sprintf("%s-0", dnsPolicyConfigMatchPath)) + require.NoError(t, err) + assert.False(t, exists, "no rule should be written when the legacy env var is set") + + exists, err = registryKeyExists(dnsPolicyConfigExemptLocalPath) + require.NoError(t, err) + assert.False(t, exists, "no exemption without a claimed root namespace") +} + func registryKeyExists(path string) (bool, error) { k, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.QUERY_VALUE) if err != nil { @@ -106,9 +245,65 @@ func registryKeyExists(path string) (bool, error) { return true, nil } +// TestNRPTCleanupWithoutRuleCount verifies that rules written by a previous run +// are removed by a configurator that has no record of how many there are: an +// unclean exit loses the in-memory count and a clean disconnect deletes the +// persisted one, so cleanup cannot depend on either. +func TestNRPTCleanupWithoutRuleCount(t *testing.T) { + if testing.Short() { + t.Skip("skipping registry integration test in short mode") + } + + defer cleanupRegistryKeys(t) + cleanupRegistryKeys(t) + + testIP := netip.MustParseAddr("100.64.0.1") + + // 75 domains produce two indexed rules, as the current layout does + domains := make([]string, 75) + for i := range domains { + domains[i] = fmt.Sprintf(".domain%d.com", i+1) + } + + previousRun := ®istryConfigurator{} + require.NoError(t, previousRun.addDNSMatchPolicy(domains, testIP)) + + // the unsuffixed key an older version would have written + require.NoError(t, previousRun.configureDNSPolicy(dnsPolicyConfigMatchPath, []string{".legacy.example.com"}, testIP)) + + // a policy owned by someone else, which cleanup must not touch + foreignPath := DNSPolicyConfigRoot + `\DnsPolicyConfigTestForeign` + foreignKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, foreignPath, registry.SET_VALUE) + require.NoError(t, err, "Should create foreign policy key") + foreignKey.Close() + defer func() { + _ = registry.DeleteKey(registry.LOCAL_MACHINE, foreignPath) + }() + + require.Equal(t, 3, countNRPTRuleKeys(t), "Should have two indexed rules and the legacy one") + + // a configurator that never applied a DNS config, as one built after a + // restart or from a shutdown state without a count is + freshRun := ®istryConfigurator{} + require.NoError(t, freshRun.removeDNSMatchPolicies()) + + assert.Equal(t, 0, countNRPTRuleKeys(t), "Should remove every rule left by the previous run") + + exists, err := registryKeyExists(foreignPath) + require.NoError(t, err) + assert.True(t, exists, "Should not remove a policy that is not ours") +} + +func countNRPTRuleKeys(t *testing.T) int { + t.Helper() + + names, err := listNRPTRuleKeys(DNSPolicyConfigRoot) + require.NoError(t, err, "Should list NRPT rule keys") + return len(names) +} + func cleanupRegistryKeys(*testing.T) { - // Clean up more entries to account for batching tests with many domains - cfg := ®istryConfigurator{nrptEntryCount: 20} + cfg := ®istryConfigurator{} _ = cfg.removeDNSMatchPolicies() } @@ -125,7 +320,7 @@ func TestNRPTDomainBatching(t *testing.T) { // Create a test interface registry key so updateSearchDomains doesn't fail testGUID := "{12345678-1234-1234-1234-123456789ABC}" - interfacePath := `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\` + testGUID + interfacePath := InterfaceConfigPath + `\` + testGUID testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE) require.NoError(t, err, "Should create test interface registry key") testKey.Close() @@ -193,7 +388,7 @@ func TestNRPTDomainBatching(t *testing.T) { require.NoError(t, err) // Verify that exactly expectedRuleCount rules were created - assert.Equal(t, tc.expectedRuleCount, cfg.nrptEntryCount, + assert.Equal(t, tc.expectedRuleCount, countNRPTRuleKeys(t), "Should create %d NRPT rules for %d domains", tc.expectedRuleCount, tc.domainCount) // Verify all expected rules exist diff --git a/client/internal/dns/hosts_dns_holder.go b/client/internal/dns/hosts_dns_holder.go index 980d917a7..9ecc397be 100644 --- a/client/internal/dns/hosts_dns_holder.go +++ b/client/internal/dns/hosts_dns_holder.go @@ -25,6 +25,7 @@ func (h *hostsDNSHolder) set(list []netip.AddrPort) { h.mutex.Unlock() } +//nolint:unused func (h *hostsDNSHolder) get() map[netip.AddrPort]struct{} { h.mutex.RLock() l := h.unprotectedDNSList diff --git a/client/internal/dns/interface_index.go b/client/internal/dns/interface_index.go new file mode 100644 index 000000000..9e7dca080 --- /dev/null +++ b/client/internal/dns/interface_index.go @@ -0,0 +1,15 @@ +package dns + +import ( + "fmt" + "net" +) + +func getInterfaceIndex(interfaceName string) (int, error) { + iface, err := net.InterfaceByName(interfaceName) + if err != nil { + return 0, fmt.Errorf("lookup interface %q: %w", interfaceName, err) + } + + return iface.Index, nil +} diff --git a/client/internal/dns/interface_index_test.go b/client/internal/dns/interface_index_test.go new file mode 100644 index 000000000..9b146398a --- /dev/null +++ b/client/internal/dns/interface_index_test.go @@ -0,0 +1,35 @@ +package dns + +import ( + "net" + "testing" +) + +func TestGetInterfaceIndexExisting(t *testing.T) { + interfaces, err := net.Interfaces() + if err != nil { + t.Fatalf("list network interfaces: %v", err) + } + if len(interfaces) == 0 { + t.Fatal("expected at least one network interface") + } + + iface := interfaces[0] + index, err := getInterfaceIndex(iface.Name) + if err != nil { + t.Fatalf("look up existing interface %q: %v", iface.Name, err) + } + if index != iface.Index { + t.Fatalf("expected interface index %d, got %d", iface.Index, index) + } +} + +func TestGetInterfaceIndexMissing(t *testing.T) { + index, err := getInterfaceIndex("netbird-interface-that-does-not-exist") + if index != 0 { + t.Fatalf("expected missing interface index to be 0, got %d", index) + } + if err == nil { + t.Fatal("expected missing interface lookup to return an error") + } +} diff --git a/client/internal/dns/local/local.go b/client/internal/dns/local/local.go index a67a23945..fef35fd41 100644 --- a/client/internal/dns/local/local.go +++ b/client/internal/dns/local/local.go @@ -6,6 +6,7 @@ import ( "fmt" "net" "net/netip" + "os" "slices" "strings" "sync" @@ -13,7 +14,6 @@ import ( "github.com/miekg/dns" log "github.com/sirupsen/logrus" - "golang.org/x/exp/maps" "github.com/netbirdio/netbird/client/internal/dns/resutil" "github.com/netbirdio/netbird/client/internal/dns/types" @@ -27,6 +27,55 @@ type resolver interface { LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error) } +// PeerConnectivity reports whether a tunnel IP belongs to a peer the +// client knows about and whether that peer is currently connected. The +// local resolver uses this to suppress A/AAAA answers whose RDATA points +// at a disconnected peer (typical case: a synthesized private-service +// record pointing at an embedded proxy peer that just went offline). +// +// known=false means the IP isn't in the local peerstore at all — the +// record is left alone (it points at something outside our mesh, e.g. +// a non-peer upstream). +type PeerConnectivity interface { + IsConnectedByIP(ip netip.Addr) (known, connected bool) +} + +// PeerActivator wakes lazy-connection peers on demand. The local resolver calls +// it with the tunnel IPs an answer points at, so a peer that is idle (lazily +// disconnected) starts connecting at DNS-resolution time rather than racing the +// client's first request packet. nil disables warm-up. +type PeerActivator interface { + // ActivatePeersByIP triggers wake-up for the peer(s) owning addrs and blocks + // until one is connected or ctx (a short per-query budget) expires. It is a + // fast no-op for unknown or already-connected addresses. + ActivatePeersByIP(ctx context.Context, addrs []netip.Addr) +} + +const ( + defaultLazyWarmupTimeout = 2 * time.Second + envLazyWarmupTimeout = "NB_DNS_LAZY_WARMUP_TIMEOUT" +) + +// lazyWarmupTimeoutFromEnv returns the per-query budget for waking a +// lazy-connection peer a DNS answer points at. Tunable via +// NB_DNS_LAZY_WARMUP_TIMEOUT (a Go duration). Parsed once at construction time. +func lazyWarmupTimeoutFromEnv() time.Duration { + v := os.Getenv(envLazyWarmupTimeout) + if v == "" { + return defaultLazyWarmupTimeout + } + d, err := time.ParseDuration(v) + if err != nil { + log.Warnf("invalid %s value %q, using default %s: %v", envLazyWarmupTimeout, v, defaultLazyWarmupTimeout, err) + return defaultLazyWarmupTimeout + } + if d <= 0 { + log.Warnf("non-positive %s value %q, using default %s", envLazyWarmupTimeout, v, defaultLazyWarmupTimeout) + return defaultLazyWarmupTimeout + } + return d +} + type Resolver struct { mu sync.RWMutex records map[dns.Question][]dns.RR @@ -34,6 +83,17 @@ type Resolver struct { // zones maps zone domain -> NonAuthoritative (true = non-authoritative, user-created zone) zones map[domain.Domain]bool resolver resolver + // peerConn, when non-nil, is consulted on every A/AAAA answer to + // drop records pointing at disconnected peers. nil disables the + // filter and preserves the legacy "return whatever is registered" + // behaviour for callers that never wire a status source. + peerConn PeerConnectivity + // peerActivator, when non-nil, is called at resolution time to warm the + // lazy connection to the peer(s) an answer points at. nil disables warm-up. + peerActivator PeerActivator + // warmupTimeout is the per-query budget for the lazy-connection warm-up + // wait, resolved from the environment once at construction time. + warmupTimeout time.Duration ctx context.Context cancel context.CancelFunc @@ -42,14 +102,32 @@ type Resolver struct { func NewResolver() *Resolver { ctx, cancel := context.WithCancel(context.Background()) return &Resolver{ - records: make(map[dns.Question][]dns.RR), - domains: make(map[domain.Domain]struct{}), - zones: make(map[domain.Domain]bool), - ctx: ctx, - cancel: cancel, + records: make(map[dns.Question][]dns.RR), + domains: make(map[domain.Domain]struct{}), + zones: make(map[domain.Domain]bool), + warmupTimeout: lazyWarmupTimeoutFromEnv(), + ctx: ctx, + cancel: cancel, } } +// SetPeerConnectivity wires the per-IP connectivity check used to filter +// out A/AAAA answers pointing at disconnected peers. Pass nil to disable. +// Safe to call multiple times; the latest value wins. +func (d *Resolver) SetPeerConnectivity(p PeerConnectivity) { + d.mu.Lock() + defer d.mu.Unlock() + d.peerConn = p +} + +// SetPeerActivator wires the DNS-time lazy-connection warm-up. Pass nil to +// disable. Safe to call multiple times; the latest value wins. +func (d *Resolver) SetPeerActivator(a PeerActivator) { + d.mu.Lock() + defer d.mu.Unlock() + d.peerActivator = a +} + func (d *Resolver) MatchSubdomains() bool { return true } @@ -67,9 +145,9 @@ func (d *Resolver) Stop() { d.mu.Lock() defer d.mu.Unlock() - maps.Clear(d.records) - maps.Clear(d.domains) - maps.Clear(d.zones) + clear(d.records) + clear(d.domains) + clear(d.zones) } // ID returns the unique handler ID @@ -77,8 +155,6 @@ func (d *Resolver) ID() types.HandlerID { return "local-resolver" } -func (d *Resolver) ProbeAvailability(context.Context) {} - // ServeDNS handles a DNS request func (d *Resolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { logger := log.WithFields(log.Fields{ @@ -98,6 +174,10 @@ func (d *Resolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { replyMessage.RecursionAvailable = true result := d.lookupRecords(logger, question) + // Warm before filtering: activation flips a lazily-idle target to connected, + // which then lets it survive the disconnected-peer filter below. + d.warmLazyPeers(question, result.records) + result.records = d.filterDisconnectedPeerAnswers(logger, question, result.records) replyMessage.Authoritative = !result.hasExternalData replyMessage.Answer = result.records replyMessage.Rcode = d.determineRcode(question, result) @@ -439,14 +519,121 @@ func (d *Resolver) logDNSError(logger *log.Entry, hostname string, qtype uint16, } } +// filterDisconnectedPeerAnswers drops A/AAAA records whose RDATA matches +// a known but disconnected peer. The synthesized private-service zones +// emit one A record per connected proxy peer in a cluster; when a peer +// goes offline, the server-side refresh removes the record from the +// next netmap, but the client may still hold the previous netmap for a +// short window. This filter is the local belt to that braces — even on +// the stale netmap, the resolver hides the offline target. +// +// Records pointing at unknown IPs (outside the local peerstore, e.g. +// non-mesh upstreams) are never dropped. Non-A/AAAA records pass +// through untouched. +// +// Escape hatch: if filtering would leave the answer empty AND at least +// one record was filtered, the original list is returned. Better to +// hand the client a record that may not respond than NXDOMAIN it +// completely when every proxy peer is offline (the upstream may still +// be reachable some other way, or the peerstore may be stale). +func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns.Question, records []dns.RR) []dns.RR { + if len(records) < 2 { + return records + } + d.mu.RLock() + checker := d.peerConn + d.mu.RUnlock() + if checker == nil { + return records + } + + kept := make([]dns.RR, 0, len(records)) + var dropped int + for _, rr := range records { + ip, ok := extractRecordAddr(rr) + if !ok { + kept = append(kept, rr) + continue + } + known, connected := checker.IsConnectedByIP(ip) + if known && !connected { + dropped++ + continue + } + kept = append(kept, rr) + } + if dropped == 0 { + return records + } + if len(kept) == 0 { + logger.Debugf("all %d answers for %s point at disconnected peers; returning the original list", dropped, question.Name) + return records + } + logger.Tracef("dropped %d disconnected-peer answer(s) for %s, returning %d", dropped, question.Name, len(kept)) + return kept +} + +// warmLazyPeers triggers lazy-connection wake-up for the peers a resolved +// answer points at and waits briefly for one to connect, so the caller's first +// request doesn't race the connection establishment. Warm-up is scoped to +// match-only (non-authoritative) zones — the synthesized private-service zones +// and user-created zones whose records point at specific peers. The account's +// peer zone is authoritative, so plain peer-name lookups never trigger warm-up; +// otherwise resolving any peer's name would wake its idle connection, defeating +// laziness mesh-wide. No-op when no activator is wired (lazy connections +// disabled) or the answer carries no peer IPs. +func (d *Resolver) warmLazyPeers(question dns.Question, records []dns.RR) { + if len(records) < 2 { + return + } + d.mu.RLock() + activator := d.peerActivator + var nonAuth, found bool + if activator != nil { + nonAuth, found = d.findZone(question.Name) + } + d.mu.RUnlock() + if activator == nil || !found || !nonAuth { + return + } + + var addrs []netip.Addr + for _, rr := range records { + if addr, ok := extractRecordAddr(rr); ok { + addrs = append(addrs, addr) + } + } + if len(addrs) == 0 { + return + } + + ctx, cancel := context.WithTimeout(d.ctx, d.warmupTimeout) + defer cancel() + activator.ActivatePeersByIP(ctx, addrs) +} + +// extractRecordAddr returns the IP address carried by an A or AAAA record. +// ok is false for any other record type or a record with no address. +func extractRecordAddr(rr dns.RR) (netip.Addr, bool) { + switch r := rr.(type) { + case *dns.A: + addr, ok := netip.AddrFromSlice(r.A) + return addr.Unmap(), ok + case *dns.AAAA: + addr, ok := netip.AddrFromSlice(r.AAAA) + return addr.Unmap(), ok + } + return netip.Addr{}, false +} + // Update replaces all zones and their records func (d *Resolver) Update(customZones []nbdns.CustomZone) { d.mu.Lock() defer d.mu.Unlock() - maps.Clear(d.records) - maps.Clear(d.domains) - maps.Clear(d.zones) + clear(d.records) + clear(d.domains) + clear(d.zones) for _, zone := range customZones { zoneDomain := domain.Domain(strings.ToLower(dns.Fqdn(zone.Domain))) diff --git a/client/internal/dns/local/local_test.go b/client/internal/dns/local/local_test.go index 2c6b7dbc3..89e896c0a 100644 --- a/client/internal/dns/local/local_test.go +++ b/client/internal/dns/local/local_test.go @@ -30,6 +30,21 @@ func (m *mockResolver) LookupNetIP(ctx context.Context, network, host string) ([ return nil, nil } +// mockPeerConnectivity returns canned (known, connected) results per IP. +// Used by the disconnected-peer filter tests below. IPs not in the map +// are reported as unknown so the filter leaves them alone. +type mockPeerConnectivity struct { + byIP map[string]struct{ known, connected bool } +} + +func (m mockPeerConnectivity) IsConnectedByIP(ip netip.Addr) (known, connected bool) { + v, ok := m.byIP[ip.String()] + if !ok { + return false, false + } + return v.known, v.connected +} + func TestLocalResolver_ServeDNS(t *testing.T) { recordA := nbdns.SimpleRecord{ Name: "peera.netbird.cloud.", @@ -2652,3 +2667,125 @@ func BenchmarkIsInManagedZone_ManyZones(b *testing.B) { resolver.isInManagedZone(qname) } } + +// TestLocalResolver_FilterDisconnectedPeerAnswers verifies the +// connectivity-aware filtering layered on top of lookupRecords: +// when an A record's IP belongs to a known peer that's disconnected, +// the record is dropped from the answer. Records for unknown IPs pass +// through. If filtering would empty the answer entirely and at least +// one record was dropped, the original list is restored (escape hatch +// for the "all proxies offline" case). +func TestLocalResolver_FilterDisconnectedPeerAnswers(t *testing.T) { + zone := "svc.cluster.netbird." + connectedRec := nbdns.SimpleRecord{ + Name: zone, + Type: int(dns.TypeA), + Class: nbdns.DefaultClass, + TTL: 5, + RData: "100.64.0.10", + } + disconnectedRec := nbdns.SimpleRecord{ + Name: zone, + Type: int(dns.TypeA), + Class: nbdns.DefaultClass, + TTL: 5, + RData: "100.64.0.11", + } + unknownRec := nbdns.SimpleRecord{ + Name: zone, + Type: int(dns.TypeA), + Class: nbdns.DefaultClass, + TTL: 5, + RData: "203.0.113.5", + } + + type ipState struct{ known, connected bool } + tests := []struct { + name string + records []nbdns.SimpleRecord + connByIP map[string]ipState + wantInOrder []string + }{ + { + name: "drops disconnected peer, keeps connected", + records: []nbdns.SimpleRecord{connectedRec, disconnectedRec}, + connByIP: map[string]ipState{ + "100.64.0.10": {known: true, connected: true}, + "100.64.0.11": {known: true, connected: false}, + }, + wantInOrder: []string{"100.64.0.10"}, + }, + { + name: "unknown IPs pass through untouched", + records: []nbdns.SimpleRecord{unknownRec, disconnectedRec}, + connByIP: map[string]ipState{ + "100.64.0.11": {known: true, connected: false}, + }, + wantInOrder: []string{"203.0.113.5"}, + }, + { + name: "all disconnected falls back to original list", + records: []nbdns.SimpleRecord{disconnectedRec, connectedRec}, + connByIP: map[string]ipState{ + "100.64.0.10": {known: true, connected: false}, + "100.64.0.11": {known: true, connected: false}, + }, + wantInOrder: []string{"100.64.0.11", "100.64.0.10"}, + }, + { + name: "no checker wired returns all records", + records: []nbdns.SimpleRecord{connectedRec, disconnectedRec}, + connByIP: nil, + wantInOrder: []string{"100.64.0.10", "100.64.0.11"}, + }, + { + // A single answer is never filtered: dropping it would only + // trigger the empty-answer escape hatch, so the fast path + // returns it untouched. + name: "single disconnected answer passes through", + records: []nbdns.SimpleRecord{disconnectedRec}, + connByIP: map[string]ipState{ + "100.64.0.11": {known: true, connected: false}, + }, + wantInOrder: []string{"100.64.0.11"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + resolver := NewResolver() + if tc.connByIP != nil { + cm := mockPeerConnectivity{byIP: make(map[string]struct{ known, connected bool }, len(tc.connByIP))} + for ip, st := range tc.connByIP { + cm.byIP[ip] = struct{ known, connected bool }{st.known, st.connected} + } + resolver.SetPeerConnectivity(cm) + } + resolver.Update([]nbdns.CustomZone{{ + Domain: strings.TrimSuffix(zone, "."), + Records: tc.records, + NonAuthoritative: true, + }}) + + var got *dns.Msg + writer := &test.MockResponseWriter{ + WriteMsgFunc: func(m *dns.Msg) error { + got = m + return nil + }, + } + req := new(dns.Msg).SetQuestion(zone, dns.TypeA) + resolver.ServeDNS(writer, req) + + require.NotNil(t, got, "resolver must produce a response") + require.Len(t, got.Answer, len(tc.wantInOrder), + "answer count must match expected: %v", tc.wantInOrder) + for i, want := range tc.wantInOrder { + a, ok := got.Answer[i].(*dns.A) + require.True(t, ok, "answer[%d] must be an A record", i) + assert.Equal(t, want, a.A.String(), + "answer[%d] expected %s got %s", i, want, a.A.String()) + } + }) + } +} diff --git a/client/internal/dns/local/warmup_test.go b/client/internal/dns/local/warmup_test.go new file mode 100644 index 000000000..0e77aa963 --- /dev/null +++ b/client/internal/dns/local/warmup_test.go @@ -0,0 +1,204 @@ +package local + +import ( + "context" + "net" + "net/netip" + "sync" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/dns/test" + nbdns "github.com/netbirdio/netbird/dns" +) + +// recordingActivator records the addresses it was asked to warm and returns +// immediately, so ServeDNS is not blocked by the test. +type recordingActivator struct { + mu sync.Mutex + called bool + addrs []netip.Addr +} + +func (r *recordingActivator) ActivatePeersByIP(_ context.Context, addrs []netip.Addr) { + r.mu.Lock() + defer r.mu.Unlock() + r.called = true + r.addrs = append(r.addrs, addrs...) +} + +func serveA(t *testing.T, resolver *Resolver, name string) *dns.Msg { + t.Helper() + var resp *dns.Msg + w := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }} + resolver.ServeDNS(w, new(dns.Msg).SetQuestion(name, dns.TypeA)) + return resp +} + +// serviceZone registers rec in a match-only (non-authoritative) zone, the shape +// the synthesized private-service zones arrive in. +func serviceZone(t *testing.T, resolver *Resolver, zone string, records ...nbdns.SimpleRecord) { + t.Helper() + resolver.Update([]nbdns.CustomZone{{ + Domain: zone, + Records: records, + NonAuthoritative: true, + }}) +} + +func TestLocalResolver_WarmsLazyPeerOnResolve(t *testing.T) { + // Warm-up fires only for multi-record answers (the HA / round-robin shape of + // the synthesized private-service zones), so register two peer targets. + const name = "svc.proxy.netbird.cloud." + recs := []nbdns.SimpleRecord{ + {Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"}, + {Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.8"}, + } + resolver := NewResolver() + serviceZone(t, resolver, "proxy.netbird.cloud", recs...) + + act := &recordingActivator{} + resolver.SetPeerActivator(act) + + resp := serveA(t, resolver, name) + require.NotNil(t, resp, "resolver must answer") + require.NotEmpty(t, resp.Answer, "answer must carry the A records") + + act.mu.Lock() + defer act.mu.Unlock() + assert.True(t, act.called, "activator must be invoked for a multi-record service-zone answer") + assert.Contains(t, act.addrs, netip.MustParseAddr("100.64.0.7"), "activator must receive the first peer IP") + assert.Contains(t, act.addrs, netip.MustParseAddr("100.64.0.8"), "activator must receive the second peer IP") +} + +func TestLocalResolver_NoWarmupForSingleRecord(t *testing.T) { + // A single-record answer does not trigger warm-up; the resolver only warms + // multi-record answers. + rec := nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"} + resolver := NewResolver() + serviceZone(t, resolver, "proxy.netbird.cloud", rec) + + act := &recordingActivator{} + resolver.SetPeerActivator(act) + + resp := serveA(t, resolver, rec.Name) + require.NotNil(t, resp, "resolver must answer") + require.NotEmpty(t, resp.Answer, "answer must carry the A record") + + act.mu.Lock() + defer act.mu.Unlock() + assert.False(t, act.called, "activator must not be invoked for a single-record answer") +} + +func TestLocalResolver_NoActivatorNoWarmup(t *testing.T) { + // With no activator wired the resolver behaves exactly as before. + rec := nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"} + resolver := NewResolver() + serviceZone(t, resolver, "proxy.netbird.cloud", rec) + + resp := serveA(t, resolver, rec.Name) + require.NotNil(t, resp, "resolver must still answer without an activator") + require.NotEmpty(t, resp.Answer, "answer must carry the A record") +} + +func TestLocalResolver_NoWarmupForMissingRecord(t *testing.T) { + // A query that resolves to nothing must not invoke the activator (no IPs). + resolver := NewResolver() + serviceZone(t, resolver, "proxy.netbird.cloud", + nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"}) + + act := &recordingActivator{} + resolver.SetPeerActivator(act) + + serveA(t, resolver, "absent.proxy.netbird.cloud.") + + act.mu.Lock() + defer act.mu.Unlock() + assert.False(t, act.called, "activator must not be invoked when there is no answer") +} + +func TestLocalResolver_NoWarmupInAuthoritativeZone(t *testing.T) { + // The account's peer zone is authoritative; resolving a peer's name there + // must not wake its lazy connection — warm-up is scoped to match-only + // (non-authoritative) zones such as the synthesized private-service zones. + // Use a multi-record answer so the authoritative-zone scoping is the only + // reason warm-up is skipped, not the single-record guard. + const name = "peer.netbird.cloud." + recs := []nbdns.SimpleRecord{ + {Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.9"}, + {Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.10"}, + } + resolver := NewResolver() + resolver.Update([]nbdns.CustomZone{{ + Domain: "netbird.cloud", + Records: recs, + }}) + + act := &recordingActivator{} + resolver.SetPeerActivator(act) + + resp := serveA(t, resolver, name) + require.NotNil(t, resp, "resolver must answer") + require.NotEmpty(t, resp.Answer, "answer must carry the A records") + + act.mu.Lock() + defer act.mu.Unlock() + assert.False(t, act.called, "activator must not be invoked for authoritative-zone answers") +} + +func TestLazyWarmupTimeoutFromEnv(t *testing.T) { + tests := []struct { + name string + value string + envSet bool + want time.Duration + }{ + {name: "unset uses default", want: defaultLazyWarmupTimeout}, + {name: "valid overrides", value: "5s", envSet: true, want: 5 * time.Second}, + {name: "invalid falls back", value: "not-a-duration", envSet: true, want: defaultLazyWarmupTimeout}, + {name: "negative falls back", value: "-1s", envSet: true, want: defaultLazyWarmupTimeout}, + {name: "zero falls back", value: "0s", envSet: true, want: defaultLazyWarmupTimeout}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envSet { + t.Setenv(envLazyWarmupTimeout, tt.value) + } + assert.Equal(t, tt.want, lazyWarmupTimeoutFromEnv()) + assert.Equal(t, tt.want, NewResolver().warmupTimeout, "constructor must resolve the timeout once") + }) + } +} + +func TestExtractRecordAddr(t *testing.T) { + t.Run("A record yields unmapped v4", func(t *testing.T) { + // net.ParseIP returns the 16-byte v4-in-v6 form, the same shape + // miekg/dns stores after parsing an A record; the extracted address + // must compare equal to a plain v4 netip.Addr. + addr, ok := extractRecordAddr(&dns.A{A: net.ParseIP("100.64.0.7")}) + require.True(t, ok) + assert.True(t, addr.Is4()) + assert.Equal(t, netip.MustParseAddr("100.64.0.7"), addr) + }) + + t.Run("AAAA record yields v6", func(t *testing.T) { + addr, ok := extractRecordAddr(&dns.AAAA{AAAA: net.ParseIP("fd00::1")}) + require.True(t, ok) + assert.Equal(t, netip.MustParseAddr("fd00::1"), addr) + }) + + t.Run("A record without address", func(t *testing.T) { + _, ok := extractRecordAddr(&dns.A{}) + assert.False(t, ok) + }) + + t.Run("non-address record", func(t *testing.T) { + _, ok := extractRecordAddr(&dns.CNAME{Target: "target.netbird.cloud."}) + assert.False(t, ok) + }) +} diff --git a/client/internal/dns/mgmt/mgmt.go b/client/internal/dns/mgmt/mgmt.go index 988e427fb..ddc8cf585 100644 --- a/client/internal/dns/mgmt/mgmt.go +++ b/client/internal/dns/mgmt/mgmt.go @@ -51,13 +51,20 @@ type cachedRecord struct { } // Resolver caches critical NetBird infrastructure domains. -// records, refreshing, mgmtDomain and serverDomains are all guarded by mutex. +// records, refreshing, failedResolves, mgmtDomain and serverDomains are all +// guarded by mutex. type Resolver struct { records map[dns.Question]*cachedRecord mgmtDomain *domain.Domain serverDomains *dnsconfig.ServerDomains mutex sync.RWMutex + // failedResolves records the last failed initial resolve per domain so a + // domain that never resolves isn't retried on every server-domains update + // until refreshBackoff elapses. Entries are cleared on success and pruned + // to the current server-domains set. + failedResolves map[domain.Domain]time.Time + chain ChainResolver chainMaxPriority int refreshGroup singleflight.Group @@ -76,9 +83,10 @@ type Resolver struct { // NewResolver creates a new management domains cache resolver. func NewResolver() *Resolver { return &Resolver{ - records: make(map[dns.Question]*cachedRecord), - refreshing: make(map[dns.Question]*atomic.Bool), - cacheTTL: resolveCacheTTL(), + records: make(map[dns.Question]*cachedRecord), + refreshing: make(map[dns.Question]*atomic.Bool), + failedResolves: make(map[domain.Domain]time.Time), + cacheTTL: resolveCacheTTL(), } } @@ -173,7 +181,9 @@ func (m *Resolver) continueToNext(w dns.ResponseWriter, r *dns.Msg) { // AddDomain resolves a domain and stores its A/AAAA records in the cache. // A family that resolves NODATA (nil err, zero records) evicts any stale -// entry for that qtype. +// entry for that qtype. When one family hard-errors while the other succeeds, +// the resolved family is still cached but AddDomain returns an error so the +// caller retries the incomplete resolve rather than treating it as complete. func (m *Resolver) AddDomain(ctx context.Context, d domain.Domain) error { dnsName := strings.ToLower(dns.Fqdn(d.PunycodeString())) @@ -203,6 +213,10 @@ func (m *Resolver) AddDomain(ctx context.Context, d domain.Domain) error { log.Debugf("added/updated domain=%s with %d A records and %d AAAA records", d.SafeString(), len(aRecords), len(aaaaRecords)) + if errA != nil || errAAAA != nil { + return fmt.Errorf("resolve %s: incomplete, a family failed: %w", d.SafeString(), errors.Join(errA, errAAAA)) + } + return nil } @@ -462,6 +476,7 @@ func (m *Resolver) RemoveDomain(d domain.Domain) error { delete(m.records, qAAAA) delete(m.refreshing, qA) delete(m.refreshing, qAAAA) + delete(m.failedResolves, d) log.Debugf("removed domain=%s from cache", d.SafeString()) return nil @@ -505,6 +520,7 @@ func (m *Resolver) UpdateFromServerDomains(ctx context.Context, serverDomains dn allDomains := m.extractDomainsFromServerDomains(updatedServerDomains) currentDomains := m.GetCachedDomains() removedDomains = m.removeStaleDomains(currentDomains, allDomains) + m.pruneFailedResolves(allDomains) } m.addNewDomains(ctx, newDomains) @@ -577,13 +593,85 @@ func (m *Resolver) isManagementDomain(domain domain.Domain) bool { return m.mgmtDomain != nil && domain == *m.mgmtDomain } -// addNewDomains resolves and caches all domains from the update +// addNewDomains resolves and caches domains that are not yet in the cache, +// running the lookups concurrently. Domains already cached are skipped and left +// to the stale-while-revalidate refresh path, so a sync never re-resolves them +// synchronously: once NetBird owns the OS resolver the resolve runs through the +// handler chain and would otherwise dial the managed upstreams under the engine +// sync lock on every update. func (m *Resolver) addNewDomains(ctx context.Context, newDomains domain.List) { + var wg sync.WaitGroup + seen := make(map[domain.Domain]struct{}, len(newDomains)) for _, newDomain := range newDomains { - if err := m.AddDomain(ctx, newDomain); err != nil { - log.Warnf("failed to add/update domain=%s: %v", newDomain.SafeString(), err) - } else { - log.Debugf("added/updated management cache domain=%s", newDomain.SafeString()) + if _, dup := seen[newDomain]; dup { + continue + } + seen[newDomain] = struct{}{} + + if !m.needsResolve(newDomain) { + continue + } + + wg.Add(1) + go func(d domain.Domain) { + defer wg.Done() + if err := m.AddDomain(ctx, d); err != nil { + m.markResolveFailed(d) + log.Warnf("failed to add/update domain=%s: %v", d.SafeString(), err) + return + } + m.clearResolveFailed(d) + log.Debugf("added/updated management cache domain=%s", d.SafeString()) + }(newDomain) + } + wg.Wait() +} + +// needsResolve reports whether d should be resolved now. A recent failed or +// incomplete resolve gates retries on the backoff even when one family is +// already cached, so a transiently-failed family is retried instead of being +// treated as fully resolved. Otherwise a domain with any cached record is left +// to the stale-while-revalidate refresh path. +func (m *Resolver) needsResolve(d domain.Domain) bool { + dnsName := strings.ToLower(dns.Fqdn(d.PunycodeString())) + + m.mutex.RLock() + defer m.mutex.RUnlock() + + if failedAt, ok := m.failedResolves[d]; ok { + return time.Since(failedAt) >= refreshBackoff + } + + for _, qtype := range []uint16{dns.TypeA, dns.TypeAAAA} { + q := dns.Question{Name: dnsName, Qtype: qtype, Qclass: dns.ClassINET} + if _, ok := m.records[q]; ok { + return false + } + } + return true +} + +func (m *Resolver) markResolveFailed(d domain.Domain) { + m.mutex.Lock() + m.failedResolves[d] = time.Now() + m.mutex.Unlock() +} + +func (m *Resolver) clearResolveFailed(d domain.Domain) { + m.mutex.Lock() + delete(m.failedResolves, d) + m.mutex.Unlock() +} + +// pruneFailedResolves drops failure markers for domains no longer present in +// the server-domains set, keeping the map bounded to the current set (a +// failed-only domain has no cached record, so RemoveDomain never sees it). +func (m *Resolver) pruneFailedResolves(domains domain.List) { + m.mutex.Lock() + defer m.mutex.Unlock() + for d := range m.failedResolves { + if !slices.Contains(domains, d) { + delete(m.failedResolves, d) } } } diff --git a/client/internal/dns/mgmt/mgmt_refresh_test.go b/client/internal/dns/mgmt/mgmt_refresh_test.go index 9faa5a0b8..0e3e6ab36 100644 --- a/client/internal/dns/mgmt/mgmt_refresh_test.go +++ b/client/internal/dns/mgmt/mgmt_refresh_test.go @@ -21,6 +21,7 @@ type fakeChain struct { mu sync.Mutex calls map[string]int answers map[string][]dns.RR + qErr map[string]error err error hasRoot bool onLookup func() @@ -30,6 +31,7 @@ func newFakeChain() *fakeChain { return &fakeChain{ calls: map[string]int{}, answers: map[string][]dns.RR{}, + qErr: map[string]error{}, hasRoot: true, } } @@ -47,6 +49,9 @@ func (f *fakeChain) ResolveInternal(ctx context.Context, msg *dns.Msg, maxPriori f.calls[key]++ answers := f.answers[key] err := f.err + if err == nil { + err = f.qErr[key] + } onLookup := f.onLookup f.mu.Unlock() @@ -75,6 +80,12 @@ func (f *fakeChain) setAnswer(name string, qtype uint16, ip string) { } } +func (f *fakeChain) setErr(name string, qtype uint16, err error) { + f.mu.Lock() + defer f.mu.Unlock() + f.qErr[name+"|"+dns.TypeToString[qtype]] = err +} + func (f *fakeChain) callCount(name string, qtype uint16) int { f.mu.Lock() defer f.mu.Unlock() @@ -213,6 +224,7 @@ func TestResolver_StaleTriggersAsyncRefresh(t *testing.T) { } func TestResolver_ConcurrentStaleHitsCollapseRefresh(t *testing.T) { + semaphore := make(chan struct{}) r := NewResolver() chain := newFakeChain() chain.setAnswer("mgmt.example.com.", dns.TypeA, "10.0.0.2") @@ -228,7 +240,7 @@ func TestResolver_ConcurrentStaleHitsCollapseRefresh(t *testing.T) { break } } - time.Sleep(50 * time.Millisecond) // hold inflight long enough to collide + <-semaphore // block the call to force request collision } r.SetChainResolver(chain, 50) @@ -244,17 +256,17 @@ func TestResolver_ConcurrentStaleHitsCollapseRefresh(t *testing.T) { var wg sync.WaitGroup for i := 0; i < 50; i++ { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { queryA(t, r, "mgmt.example.com.") - }() + }) } + + assert.Eventually(t, func() bool { return inflight.Load() >= 1 }, 2*time.Second, 100*time.Millisecond) + + close(semaphore) wg.Wait() - waitFor(t, 2*time.Second, func() bool { - return inflight.Load() == 0 - }) + assert.Eventually(t, func() bool { return inflight.Load() == 0 }, 2*time.Second, 100*time.Millisecond) calls := chain.callCount("mgmt.example.com.", dns.TypeA) assert.LessOrEqual(t, calls, 2, "singleflight must collapse concurrent refreshes (got %d)", calls) diff --git a/client/internal/dns/mgmt/mgmt_resolve_test.go b/client/internal/dns/mgmt/mgmt_resolve_test.go new file mode 100644 index 000000000..5cfbac8f0 --- /dev/null +++ b/client/internal/dns/mgmt/mgmt_resolve_test.go @@ -0,0 +1,183 @@ +package mgmt + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config" + "github.com/netbirdio/netbird/shared/management/domain" +) + +// A domain already in the cache must not be re-resolved on a subsequent server +// domains update; it is left to the stale-while-revalidate refresh path. +func TestResolver_UpdateFromServerDomains_SkipsCached(t *testing.T) { + r := NewResolver() + chain := newFakeChain() + chain.setAnswer("signal.example.com.", dns.TypeA, "10.0.0.2") + r.SetChainResolver(chain, 50) + + sd := dnsconfig.ServerDomains{Signal: domain.Domain("signal.example.com")} + + _, err := r.UpdateFromServerDomains(context.Background(), sd) + require.NoError(t, err) + require.Equal(t, 1, chain.callCount("signal.example.com.", dns.TypeA), + "first update must resolve the domain") + + _, err = r.UpdateFromServerDomains(context.Background(), sd) + require.NoError(t, err) + assert.Equal(t, 1, chain.callCount("signal.example.com.", dns.TypeA), + "cached domain must not be re-resolved on a subsequent update") +} + +// New domains in a single update must resolve concurrently rather than serially. +func TestResolver_AddNewDomains_ResolvesConcurrently(t *testing.T) { + r := NewResolver() + chain := newFakeChain() + + var inflight, maxInflight atomic.Int32 + chain.onLookup = func() { + n := inflight.Add(1) + for { + old := maxInflight.Load() + if n <= old || maxInflight.CompareAndSwap(old, n) { + break + } + } + time.Sleep(50 * time.Millisecond) + inflight.Add(-1) + } + + relays := []domain.Domain{"a.example.com", "b.example.com", "c.example.com", "d.example.com"} + for _, d := range relays { + chain.setAnswer(dns.Fqdn(string(d)), dns.TypeA, "10.0.0.2") + } + r.SetChainResolver(chain, 50) + + start := time.Now() + _, err := r.UpdateFromServerDomains(context.Background(), dnsconfig.ServerDomains{Relay: relays}) + require.NoError(t, err) + elapsed := time.Since(start) + + assert.GreaterOrEqual(t, int(maxInflight.Load()), 2, "domains must resolve concurrently") + // Serial resolution of 4 domains would take at least 4*50ms; concurrent is far less. + assert.Less(t, elapsed, 300*time.Millisecond, "resolution should not be serial") +} + +// A domain that fails to resolve must not be retried on every update; the +// failure backoff suppresses re-resolution until it expires. +func TestResolver_UpdateFromServerDomains_BacksOffFailures(t *testing.T) { + r := NewResolver() + chain := newFakeChain() + chain.err = errors.New("resolve boom") + r.SetChainResolver(chain, 50) + + sd := dnsconfig.ServerDomains{Signal: domain.Domain("signal.example.com")} + + _, err := r.UpdateFromServerDomains(context.Background(), sd) + require.NoError(t, err) + require.Equal(t, 1, chain.callCount("signal.example.com.", dns.TypeA), + "first update must attempt the resolve") + + _, err = r.UpdateFromServerDomains(context.Background(), sd) + require.NoError(t, err) + assert.Equal(t, 1, chain.callCount("signal.example.com.", dns.TypeA), + "failed resolve must back off and not retry on the next update") +} + +// A domain listed under more than one server-domain type (e.g. STUN and TURN on +// the same host) must be resolved once per update, not once per occurrence. +func TestResolver_AddNewDomains_DedupesDuplicateDomains(t *testing.T) { + r := NewResolver() + chain := newFakeChain() + chain.setAnswer("dup.example.com.", dns.TypeA, "10.0.0.9") + r.SetChainResolver(chain, 50) + + sd := dnsconfig.ServerDomains{ + Stuns: []domain.Domain{"dup.example.com"}, + Turns: []domain.Domain{"dup.example.com"}, + } + + _, err := r.UpdateFromServerDomains(context.Background(), sd) + require.NoError(t, err) + assert.Equal(t, 1, chain.callCount("dup.example.com.", dns.TypeA), + "a domain appearing under multiple server-domain types must resolve once") +} + +// A failure marker must be dropped once its domain leaves the server-domains set +// so the map stays bounded to the current set. +func TestResolver_UpdateFromServerDomains_PrunesFailedResolves(t *testing.T) { + r := NewResolver() + chain := newFakeChain() + chain.err = errors.New("resolve boom") + r.SetChainResolver(chain, 50) + + _, err := r.UpdateFromServerDomains(context.Background(), dnsconfig.ServerDomains{Signal: domain.Domain("gone.example.com")}) + require.NoError(t, err) + r.mutex.RLock() + _, marked := r.failedResolves[domain.Domain("gone.example.com")] + r.mutex.RUnlock() + require.True(t, marked, "failed resolve must be recorded") + + _, err = r.UpdateFromServerDomains(context.Background(), dnsconfig.ServerDomains{Signal: domain.Domain("other.example.com")}) + require.NoError(t, err) + r.mutex.RLock() + _, stillMarked := r.failedResolves[domain.Domain("gone.example.com")] + r.mutex.RUnlock() + assert.False(t, stillMarked, "failure marker for a domain no longer in the set must be pruned") +} + +// When one family hard-errors while the other resolves, the domain is cached +// for the working family but recorded as incomplete so the failed family is +// retried under backoff instead of being treated as fully resolved forever. +func TestResolver_AddNewDomains_RetriesPartialFamilyFailure(t *testing.T) { + d := domain.Domain("relay.example.com") + r := NewResolver() + chain := newFakeChain() + chain.setAnswer("relay.example.com.", dns.TypeA, "10.0.0.2") + chain.setErr("relay.example.com.", dns.TypeAAAA, errors.New("servfail")) + r.SetChainResolver(chain, 50) + + _, err := r.UpdateFromServerDomains(context.Background(), dnsconfig.ServerDomains{Relay: []domain.Domain{d}}) + require.NoError(t, err) + + r.mutex.RLock() + _, aCached := r.records[dns.Question{Name: "relay.example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}] + _, marked := r.failedResolves[d] + r.mutex.RUnlock() + require.True(t, aCached, "the working family must still be cached") + require.True(t, marked, "a partial failure must be recorded so the failed family is retried") + + assert.False(t, r.needsResolve(d), "within the backoff window the domain is not retried") + + r.mutex.Lock() + r.failedResolves[d] = time.Now().Add(-2 * refreshBackoff) + r.mutex.Unlock() + assert.True(t, r.needsResolve(d), "after the backoff elapses the domain is retried to pick up the missing family") +} + +// A family that returns NODATA (legitimately absent, e.g. an IPv4-only host) is +// not a failure: the domain must not be marked for retry, otherwise it would be +// re-resolved on every sync. +func TestResolver_AddNewDomains_NodataIsNotFailure(t *testing.T) { + d := domain.Domain("v4only.example.com") + r := NewResolver() + chain := newFakeChain() + chain.setAnswer("v4only.example.com.", dns.TypeA, "10.0.0.2") + r.SetChainResolver(chain, 50) + + _, err := r.UpdateFromServerDomains(context.Background(), dnsconfig.ServerDomains{Relay: []domain.Domain{d}}) + require.NoError(t, err) + + r.mutex.RLock() + _, marked := r.failedResolves[d] + r.mutex.RUnlock() + assert.False(t, marked, "a NODATA family must not be recorded as a failure") + assert.False(t, r.needsResolve(d), "an IPv4-only host must not be re-resolved on later syncs") +} diff --git a/client/internal/dns/mock_server.go b/client/internal/dns/mock_server.go index 548b1f54f..b19862c2f 100644 --- a/client/internal/dns/mock_server.go +++ b/client/internal/dns/mock_server.go @@ -8,7 +8,9 @@ import ( "github.com/miekg/dns" dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config" + "github.com/netbirdio/netbird/client/internal/dns/local" nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" ) @@ -70,10 +72,6 @@ func (m *MockServer) SearchDomains() []string { return make([]string, 0) } -// ProbeAvailability mocks implementation of ProbeAvailability from the Server interface -func (m *MockServer) ProbeAvailability() { -} - func (m *MockServer) UpdateServerConfig(domains dnsconfig.ServerDomains) error { if m.UpdateServerConfigFunc != nil { return m.UpdateServerConfigFunc(domains) @@ -85,8 +83,8 @@ func (m *MockServer) PopulateManagementDomain(mgmtURL *url.URL) error { return nil } -// SetRouteChecker mock implementation of SetRouteChecker from Server interface -func (m *MockServer) SetRouteChecker(func(netip.Addr) bool) { +// SetRouteSources mock implementation of SetRouteSources from Server interface +func (m *MockServer) SetRouteSources(selected, active func() route.HAMap) { // Mock implementation - no-op } @@ -95,6 +93,11 @@ func (m *MockServer) SetFirewall(Firewall) { // Mock implementation - no-op } +// SetPeerActivator mock implementation of SetPeerActivator from Server interface +func (m *MockServer) SetPeerActivator(local.PeerActivator) { + // Mock implementation - no-op +} + // BeginBatch mock implementation of BeginBatch from Server interface func (m *MockServer) BeginBatch() { // Mock implementation - no-op diff --git a/client/internal/dns/network_manager_unix.go b/client/internal/dns/network_manager_unix.go index e4ccc8cbd..3932e78b7 100644 --- a/client/internal/dns/network_manager_unix.go +++ b/client/internal/dns/network_manager_unix.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "net/netip" + "slices" "strings" "time" @@ -32,6 +33,15 @@ const ( networkManagerDbusDeviceGetAppliedConnectionMethod = networkManagerDbusDeviceInterface + ".GetAppliedConnection" networkManagerDbusDeviceReapplyMethod = networkManagerDbusDeviceInterface + ".Reapply" networkManagerDbusDeviceDeleteMethod = networkManagerDbusDeviceInterface + ".Delete" + networkManagerDbusDeviceIp4ConfigProperty = networkManagerDbusDeviceInterface + ".Ip4Config" + networkManagerDbusDeviceIp6ConfigProperty = networkManagerDbusDeviceInterface + ".Ip6Config" + networkManagerDbusDeviceIfaceProperty = networkManagerDbusDeviceInterface + ".Interface" + networkManagerDbusGetDevicesMethod = networkManagerDest + ".GetDevices" + networkManagerDbusIp4ConfigInterface = "org.freedesktop.NetworkManager.IP4Config" + networkManagerDbusIp6ConfigInterface = "org.freedesktop.NetworkManager.IP6Config" + networkManagerDbusIp4ConfigNameserverDataProperty = networkManagerDbusIp4ConfigInterface + ".NameserverData" + networkManagerDbusIp4ConfigNameserversProperty = networkManagerDbusIp4ConfigInterface + ".Nameservers" + networkManagerDbusIp6ConfigNameserversProperty = networkManagerDbusIp6ConfigInterface + ".Nameservers" networkManagerDbusDefaultBehaviorFlag networkManagerConfigBehavior = 0 networkManagerDbusIPv4Key = "ipv4" networkManagerDbusIPv6Key = "ipv6" @@ -51,9 +61,10 @@ var supportedNetworkManagerVersionConstraints = []string{ } type networkManagerDbusConfigurator struct { - dbusLinkObject dbus.ObjectPath - routingAll bool - ifaceName string + dbusLinkObject dbus.ObjectPath + routingAll bool + ifaceName string + origNameservers []netip.Addr } // the types below are based on dbus specification, each field is mapped to a dbus type @@ -92,10 +103,200 @@ func newNetworkManagerDbusConfigurator(wgInterface string) (*networkManagerDbusC log.Debugf("got network manager dbus Link Object: %s from net interface %s", s, wgInterface) - return &networkManagerDbusConfigurator{ + c := &networkManagerDbusConfigurator{ dbusLinkObject: dbus.ObjectPath(s), ifaceName: wgInterface, - }, nil + } + + origNameservers, err := c.captureOriginalNameservers() + switch { + case err != nil: + log.Warnf("capture original nameservers from NetworkManager: %v", err) + case len(origNameservers) == 0: + log.Warnf("no original nameservers captured from non-WG NetworkManager devices; DNS fallback will be empty") + default: + log.Debugf("captured %d original nameservers from non-WG NetworkManager devices: %v", len(origNameservers), origNameservers) + } + c.origNameservers = origNameservers + return c, nil +} + +// captureOriginalNameservers reads DNS servers from every NM device's +// IP4Config / IP6Config except our WG device. +func (n *networkManagerDbusConfigurator) captureOriginalNameservers() ([]netip.Addr, error) { + devices, err := networkManagerListDevices() + if err != nil { + return nil, fmt.Errorf("list devices: %w", err) + } + + seen := make(map[netip.Addr]struct{}) + var out []netip.Addr + for _, dev := range devices { + if dev == n.dbusLinkObject { + continue + } + ifaceName := readNetworkManagerDeviceInterface(dev) + for _, addr := range readNetworkManagerDeviceDNS(dev) { + addr = addr.Unmap() + if !addr.IsValid() || addr.IsUnspecified() { + continue + } + // IP6Config.Nameservers is a byte slice without zone info; + // reattach the device's interface name so a captured fe80::… + // stays routable. + if addr.IsLinkLocalUnicast() && ifaceName != "" { + addr = addr.WithZone(ifaceName) + } + if _, dup := seen[addr]; dup { + continue + } + seen[addr] = struct{}{} + out = append(out, addr) + } + } + return out, nil +} + +func readNetworkManagerDeviceInterface(devicePath dbus.ObjectPath) string { + obj, closeConn, err := getDbusObject(networkManagerDest, devicePath) + if err != nil { + return "" + } + defer closeConn() + v, err := obj.GetProperty(networkManagerDbusDeviceIfaceProperty) + if err != nil { + return "" + } + s, _ := v.Value().(string) + return s +} + +func networkManagerListDevices() ([]dbus.ObjectPath, error) { + obj, closeConn, err := getDbusObject(networkManagerDest, networkManagerDbusObjectNode) + if err != nil { + return nil, fmt.Errorf("dbus NetworkManager: %w", err) + } + defer closeConn() + var devs []dbus.ObjectPath + if err := obj.Call(networkManagerDbusGetDevicesMethod, dbusDefaultFlag).Store(&devs); err != nil { + return nil, err + } + return devs, nil +} + +func readNetworkManagerDeviceDNS(devicePath dbus.ObjectPath) []netip.Addr { + obj, closeConn, err := getDbusObject(networkManagerDest, devicePath) + if err != nil { + return nil + } + defer closeConn() + + var out []netip.Addr + if path := readNetworkManagerConfigPath(obj, networkManagerDbusDeviceIp4ConfigProperty); path != "" { + out = append(out, readIPv4ConfigDNS(path)...) + } + if path := readNetworkManagerConfigPath(obj, networkManagerDbusDeviceIp6ConfigProperty); path != "" { + out = append(out, readIPv6ConfigDNS(path)...) + } + return out +} + +func readNetworkManagerConfigPath(obj dbus.BusObject, property string) dbus.ObjectPath { + v, err := obj.GetProperty(property) + if err != nil { + return "" + } + path, ok := v.Value().(dbus.ObjectPath) + if !ok || path == "/" { + return "" + } + return path +} + +func readIPv4ConfigDNS(path dbus.ObjectPath) []netip.Addr { + obj, closeConn, err := getDbusObject(networkManagerDest, path) + if err != nil { + return nil + } + defer closeConn() + + // NameserverData (NM 1.13+) carries strings; older NMs only expose the + // legacy uint32 Nameservers property. + if out := readIPv4NameserverData(obj); len(out) > 0 { + return out + } + return readIPv4LegacyNameservers(obj) +} + +func readIPv4NameserverData(obj dbus.BusObject) []netip.Addr { + v, err := obj.GetProperty(networkManagerDbusIp4ConfigNameserverDataProperty) + if err != nil { + return nil + } + entries, ok := v.Value().([]map[string]dbus.Variant) + if !ok { + return nil + } + var out []netip.Addr + for _, entry := range entries { + addrVar, ok := entry["address"] + if !ok { + continue + } + s, ok := addrVar.Value().(string) + if !ok { + continue + } + if a, err := netip.ParseAddr(s); err == nil { + out = append(out, a) + } + } + return out +} + +func readIPv4LegacyNameservers(obj dbus.BusObject) []netip.Addr { + v, err := obj.GetProperty(networkManagerDbusIp4ConfigNameserversProperty) + if err != nil { + return nil + } + raw, ok := v.Value().([]uint32) + if !ok { + return nil + } + out := make([]netip.Addr, 0, len(raw)) + for _, n := range raw { + var b [4]byte + binary.LittleEndian.PutUint32(b[:], n) + out = append(out, netip.AddrFrom4(b)) + } + return out +} + +func readIPv6ConfigDNS(path dbus.ObjectPath) []netip.Addr { + obj, closeConn, err := getDbusObject(networkManagerDest, path) + if err != nil { + return nil + } + defer closeConn() + v, err := obj.GetProperty(networkManagerDbusIp6ConfigNameserversProperty) + if err != nil { + return nil + } + raw, ok := v.Value().([][]byte) + if !ok { + return nil + } + out := make([]netip.Addr, 0, len(raw)) + for _, b := range raw { + if a, ok := netip.AddrFromSlice(b); ok { + out = append(out, a) + } + } + return out +} + +func (n *networkManagerDbusConfigurator) getOriginalNameservers() []netip.Addr { + return slices.Clone(n.origNameservers) } func (n *networkManagerDbusConfigurator) supportCustomPort() bool { @@ -110,8 +311,25 @@ func (n *networkManagerDbusConfigurator) applyDNSConfig(config HostDNSConfig, st connSettings.cleanDeprecatedSettings() - convDNSIP := binary.LittleEndian.Uint32(config.ServerIP.AsSlice()) - connSettings[networkManagerDbusIPv4Key][networkManagerDbusDNSKey] = dbus.MakeVariant([]uint32{convDNSIP}) + ipKey := networkManagerDbusIPv4Key + staleKey := networkManagerDbusIPv6Key + if config.ServerIP.Is6() { + ipKey = networkManagerDbusIPv6Key + staleKey = networkManagerDbusIPv4Key + raw := config.ServerIP.As16() + connSettings[ipKey][networkManagerDbusDNSKey] = dbus.MakeVariant([][]byte{raw[:]}) + } else { + convDNSIP := binary.LittleEndian.Uint32(config.ServerIP.AsSlice()) + connSettings[ipKey][networkManagerDbusDNSKey] = dbus.MakeVariant([]uint32{convDNSIP}) + } + + // Clear stale DNS settings from the opposite address family to avoid + // leftover entries if the server IP family changed. + if staleSettings, ok := connSettings[staleKey]; ok { + delete(staleSettings, networkManagerDbusDNSKey) + delete(staleSettings, networkManagerDbusDNSPriorityKey) + delete(staleSettings, networkManagerDbusDNSSearchKey) + } var ( searchDomains []string matchDomains []string @@ -146,8 +364,8 @@ func (n *networkManagerDbusConfigurator) applyDNSConfig(config HostDNSConfig, st n.routingAll = false } - connSettings[networkManagerDbusIPv4Key][networkManagerDbusDNSPriorityKey] = dbus.MakeVariant(priority) - connSettings[networkManagerDbusIPv4Key][networkManagerDbusDNSSearchKey] = dbus.MakeVariant(newDomainList) + connSettings[ipKey][networkManagerDbusDNSPriorityKey] = dbus.MakeVariant(priority) + connSettings[ipKey][networkManagerDbusDNSSearchKey] = dbus.MakeVariant(newDomainList) state := &ShutdownState{ ManagerType: networkManager, diff --git a/client/internal/dns/notifier.go b/client/internal/dns/notifier.go index 35cb6ff82..79d924a78 100644 --- a/client/internal/dns/notifier.go +++ b/client/internal/dns/notifier.go @@ -51,7 +51,5 @@ func (n *notifier) notify() { return } - go func(l listener.NetworkChangeListener) { - l.OnNetworkChanged("") - }(n.listener) + n.listener.OnNetworkChanged("") } diff --git a/client/internal/dns/response_writer_test.go b/client/internal/dns/response_writer_test.go index 857964406..bc8416029 100644 --- a/client/internal/dns/response_writer_test.go +++ b/client/internal/dns/response_writer_test.go @@ -4,7 +4,7 @@ import ( "net" "testing" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/google/gopacket" "github.com/google/gopacket/layers" "github.com/miekg/dns" diff --git a/client/internal/dns/resutil/resolve.go b/client/internal/dns/resutil/resolve.go index 5a3744719..931938755 100644 --- a/client/internal/dns/resutil/resolve.go +++ b/client/internal/dns/resutil/resolve.go @@ -8,12 +8,17 @@ import ( "errors" "net" "net/netip" + "slices" "strings" "github.com/miekg/dns" log "github.com/sirupsen/logrus" ) +// errNoSuitableAddress mirrors the unexported error string the net package +// uses when a resolved host has no addresses of the requested family. +const errNoSuitableAddress = "no suitable address found" + // GenerateRequestID creates a random 8-character hex string for request tracing. func GenerateRequestID() string { bytes := make([]byte, 4) @@ -126,6 +131,14 @@ func LookupIP(ctx context.Context, r resolver, network, host string, qtype uint1 } func getRcodeForError(ctx context.Context, r resolver, host string, qtype uint16, err error) int { + // The net package returns this AddrError when the host resolves but has + // no addresses of the requested family. The domain exists, so answer + // NODATA instead of SERVFAIL. + var addrErr *net.AddrError + if errors.As(err, &addrErr) && addrErr.Err == errNoSuitableAddress { + return dns.RcodeSuccess + } + var dnsErr *net.DNSError if !errors.As(err, &dnsErr) { return dns.RcodeServerFailure @@ -155,7 +168,10 @@ func getRcodeForNotFound(ctx context.Context, r resolver, domain string, origina case dns.TypeA: alternativeNetwork = "ip6" default: - return dns.RcodeNameError + // Non-address types reach LookupIP only unexpectedly; without an + // address pair to probe we cannot prove the name is absent, so answer + // NODATA rather than a poisoning NXDOMAIN. + return dns.RcodeSuccess } if _, err := r.LookupNetIP(ctx, alternativeNetwork, domain); err != nil { @@ -172,6 +188,230 @@ func getRcodeForNotFound(ctx context.Context, r resolver, domain string, origina return dns.RcodeSuccess } +// RecordResolver is the host resolver surface used to forward non-address +// record queries. net.DefaultResolver satisfies it. +type RecordResolver interface { + LookupMX(ctx context.Context, name string) ([]*net.MX, error) + LookupTXT(ctx context.Context, name string) ([]string, error) + LookupNS(ctx context.Context, name string) ([]*net.NS, error) + LookupSRV(ctx context.Context, service, proto, name string) (string, []*net.SRV, error) + LookupCNAME(ctx context.Context, host string) (string, error) + LookupAddr(ctx context.Context, addr string) ([]string, error) +} + +// LookupRecords resolves a non-address DNS record type through the host +// resolver and returns the resource records and the DNS rcode. Types the host +// resolver cannot answer (anything not covered by the net.Resolver Lookup* +// methods) yield NODATA so that a routed name is never poisoned with NXDOMAIN +// for an unsupported type. +func LookupRecords(ctx context.Context, r RecordResolver, name string, qtype uint16, ttl uint32) ([]dns.RR, int) { + fqdn := dns.Fqdn(name) + + switch qtype { + case dns.TypeMX: + return lookupMX(ctx, r, name, fqdn, ttl) + case dns.TypeTXT: + return lookupTXT(ctx, r, name, fqdn, ttl) + case dns.TypeNS: + return lookupNS(ctx, r, name, fqdn, ttl) + case dns.TypeSRV: + return lookupSRV(ctx, r, name, fqdn, ttl) + case dns.TypeCNAME: + return lookupCNAME(ctx, r, name, fqdn, ttl) + case dns.TypePTR: + return lookupPTR(ctx, r, name, fqdn, ttl) + default: + return nil, dns.RcodeSuccess + } +} + +func recordHeader(fqdn string, rrtype uint16, ttl uint32) dns.RR_Header { + return dns.RR_Header{Name: fqdn, Rrtype: rrtype, Class: dns.ClassINET, Ttl: ttl} +} + +func lookupMX(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) { + recs, err := r.LookupMX(ctx, name) + if err != nil { + return nil, rcodeForRecordError(err) + } + rrs := make([]dns.RR, 0, len(recs)) + for _, mx := range recs { + rrs = append(rrs, &dns.MX{ + Hdr: recordHeader(fqdn, dns.TypeMX, ttl), + Preference: mx.Pref, + Mx: dns.Fqdn(mx.Host), + }) + } + return rrs, dns.RcodeSuccess +} + +func lookupTXT(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) { + recs, err := r.LookupTXT(ctx, name) + if err != nil { + return nil, rcodeForRecordError(err) + } + rrs := make([]dns.RR, 0, len(recs)) + for _, txt := range recs { + rrs = append(rrs, &dns.TXT{ + Hdr: recordHeader(fqdn, dns.TypeTXT, ttl), + Txt: chunkTXT(txt), + }) + } + return rrs, dns.RcodeSuccess +} + +func lookupNS(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) { + recs, err := r.LookupNS(ctx, name) + if err != nil { + return nil, rcodeForRecordError(err) + } + rrs := make([]dns.RR, 0, len(recs)) + for _, ns := range recs { + rrs = append(rrs, &dns.NS{ + Hdr: recordHeader(fqdn, dns.TypeNS, ttl), + Ns: dns.Fqdn(ns.Host), + }) + } + return rrs, dns.RcodeSuccess +} + +func lookupSRV(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) { + _, recs, err := r.LookupSRV(ctx, "", "", name) + if err != nil { + return nil, rcodeForRecordError(err) + } + rrs := make([]dns.RR, 0, len(recs)) + for _, srv := range recs { + rrs = append(rrs, &dns.SRV{ + Hdr: recordHeader(fqdn, dns.TypeSRV, ttl), + Priority: srv.Priority, + Weight: srv.Weight, + Port: srv.Port, + Target: dns.Fqdn(srv.Target), + }) + } + return rrs, dns.RcodeSuccess +} + +func lookupCNAME(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) { + cname, err := r.LookupCNAME(ctx, name) + if err != nil { + return nil, rcodeForRecordError(err) + } + // LookupCNAME returns the queried name itself when the name resolves but + // has no CNAME record; that is a NODATA result, not a CNAME. + if strings.EqualFold(dns.Fqdn(cname), fqdn) { + return nil, dns.RcodeSuccess + } + return []dns.RR{&dns.CNAME{ + Hdr: recordHeader(fqdn, dns.TypeCNAME, ttl), + Target: dns.Fqdn(cname), + }}, dns.RcodeSuccess +} + +func lookupPTR(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) { + addr, ok := ptrQueryAddr(name) + if !ok { + return nil, dns.RcodeSuccess + } + names, err := r.LookupAddr(ctx, addr) + if err != nil { + return nil, rcodeForRecordError(err) + } + rrs := make([]dns.RR, 0, len(names)) + for _, n := range names { + rrs = append(rrs, &dns.PTR{ + Hdr: recordHeader(fqdn, dns.TypePTR, ttl), + Ptr: dns.Fqdn(n), + }) + } + return rrs, dns.RcodeSuccess +} + +// ptrQueryAddr converts a reverse-DNS query name (in-addr.arpa or ip6.arpa) +// into the address string expected by net.Resolver.LookupAddr. It reports false +// when the name is not a well-formed reverse name. +func ptrQueryAddr(qname string) (string, bool) { + name := strings.TrimSuffix(strings.ToLower(dns.Fqdn(qname)), ".") + + switch { + case strings.HasSuffix(name, ".in-addr.arpa"): + return parseInAddrArpa(strings.TrimSuffix(name, ".in-addr.arpa")) + case strings.HasSuffix(name, ".ip6.arpa"): + return parseIP6Arpa(strings.TrimSuffix(name, ".ip6.arpa")) + default: + return "", false + } +} + +// parseInAddrArpa turns the label portion of an in-addr.arpa name into an IPv4 +// address string, reporting false when it is not a well-formed reverse name. +func parseInAddrArpa(labelPart string) (string, bool) { + labels := strings.Split(labelPart, ".") + if len(labels) != 4 { + return "", false + } + slices.Reverse(labels) + addr, err := netip.ParseAddr(strings.Join(labels, ".")) + if err != nil || !addr.Is4() { + return "", false + } + return addr.String(), true +} + +// parseIP6Arpa turns the nibble portion of an ip6.arpa name into an IPv6 +// address string, reporting false when it is not a well-formed reverse name. +func parseIP6Arpa(nibblePart string) (string, bool) { + nibbles := strings.Split(nibblePart, ".") + if len(nibbles) != 32 { + return "", false + } + slices.Reverse(nibbles) + var sb strings.Builder + for i, n := range nibbles { + if i > 0 && i%4 == 0 { + sb.WriteByte(':') + } + sb.WriteString(n) + } + addr, err := netip.ParseAddr(sb.String()) + if err != nil || !addr.Is6() { + return "", false + } + return addr.String(), true +} + +// rcodeForRecordError maps a non-address lookup error to a DNS rcode. A +// not-found result becomes NODATA rather than NXDOMAIN: net.DNSError.IsNotFound +// does not distinguish a missing name from a name that exists only with records +// of other types, so the name cannot be proven absent and must not be poisoned. +func rcodeForRecordError(err error) int { + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) && dnsErr.IsNotFound { + return dns.RcodeSuccess + } + return dns.RcodeServerFailure +} + +// chunkTXT splits a TXT string into character-strings no longer than 255 bytes +// so the record can be packed. The chunks form one TXT resource record. +func chunkTXT(s string) []string { + const maxLen = 255 + if len(s) <= maxLen { + return []string{s} + } + + var chunks []string + for len(s) > maxLen { + chunks = append(chunks, s[:maxLen]) + s = s[maxLen:] + } + if len(s) > 0 { + chunks = append(chunks, s) + } + return chunks +} + // FormatAnswers formats DNS resource records for logging. func FormatAnswers(answers []dns.RR) string { if len(answers) == 0 { @@ -195,3 +435,35 @@ func FormatAnswers(answers []dns.RR) string { } return "[" + strings.Join(parts, ", ") + "]" } + +// StripOPT removes any OPT pseudo-RRs from the message's Extra section. Per +// RFC 6891 a responder must not include an OPT RR toward a client that did not +// advertise EDNS0. +func StripOPT(msg *dns.Msg) { + if len(msg.Extra) == 0 { + return + } + out := msg.Extra[:0] + for _, rr := range msg.Extra { + if _, ok := rr.(*dns.OPT); ok { + continue + } + out = append(out, rr) + } + msg.Extra = out +} + +// ExtractEDE returns the first Extended DNS Error (RFC 8914) option carried in +// the message, if present. +func ExtractEDE(msg *dns.Msg) (*dns.EDNS0_EDE, bool) { + opt := msg.IsEdns0() + if opt == nil { + return nil, false + } + for _, o := range opt.Option { + if ede, ok := o.(*dns.EDNS0_EDE); ok { + return ede, true + } + } + return nil, false +} diff --git a/client/internal/dns/resutil/resolve_test.go b/client/internal/dns/resutil/resolve_test.go new file mode 100644 index 000000000..f51092a83 --- /dev/null +++ b/client/internal/dns/resutil/resolve_test.go @@ -0,0 +1,320 @@ +package resutil + +import ( + "context" + "errors" + "net" + "net/netip" + "strings" + "testing" + + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockResolver struct { + // results maps network ("ip4"/"ip6") to the lookup outcome. + results map[string]mockLookup +} + +type mockLookup struct { + ips []netip.Addr + err error +} + +func (m *mockResolver) LookupNetIP(_ context.Context, network, _ string) ([]netip.Addr, error) { + res, ok := m.results[network] + if !ok { + return nil, errors.New("unexpected network: " + network) + } + return res.ips, res.err +} + +func TestLookupIP_Success(t *testing.T) { + r := &mockResolver{results: map[string]mockLookup{ + "ip4": {ips: []netip.Addr{netip.MustParseAddr("::ffff:192.0.2.1")}}, + }} + + result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA) + + assert.Equal(t, dns.RcodeSuccess, result.Rcode, "successful lookup should return NOERROR") + require.Len(t, result.IPs, 1, "should return the resolved address") + assert.Equal(t, netip.MustParseAddr("192.0.2.1"), result.IPs[0], "v4-mapped address should be unmapped") +} + +func TestLookupIP_NoSuitableAddress(t *testing.T) { + // The net package returns this AddrError when the host resolves but has + // no addresses of the requested family (e.g. AAAA query for a v4-only + // hosts file entry). The domain exists, so this is NODATA, not SERVFAIL. + r := &mockResolver{results: map[string]mockLookup{ + "ip6": {err: &net.AddrError{Err: "no suitable address found", Addr: "example.com."}}, + }} + + result := LookupIP(context.Background(), r, "ip6", "example.com.", dns.TypeAAAA) + + assert.Equal(t, dns.RcodeSuccess, result.Rcode, "no suitable address should map to NODATA") + assert.Empty(t, result.IPs, "NODATA response should carry no addresses") +} + +// TestErrNoSuitableAddressMatchesNetPackage pins our copy of the error string +// to what the net package actually emits. A literal IP of the wrong family +// takes the same filterAddrList path as a resolved hostname, without network +// access. +func TestErrNoSuitableAddressMatchesNetPackage(t *testing.T) { + _, err := (&net.Resolver{}).LookupNetIP(context.Background(), "ip6", "192.0.2.1") + require.Error(t, err) + + var addrErr *net.AddrError + require.ErrorAs(t, err, &addrErr, "wrong-family lookup should return AddrError") + assert.Equal(t, errNoSuitableAddress, addrErr.Err, "net package error string should match our constant") +} + +func TestLookupIP_OtherAddrError(t *testing.T) { + r := &mockResolver{results: map[string]mockLookup{ + "ip4": {err: &net.AddrError{Err: "some other address problem", Addr: "example.com."}}, + }} + + result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA) + + assert.Equal(t, dns.RcodeServerFailure, result.Rcode, "unrecognized AddrError should map to SERVFAIL") +} + +func TestLookupIP_NotFoundNXDomain(t *testing.T) { + r := &mockResolver{results: map[string]mockLookup{ + "ip4": {err: &net.DNSError{Err: "no such host", Name: "example.com.", IsNotFound: true}}, + "ip6": {err: &net.DNSError{Err: "no such host", Name: "example.com.", IsNotFound: true}}, + }} + + result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA) + + assert.Equal(t, dns.RcodeNameError, result.Rcode, "not found for both families should map to NXDOMAIN") +} + +func TestLookupIP_NotFoundNoData(t *testing.T) { + r := &mockResolver{results: map[string]mockLookup{ + "ip6": {err: &net.DNSError{Err: "no such host", Name: "example.com.", IsNotFound: true}}, + "ip4": {ips: []netip.Addr{netip.MustParseAddr("192.0.2.1")}}, + }} + + result := LookupIP(context.Background(), r, "ip6", "example.com.", dns.TypeAAAA) + + assert.Equal(t, dns.RcodeSuccess, result.Rcode, "not found with the other family present should map to NODATA") +} + +func TestLookupIP_GenericError(t *testing.T) { + r := &mockResolver{results: map[string]mockLookup{ + "ip4": {err: errors.New("connection refused")}, + }} + + result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA) + + assert.Equal(t, dns.RcodeServerFailure, result.Rcode, "generic error should map to SERVFAIL") +} + +func TestLookupIP_DNSErrorNotIsNotFound(t *testing.T) { + r := &mockResolver{results: map[string]mockLookup{ + "ip4": {err: &net.DNSError{Err: "server misbehaving", Name: "example.com.", IsTemporary: true}}, + }} + + result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA) + + assert.Equal(t, dns.RcodeServerFailure, result.Rcode, "upstream failure should map to SERVFAIL") +} + +func TestPtrQueryAddr(t *testing.T) { + tests := []struct { + name string + qname string + want string + wantOK bool + }{ + {name: "ipv4", qname: "4.3.2.1.in-addr.arpa.", want: "1.2.3.4", wantOK: true}, + {name: "ipv4 no trailing dot", qname: "1.0.0.127.in-addr.arpa", want: "127.0.0.1", wantOK: true}, + { + name: "ipv6", + qname: "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.", + want: "2001:db8::1", + wantOK: true, + }, + {name: "ipv4 wrong label count", qname: "2.1.in-addr.arpa.", wantOK: false}, + {name: "ipv6 wrong nibble count", qname: "1.0.ip6.arpa.", wantOK: false}, + {name: "not a reverse name", qname: "example.com.", wantOK: false}, + {name: "ipv4 bad octet", qname: "4.3.2.999.in-addr.arpa.", wantOK: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := ptrQueryAddr(tt.qname) + assert.Equal(t, tt.wantOK, ok, "parse success mismatch") + if tt.wantOK { + assert.Equal(t, tt.want, got, "parsed address mismatch") + } + }) + } +} + +type mockRecordResolver struct { + mx []*net.MX + txt []string + ns []*net.NS + srv []*net.SRV + cname string + ptr []string + err error +} + +func (m *mockRecordResolver) LookupMX(context.Context, string) ([]*net.MX, error) { + return m.mx, m.err +} +func (m *mockRecordResolver) LookupTXT(context.Context, string) ([]string, error) { + return m.txt, m.err +} +func (m *mockRecordResolver) LookupNS(context.Context, string) ([]*net.NS, error) { + return m.ns, m.err +} +func (m *mockRecordResolver) LookupSRV(context.Context, string, string, string) (string, []*net.SRV, error) { + return "", m.srv, m.err +} +func (m *mockRecordResolver) LookupCNAME(context.Context, string) (string, error) { + return m.cname, m.err +} +func (m *mockRecordResolver) LookupAddr(context.Context, string) ([]string, error) { + return m.ptr, m.err +} + +func TestLookupRecords(t *testing.T) { + notFound := &net.DNSError{IsNotFound: true, Name: "example.com."} + + t.Run("MX success", func(t *testing.T) { + r := &mockRecordResolver{mx: []*net.MX{{Host: "mail.example.com.", Pref: 10}}} + rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeMX, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + require.Len(t, rrs, 1) + assert.Equal(t, "mail.example.com.", rrs[0].(*dns.MX).Mx) + }) + + t.Run("TXT short string is one character-string", func(t *testing.T) { + r := &mockRecordResolver{txt: []string{"v=spf1 -all"}} + rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeTXT, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + require.Len(t, rrs, 1) + assert.Equal(t, []string{"v=spf1 -all"}, rrs[0].(*dns.TXT).Txt) + }) + + t.Run("TXT chunks long strings", func(t *testing.T) { + long := strings.Repeat("a", 300) + r := &mockRecordResolver{txt: []string{long}} + rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeTXT, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + require.Len(t, rrs, 1) + txt := rrs[0].(*dns.TXT).Txt + require.Len(t, txt, 2, "300-byte string should split into two character-strings") + assert.Equal(t, 255, len(txt[0])) + assert.Equal(t, 45, len(txt[1])) + }) + + t.Run("NS success", func(t *testing.T) { + r := &mockRecordResolver{ns: []*net.NS{{Host: "ns1.example.com."}}} + rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeNS, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + require.Len(t, rrs, 1) + assert.Equal(t, "ns1.example.com.", rrs[0].(*dns.NS).Ns) + }) + + t.Run("SRV success", func(t *testing.T) { + r := &mockRecordResolver{srv: []*net.SRV{{Target: "sip.example.com.", Port: 5060}}} + rrs, rcode := LookupRecords(context.Background(), r, "_sip._tcp.example.com.", dns.TypeSRV, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + require.Len(t, rrs, 1) + assert.Equal(t, uint16(5060), rrs[0].(*dns.SRV).Port) + }) + + t.Run("CNAME success", func(t *testing.T) { + r := &mockRecordResolver{cname: "target.example.com."} + rrs, rcode := LookupRecords(context.Background(), r, "www.example.com.", dns.TypeCNAME, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + require.Len(t, rrs, 1) + assert.Equal(t, "target.example.com.", rrs[0].(*dns.CNAME).Target) + }) + + t.Run("CNAME equal to name is NODATA", func(t *testing.T) { + r := &mockRecordResolver{cname: "example.com."} + rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeCNAME, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + assert.Empty(t, rrs, "self-referential CNAME is NODATA") + }) + + t.Run("PTR success", func(t *testing.T) { + r := &mockRecordResolver{ptr: []string{"host.example.com."}} + rrs, rcode := LookupRecords(context.Background(), r, "4.3.2.1.in-addr.arpa.", dns.TypePTR, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + require.Len(t, rrs, 1) + assert.Equal(t, "host.example.com.", rrs[0].(*dns.PTR).Ptr) + }) + + t.Run("PTR malformed name is NODATA", func(t *testing.T) { + r := &mockRecordResolver{} + rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypePTR, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + assert.Empty(t, rrs) + }) + + t.Run("not found is NODATA never NXDOMAIN", func(t *testing.T) { + r := &mockRecordResolver{err: notFound} + _, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeMX, 300) + assert.Equal(t, dns.RcodeSuccess, rcode, "missing record must not poison the name") + }) + + t.Run("server failure maps to SERVFAIL", func(t *testing.T) { + r := &mockRecordResolver{err: &net.DNSError{Err: "server misbehaving", IsTemporary: true}} + _, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeMX, 300) + assert.Equal(t, dns.RcodeServerFailure, rcode) + }) + + t.Run("unsupported type is NODATA", func(t *testing.T) { + r := &mockRecordResolver{} + rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeCAA, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + assert.Empty(t, rrs) + }) +} + +func TestStripOPT(t *testing.T) { + rm := &dns.Msg{ + Extra: []dns.RR{ + &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}}, + &dns.A{Hdr: dns.RR_Header{Name: "x.", Rrtype: dns.TypeA}, A: net.IPv4(1, 2, 3, 4)}, + }, + } + StripOPT(rm) + assert.Len(t, rm.Extra, 1, "OPT should be removed, A kept") + _, isOPT := rm.Extra[0].(*dns.OPT) + assert.False(t, isOPT, "remaining record must not be OPT") +} + +func TestExtractEDE(t *testing.T) { + t.Run("no edns", func(t *testing.T) { + _, ok := ExtractEDE(&dns.Msg{}) + assert.False(t, ok, "message without OPT has no EDE") + }) + + t.Run("edns without ede", func(t *testing.T) { + rm := &dns.Msg{} + rm.SetEdns0(4096, false) + _, ok := ExtractEDE(rm) + assert.False(t, ok, "OPT without EDE option returns false") + }) + + t.Run("with ede", func(t *testing.T) { + rm := &dns.Msg{} + opt := &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}} + opt.Option = append(opt.Option, &dns.EDNS0_EDE{InfoCode: 49152, ExtraText: "upstream timeout"}) + rm.Extra = append(rm.Extra, opt) + + ede, ok := ExtractEDE(rm) + assert.True(t, ok, "EDE option should be found") + assert.Equal(t, uint16(49152), ede.InfoCode) + assert.Equal(t, "upstream timeout", ede.ExtraText) + }) +} diff --git a/client/internal/dns/server.go b/client/internal/dns/server.go index d4f54dec5..3af912792 100644 --- a/client/internal/dns/server.go +++ b/client/internal/dns/server.go @@ -7,10 +7,10 @@ import ( "net/netip" "net/url" "os" - "runtime" - "strconv" + "slices" "strings" "sync" + "time" "github.com/miekg/dns" "github.com/mitchellh/hashstructure/v2" @@ -25,11 +25,35 @@ import ( "github.com/netbirdio/netbird/client/internal/listener" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/statemanager" + "github.com/netbirdio/netbird/client/proto" nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" ) -const envSkipDNSProbe = "NB_SKIP_DNS_PROBE" +const ( + // healthLookback must exceed the upstream query timeout so one + // query per refresh cycle is enough to keep a group marked healthy. + healthLookback = 60 * time.Second + nsGroupHealthRefreshInterval = 10 * time.Second + // defaultWarningDelayBase is the starting grace window before a + // "Nameserver group unreachable" event fires for a group that's + // never been healthy and only has overlay upstreams with no + // Connected peer. Per-server and overridable via envWarningDelay; + // see warningDelay. + defaultWarningDelayBase = 60 * time.Second + // warningDelayBonusCap caps the route-count bonus added to the + // base grace window. See warningDelay. + warningDelayBonusCap = 30 * time.Second + // envWarningDelay overrides defaultWarningDelayBase with a Go duration + // string (e.g. "90s", "2m"). Invalid or non-positive values are ignored. + envWarningDelay = "NB_DNS_HEALTH_WARNING_DELAY" +) + +// errNoUsableNameservers signals that a merged-domain group has no usable +// upstream servers. Callers should skip the group without treating it as a +// build failure. +var errNoUsableNameservers = errors.New("no usable nameservers") // ReadyListener is a notification mechanism what indicate the server is ready to handle host dns address changes type ReadyListener interface { @@ -54,11 +78,11 @@ type Server interface { UpdateDNSServer(serial uint64, update nbdns.Config) error OnUpdatedHostDNSServer(addrs []netip.AddrPort) SearchDomains() []string - ProbeAvailability() UpdateServerConfig(domains dnsconfig.ServerDomains) error PopulateManagementDomain(mgmtURL *url.URL) error - SetRouteChecker(func(netip.Addr) bool) + SetRouteSources(selected, active func() route.HAMap) SetFirewall(Firewall) + SetPeerActivator(local.PeerActivator) } type nsGroupsByDomain struct { @@ -66,12 +90,47 @@ type nsGroupsByDomain struct { groups []*nbdns.NameServerGroup } -// hostManagerWithOriginalNS extends the basic hostManager interface -type hostManagerWithOriginalNS interface { - hostManager - getOriginalNameservers() []netip.Addr +// nsGroupID identifies a nameserver group by the tuple (server list, domain +// list) so config updates produce stable IDs across recomputations. +type nsGroupID string + +// nsHealthSnapshot is the input to projectNSGroupHealth, captured under +// s.mux so projection runs lock-free. +type nsHealthSnapshot struct { + groups []*nbdns.NameServerGroup + merged map[netip.AddrPort]UpstreamHealth + selected route.HAMap + active route.HAMap } +// nsGroupProj holds per-group state for the emission rules. +type nsGroupProj struct { + // unhealthySince is the start of the current Unhealthy streak, + // zero when the group is not currently Unhealthy. + unhealthySince time.Time + // everHealthy is sticky: once the group has been Healthy at least + // once this session, subsequent failures skip warningDelay. + everHealthy bool + // warningActive tracks whether we've already published a warning + // for the current streak, so recovery emits iff a warning did. + warningActive bool +} + +// nsGroupVerdict is the outcome of evaluateNSGroupHealth. +type nsGroupVerdict int + +const ( + // nsVerdictUndecided means no upstream has a fresh observation + // (startup before first query, or records aged past healthLookback). + nsVerdictUndecided nsGroupVerdict = iota + // nsVerdictHealthy means at least one upstream's most-recent + // in-lookback observation is a success. + nsVerdictHealthy + // nsVerdictUnhealthy means at least one upstream has a recent + // failure and none has a fresher success. + nsVerdictUnhealthy +) + // DefaultServer dns server object type DefaultServer struct { ctx context.Context @@ -82,7 +141,7 @@ type DefaultServer struct { disableSys bool mux sync.Mutex service service - dnsMuxMap registeredHandlerMap + dnsMuxHandlers []handlerWrapper localResolver *local.Resolver wgInterface WGIface hostManager hostManager @@ -100,34 +159,52 @@ type DefaultServer struct { permanent bool hostsDNSHolder *hostsDNSHolder + // fallbackHandler is the upstream resolver currently registered at + // PriorityFallback. Tracked so registerFallback can Stop() the previous + // instance instead of leaking its context. + fallbackHandler handlerWithStop + // make sense on mobile only searchDomainNotifier *notifier iosDnsManager IosDnsManager statusRecorder *peer.Status stateManager *statemanager.Manager - routeMatch func(netip.Addr) bool + // selectedRoutes returns admin-enabled client routes. + selectedRoutes func() route.HAMap + // activeRoutes returns the subset whose peer is in StatusConnected. + activeRoutes func() route.HAMap - probeMu sync.Mutex - probeCancel context.CancelFunc - probeWg sync.WaitGroup + nsGroups []*nbdns.NameServerGroup + healthProjectMu sync.Mutex + // nsGroupProj is the per-group state used by the emission rules. + // Accessed only under healthProjectMu. + nsGroupProj map[nsGroupID]*nsGroupProj + // warningDelayBase is the base grace window for health projection. + // Set at construction, mutated only by tests. Read by the + // refresher goroutine so never change it while one is running. + warningDelayBase time.Duration + // healthRefresh is buffered=1; writers coalesce, senders never block. + // See refreshHealth for the lock-order rationale. + healthRefresh chan struct{} } type handlerWithStop interface { dns.Handler Stop() - ProbeAvailability(context.Context) ID() types.HandlerID } +type upstreamHealthReporter interface { + UpstreamHealth() map[netip.AddrPort]UpstreamHealth +} + type handlerWrapper struct { domain string handler handlerWithStop priority int } -type registeredHandlerMap map[types.HandlerID]handlerWrapper - // DefaultServerConfig holds configuration parameters for NewDefaultServer type DefaultServerConfig struct { WgInterface WGIface @@ -174,29 +251,24 @@ func NewDefaultServerPermanentUpstream( ds.hostsDNSHolder.set(hostsDnsList) ds.permanent = true - ds.addHostRootZone() ds.currentConfig = dnsConfigToHostDNSConfig(config, ds.service.RuntimeIP(), ds.service.RuntimePort()) - ds.searchDomainNotifier = newNotifier(ds.SearchDomains()) + ds.searchDomainNotifier = newNotifier(ds.searchDomains()) ds.searchDomainNotifier.setListener(listener) setServerDns(ds) return ds } -// NewDefaultServerIos returns a new dns server. It optimized for ios +// NewDefaultServerIos returns a new dns server. It optimized for ios. func NewDefaultServerIos( ctx context.Context, wgInterface WGIface, iosDnsManager IosDnsManager, - hostsDnsList []netip.AddrPort, statusRecorder *peer.Status, disableSys bool, ) *DefaultServer { - log.Debugf("iOS host dns address list is: %v", hostsDnsList) ds := newDefaultServer(ctx, wgInterface, NewServiceViaMemory(wgInterface), statusRecorder, nil, disableSys) ds.iosDnsManager = iosDnsManager - ds.hostsDNSHolder.set(hostsDnsList) ds.permanent = true - ds.addHostRootZone() return ds } @@ -221,7 +293,6 @@ func newDefaultServer( service: dnsService, handlerChain: handlerChain, extraDomains: make(map[domain.Domain]int), - dnsMuxMap: make(registeredHandlerMap), localResolver: local.NewResolver(), wgInterface: wgInterface, statusRecorder: statusRecorder, @@ -230,7 +301,14 @@ func newDefaultServer( hostManager: &noopHostConfigurator{}, mgmtCacheResolver: mgmtCacheResolver, currentConfigHash: ^uint64(0), // Initialize to max uint64 to ensure first config is always applied + warningDelayBase: warningDelayBaseFromEnv(), + healthRefresh: make(chan struct{}, 1), } + // Wire the local resolver against the peer status recorder so it can + // suppress A/AAAA answers that point at disconnected peers (typical + // case: synthesised private-service records pointing at an embedded + // proxy peer that just went offline). + defaultServer.localResolver.SetPeerConnectivity(localPeerConnectivity{statusRecorder}) // register with root zone, handler chain takes care of the routing dnsService.RegisterMux(".", handlerChain) @@ -238,12 +316,26 @@ func newDefaultServer( return defaultServer } -// SetRouteChecker sets the function used by upstream resolvers to determine -// whether an IP is routed through the tunnel. -func (s *DefaultServer) SetRouteChecker(f func(netip.Addr) bool) { +// SetRouteSources wires the route-manager accessors used by health +// projection to classify each upstream for emission timing. +func (s *DefaultServer) SetRouteSources(selected, active func() route.HAMap) { s.mux.Lock() defer s.mux.Unlock() - s.routeMatch = f + s.selectedRoutes = selected + s.activeRoutes = active + + // Permanent / iOS constructors build the root handler before the + // engine wires route sources, so its selectedRoutes callback would + // otherwise remain nil and overlay upstreams would be classified + // as public. Propagate the new accessors to existing handlers. + type routeSettable interface { + setSelectedRoutes(func() route.HAMap) + } + for _, entry := range s.dnsMuxHandlers { + if h, ok := entry.handler.(routeSettable); ok { + h.setSelectedRoutes(selected) + } + } } // RegisterHandler registers a handler for the given domains with the given priority. @@ -256,7 +348,6 @@ func (s *DefaultServer) RegisterHandler(domains domain.List, handler dns.Handler // TODO: This will take over zones for non-wildcard domains, for which we might not have a handler in the chain for _, domain := range domains { - // convert to zone with simple ref counter s.extraDomains[toZone(domain)]++ } if !s.batchMode { @@ -357,6 +448,8 @@ func (s *DefaultServer) Initialize() (err error) { s.stateManager.RegisterState(&ShutdownState{}) + s.startHealthRefresher() + // Keep using noop host manager if dns off requested or running in netstack mode. // Netstack mode currently doesn't have a way to receive DNS requests. // TODO: Use listener on localhost in netstack mode when running as root. @@ -370,6 +463,13 @@ func (s *DefaultServer) Initialize() (err error) { return fmt.Errorf("initialize: %w", err) } s.hostManager = hostManager + // On mobile-permanent setups the seeded host DNS list is the only + // source until the first network-map arrives; register it now so DNS + // works in that window. Desktop host managers register fallback when + // applyConfiguration runs. + if s.permanent { + s.registerFallback() + } return nil } @@ -392,15 +492,16 @@ func (s *DefaultServer) SetFirewall(fw Firewall) { } } +// SetPeerActivator wires the DNS-time lazy-connection warm-up on the local +// resolver. Injected after the connection manager exists (it does not at +// DNS-server construction time). Pass nil to disable. +func (s *DefaultServer) SetPeerActivator(a local.PeerActivator) { + s.localResolver.SetPeerActivator(a) +} + // Stop stops the server func (s *DefaultServer) Stop() { - s.probeMu.Lock() - if s.probeCancel != nil { - s.probeCancel() - } s.ctxCancel() - s.probeMu.Unlock() - s.probeWg.Wait() s.shutdownWg.Wait() s.mux.Lock() @@ -410,7 +511,14 @@ func (s *DefaultServer) Stop() { log.Errorf("failed to disable DNS: %v", err) } - maps.Clear(s.extraDomains) + clear(s.extraDomains) + + // Clear health projection state so a subsequent Start doesn't + // inherit sticky flags (notably everHealthy) that would bypass + // the grace window during the next peer handshake. + s.healthProjectMu.Lock() + s.nsGroupProj = nil + s.healthProjectMu.Unlock() } func (s *DefaultServer) disableDNS() (retErr error) { @@ -424,10 +532,9 @@ func (s *DefaultServer) disableDNS() (retErr error) { return nil } - // Deregister original nameservers if they were registered as fallback - if srvs, ok := s.hostManager.(hostManagerWithOriginalNS); ok && len(srvs.getOriginalNameservers()) > 0 { - log.Debugf("deregistering original nameservers as fallback handlers") - s.deregisterHandler([]string{nbdns.RootZone}, PriorityFallback) + if s.fallbackHandler != nil { + log.Debugf("deregistering fallback handlers") + s.clearFallback() } if err := s.hostManager.restoreHostDNS(); err != nil { @@ -441,27 +548,16 @@ func (s *DefaultServer) disableDNS() (retErr error) { return nil } -// OnUpdatedHostDNSServer update the DNS servers addresses for root zones -// It will be applied if the mgm server do not enforce DNS settings for root zone +// OnUpdatedHostDNSServer updates the fallback DNS upstreams. Called by Android +// outside the engine's sync mux when the OS reports a network change, so it +// takes s.mux to serialize against host manager swaps in Initialize/enableDNS. func (s *DefaultServer) OnUpdatedHostDNSServer(hostsDnsList []netip.AddrPort) { s.hostsDNSHolder.set(hostsDnsList) - - // Check if there's any root handler - var hasRootHandler bool - for _, handler := range s.dnsMuxMap { - if handler.domain == nbdns.RootZone { - hasRootHandler = true - break - } - } - - if hasRootHandler { - log.Debugf("on new host DNS config but skip to apply it") - return - } - log.Debugf("update host DNS settings: %+v", hostsDnsList) - s.addHostRootZone() + + s.mux.Lock() + defer s.mux.Unlock() + s.registerFallback() } // UpdateDNSServer processes an update received from the management service @@ -506,6 +602,12 @@ func (s *DefaultServer) UpdateDNSServer(serial uint64, update nbdns.Config) erro } func (s *DefaultServer) SearchDomains() []string { + s.mux.Lock() + defer s.mux.Unlock() + return s.searchDomains() +} + +func (s *DefaultServer) searchDomains() []string { var searchDomains []string for _, dConf := range s.currentConfig.Domains { @@ -520,69 +622,6 @@ func (s *DefaultServer) SearchDomains() []string { return searchDomains } -// ProbeAvailability tests each upstream group's servers for availability -// 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() { - 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 { s.mux.Lock() defer s.mux.Unlock() @@ -653,7 +692,7 @@ func (s *DefaultServer) applyConfiguration(update nbdns.Config) error { }() if s.searchDomainNotifier != nil { - s.searchDomainNotifier.onNewSearchDomains(s.SearchDomains()) + s.searchDomainNotifier.onNewSearchDomains(s.searchDomains()) } s.updateNSGroupStates(update.NameServerGroups) @@ -746,23 +785,32 @@ func (s *DefaultServer) applyHostConfig() { s.currentConfigHash = hash } - s.registerFallback(config) + s.registerFallback() } // registerFallback registers original nameservers as low-priority fallback handlers. -func (s *DefaultServer) registerFallback(config HostDNSConfig) { - hostMgrWithNS, ok := s.hostManager.(hostManagerWithOriginalNS) - if !ok { +// Replaces and Stop()s the previously-registered fallback handler so its +// context is released rather than leaked until GC. +func (s *DefaultServer) registerFallback() { + originalNameservers := s.hostManager.getOriginalNameservers() + + serverIP := s.service.RuntimeIP() + var servers []netip.AddrPort + for _, ns := range originalNameservers { + if ns == serverIP { + log.Debugf("skipping original nameserver %s as it is the same as the server IP %s", ns, serverIP) + continue + } + servers = append(servers, netip.AddrPortFrom(ns, DefaultPort)) + } + + if len(servers) == 0 { + log.Debugf("no fallback upstreams to register; clearing PriorityFallback handler") + s.clearFallback() return } - originalNameservers := hostMgrWithNS.getOriginalNameservers() - if len(originalNameservers) == 0 { - s.deregisterHandler([]string{nbdns.RootZone}, PriorityFallback) - return - } - - log.Infof("registering original nameservers %v as upstream handlers with priority %d", originalNameservers, PriorityFallback) + log.Infof("registering original nameservers %v as upstream handlers with priority %d", servers, PriorityFallback) handler, err := newUpstreamResolver( s.ctx, @@ -775,21 +823,23 @@ func (s *DefaultServer) registerFallback(config HostDNSConfig) { log.Errorf("failed to create upstream resolver for original nameservers: %v", err) return } - handler.routeMatch = s.routeMatch - - for _, ns := range originalNameservers { - if ns == config.ServerIP { - log.Debugf("skipping original nameserver %s as it is the same as the server IP %s", ns, config.ServerIP) - continue - } - - addrPort := netip.AddrPortFrom(ns, DefaultPort) - handler.upstreamServers = append(handler.upstreamServers, addrPort) - } - handler.deactivate = func(error) { /* always active */ } - handler.reactivate = func() { /* always active */ } + handler.selectedRoutes = s.selectedRoutes + handler.addRace(servers) + prev := s.fallbackHandler + s.fallbackHandler = handler s.registerHandler([]string{nbdns.RootZone}, handler, PriorityFallback) + if prev != nil { + prev.Stop() + } +} + +func (s *DefaultServer) clearFallback() { + s.deregisterHandler([]string{nbdns.RootZone}, PriorityFallback) + if s.fallbackHandler != nil { + s.fallbackHandler.Stop() + s.fallbackHandler = nil + } } func (s *DefaultServer) buildLocalHandlerUpdate(customZones []nbdns.CustomZone) ([]handlerWrapper, []nbdns.CustomZone, error) { @@ -847,279 +897,483 @@ func (s *DefaultServer) buildUpstreamHandlerUpdate(nameServerGroups []*nbdns.Nam groupedNS := groupNSGroupsByDomain(nameServerGroups) for _, domainGroup := range groupedNS { - basePriority := PriorityUpstream + priority := PriorityUpstream if domainGroup.domain == nbdns.RootZone { - basePriority = PriorityDefault + priority = PriorityDefault } - updates, err := s.createHandlersForDomainGroup(domainGroup, basePriority) + update, err := s.buildMergedDomainHandler(domainGroup, priority) if err != nil { + if errors.Is(err, errNoUsableNameservers) { + log.Errorf("no usable nameservers for domain=%s", domainGroup.domain) + continue + } return nil, err } - muxUpdates = append(muxUpdates, updates...) + muxUpdates = append(muxUpdates, *update) } return muxUpdates, nil } -func (s *DefaultServer) createHandlersForDomainGroup(domainGroup nsGroupsByDomain, basePriority int) ([]handlerWrapper, error) { - var muxUpdates []handlerWrapper - - for i, nsGroup := range domainGroup.groups { - // Decrement priority by handler index (0, 1, 2, ...) to avoid conflicts - priority := basePriority - i - - // Check if we're about to overlap with the next priority tier - if s.leaksPriority(domainGroup, basePriority, priority) { - break - } - - log.Debugf("creating handler for domain=%s with priority=%d", domainGroup.domain, priority) - handler, err := newUpstreamResolver( - s.ctx, - s.wgInterface, - s.statusRecorder, - s.hostsDNSHolder, - domainGroup.domain, - ) - if err != nil { - return nil, fmt.Errorf("create upstream resolver: %v", err) - } - handler.routeMatch = s.routeMatch - - for _, ns := range nsGroup.NameServers { - if ns.NSType != nbdns.UDPNameServerType { - log.Warnf("skipping nameserver %s with type %s, this peer supports only %s", - ns.IP.String(), ns.NSType.String(), nbdns.UDPNameServerType.String()) - continue - } - - if ns.IP == s.service.RuntimeIP() { - log.Warnf("skipping nameserver %s as it matches our DNS server IP, preventing potential loop", ns.IP) - continue - } - - handler.upstreamServers = append(handler.upstreamServers, ns.AddrPort()) - } - - if len(handler.upstreamServers) == 0 { - handler.Stop() - log.Errorf("received a nameserver group with an invalid nameserver list") - continue - } - - // when upstream fails to resolve domain several times over all it servers - // it will calls this hook to exclude self from the configuration and - // reapply DNS settings, but it not touch the original configuration and serial number - // because it is temporal deactivation until next try - // - // after some period defined by upstream it tries to reactivate self by calling this hook - // everything we need here is just to re-apply current configuration because it already - // contains this upstream settings (temporal deactivation not removed it) - handler.deactivate, handler.reactivate = s.upstreamCallbacks(nsGroup, handler, priority) - - muxUpdates = append(muxUpdates, handlerWrapper{ - domain: domainGroup.domain, - handler: handler, - priority: priority, - }) - } - - return muxUpdates, nil -} - -func (s *DefaultServer) leaksPriority(domainGroup nsGroupsByDomain, basePriority int, priority int) bool { - if basePriority == PriorityUpstream && priority <= PriorityDefault { - log.Warnf("too many handlers for domain=%s, would overlap with default priority tier (diff=%d). Skipping remaining handlers", - domainGroup.domain, PriorityUpstream-PriorityDefault) - return true - } - if basePriority == PriorityDefault && priority <= PriorityFallback { - log.Warnf("too many handlers for domain=%s, would overlap with fallback priority tier (diff=%d). Skipping remaining handlers", - domainGroup.domain, PriorityDefault-PriorityFallback) - return true - } - - return false -} - -func (s *DefaultServer) updateMux(muxUpdates []handlerWrapper) { - // this will introduce a short period of time when the server is not able to handle DNS requests - for _, existing := range s.dnsMuxMap { - s.deregisterHandler([]string{existing.domain}, existing.priority) - existing.handler.Stop() - } - - muxUpdateMap := make(registeredHandlerMap) - var containsRootUpdate bool - - for _, update := range muxUpdates { - if update.domain == nbdns.RootZone { - containsRootUpdate = true - } - s.registerHandler([]string{update.domain}, update.handler, update.priority) - muxUpdateMap[update.handler.ID()] = update - } - - // If there's no root update and we had a root handler, restore it - if !containsRootUpdate { - for _, existing := range s.dnsMuxMap { - if existing.domain == nbdns.RootZone { - s.addHostRootZone() - break - } - } - } - - s.dnsMuxMap = muxUpdateMap -} - -// upstreamCallbacks returns two functions, the first one is used to deactivate -// the upstream resolver from the configuration, the second one is used to -// reactivate it. Not allowed to call reactivate before deactivate. -func (s *DefaultServer) upstreamCallbacks( - nsGroup *nbdns.NameServerGroup, - handler dns.Handler, - priority int, -) (deactivate func(error), reactivate func()) { - var removeIndex map[string]int - deactivate = func(err error) { - s.mux.Lock() - defer s.mux.Unlock() - - l := log.WithField("nameservers", nsGroup.NameServers) - l.Info("Temporarily deactivating nameservers group due to timeout") - - removeIndex = make(map[string]int) - for _, domain := range nsGroup.Domains { - removeIndex[domain] = -1 - } - if nsGroup.Primary { - removeIndex[nbdns.RootZone] = -1 - s.currentConfig.RouteAll = false - s.deregisterHandler([]string{nbdns.RootZone}, priority) - } - - for i, item := range s.currentConfig.Domains { - if _, found := removeIndex[item.Domain]; found { - s.currentConfig.Domains[i].Disabled = true - s.deregisterHandler([]string{item.Domain}, priority) - removeIndex[item.Domain] = i - } - } - - // Always apply host config when nameserver goes down, regardless of batch mode - s.applyHostConfig() - - go func() { - if err := s.stateManager.PersistState(s.ctx); err != nil { - l.Errorf("Failed to persist dns state: %v", err) - } - }() - - if runtime.GOOS == "android" && nsGroup.Primary && len(s.hostsDNSHolder.get()) > 0 { - s.addHostRootZone() - } - - s.updateNSState(nsGroup, err, false) - } - - reactivate = func() { - s.mux.Lock() - defer s.mux.Unlock() - - for domain, i := range removeIndex { - if i == -1 || i >= len(s.currentConfig.Domains) || s.currentConfig.Domains[i].Domain != domain { - continue - } - s.currentConfig.Domains[i].Disabled = false - s.registerHandler([]string{domain}, handler, priority) - } - - l := log.WithField("nameservers", nsGroup.NameServers) - l.Debug("reactivate temporary disabled nameserver group") - - if nsGroup.Primary { - s.currentConfig.RouteAll = true - 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) - } - return -} - -func (s *DefaultServer) addHostRootZone() { - hostDNSServers := s.hostsDNSHolder.get() - if len(hostDNSServers) == 0 { - log.Debug("no host DNS servers available, skipping root zone handler creation") - return - } - +// buildMergedDomainHandler merges every nameserver group that targets the +// same domain into one handler whose inner groups are raced in parallel. +func (s *DefaultServer) buildMergedDomainHandler(domainGroup nsGroupsByDomain, priority int) (*handlerWrapper, error) { handler, err := newUpstreamResolver( s.ctx, s.wgInterface, s.statusRecorder, s.hostsDNSHolder, - nbdns.RootZone, + domain.Domain(domainGroup.domain), ) if err != nil { - log.Errorf("unable to create a new upstream resolver, error: %v", err) + return nil, fmt.Errorf("create upstream resolver: %v", err) + } + handler.selectedRoutes = s.selectedRoutes + + for _, nsGroup := range domainGroup.groups { + servers := s.filterNameServers(nsGroup.NameServers) + if len(servers) == 0 { + log.Warnf("nameserver group for domain=%s yielded no usable servers, skipping", domainGroup.domain) + continue + } + handler.addRace(servers) + } + + if len(handler.upstreamServers) == 0 { + handler.Stop() + return nil, errNoUsableNameservers + } + + log.Debugf("creating merged handler for domain=%s with %d group(s) priority=%d", domainGroup.domain, len(handler.upstreamServers), priority) + + return &handlerWrapper{ + domain: domainGroup.domain, + handler: handler, + priority: priority, + }, nil +} + +func (s *DefaultServer) filterNameServers(nameServers []nbdns.NameServer) []netip.AddrPort { + var out []netip.AddrPort + for _, ns := range nameServers { + if ns.NSType != nbdns.UDPNameServerType { + log.Warnf("skipping nameserver %s with type %s, this peer supports only %s", + ns.IP.String(), ns.NSType.String(), nbdns.UDPNameServerType.String()) + continue + } + if ns.IP == s.service.RuntimeIP() { + log.Warnf("skipping nameserver %s as it matches our DNS server IP, preventing potential loop", ns.IP) + continue + } + out = append(out, ns.AddrPort()) + } + return out +} + +// usableNameServers returns the subset of nameServers the handler would +// actually query. Matches filterNameServers without the warning logs, so +// it's safe to call on every health-projection tick. +func (s *DefaultServer) usableNameServers(nameServers []nbdns.NameServer) []netip.AddrPort { + var runtimeIP netip.Addr + if s.service != nil { + runtimeIP = s.service.RuntimeIP() + } + var out []netip.AddrPort + for _, ns := range nameServers { + if ns.NSType != nbdns.UDPNameServerType { + continue + } + if runtimeIP.IsValid() && ns.IP == runtimeIP { + continue + } + out = append(out, ns.AddrPort()) + } + return out +} + +func (s *DefaultServer) updateMux(muxUpdates []handlerWrapper) { + // this will introduce a short period of time when the server is not able to handle DNS requests + for _, existing := range s.dnsMuxHandlers { + s.deregisterHandler([]string{existing.domain}, existing.priority) + // The local resolver is a persistent singleton shared by every custom + // zone and reused across config updates. Its chain registrations are + // per-config and must be deregistered, but Stop() cancels its lookup + // context (breaking external CNAME-target resolution) and clears its + // records, so it must not be torn down here. + if existing.handler != s.localResolver { + existing.handler.Stop() + } + } + + for _, update := range muxUpdates { + s.registerHandler([]string{update.domain}, update.handler, update.priority) + } + + s.dnsMuxHandlers = muxUpdates +} + +// updateNSGroupStates records the new group set and pokes the refresher. +// Must hold s.mux; projection runs async (see refreshHealth for why). +func (s *DefaultServer) updateNSGroupStates(groups []*nbdns.NameServerGroup) { + s.nsGroups = groups + select { + case s.healthRefresh <- struct{}{}: + default: + } +} + +// refreshHealth runs one projection cycle. Must not be called while +// holding s.mux: the route callbacks re-enter routemanager's lock. +func (s *DefaultServer) refreshHealth() { + s.mux.Lock() + groups := s.nsGroups + merged := s.collectUpstreamHealth() + selFn := s.selectedRoutes + actFn := s.activeRoutes + s.mux.Unlock() + + var selected, active route.HAMap + if selFn != nil { + selected = selFn() + } + if actFn != nil { + active = actFn() + } + + s.projectNSGroupHealth(nsHealthSnapshot{ + groups: groups, + merged: merged, + selected: selected, + active: active, + }) +} + +// projectNSGroupHealth applies the emission rules to the snapshot and +// publishes the resulting NSGroupStates. Serialized by healthProjectMu, +// lock-free wrt s.mux. +// +// Rules: +// - Healthy: emit recovery iff warningActive; set everHealthy. +// - Unhealthy: stamp unhealthySince on streak start; emit warning +// iff any of immediate / everHealthy / elapsed >= effective delay. +// - Undecided: no-op. +// +// "Immediate" means the group has at least one upstream that's public +// or overlay+Connected: no peer-startup race to wait out. +func (s *DefaultServer) projectNSGroupHealth(snap nsHealthSnapshot) { + if s.statusRecorder == nil { return } - handler.routeMatch = s.routeMatch - handler.upstreamServers = maps.Keys(hostDNSServers) - handler.deactivate = func(error) {} - handler.reactivate = func() {} + s.healthProjectMu.Lock() + defer s.healthProjectMu.Unlock() - s.registerHandler([]string{nbdns.RootZone}, handler, PriorityDefault) -} + if s.nsGroupProj == nil { + s.nsGroupProj = make(map[nsGroupID]*nsGroupProj) + } -func (s *DefaultServer) updateNSGroupStates(groups []*nbdns.NameServerGroup) { - var states []peer.NSGroupState + now := time.Now() + delay := s.warningDelay(haMapRouteCount(snap.selected)) + states := make([]peer.NSGroupState, 0, len(snap.groups)) + seen := make(map[nsGroupID]struct{}, len(snap.groups)) + for _, group := range snap.groups { + servers := s.usableNameServers(group.NameServers) + if len(servers) == 0 { + continue + } + verdict, groupErr := evaluateNSGroupHealth(snap.merged, servers, now) + id := generateGroupKey(group) + seen[id] = struct{}{} - for _, group := range groups { - var servers []netip.AddrPort - for _, ns := range group.NameServers { - servers = append(servers, ns.AddrPort()) + immediate := s.groupHasImmediateUpstream(servers, snap) + + p, known := s.nsGroupProj[id] + if !known { + p = &nsGroupProj{} + s.nsGroupProj[id] = p } - state := peer.NSGroupState{ - ID: generateGroupKey(group), + enabled := true + switch verdict { + case nsVerdictHealthy: + enabled = s.projectHealthy(p, servers) + case nsVerdictUnhealthy: + enabled = s.projectUnhealthy(p, servers, immediate, now, delay) + case nsVerdictUndecided: + // Stay Available until evidence says otherwise, unless a + // warning is already active for this group. Also clear any + // prior Unhealthy streak so a later Unhealthy verdict starts + // a fresh grace window rather than inheriting a stale one. + p.unhealthySince = time.Time{} + enabled = !p.warningActive + groupErr = nil + } + + states = append(states, peer.NSGroupState{ + ID: string(id), Servers: servers, Domains: group.Domains, - // The probe will determine the state, default enabled - Enabled: true, - Error: nil, - } - states = append(states, state) + Enabled: enabled, + Error: groupErr, + }) } - s.statusRecorder.UpdateDNSStates(states) -} - -func (s *DefaultServer) updateNSState(nsGroup *nbdns.NameServerGroup, err error, enabled bool) { - states := s.statusRecorder.GetDNSStates() - id := generateGroupKey(nsGroup) - for i, state := range states { - if state.ID == id { - states[i].Enabled = enabled - states[i].Error = err - break + for id := range s.nsGroupProj { + if _, ok := seen[id]; !ok { + delete(s.nsGroupProj, id) } } s.statusRecorder.UpdateDNSStates(states) } -func generateGroupKey(nsGroup *nbdns.NameServerGroup) string { - var servers []string +// projectHealthy records a healthy tick on p and publishes a recovery +// event iff a warning was active for the current streak. Returns the +// Enabled flag to record in NSGroupState. +func (s *DefaultServer) projectHealthy(p *nsGroupProj, servers []netip.AddrPort) bool { + p.everHealthy = true + p.unhealthySince = time.Time{} + if !p.warningActive { + return true + } + log.Debugf("DNS health: group [%s] recovered, emitting event", joinAddrPorts(servers)) + s.statusRecorder.PublishEvent( + proto.SystemEvent_INFO, + proto.SystemEvent_DNS, + "Nameserver group recovered", + "DNS servers are reachable again.", + map[string]string{"upstreams": joinAddrPorts(servers)}, + ) + p.warningActive = false + return true +} + +// projectUnhealthy records an unhealthy tick on p, publishes the +// warning when the emission rules fire, and returns the Enabled flag +// to record in NSGroupState. +func (s *DefaultServer) projectUnhealthy(p *nsGroupProj, servers []netip.AddrPort, immediate bool, now time.Time, delay time.Duration) bool { + streakStart := p.unhealthySince.IsZero() + if streakStart { + p.unhealthySince = now + } + reason := unhealthyEmitReason(immediate, p.everHealthy, now.Sub(p.unhealthySince), delay) + switch { + case reason != "" && !p.warningActive: + log.Debugf("DNS health: group [%s] unreachable, emitting event (reason=%s)", joinAddrPorts(servers), reason) + s.statusRecorder.PublishEvent( + proto.SystemEvent_WARNING, + proto.SystemEvent_DNS, + "Nameserver group unreachable", + "Unable to reach one or more DNS servers. This might affect your ability to connect to some services.", + map[string]string{"upstreams": joinAddrPorts(servers)}, + ) + p.warningActive = true + case streakStart && reason == "": + // One line per streak, not per tick. + log.Debugf("DNS health: group [%s] unreachable but holding warning for up to %v (overlay-routed, no connected peer)", joinAddrPorts(servers), delay) + } + return false +} + +// warningDelayBaseFromEnv returns the base grace window, honoring +// envWarningDelay when it holds a valid positive Go duration. Invalid or +// non-positive values fall back to defaultWarningDelayBase. +func warningDelayBaseFromEnv() time.Duration { + val := os.Getenv(envWarningDelay) + if val == "" { + return defaultWarningDelayBase + } + d, err := time.ParseDuration(val) + if err != nil { + log.Warnf("invalid %s value %q, using default %v: %v", envWarningDelay, val, defaultWarningDelayBase, err) + return defaultWarningDelayBase + } + if d <= 0 { + log.Warnf("%s must be positive, got %v, using default %v", envWarningDelay, d, defaultWarningDelayBase) + return defaultWarningDelayBase + } + return d +} + +// warningDelay returns the grace window for the given selected-route +// count. Scales gently: +1s per 100 routes, capped by +// warningDelayBonusCap. Parallel handshakes mean handshake time grows +// much slower than route count, so linear scaling would overcorrect. +// +// TODO: revisit the scaling curve with real-world data — the current +// values are a reasonable starting point, not a measured fit. +func (s *DefaultServer) warningDelay(routeCount int) time.Duration { + bonus := time.Duration(routeCount/100) * time.Second + if bonus > warningDelayBonusCap { + bonus = warningDelayBonusCap + } + return s.warningDelayBase + bonus +} + +// groupHasImmediateUpstream reports whether the group has at least one +// upstream in a classification that bypasses the grace window: public +// (outside the overlay range and not routed), or overlay/routed with a +// Connected peer. +// +// TODO(ipv6): include the v6 overlay prefix once it's plumbed in. +func (s *DefaultServer) groupHasImmediateUpstream(servers []netip.AddrPort, snap nsHealthSnapshot) bool { + var overlayV4 netip.Prefix + if s.wgInterface != nil { + overlayV4 = s.wgInterface.Address().Network + } + for _, srv := range servers { + addr := srv.Addr().Unmap() + overlay := overlayV4.IsValid() && overlayV4.Contains(addr) + selMatched, selDynamic := haMapContains(snap.selected, addr) + // Treat an unknown (dynamic selected route) as possibly routed: + // the upstream might reach through a dynamic route whose Network + // hasn't resolved yet, and classifying as public would bypass + // the startup grace window. + routed := selMatched || selDynamic + if !overlay && !routed { + return true + } + if actMatched, _ := haMapContains(snap.active, addr); actMatched { + return true + } + } + return false +} + +// collectUpstreamHealth merges health snapshots across handlers, keeping +// the most recent success and failure per upstream when an address appears +// in more than one handler. +func (s *DefaultServer) collectUpstreamHealth() map[netip.AddrPort]UpstreamHealth { + merged := make(map[netip.AddrPort]UpstreamHealth) + for _, entry := range s.dnsMuxHandlers { + reporter, ok := entry.handler.(upstreamHealthReporter) + if !ok { + continue + } + for addr, h := range reporter.UpstreamHealth() { + existing, have := merged[addr] + if !have { + merged[addr] = h + continue + } + if h.LastOk.After(existing.LastOk) { + existing.LastOk = h.LastOk + } + if h.LastFail.After(existing.LastFail) { + existing.LastFail = h.LastFail + existing.LastErr = h.LastErr + } + merged[addr] = existing + } + } + return merged +} + +func (s *DefaultServer) startHealthRefresher() { + s.shutdownWg.Add(1) + go func() { + defer s.shutdownWg.Done() + ticker := time.NewTicker(nsGroupHealthRefreshInterval) + defer ticker.Stop() + for { + select { + case <-s.ctx.Done(): + return + case <-ticker.C: + case <-s.healthRefresh: + } + s.refreshHealth() + } + }() +} + +// evaluateNSGroupHealth decides a group's verdict from query records +// alone. Per upstream, the most-recent-in-lookback observation wins. +// Group is Healthy if any upstream is fresh-working, Unhealthy if any +// is fresh-broken with no fresh-working sibling, Undecided otherwise. +func evaluateNSGroupHealth(merged map[netip.AddrPort]UpstreamHealth, servers []netip.AddrPort, now time.Time) (nsGroupVerdict, error) { + anyWorking := false + anyBroken := false + var mostRecentFail time.Time + var mostRecentErr string + + for _, srv := range servers { + h, ok := merged[srv] + if !ok { + continue + } + switch classifyUpstreamHealth(h, now) { + case upstreamFresh: + anyWorking = true + case upstreamBroken: + anyBroken = true + if h.LastFail.After(mostRecentFail) { + mostRecentFail = h.LastFail + mostRecentErr = h.LastErr + } + } + } + + if anyWorking { + return nsVerdictHealthy, nil + } + if anyBroken { + if mostRecentErr == "" { + return nsVerdictUnhealthy, nil + } + return nsVerdictUnhealthy, errors.New(mostRecentErr) + } + return nsVerdictUndecided, nil +} + +// upstreamClassification is the per-upstream verdict within healthLookback. +type upstreamClassification int + +const ( + upstreamStale upstreamClassification = iota + upstreamFresh + upstreamBroken +) + +// classifyUpstreamHealth compares the last ok and last fail timestamps +// against healthLookback and returns which one (if any) counts. Fresh +// wins when both are in-window and ok is newer; broken otherwise. +func classifyUpstreamHealth(h UpstreamHealth, now time.Time) upstreamClassification { + okRecent := !h.LastOk.IsZero() && now.Sub(h.LastOk) <= healthLookback + failRecent := !h.LastFail.IsZero() && now.Sub(h.LastFail) <= healthLookback + switch { + case okRecent && failRecent: + if h.LastOk.After(h.LastFail) { + return upstreamFresh + } + return upstreamBroken + case okRecent: + return upstreamFresh + case failRecent: + return upstreamBroken + } + return upstreamStale +} + +func joinAddrPorts(servers []netip.AddrPort) string { + parts := make([]string, 0, len(servers)) + for _, s := range servers { + parts = append(parts, s.String()) + } + return strings.Join(parts, ", ") +} + +// generateGroupKey returns a stable identity for an NS group so health +// state (everHealthy / warningActive) survives reorderings in the +// configured nameserver or domain lists. +func generateGroupKey(nsGroup *nbdns.NameServerGroup) nsGroupID { + servers := make([]string, 0, len(nsGroup.NameServers)) for _, ns := range nsGroup.NameServers { servers = append(servers, ns.AddrPort().String()) } - return fmt.Sprintf("%v_%v", servers, nsGroup.Domains) + slices.Sort(servers) + domains := slices.Clone(nsGroup.Domains) + slices.Sort(domains) + return nsGroupID(fmt.Sprintf("%v_%v", servers, domains)) } // groupNSGroupsByDomain groups nameserver groups by their match domains @@ -1161,6 +1415,21 @@ func toZone(d domain.Domain) domain.Domain { ) } +// unhealthyEmitReason returns the tag of the rule that fires the +// warning now, or "" if the group is still inside its grace window. +func unhealthyEmitReason(immediate, everHealthy bool, elapsed, delay time.Duration) string { + switch { + case immediate: + return "immediate" + case everHealthy: + return "ever-healthy" + case elapsed >= delay: + return "grace-elapsed" + default: + return "" + } +} + // PopulateManagementDomain populates the DNS cache with management domain func (s *DefaultServer) PopulateManagementDomain(mgmtURL *url.URL) error { if s.mgmtCacheResolver != nil { @@ -1168,3 +1437,25 @@ func (s *DefaultServer) PopulateManagementDomain(mgmtURL *url.URL) error { } return nil } + +// localPeerConnectivity adapts *peer.Status to local.PeerConnectivity so +// the local resolver can ask "is this IP a known peer and is it +// connected?" without taking on the peer package as a dependency. +// A nil status recorder always reports known=false so the resolver +// short-circuits to the legacy "return everything" path. +type localPeerConnectivity struct { + status *peer.Status +} + +// IsConnectedByIP looks the IP up in the peerstore and surfaces both +// the known and connected bits. Used by Resolver.filterDisconnectedPeerAnswers. +func (l localPeerConnectivity) IsConnectedByIP(ip netip.Addr) (known, connected bool) { + if l.status == nil { + return false, false + } + state, ok := l.status.PeerStateByIP(ip.String()) + if !ok { + return false, false + } + return true, state.ConnStatus == peer.StatusConnected +} diff --git a/client/internal/dns/server_android.go b/client/internal/dns/server_android.go index 7ca12d69d..b2cb26f65 100644 --- a/client/internal/dns/server_android.go +++ b/client/internal/dns/server_android.go @@ -1,5 +1,5 @@ package dns func (s *DefaultServer) initialize() (manager hostManager, err error) { - return newHostManager() + return newHostManager(s.hostsDNSHolder) } diff --git a/client/internal/dns/server_privileged_test.go b/client/internal/dns/server_privileged_test.go new file mode 100644 index 000000000..a17044cf5 --- /dev/null +++ b/client/internal/dns/server_privileged_test.go @@ -0,0 +1,485 @@ +//go:build privileged + +package dns + +import ( + "context" + "fmt" + "net/netip" + "os" + "testing" + + "go.uber.org/mock/gomock" + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + "github.com/netbirdio/netbird/client/iface" + pfmock "github.com/netbirdio/netbird/client/iface/mocks" + "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/internal/dns/local" + "github.com/netbirdio/netbird/client/internal/dns/test" + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/stdnet" + nbdns "github.com/netbirdio/netbird/dns" +) + +func TestUpdateDNSServer(t *testing.T) { + + nameServers := []nbdns.NameServer{ + { + IP: netip.MustParseAddr("8.8.8.8"), + NSType: nbdns.UDPNameServerType, + Port: 53, + }, + { + IP: netip.MustParseAddr("8.8.4.4"), + NSType: nbdns.UDPNameServerType, + Port: 53, + }, + } + + testCases := []struct { + name string + initUpstreamMap []handlerWrapper + initLocalZones []nbdns.CustomZone + initSerial uint64 + inputSerial uint64 + inputUpdate nbdns.Config + shouldFail bool + expectedUpstreamMap []handlerWrapper + expectedLocalQs []dns.Question + }{ + { + name: "Initial Config Should Succeed", + initUpstreamMap: nil, + initSerial: 0, + inputSerial: 1, + inputUpdate: nbdns.Config{ + ServiceEnable: true, + CustomZones: []nbdns.CustomZone{ + { + Domain: "netbird.cloud", + Records: zoneRecords, + }, + }, + NameServerGroups: []*nbdns.NameServerGroup{ + { + Domains: []string{"netbird.io"}, + NameServers: nameServers, + }, + { + NameServers: nameServers, + Primary: true, + }, + }, + }, + expectedUpstreamMap: []handlerWrapper{ + { + domain: "netbird.io", + priority: PriorityUpstream, + }, + { + domain: "netbird.cloud", + priority: PriorityLocal, + }, + { + domain: nbdns.RootZone, + priority: PriorityDefault, + }, + }, + expectedLocalQs: []dns.Question{{Name: "peera.netbird.cloud.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + }, + { + name: "New Config Should Succeed", + initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}}, + initUpstreamMap: []handlerWrapper{ + { + domain: "netbird.cloud", + handler: &mockHandler{}, + priority: PriorityUpstream, + }, + }, + initSerial: 0, + inputSerial: 1, + inputUpdate: nbdns.Config{ + ServiceEnable: true, + CustomZones: []nbdns.CustomZone{ + { + Domain: "netbird.cloud", + Records: zoneRecords, + }, + }, + NameServerGroups: []*nbdns.NameServerGroup{ + { + Domains: []string{"netbird.io"}, + NameServers: nameServers, + }, + }, + }, + expectedUpstreamMap: []handlerWrapper{ + { + domain: "netbird.io", + priority: PriorityUpstream, + }, + { + domain: "netbird.cloud", + priority: PriorityLocal, + }, + }, + expectedLocalQs: []dns.Question{{Name: zoneRecords[0].Name, Qtype: 1, Qclass: 1}}, + }, + { + name: "Smaller Config Serial Should Be Skipped", + initLocalZones: []nbdns.CustomZone{}, + initUpstreamMap: nil, + initSerial: 2, + inputSerial: 1, + shouldFail: true, + }, + { + name: "Empty NS Group Domain Or Not Primary Element Should Fail", + initLocalZones: []nbdns.CustomZone{}, + initUpstreamMap: nil, + initSerial: 0, + inputSerial: 1, + inputUpdate: nbdns.Config{ + ServiceEnable: true, + CustomZones: []nbdns.CustomZone{ + { + Domain: "netbird.cloud", + Records: zoneRecords, + }, + }, + NameServerGroups: []*nbdns.NameServerGroup{ + { + NameServers: nameServers, + }, + }, + }, + shouldFail: true, + }, + { + name: "Invalid NS Group Nameservers list Should Fail", + initLocalZones: []nbdns.CustomZone{}, + initUpstreamMap: nil, + initSerial: 0, + inputSerial: 1, + inputUpdate: nbdns.Config{ + ServiceEnable: true, + CustomZones: []nbdns.CustomZone{ + { + Domain: "netbird.cloud", + Records: zoneRecords, + }, + }, + NameServerGroups: []*nbdns.NameServerGroup{ + { + NameServers: nameServers, + }, + }, + }, + shouldFail: true, + }, + { + name: "Invalid Custom Zone Records list Should Skip", + initLocalZones: []nbdns.CustomZone{}, + initUpstreamMap: nil, + initSerial: 0, + inputSerial: 1, + inputUpdate: nbdns.Config{ + ServiceEnable: true, + CustomZones: []nbdns.CustomZone{ + { + Domain: "netbird.cloud", + }, + }, + NameServerGroups: []*nbdns.NameServerGroup{ + { + NameServers: nameServers, + Primary: true, + }, + }, + }, + expectedUpstreamMap: []handlerWrapper{{ + domain: ".", + priority: PriorityDefault, + }}, + }, + { + name: "Empty Config Should Succeed and Clean Maps", + initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}}, + initUpstreamMap: []handlerWrapper{ + { + domain: zoneRecords[0].Name, + handler: &mockHandler{}, + priority: PriorityUpstream, + }, + }, + initSerial: 0, + inputSerial: 1, + inputUpdate: nbdns.Config{ServiceEnable: true}, + expectedUpstreamMap: nil, + expectedLocalQs: []dns.Question{}, + }, + { + name: "Disabled Service Should clean map", + initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}}, + initUpstreamMap: []handlerWrapper{ + { + domain: zoneRecords[0].Name, + handler: &mockHandler{}, + priority: PriorityUpstream, + }, + }, + initSerial: 0, + inputSerial: 1, + inputUpdate: nbdns.Config{ServiceEnable: false}, + expectedUpstreamMap: nil, + expectedLocalQs: []dns.Question{}, + }, + } + + for n, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + privKey, _ := wgtypes.GenerateKey() + newNet, err := stdnet.NewNet(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + + opts := iface.WGIFaceOpts{ + IFaceName: fmt.Sprintf("utun230%d", n), + Address: wgaddr.MustParseWGAddress(fmt.Sprintf("100.66.100.%d/32", n+1)), + WGPort: 33100, + WGPrivKey: privKey.String(), + MTU: iface.DefaultMTU, + TransportNet: newNet, + } + + wgIface, err := iface.NewWGIFace(opts) + if err != nil { + t.Fatal(err) + } + err = wgIface.Create() + if err != nil { + t.Fatal(err) + } + defer func() { + err = wgIface.Close() + if err != nil { + t.Log(err) + } + }() + dnsServer, err := NewDefaultServer(context.Background(), DefaultServerConfig{ + WgInterface: wgIface, + CustomAddress: "", + StatusRecorder: peer.NewRecorder("mgm"), + StateManager: nil, + DisableSys: false, + }) + if err != nil { + t.Fatal(err) + } + err = dnsServer.Initialize() + if err != nil { + t.Fatal(err) + } + defer func() { + err = dnsServer.hostManager.restoreHostDNS() + if err != nil { + t.Log(err) + } + }() + + dnsServer.dnsMuxHandlers = testCase.initUpstreamMap + dnsServer.localResolver.Update(testCase.initLocalZones) + dnsServer.updateSerial = testCase.initSerial + + err = dnsServer.UpdateDNSServer(testCase.inputSerial, testCase.inputUpdate) + if err != nil { + if testCase.shouldFail { + return + } + t.Fatalf("update dns server should not fail, got error: %v", err) + } + + if len(dnsServer.dnsMuxHandlers) != len(testCase.expectedUpstreamMap) { + t.Fatalf("update upstream failed, map size is different than expected, want %d, got %d", len(testCase.expectedUpstreamMap), len(dnsServer.dnsMuxHandlers)) + } + + for _, expected := range testCase.expectedUpstreamMap { + found := false + for _, got := range dnsServer.dnsMuxHandlers { + if got.domain == expected.domain && got.priority == expected.priority { + found = true + break + } + } + if !found { + t.Fatalf("update upstream failed, handler for domain=%s priority=%d not found in dnsMuxHandlers: %#v", expected.domain, expected.priority, dnsServer.dnsMuxHandlers) + } + } + + var responseMSG *dns.Msg + responseWriter := &test.MockResponseWriter{ + WriteMsgFunc: func(m *dns.Msg) error { + responseMSG = m + return nil + }, + } + for _, q := range testCase.expectedLocalQs { + dnsServer.localResolver.ServeDNS(responseWriter, &dns.Msg{ + Question: []dns.Question{q}, + }) + } + + if len(testCase.expectedLocalQs) > 0 { + assert.NotNil(t, responseMSG, "response message should not be nil") + assert.Equal(t, dns.RcodeSuccess, responseMSG.Rcode, "response code should be success") + assert.NotEmpty(t, responseMSG.Answer, "response message should have answers") + } + }) + } +} + +func TestDNSFakeResolverHandleUpdates(t *testing.T) { + ov := os.Getenv("NB_WG_KERNEL_DISABLED") + defer t.Setenv("NB_WG_KERNEL_DISABLED", ov) + + t.Setenv("NB_WG_KERNEL_DISABLED", "true") + newNet, err := stdnet.NewNet(context.Background(), []string{"utun2301"}) + if err != nil { + t.Errorf("create stdnet: %v", err) + return + } + + privKey, _ := wgtypes.GeneratePrivateKey() + opts := iface.WGIFaceOpts{ + IFaceName: "utun2301", + Address: wgaddr.MustParseWGAddress("100.66.100.1/32"), + WGPort: 33100, + WGPrivKey: privKey.String(), + MTU: iface.DefaultMTU, + TransportNet: newNet, + } + wgIface, err := iface.NewWGIFace(opts) + if err != nil { + t.Errorf("build interface wireguard: %v", err) + return + } + + err = wgIface.Create() + if err != nil { + t.Errorf("create and init wireguard interface: %v", err) + return + } + defer func() { + if err = wgIface.Close(); err != nil { + t.Logf("close wireguard interface: %v", err) + } + }() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + packetfilter := pfmock.NewMockPacketFilter(ctrl) + packetfilter.EXPECT().FilterOutbound(gomock.Any(), gomock.Any()).AnyTimes() + packetfilter.EXPECT().SetUDPPacketHook(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + packetfilter.EXPECT().SetTCPPacketHook(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + + if err := wgIface.SetFilter(packetfilter); err != nil { + t.Errorf("set packet filter: %v", err) + return + } + + dnsServer, err := NewDefaultServer(context.Background(), DefaultServerConfig{ + WgInterface: wgIface, + CustomAddress: "", + StatusRecorder: peer.NewRecorder("mgm"), + StateManager: nil, + DisableSys: false, + }) + if err != nil { + t.Errorf("create DNS server: %v", err) + return + } + + err = dnsServer.Initialize() + if err != nil { + t.Errorf("run DNS server: %v", err) + return + } + defer func() { + if err = dnsServer.hostManager.restoreHostDNS(); err != nil { + t.Logf("restore DNS settings on the host: %v", err) + return + } + }() + + dnsServer.dnsMuxHandlers = []handlerWrapper{ + { + domain: zoneRecords[0].Name, + handler: &local.Resolver{}, + priority: PriorityUpstream, + }, + } + dnsServer.localResolver.Update([]nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}}) + dnsServer.updateSerial = 0 + + nameServers := []nbdns.NameServer{ + { + IP: netip.MustParseAddr("8.8.8.8"), + NSType: nbdns.UDPNameServerType, + Port: 53, + }, + { + IP: netip.MustParseAddr("8.8.4.4"), + NSType: nbdns.UDPNameServerType, + Port: 53, + }, + } + + update := nbdns.Config{ + ServiceEnable: true, + CustomZones: []nbdns.CustomZone{ + { + Domain: "netbird.cloud", + Records: zoneRecords, + }, + }, + NameServerGroups: []*nbdns.NameServerGroup{ + { + Domains: []string{"netbird.io"}, + NameServers: nameServers, + }, + { + NameServers: nameServers, + Primary: true, + }, + }, + } + + // Start the server with regular configuration + if err := dnsServer.UpdateDNSServer(1, update); err != nil { + t.Fatalf("update dns server should not fail, got error: %v", err) + return + } + + update2 := update + update2.ServiceEnable = false + // Disable the server, stop the listener + if err := dnsServer.UpdateDNSServer(2, update2); err != nil { + t.Fatalf("update dns server should not fail, got error: %v", err) + return + } + + update3 := update2 + update3.NameServerGroups = update3.NameServerGroups[:1] + // But service still get updates and we checking that we handle + // internal state in the right way + if err := dnsServer.UpdateDNSServer(3, update3); err != nil { + t.Fatalf("update dns server should not fail, got error: %v", err) + return + } +} diff --git a/client/internal/dns/server_test.go b/client/internal/dns/server_test.go index f77f6e898..0144a4a8b 100644 --- a/client/internal/dns/server_test.go +++ b/client/internal/dns/server_test.go @@ -6,15 +6,15 @@ import ( "net" "net/netip" "os" - "strings" + "runtime" "testing" "time" - "github.com/golang/mock/gomock" "github.com/miekg/dns" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" "golang.zx2c4.com/wireguard/tun/netstack" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" @@ -22,7 +22,6 @@ import ( "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/iface/configurer" "github.com/netbirdio/netbird/client/iface/device" - pfmock "github.com/netbirdio/netbird/client/iface/mocks" "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/internal/dns/local" "github.com/netbirdio/netbird/client/internal/dns/test" @@ -31,8 +30,10 @@ import ( "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/statemanager" "github.com/netbirdio/netbird/client/internal/stdnet" + "github.com/netbirdio/netbird/client/proto" nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/formatter" + "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" ) @@ -101,480 +102,6 @@ func init() { formatter.SetTextFormatter(log.StandardLogger()) } -func generateDummyHandler(domain string, servers []nbdns.NameServer) *upstreamResolverBase { - var srvs []netip.AddrPort - for _, srv := range servers { - srvs = append(srvs, srv.AddrPort()) - } - return &upstreamResolverBase{ - domain: domain, - upstreamServers: srvs, - cancel: func() {}, - } -} - -func TestUpdateDNSServer(t *testing.T) { - - nameServers := []nbdns.NameServer{ - { - IP: netip.MustParseAddr("8.8.8.8"), - NSType: nbdns.UDPNameServerType, - Port: 53, - }, - { - IP: netip.MustParseAddr("8.8.4.4"), - NSType: nbdns.UDPNameServerType, - Port: 53, - }, - } - - dummyHandler := local.NewResolver() - - testCases := []struct { - name string - initUpstreamMap registeredHandlerMap - initLocalZones []nbdns.CustomZone - initSerial uint64 - inputSerial uint64 - inputUpdate nbdns.Config - shouldFail bool - expectedUpstreamMap registeredHandlerMap - expectedLocalQs []dns.Question - }{ - { - name: "Initial Config Should Succeed", - initUpstreamMap: make(registeredHandlerMap), - initSerial: 0, - inputSerial: 1, - inputUpdate: nbdns.Config{ - ServiceEnable: true, - CustomZones: []nbdns.CustomZone{ - { - Domain: "netbird.cloud", - Records: zoneRecords, - }, - }, - NameServerGroups: []*nbdns.NameServerGroup{ - { - Domains: []string{"netbird.io"}, - NameServers: nameServers, - }, - { - NameServers: nameServers, - Primary: true, - }, - }, - }, - expectedUpstreamMap: registeredHandlerMap{ - generateDummyHandler("netbird.io", nameServers).ID(): handlerWrapper{ - domain: "netbird.io", - handler: dummyHandler, - priority: PriorityUpstream, - }, - dummyHandler.ID(): handlerWrapper{ - domain: "netbird.cloud", - handler: dummyHandler, - priority: PriorityLocal, - }, - generateDummyHandler(".", nameServers).ID(): handlerWrapper{ - domain: nbdns.RootZone, - handler: dummyHandler, - priority: PriorityDefault, - }, - }, - expectedLocalQs: []dns.Question{{Name: "peera.netbird.cloud.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, - }, - { - name: "New Config Should Succeed", - initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}}, - initUpstreamMap: registeredHandlerMap{ - generateDummyHandler(zoneRecords[0].Name, nameServers).ID(): handlerWrapper{ - domain: "netbird.cloud", - handler: dummyHandler, - priority: PriorityUpstream, - }, - }, - initSerial: 0, - inputSerial: 1, - inputUpdate: nbdns.Config{ - ServiceEnable: true, - CustomZones: []nbdns.CustomZone{ - { - Domain: "netbird.cloud", - Records: zoneRecords, - }, - }, - NameServerGroups: []*nbdns.NameServerGroup{ - { - Domains: []string{"netbird.io"}, - NameServers: nameServers, - }, - }, - }, - expectedUpstreamMap: registeredHandlerMap{ - generateDummyHandler("netbird.io", nameServers).ID(): handlerWrapper{ - domain: "netbird.io", - handler: dummyHandler, - priority: PriorityUpstream, - }, - "local-resolver": handlerWrapper{ - domain: "netbird.cloud", - handler: dummyHandler, - priority: PriorityLocal, - }, - }, - expectedLocalQs: []dns.Question{{Name: zoneRecords[0].Name, Qtype: 1, Qclass: 1}}, - }, - { - name: "Smaller Config Serial Should Be Skipped", - initLocalZones: []nbdns.CustomZone{}, - initUpstreamMap: make(registeredHandlerMap), - initSerial: 2, - inputSerial: 1, - shouldFail: true, - }, - { - name: "Empty NS Group Domain Or Not Primary Element Should Fail", - initLocalZones: []nbdns.CustomZone{}, - initUpstreamMap: make(registeredHandlerMap), - initSerial: 0, - inputSerial: 1, - inputUpdate: nbdns.Config{ - ServiceEnable: true, - CustomZones: []nbdns.CustomZone{ - { - Domain: "netbird.cloud", - Records: zoneRecords, - }, - }, - NameServerGroups: []*nbdns.NameServerGroup{ - { - NameServers: nameServers, - }, - }, - }, - shouldFail: true, - }, - { - name: "Invalid NS Group Nameservers list Should Fail", - initLocalZones: []nbdns.CustomZone{}, - initUpstreamMap: make(registeredHandlerMap), - initSerial: 0, - inputSerial: 1, - inputUpdate: nbdns.Config{ - ServiceEnable: true, - CustomZones: []nbdns.CustomZone{ - { - Domain: "netbird.cloud", - Records: zoneRecords, - }, - }, - NameServerGroups: []*nbdns.NameServerGroup{ - { - NameServers: nameServers, - }, - }, - }, - shouldFail: true, - }, - { - name: "Invalid Custom Zone Records list Should Skip", - initLocalZones: []nbdns.CustomZone{}, - initUpstreamMap: make(registeredHandlerMap), - initSerial: 0, - inputSerial: 1, - inputUpdate: nbdns.Config{ - ServiceEnable: true, - CustomZones: []nbdns.CustomZone{ - { - Domain: "netbird.cloud", - }, - }, - NameServerGroups: []*nbdns.NameServerGroup{ - { - NameServers: nameServers, - Primary: true, - }, - }, - }, - expectedUpstreamMap: registeredHandlerMap{generateDummyHandler(".", nameServers).ID(): handlerWrapper{ - domain: ".", - handler: dummyHandler, - priority: PriorityDefault, - }}, - }, - { - name: "Empty Config Should Succeed and Clean Maps", - initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}}, - initUpstreamMap: registeredHandlerMap{ - generateDummyHandler(zoneRecords[0].Name, nameServers).ID(): handlerWrapper{ - domain: zoneRecords[0].Name, - handler: dummyHandler, - priority: PriorityUpstream, - }, - }, - initSerial: 0, - inputSerial: 1, - inputUpdate: nbdns.Config{ServiceEnable: true}, - expectedUpstreamMap: make(registeredHandlerMap), - expectedLocalQs: []dns.Question{}, - }, - { - name: "Disabled Service Should clean map", - initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}}, - initUpstreamMap: registeredHandlerMap{ - generateDummyHandler(zoneRecords[0].Name, nameServers).ID(): handlerWrapper{ - domain: zoneRecords[0].Name, - handler: dummyHandler, - priority: PriorityUpstream, - }, - }, - initSerial: 0, - inputSerial: 1, - inputUpdate: nbdns.Config{ServiceEnable: false}, - expectedUpstreamMap: make(registeredHandlerMap), - expectedLocalQs: []dns.Question{}, - }, - } - - for n, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - privKey, _ := wgtypes.GenerateKey() - newNet, err := stdnet.NewNet(context.Background(), nil) - if err != nil { - t.Fatal(err) - } - - opts := iface.WGIFaceOpts{ - IFaceName: fmt.Sprintf("utun230%d", n), - Address: fmt.Sprintf("100.66.100.%d/32", n+1), - WGPort: 33100, - WGPrivKey: privKey.String(), - MTU: iface.DefaultMTU, - TransportNet: newNet, - } - - wgIface, err := iface.NewWGIFace(opts) - if err != nil { - t.Fatal(err) - } - err = wgIface.Create() - if err != nil { - t.Fatal(err) - } - defer func() { - err = wgIface.Close() - if err != nil { - t.Log(err) - } - }() - dnsServer, err := NewDefaultServer(context.Background(), DefaultServerConfig{ - WgInterface: wgIface, - CustomAddress: "", - StatusRecorder: peer.NewRecorder("mgm"), - StateManager: nil, - DisableSys: false, - }) - if err != nil { - t.Fatal(err) - } - err = dnsServer.Initialize() - if err != nil { - t.Fatal(err) - } - defer func() { - err = dnsServer.hostManager.restoreHostDNS() - if err != nil { - t.Log(err) - } - }() - - dnsServer.dnsMuxMap = testCase.initUpstreamMap - dnsServer.localResolver.Update(testCase.initLocalZones) - dnsServer.updateSerial = testCase.initSerial - - err = dnsServer.UpdateDNSServer(testCase.inputSerial, testCase.inputUpdate) - if err != nil { - if testCase.shouldFail { - return - } - t.Fatalf("update dns server should not fail, got error: %v", err) - } - - if len(dnsServer.dnsMuxMap) != len(testCase.expectedUpstreamMap) { - t.Fatalf("update upstream failed, map size is different than expected, want %d, got %d", len(testCase.expectedUpstreamMap), len(dnsServer.dnsMuxMap)) - } - - for key := range testCase.expectedUpstreamMap { - _, found := dnsServer.dnsMuxMap[key] - if !found { - t.Fatalf("update upstream failed, key %s was not found in the dnsMuxMap: %#v", key, dnsServer.dnsMuxMap) - } - } - - var responseMSG *dns.Msg - responseWriter := &test.MockResponseWriter{ - WriteMsgFunc: func(m *dns.Msg) error { - responseMSG = m - return nil - }, - } - for _, q := range testCase.expectedLocalQs { - dnsServer.localResolver.ServeDNS(responseWriter, &dns.Msg{ - Question: []dns.Question{q}, - }) - } - - if len(testCase.expectedLocalQs) > 0 { - assert.NotNil(t, responseMSG, "response message should not be nil") - assert.Equal(t, dns.RcodeSuccess, responseMSG.Rcode, "response code should be success") - assert.NotEmpty(t, responseMSG.Answer, "response message should have answers") - } - }) - } -} - -func TestDNSFakeResolverHandleUpdates(t *testing.T) { - ov := os.Getenv("NB_WG_KERNEL_DISABLED") - defer t.Setenv("NB_WG_KERNEL_DISABLED", ov) - - t.Setenv("NB_WG_KERNEL_DISABLED", "true") - newNet, err := stdnet.NewNet(context.Background(), []string{"utun2301"}) - if err != nil { - t.Errorf("create stdnet: %v", err) - return - } - - privKey, _ := wgtypes.GeneratePrivateKey() - opts := iface.WGIFaceOpts{ - IFaceName: "utun2301", - Address: "100.66.100.1/32", - WGPort: 33100, - WGPrivKey: privKey.String(), - MTU: iface.DefaultMTU, - TransportNet: newNet, - } - wgIface, err := iface.NewWGIFace(opts) - if err != nil { - t.Errorf("build interface wireguard: %v", err) - return - } - - err = wgIface.Create() - if err != nil { - t.Errorf("create and init wireguard interface: %v", err) - return - } - defer func() { - if err = wgIface.Close(); err != nil { - t.Logf("close wireguard interface: %v", err) - } - }() - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - packetfilter := pfmock.NewMockPacketFilter(ctrl) - packetfilter.EXPECT().FilterOutbound(gomock.Any(), gomock.Any()).AnyTimes() - packetfilter.EXPECT().SetUDPPacketHook(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() - packetfilter.EXPECT().SetTCPPacketHook(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() - - if err := wgIface.SetFilter(packetfilter); err != nil { - t.Errorf("set packet filter: %v", err) - return - } - - dnsServer, err := NewDefaultServer(context.Background(), DefaultServerConfig{ - WgInterface: wgIface, - CustomAddress: "", - StatusRecorder: peer.NewRecorder("mgm"), - StateManager: nil, - DisableSys: false, - }) - if err != nil { - t.Errorf("create DNS server: %v", err) - return - } - - err = dnsServer.Initialize() - if err != nil { - t.Errorf("run DNS server: %v", err) - return - } - defer func() { - if err = dnsServer.hostManager.restoreHostDNS(); err != nil { - t.Logf("restore DNS settings on the host: %v", err) - return - } - }() - - dnsServer.dnsMuxMap = registeredHandlerMap{ - "id1": handlerWrapper{ - domain: zoneRecords[0].Name, - handler: &local.Resolver{}, - priority: PriorityUpstream, - }, - } - dnsServer.localResolver.Update([]nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}}) - dnsServer.updateSerial = 0 - - nameServers := []nbdns.NameServer{ - { - IP: netip.MustParseAddr("8.8.8.8"), - NSType: nbdns.UDPNameServerType, - Port: 53, - }, - { - IP: netip.MustParseAddr("8.8.4.4"), - NSType: nbdns.UDPNameServerType, - Port: 53, - }, - } - - update := nbdns.Config{ - ServiceEnable: true, - CustomZones: []nbdns.CustomZone{ - { - Domain: "netbird.cloud", - Records: zoneRecords, - }, - }, - NameServerGroups: []*nbdns.NameServerGroup{ - { - Domains: []string{"netbird.io"}, - NameServers: nameServers, - }, - { - NameServers: nameServers, - Primary: true, - }, - }, - } - - // Start the server with regular configuration - if err := dnsServer.UpdateDNSServer(1, update); err != nil { - t.Fatalf("update dns server should not fail, got error: %v", err) - return - } - - update2 := update - update2.ServiceEnable = false - // Disable the server, stop the listener - if err := dnsServer.UpdateDNSServer(2, update2); err != nil { - t.Fatalf("update dns server should not fail, got error: %v", err) - return - } - - update3 := update2 - update3.NameServerGroups = update3.NameServerGroups[:1] - // But service still get updates and we checking that we handle - // internal state in the right way - if err := dnsServer.UpdateDNSServer(3, update3); err != nil { - t.Fatalf("update dns server should not fail, got error: %v", err) - return - } -} - func TestDNSServerStartStop(t *testing.T) { testCases := []struct { name string @@ -653,74 +180,8 @@ func TestDNSServerStartStop(t *testing.T) { } } -func TestDNSServerUpstreamDeactivateCallback(t *testing.T) { - hostManager := &mockHostConfigurator{} - server := DefaultServer{ - ctx: context.Background(), - service: NewServiceViaMemory(&mocWGIface{}), - localResolver: local.NewResolver(), - handlerChain: NewHandlerChain(), - hostManager: hostManager, - currentConfig: HostDNSConfig{ - Domains: []DomainConfig{ - {false, "domain0", false}, - {false, "domain1", false}, - {false, "domain2", false}, - }, - }, - statusRecorder: peer.NewRecorder("mgm"), - } - - var domainsUpdate string - hostManager.applyDNSConfigFunc = func(config HostDNSConfig, statemanager *statemanager.Manager) error { - domains := []string{} - for _, item := range config.Domains { - if item.Disabled { - continue - } - domains = append(domains, item.Domain) - } - domainsUpdate = strings.Join(domains, ",") - return nil - } - - deactivate, reactivate := server.upstreamCallbacks(&nbdns.NameServerGroup{ - Domains: []string{"domain1"}, - NameServers: []nbdns.NameServer{ - {IP: netip.MustParseAddr("8.8.0.0"), NSType: nbdns.UDPNameServerType, Port: 53}, - }, - }, nil, 0) - - deactivate(nil) - expected := "domain0,domain2" - domains := []string{} - for _, item := range server.currentConfig.Domains { - if item.Disabled { - continue - } - domains = append(domains, item.Domain) - } - got := strings.Join(domains, ",") - if expected != got { - t.Errorf("expected domains list: %q, got %q", expected, got) - } - - reactivate() - expected = "domain0,domain1,domain2" - domains = []string{} - for _, item := range server.currentConfig.Domains { - if item.Disabled { - continue - } - domains = append(domains, item.Domain) - } - got = strings.Join(domains, ",") - if expected != got { - t.Errorf("expected domains list: %q, got %q", expected, domainsUpdate) - } -} - func TestDNSPermanent_updateHostDNS_emptyUpstream(t *testing.T) { + skipUnlessAndroid(t) wgIFace, err := createWgInterfaceWithBind(t) if err != nil { t.Fatal("failed to initialize wg interface") @@ -748,6 +209,7 @@ func TestDNSPermanent_updateHostDNS_emptyUpstream(t *testing.T) { } func TestDNSPermanent_updateUpstream(t *testing.T) { + skipUnlessAndroid(t) wgIFace, err := createWgInterfaceWithBind(t) if err != nil { t.Fatal("failed to initialize wg interface") @@ -841,6 +303,7 @@ func TestDNSPermanent_updateUpstream(t *testing.T) { } func TestDNSPermanent_matchOnly(t *testing.T) { + skipUnlessAndroid(t) wgIFace, err := createWgInterfaceWithBind(t) if err != nil { t.Fatal("failed to initialize wg interface") @@ -913,6 +376,18 @@ func TestDNSPermanent_matchOnly(t *testing.T) { } } +// skipUnlessAndroid marks tests that exercise the mobile-permanent DNS path, +// which only matches a real production setup on android (NewDefaultServerPermanentUpstream +// + androidHostManager). On non-android the desktop host manager replaces it +// during Initialize and the assertion stops making sense. Skipped here until we +// have an android CI runner. +func skipUnlessAndroid(t *testing.T) { + t.Helper() + if runtime.GOOS != "android" { + t.Skip("requires android runner; mobile-permanent path doesn't match production on this OS") + } +} + func createWgInterfaceWithBind(t *testing.T) (*iface.WGIface, error) { t.Helper() ov := os.Getenv("NB_WG_KERNEL_DISABLED") @@ -929,7 +404,7 @@ func createWgInterfaceWithBind(t *testing.T) (*iface.WGIface, error) { opts := iface.WGIFaceOpts{ IFaceName: "utun2301", - Address: "100.66.100.2/24", + Address: wgaddr.MustParseWGAddress("100.66.100.2/24"), WGPort: 33100, WGPrivKey: privKey.String(), MTU: iface.DefaultMTU, @@ -948,7 +423,7 @@ func createWgInterfaceWithBind(t *testing.T) (*iface.WGIface, error) { return nil, err } - pf, err := uspfilter.Create(wgIface, false, flowLogger, iface.DefaultMTU) + pf, err := uspfilter.Create(uspfilter.Config{IFace: wgIface, FlowLogger: flowLogger, MTU: iface.DefaultMTU}) if err != nil { t.Fatalf("failed to create uspfilter: %v", err) return nil, err @@ -1065,7 +540,6 @@ type mockHandler struct { func (m *mockHandler) ServeDNS(dns.ResponseWriter, *dns.Msg) {} func (m *mockHandler) Stop() {} -func (m *mockHandler) ProbeAvailability(context.Context) {} func (m *mockHandler) ID() types.HandlerID { return types.HandlerID(m.Id) } type mockService struct{} @@ -1078,15 +552,15 @@ func (m *mockService) RegisterMux(string, dns.Handler) {} func (m *mockService) DeregisterMux(string) {} func TestDefaultServer_UpdateMux(t *testing.T) { - baseMatchHandlers := registeredHandlerMap{ - "upstream-group1": { + baseMatchHandlers := []handlerWrapper{ + { domain: "example.com", handler: &mockHandler{ Id: "upstream-group1", }, priority: PriorityUpstream, }, - "upstream-group2": { + { domain: "example.com", handler: &mockHandler{ Id: "upstream-group2", @@ -1095,15 +569,15 @@ func TestDefaultServer_UpdateMux(t *testing.T) { }, } - baseRootHandlers := registeredHandlerMap{ - "upstream-root1": { + baseRootHandlers := []handlerWrapper{ + { domain: ".", handler: &mockHandler{ Id: "upstream-root1", }, priority: PriorityDefault, }, - "upstream-root2": { + { domain: ".", handler: &mockHandler{ Id: "upstream-root2", @@ -1112,22 +586,22 @@ func TestDefaultServer_UpdateMux(t *testing.T) { }, } - baseMixedHandlers := registeredHandlerMap{ - "upstream-group1": { + baseMixedHandlers := []handlerWrapper{ + { domain: "example.com", handler: &mockHandler{ Id: "upstream-group1", }, priority: PriorityUpstream, }, - "upstream-group2": { + { domain: "example.com", handler: &mockHandler{ Id: "upstream-group2", }, priority: PriorityUpstream - 1, }, - "upstream-other": { + { domain: "other.com", handler: &mockHandler{ Id: "upstream-other", @@ -1138,7 +612,7 @@ func TestDefaultServer_UpdateMux(t *testing.T) { tests := []struct { name string - initialHandlers registeredHandlerMap + initialHandlers []handlerWrapper updates []handlerWrapper expectedHandlers map[string]string // map[HandlerID]domain description string @@ -1422,32 +896,38 @@ func TestDefaultServer_UpdateMux(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { server := &DefaultServer{ - dnsMuxMap: tt.initialHandlers, - handlerChain: NewHandlerChain(), - service: &mockService{}, + dnsMuxHandlers: tt.initialHandlers, + handlerChain: NewHandlerChain(), + service: &mockService{}, } // Perform the update server.updateMux(tt.updates) // Verify the results - assert.Equal(t, len(tt.expectedHandlers), len(server.dnsMuxMap), + assert.Equal(t, len(tt.expectedHandlers), len(server.dnsMuxHandlers), "Number of handlers after update doesn't match expected") // Check each expected handler for id, expectedDomain := range tt.expectedHandlers { - handler, exists := server.dnsMuxMap[types.HandlerID(id)] - assert.True(t, exists, "Expected handler %s not found", id) - if exists { - assert.Equal(t, expectedDomain, handler.domain, + var found *handlerWrapper + for i := range server.dnsMuxHandlers { + if server.dnsMuxHandlers[i].handler.ID() == types.HandlerID(id) { + found = &server.dnsMuxHandlers[i] + break + } + } + assert.NotNil(t, found, "Expected handler %s not found", id) + if found != nil { + assert.Equal(t, expectedDomain, found.domain, "Domain mismatch for handler %s", id) } } // Verify no unexpected handlers exist - for HandlerID := range server.dnsMuxMap { - _, expected := tt.expectedHandlers[string(HandlerID)] - assert.True(t, expected, "Unexpected handler found: %s", HandlerID) + for _, entry := range server.dnsMuxHandlers { + _, expected := tt.expectedHandlers[string(entry.handler.ID())] + assert.True(t, expected, "Unexpected handler found: %s", entry.handler.ID()) } // Verify the handlerChain state and order @@ -1462,7 +942,7 @@ func TestDefaultServer_UpdateMux(t *testing.T) { // Verify handler exists in mux foundInMux := false - for _, muxEntry := range server.dnsMuxMap { + for _, muxEntry := range server.dnsMuxHandlers { if chainEntry.Handler == muxEntry.handler && chainEntry.Priority == muxEntry.priority && chainEntry.Pattern == dns.Fqdn(muxEntry.domain) { @@ -1471,12 +951,108 @@ func TestDefaultServer_UpdateMux(t *testing.T) { } } assert.True(t, foundInMux, - "Handler in chain not found in dnsMuxMap") + "Handler in chain not found in dnsMuxHandlers") } }) } } +// chainHasPattern reports whether the handler chain holds an entry registered +// for the given fqdn pattern at the given priority. +func chainHasPattern(s *DefaultServer, pattern string, priority int) bool { + for _, h := range s.handlerChain.handlers { + if h.OrigPattern == pattern && h.Priority == priority { + return true + } + } + return false +} + +// TestDefaultServer_UpdateMux_SharedHandlerZoneRemoval verifies that updateMux +// tracks each (handler, domain) registration independently when one handler +// serves multiple zones. Every custom zone is served by the same handler +// instance (the local resolver, whose ID is the constant "local-resolver"), so +// removing one zone must deregister exactly that zone's chain entry and leave +// the others in place. Tracking registrations by handler ID alone collapses all +// zones onto one entry, leaving removed zones in the chain to answer +// authoritatively with no records. +func TestDefaultServer_UpdateMux_SharedHandlerZoneRemoval(t *testing.T) { + // One handler serves every custom zone, mirroring s.localResolver. + shared := &mockHandler{Id: "local-resolver"} + + server := &DefaultServer{ + handlerChain: NewHandlerChain(), + service: &mockService{}, + } + + // Two custom zones under the same handler. The surviving zone is registered + // last, mirroring the management emission order. + server.updateMux([]handlerWrapper{ + {domain: "userzone.test", handler: shared, priority: PriorityLocal}, + {domain: "peerzone.test", handler: shared, priority: PriorityLocal}, + }) + + require.True(t, chainHasPattern(server, "userzone.test.", PriorityLocal), + "userzone.test should be registered after the first update") + require.True(t, chainHasPattern(server, "peerzone.test.", PriorityLocal), + "peerzone.test should be registered after the first update") + + // Remove one zone, keep the other. + server.updateMux([]handlerWrapper{ + {domain: "peerzone.test", handler: shared, priority: PriorityLocal}, + }) + + assert.True(t, chainHasPattern(server, "peerzone.test.", PriorityLocal), + "peerzone.test should remain after removing userzone.test") + assert.False(t, chainHasPattern(server, "userzone.test.", PriorityLocal), + "userzone.test handler must be deregistered, not leaked in the chain") +} + +// TestDefaultServer_UpdateMux_PreservesLocalResolver verifies that updateMux +// does not tear down the shared local resolver during reconfiguration. The +// resolver is a process-lifetime singleton reused across config updates; +// Stop() cancels its lookup context (breaking external CNAME-target +// resolution) and clears its records. updateMux must deregister its chain +// entries without stopping it. Records surviving a teardown update is the +// observable proxy: Stop() would have cleared them. +func TestDefaultServer_UpdateMux_PreservesLocalResolver(t *testing.T) { + resolver := local.NewResolver() + require.NoError(t, resolver.RegisterRecord(nbdns.SimpleRecord{ + Name: "peer.netbird.cloud.", + Type: int(dns.TypeA), + Class: nbdns.DefaultClass, + TTL: 300, + RData: "10.0.0.1", + })) + + server := &DefaultServer{ + handlerChain: NewHandlerChain(), + service: &mockService{}, + localResolver: resolver, + } + + server.updateMux([]handlerWrapper{ + {domain: "netbird.cloud", handler: resolver, priority: PriorityLocal}, + }) + + // Remove the zone. The resolver must survive so its records and lookup + // context stay intact for the next registration. + server.updateMux(nil) + + var response *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{ + WriteMsgFunc: func(m *dns.Msg) error { + response = m + return nil + }, + }, &dns.Msg{Question: []dns.Question{{Name: "peer.netbird.cloud.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}}) + + require.NotNil(t, response, "local resolver should answer after teardown") + assert.Equal(t, dns.RcodeSuccess, response.Rcode, + "local resolver records must survive teardown; updateMux must not Stop() the shared resolver") + assert.NotEmpty(t, response.Answer, "answer should contain the surviving record") +} + func TestExtraDomains(t *testing.T) { tests := []struct { name string @@ -2085,6 +1661,619 @@ func TestLocalResolverPriorityConstants(t *testing.T) { assert.Equal(t, "local.example.com", localMuxUpdates[0].domain) } +// TestBuildUpstreamHandler_MergesGroupsPerDomain verifies that multiple +// admin-defined nameserver groups targeting the same domain collapse into a +// single handler with each group preserved as a sequential inner list. +func TestBuildUpstreamHandler_MergesGroupsPerDomain(t *testing.T) { + wgInterface := &mocWGIface{} + service := NewServiceViaMemory(wgInterface) + server := &DefaultServer{ + ctx: context.Background(), + wgInterface: wgInterface, + service: service, + localResolver: local.NewResolver(), + handlerChain: NewHandlerChain(), + hostManager: &noopHostConfigurator{}, + } + + groups := []*nbdns.NameServerGroup{ + { + NameServers: []nbdns.NameServer{ + {IP: netip.MustParseAddr("192.0.2.1"), NSType: nbdns.UDPNameServerType, Port: 53}, + }, + Domains: []string{"example.com"}, + }, + { + NameServers: []nbdns.NameServer{ + {IP: netip.MustParseAddr("192.0.2.2"), NSType: nbdns.UDPNameServerType, Port: 53}, + {IP: netip.MustParseAddr("192.0.2.3"), NSType: nbdns.UDPNameServerType, Port: 53}, + }, + Domains: []string{"example.com"}, + }, + } + + muxUpdates, err := server.buildUpstreamHandlerUpdate(groups) + require.NoError(t, err) + require.Len(t, muxUpdates, 1, "same-domain groups should merge into one handler") + assert.Equal(t, "example.com", muxUpdates[0].domain) + assert.Equal(t, PriorityUpstream, muxUpdates[0].priority) + + handler := muxUpdates[0].handler.(*upstreamResolver) + require.Len(t, handler.upstreamServers, 2, "handler should have two groups") + assert.Equal(t, upstreamRace{netip.MustParseAddrPort("192.0.2.1:53")}, handler.upstreamServers[0]) + assert.Equal(t, upstreamRace{ + netip.MustParseAddrPort("192.0.2.2:53"), + netip.MustParseAddrPort("192.0.2.3:53"), + }, handler.upstreamServers[1]) +} + +// TestEvaluateNSGroupHealth covers the records-only verdict. The gate +// (overlay route selected-but-no-active-peer) is intentionally NOT an +// input to the evaluator anymore: the verdict drives the Enabled flag, +// which must always reflect what we actually observed. Gate-aware event +// suppression is tested separately in the projection test. +// +// Matrix per upstream: {no record, fresh Ok, fresh Fail, stale Fail, +// stale Ok, Ok newer than Fail, Fail newer than Ok}. +// Group verdict: any fresh-working → Healthy; any fresh-broken with no +// fresh-working → Unhealthy; otherwise Undecided. +func TestEvaluateNSGroupHealth(t *testing.T) { + now := time.Now() + a := netip.MustParseAddrPort("192.0.2.1:53") + b := netip.MustParseAddrPort("192.0.2.2:53") + + recentOk := UpstreamHealth{LastOk: now.Add(-2 * time.Second)} + recentFail := UpstreamHealth{LastFail: now.Add(-1 * time.Second), LastErr: "timeout"} + staleOk := UpstreamHealth{LastOk: now.Add(-10 * time.Minute)} + staleFail := UpstreamHealth{LastFail: now.Add(-10 * time.Minute), LastErr: "timeout"} + okThenFail := UpstreamHealth{ + LastOk: now.Add(-10 * time.Second), + LastFail: now.Add(-1 * time.Second), + LastErr: "timeout", + } + failThenOk := UpstreamHealth{ + LastOk: now.Add(-1 * time.Second), + LastFail: now.Add(-10 * time.Second), + LastErr: "timeout", + } + + tests := []struct { + name string + health map[netip.AddrPort]UpstreamHealth + servers []netip.AddrPort + wantVerdict nsGroupVerdict + wantErrSubst string + }{ + { + name: "no record, undecided", + servers: []netip.AddrPort{a}, + wantVerdict: nsVerdictUndecided, + }, + { + name: "fresh success, healthy", + health: map[netip.AddrPort]UpstreamHealth{a: recentOk}, + servers: []netip.AddrPort{a}, + wantVerdict: nsVerdictHealthy, + }, + { + name: "fresh failure, unhealthy", + health: map[netip.AddrPort]UpstreamHealth{a: recentFail}, + servers: []netip.AddrPort{a}, + wantVerdict: nsVerdictUnhealthy, + wantErrSubst: "timeout", + }, + { + name: "only stale success, undecided", + health: map[netip.AddrPort]UpstreamHealth{a: staleOk}, + servers: []netip.AddrPort{a}, + wantVerdict: nsVerdictUndecided, + }, + { + name: "only stale failure, undecided", + health: map[netip.AddrPort]UpstreamHealth{a: staleFail}, + servers: []netip.AddrPort{a}, + wantVerdict: nsVerdictUndecided, + }, + { + name: "both fresh, fail newer, unhealthy", + health: map[netip.AddrPort]UpstreamHealth{a: okThenFail}, + servers: []netip.AddrPort{a}, + wantVerdict: nsVerdictUnhealthy, + wantErrSubst: "timeout", + }, + { + name: "both fresh, ok newer, healthy", + health: map[netip.AddrPort]UpstreamHealth{a: failThenOk}, + servers: []netip.AddrPort{a}, + wantVerdict: nsVerdictHealthy, + }, + { + name: "two upstreams, one success wins", + health: map[netip.AddrPort]UpstreamHealth{ + a: recentFail, + b: recentOk, + }, + servers: []netip.AddrPort{a, b}, + wantVerdict: nsVerdictHealthy, + }, + { + name: "two upstreams, one fail one unseen, unhealthy", + health: map[netip.AddrPort]UpstreamHealth{ + a: recentFail, + }, + servers: []netip.AddrPort{a, b}, + wantVerdict: nsVerdictUnhealthy, + wantErrSubst: "timeout", + }, + { + name: "two upstreams, all recent failures, unhealthy", + health: map[netip.AddrPort]UpstreamHealth{ + a: {LastFail: now.Add(-5 * time.Second), LastErr: "timeout"}, + b: {LastFail: now.Add(-1 * time.Second), LastErr: "SERVFAIL"}, + }, + servers: []netip.AddrPort{a, b}, + wantVerdict: nsVerdictUnhealthy, + wantErrSubst: "SERVFAIL", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + verdict, err := evaluateNSGroupHealth(tc.health, tc.servers, now) + assert.Equal(t, tc.wantVerdict, verdict, "verdict mismatch") + if tc.wantErrSubst != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErrSubst) + } else { + assert.NoError(t, err) + } + }) + } +} + +// healthStubHandler is a minimal dnsMuxHandlers entry that exposes a fixed +// UpstreamHealth snapshot, letting tests drive recomputeNSGroupStates +// without spinning up real handlers. +type healthStubHandler struct { + health map[netip.AddrPort]UpstreamHealth +} + +func (h *healthStubHandler) ServeDNS(dns.ResponseWriter, *dns.Msg) {} +func (h *healthStubHandler) Stop() {} +func (h *healthStubHandler) ID() types.HandlerID { return "health-stub" } +func (h *healthStubHandler) UpstreamHealth() map[netip.AddrPort]UpstreamHealth { + return h.health +} + +// TestProjection_SteadyStateIsSilent guards against duplicate events: +// while a group stays Unhealthy tick after tick, only the first +// Unhealthy transition may emit. Same for staying Healthy. +func TestProjection_SteadyStateIsSilent(t *testing.T) { + fx := newProjTestFixture(t) + + fx.setHealth(UpstreamHealth{LastFail: time.Now(), LastErr: "timeout"}) + fx.tick() + fx.expectEvent("unreachable", "first fail emits warning") + + fx.setHealth(UpstreamHealth{LastFail: time.Now(), LastErr: "timeout"}) + fx.tick() + fx.tick() + fx.expectNoEvent("staying unhealthy must not re-emit") + + fx.setHealth(UpstreamHealth{LastOk: time.Now()}) + fx.tick() + fx.expectEvent("recovered", "recovery on transition") + + fx.tick() + fx.tick() + fx.expectNoEvent("staying healthy must not re-emit") +} + +// projTestFixture is the common setup for the projection tests: a +// single-upstream group whose route classification the test can flip by +// assigning to selected/active. Callers drive failures/successes by +// mutating stub.health and calling refreshHealth. +type projTestFixture struct { + t *testing.T + recorder *peer.Status + events <-chan *proto.SystemEvent + server *DefaultServer + stub *healthStubHandler + group *nbdns.NameServerGroup + srv netip.AddrPort + selected route.HAMap + active route.HAMap +} + +func newProjTestFixture(t *testing.T) *projTestFixture { + t.Helper() + recorder := peer.NewRecorder("mgm") + sub := recorder.SubscribeToEvents() + t.Cleanup(func() { recorder.UnsubscribeFromEvents(sub) }) + + srv := netip.MustParseAddrPort("100.64.0.1:53") + fx := &projTestFixture{ + t: t, + recorder: recorder, + events: sub.Events(), + stub: &healthStubHandler{health: map[netip.AddrPort]UpstreamHealth{}}, + srv: srv, + group: &nbdns.NameServerGroup{ + Domains: []string{"example.com"}, + NameServers: []nbdns.NameServer{{IP: srv.Addr(), NSType: nbdns.UDPNameServerType, Port: int(srv.Port())}}, + }, + } + fx.server = &DefaultServer{ + ctx: context.Background(), + wgInterface: &mocWGIface{}, + statusRecorder: recorder, + selectedRoutes: func() route.HAMap { return fx.selected }, + activeRoutes: func() route.HAMap { return fx.active }, + warningDelayBase: defaultWarningDelayBase, + } + fx.server.dnsMuxHandlers = []handlerWrapper{{domain: "example.com", handler: fx.stub, priority: PriorityUpstream}} + + fx.server.mux.Lock() + fx.server.updateNSGroupStates([]*nbdns.NameServerGroup{fx.group}) + fx.server.mux.Unlock() + return fx +} + +func (f *projTestFixture) setHealth(h UpstreamHealth) { + f.stub.health = map[netip.AddrPort]UpstreamHealth{f.srv: h} +} + +func (f *projTestFixture) tick() []peer.NSGroupState { + f.server.refreshHealth() + return f.recorder.GetDNSStates() +} + +func (f *projTestFixture) expectNoEvent(why string) { + f.t.Helper() + select { + case evt := <-f.events: + f.t.Fatalf("unexpected event (%s): %+v", why, evt) + case <-time.After(100 * time.Millisecond): + } +} + +func (f *projTestFixture) expectEvent(substr, why string) *proto.SystemEvent { + f.t.Helper() + select { + case evt := <-f.events: + assert.Contains(f.t, evt.Message, substr, why) + return evt + case <-time.After(time.Second): + f.t.Fatalf("expected event (%s) with %q", why, substr) + return nil + } +} + +var overlayNetForTest = netip.MustParsePrefix("100.64.0.0/16") +var overlayMapForTest = route.HAMap{"overlay": {{Network: overlayNetForTest}}} + +// TestProjection_PublicFailEmitsImmediately covers rule 1: an upstream +// that is not inside any selected route (public DNS) fires the warning +// on the first Unhealthy tick, no grace period. +func TestProjection_PublicFailEmitsImmediately(t *testing.T) { + fx := newProjTestFixture(t) + + fx.setHealth(UpstreamHealth{LastFail: time.Now(), LastErr: "timeout"}) + states := fx.tick() + require.Len(t, states, 1) + assert.False(t, states[0].Enabled) + fx.expectEvent("unreachable", "public DNS failure") +} + +// TestProjection_OverlayConnectedFailEmitsImmediately covers rule 2: +// the upstream is inside a selected route AND the route has a Connected +// peer. Tunnel is up, failure is real, emit immediately. +func TestProjection_OverlayConnectedFailEmitsImmediately(t *testing.T) { + fx := newProjTestFixture(t) + fx.selected = overlayMapForTest + fx.active = overlayMapForTest + + fx.setHealth(UpstreamHealth{LastFail: time.Now(), LastErr: "timeout"}) + states := fx.tick() + require.Len(t, states, 1) + assert.False(t, states[0].Enabled) + fx.expectEvent("unreachable", "overlay + connected failure") +} + +// TestProjection_OverlayNotConnectedDelaysWarning covers rule 3: the +// upstream is routed but no peer is Connected (Connecting/Idle/missing). +// First tick: Unhealthy display, no warning. After the grace window +// elapses with no recovery, the warning fires. +func TestProjection_OverlayNotConnectedDelaysWarning(t *testing.T) { + grace := 50 * time.Millisecond + fx := newProjTestFixture(t) + fx.server.warningDelayBase = grace + fx.selected = overlayMapForTest + // active stays nil: routed but not connected. + + fx.setHealth(UpstreamHealth{LastFail: time.Now(), LastErr: "timeout"}) + states := fx.tick() + require.Len(t, states, 1) + assert.False(t, states[0].Enabled, "display must reflect failure even during grace window") + fx.expectNoEvent("first fail tick within grace window") + + time.Sleep(grace + 10*time.Millisecond) + fx.setHealth(UpstreamHealth{LastFail: time.Now(), LastErr: "timeout"}) + fx.tick() + fx.expectEvent("unreachable", "warning after grace window") +} + +// TestProjection_OverlayAddrNoRouteDelaysWarning covers an upstream +// whose address is inside the WireGuard overlay range but is not +// covered by any selected route (peer-to-peer DNS without an explicit +// route). Until a peer reports Connected for that address, startup +// failures must be held just like the routed case. +func TestProjection_OverlayAddrNoRouteDelaysWarning(t *testing.T) { + recorder := peer.NewRecorder("mgm") + sub := recorder.SubscribeToEvents() + t.Cleanup(func() { recorder.UnsubscribeFromEvents(sub) }) + + overlayPeer := netip.MustParseAddrPort("100.66.100.5:53") + server := &DefaultServer{ + ctx: context.Background(), + wgInterface: &mocWGIface{}, + statusRecorder: recorder, + selectedRoutes: func() route.HAMap { return nil }, + activeRoutes: func() route.HAMap { return nil }, + warningDelayBase: 50 * time.Millisecond, + } + group := &nbdns.NameServerGroup{ + Domains: []string{"example.com"}, + NameServers: []nbdns.NameServer{{IP: overlayPeer.Addr(), NSType: nbdns.UDPNameServerType, Port: int(overlayPeer.Port())}}, + } + stub := &healthStubHandler{health: map[netip.AddrPort]UpstreamHealth{ + overlayPeer: {LastFail: time.Now(), LastErr: "timeout"}, + }} + server.dnsMuxHandlers = []handlerWrapper{{domain: "example.com", handler: stub, priority: PriorityUpstream}} + + server.mux.Lock() + server.updateNSGroupStates([]*nbdns.NameServerGroup{group}) + server.mux.Unlock() + server.refreshHealth() + + select { + case evt := <-sub.Events(): + t.Fatalf("unexpected event during grace window: %+v", evt) + case <-time.After(100 * time.Millisecond): + } + + time.Sleep(60 * time.Millisecond) + stub.health = map[netip.AddrPort]UpstreamHealth{overlayPeer: {LastFail: time.Now(), LastErr: "timeout"}} + server.refreshHealth() + + select { + case evt := <-sub.Events(): + assert.Contains(t, evt.Message, "unreachable") + case <-time.After(time.Second): + t.Fatal("expected warning after grace window") + } +} + +// TestProjection_StopClearsHealthState verifies that Stop wipes the +// per-group projection state so a subsequent Start doesn't inherit +// sticky flags (notably everHealthy) that would bypass the grace +// window during the next peer handshake. +func TestProjection_StopClearsHealthState(t *testing.T) { + wgIface := &mocWGIface{} + server := &DefaultServer{ + ctx: context.Background(), + wgInterface: wgIface, + service: NewServiceViaMemory(wgIface), + hostManager: &noopHostConfigurator{}, + extraDomains: map[domain.Domain]int{}, + statusRecorder: peer.NewRecorder("mgm"), + selectedRoutes: func() route.HAMap { return nil }, + activeRoutes: func() route.HAMap { return nil }, + warningDelayBase: defaultWarningDelayBase, + currentConfigHash: ^uint64(0), + } + server.ctx, server.ctxCancel = context.WithCancel(context.Background()) + + srv := netip.MustParseAddrPort("8.8.8.8:53") + group := &nbdns.NameServerGroup{ + Domains: []string{"example.com"}, + NameServers: []nbdns.NameServer{{IP: srv.Addr(), NSType: nbdns.UDPNameServerType, Port: int(srv.Port())}}, + } + stub := &healthStubHandler{health: map[netip.AddrPort]UpstreamHealth{srv: {LastOk: time.Now()}}} + server.dnsMuxHandlers = []handlerWrapper{{domain: "example.com", handler: stub, priority: PriorityUpstream}} + + server.mux.Lock() + server.updateNSGroupStates([]*nbdns.NameServerGroup{group}) + server.mux.Unlock() + server.refreshHealth() + + server.healthProjectMu.Lock() + p, ok := server.nsGroupProj[generateGroupKey(group)] + server.healthProjectMu.Unlock() + require.True(t, ok, "projection state should exist after tick") + require.True(t, p.everHealthy, "tick with success must set everHealthy") + + server.Stop() + + server.healthProjectMu.Lock() + cleared := server.nsGroupProj == nil + server.healthProjectMu.Unlock() + assert.True(t, cleared, "Stop must clear nsGroupProj") +} + +// TestProjection_OverlayRecoversDuringGrace covers the happy path of +// rule 3: startup failures while the peer is handshaking, then the peer +// comes up and a query succeeds before the grace window elapses. No +// warning should ever have fired, and no recovery either. +func TestWarningDelayBaseFromEnv(t *testing.T) { + tests := []struct { + name string + set bool + val string + want time.Duration + }{ + {name: "unset uses default", set: false, want: defaultWarningDelayBase}, + {name: "valid override", set: true, val: "90s", want: 90 * time.Second}, + {name: "valid minutes", set: true, val: "2m", want: 2 * time.Minute}, + {name: "invalid falls back", set: true, val: "notaduration", want: defaultWarningDelayBase}, + {name: "zero falls back", set: true, val: "0s", want: defaultWarningDelayBase}, + {name: "negative falls back", set: true, val: "-30s", want: defaultWarningDelayBase}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(envWarningDelay, tc.val) + if !tc.set { + os.Unsetenv(envWarningDelay) + } + assert.Equal(t, tc.want, warningDelayBaseFromEnv(), "grace window base") + }) + } +} + +func TestProjection_OverlayRecoversDuringGrace(t *testing.T) { + fx := newProjTestFixture(t) + fx.server.warningDelayBase = 200 * time.Millisecond + fx.selected = overlayMapForTest + + fx.setHealth(UpstreamHealth{LastFail: time.Now(), LastErr: "timeout"}) + fx.tick() + fx.expectNoEvent("fail within grace, warning suppressed") + + fx.active = overlayMapForTest + fx.setHealth(UpstreamHealth{LastOk: time.Now()}) + states := fx.tick() + require.Len(t, states, 1) + assert.True(t, states[0].Enabled) + fx.expectNoEvent("recovery without prior warning must not emit") +} + +// TestProjection_RecoveryOnlyAfterWarning enforces the invariant the +// whole design leans on: recovery events only appear when a warning +// event was actually emitted for the current streak. A Healthy verdict +// without a prior warning is silent, so the user never sees "recovered" +// out of thin air. +func TestProjection_RecoveryOnlyAfterWarning(t *testing.T) { + fx := newProjTestFixture(t) + + fx.setHealth(UpstreamHealth{LastOk: time.Now()}) + states := fx.tick() + require.Len(t, states, 1) + assert.True(t, states[0].Enabled) + fx.expectNoEvent("first healthy tick should not recover anything") + + fx.setHealth(UpstreamHealth{LastFail: time.Now(), LastErr: "timeout"}) + fx.tick() + fx.expectEvent("unreachable", "public fail emits immediately") + + fx.setHealth(UpstreamHealth{LastOk: time.Now()}) + fx.tick() + fx.expectEvent("recovered", "recovery follows real warning") + + fx.setHealth(UpstreamHealth{LastFail: time.Now(), LastErr: "timeout"}) + fx.tick() + fx.expectEvent("unreachable", "second cycle warning") + + fx.setHealth(UpstreamHealth{LastOk: time.Now()}) + fx.tick() + fx.expectEvent("recovered", "second cycle recovery") +} + +// TestProjection_EverHealthyOverridesDelay covers rule 4: once a group +// has ever been Healthy, subsequent failures skip the grace window even +// if classification says "routed + not connected". The system has +// proved it can work, so any new failure is real. +func TestProjection_EverHealthyOverridesDelay(t *testing.T) { + fx := newProjTestFixture(t) + // Large base so any emission must come from the everHealthy bypass, not elapsed time. + fx.server.warningDelayBase = time.Hour + fx.selected = overlayMapForTest + fx.active = overlayMapForTest + + // Establish "ever healthy". + fx.setHealth(UpstreamHealth{LastOk: time.Now()}) + fx.tick() + fx.expectNoEvent("first healthy tick") + + // Peer drops. Query fails. Routed + not connected → normally grace, + // but everHealthy flag bypasses it. + fx.active = nil + fx.setHealth(UpstreamHealth{LastFail: time.Now(), LastErr: "timeout"}) + fx.tick() + fx.expectEvent("unreachable", "failure after ever-healthy must be immediate") +} + +// TestProjection_ReconnectBlipEmitsPair covers the explicit tradeoff +// from the design discussion: once a group has been healthy, a brief +// reconnect that produces a failing tick will fire warning + recovery. +// This is by design: user-visible blips are accurate signal, not noise. +func TestProjection_ReconnectBlipEmitsPair(t *testing.T) { + fx := newProjTestFixture(t) + fx.selected = overlayMapForTest + fx.active = overlayMapForTest + + fx.setHealth(UpstreamHealth{LastOk: time.Now()}) + fx.tick() + + fx.setHealth(UpstreamHealth{LastFail: time.Now(), LastErr: "timeout"}) + fx.tick() + fx.expectEvent("unreachable", "blip warning") + + fx.setHealth(UpstreamHealth{LastOk: time.Now()}) + fx.tick() + fx.expectEvent("recovered", "blip recovery") +} + +// TestProjection_MixedGroupEmitsImmediately covers the multi-upstream +// rule: a group with at least one public upstream is in the "immediate" +// category regardless of the other upstreams' routing, because the +// public one has no peer-startup excuse. Prevents public-DNS failures +// from being hidden behind a routed sibling. +func TestProjection_MixedGroupEmitsImmediately(t *testing.T) { + recorder := peer.NewRecorder("mgm") + sub := recorder.SubscribeToEvents() + t.Cleanup(func() { recorder.UnsubscribeFromEvents(sub) }) + events := sub.Events() + + public := netip.MustParseAddrPort("8.8.8.8:53") + overlay := netip.MustParseAddrPort("100.64.0.1:53") + overlayMap := route.HAMap{"overlay": {{Network: netip.MustParsePrefix("100.64.0.0/16")}}} + + server := &DefaultServer{ + ctx: context.Background(), + statusRecorder: recorder, + selectedRoutes: func() route.HAMap { return overlayMap }, + activeRoutes: func() route.HAMap { return nil }, + warningDelayBase: time.Hour, + } + group := &nbdns.NameServerGroup{ + Domains: []string{"example.com"}, + NameServers: []nbdns.NameServer{ + {IP: public.Addr(), NSType: nbdns.UDPNameServerType, Port: int(public.Port())}, + {IP: overlay.Addr(), NSType: nbdns.UDPNameServerType, Port: int(overlay.Port())}, + }, + } + stub := &healthStubHandler{ + health: map[netip.AddrPort]UpstreamHealth{ + public: {LastFail: time.Now(), LastErr: "servfail"}, + overlay: {LastFail: time.Now(), LastErr: "timeout"}, + }, + } + server.dnsMuxHandlers = []handlerWrapper{{domain: "example.com", handler: stub, priority: PriorityUpstream}} + + server.mux.Lock() + server.updateNSGroupStates([]*nbdns.NameServerGroup{group}) + server.mux.Unlock() + server.refreshHealth() + + select { + case evt := <-events: + assert.Contains(t, evt.Message, "unreachable") + case <-time.After(time.Second): + t.Fatal("expected immediate warning because group contains a public upstream") + } +} + func TestDNSLoopPrevention(t *testing.T) { wgInterface := &mocWGIface{} service := NewServiceViaMemory(wgInterface) @@ -2097,7 +2286,6 @@ func TestDNSLoopPrevention(t *testing.T) { localResolver: local.NewResolver(), handlerChain: NewHandlerChain(), hostManager: &noopHostConfigurator{}, - dnsMuxMap: make(registeredHandlerMap), } tests := []struct { @@ -2183,17 +2371,18 @@ func TestDNSLoopPrevention(t *testing.T) { if tt.expectedHandlers > 0 { handler := muxUpdates[0].handler.(*upstreamResolver) - assert.Len(t, handler.upstreamServers, len(tt.expectedServers)) + flat := handler.flatUpstreams() + assert.Len(t, flat, len(tt.expectedServers)) if tt.shouldFilterOwnIP { - for _, upstream := range handler.upstreamServers { + for _, upstream := range flat { assert.NotEqual(t, dnsServerIP, upstream.Addr()) } } for _, expected := range tt.expectedServers { found := false - for _, upstream := range handler.upstreamServers { + for _, upstream := range flat { if upstream.Addr() == expected { found = true break diff --git a/client/internal/dns/service.go b/client/internal/dns/service.go index 1c6ce7849..04bcd5985 100644 --- a/client/internal/dns/service.go +++ b/client/internal/dns/service.go @@ -16,8 +16,8 @@ const ( // This is used when the DNS server cannot bind port 53 directly // and needs firewall rules to redirect traffic. type Firewall interface { - AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error - RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error + AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error + RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error } type service interface { diff --git a/client/internal/dns/service_listener.go b/client/internal/dns/service_listener.go index 4e09f1b7f..d65a727b1 100644 --- a/client/internal/dns/service_listener.go +++ b/client/internal/dns/service_listener.go @@ -6,6 +6,7 @@ import ( "net" "net/netip" "runtime" + "slices" "strconv" "sync" "time" @@ -17,17 +18,20 @@ import ( nberrors "github.com/netbirdio/netbird/client/errors" firewall "github.com/netbirdio/netbird/client/firewall/manager" - "github.com/netbirdio/netbird/client/internal/ebpf" - ebpfMgr "github.com/netbirdio/netbird/client/internal/ebpf/manager" ) const ( customPort = 5053 + // randomPortAttempts bounds the search for a port free on both protocols. + randomPortAttempts = 5 ) var ( defaultIP = netip.MustParseAddr("127.0.0.1") customIP = netip.MustParseAddr("127.0.0.153") + + // dnatProtocols are the protocols the port 53 redirect covers. + dnatProtocols = []firewall.Protocol{firewall.ProtocolUDP, firewall.ProtocolTCP} ) type serviceViaListener struct { @@ -40,9 +44,20 @@ type serviceViaListener struct { listenPort uint16 listenerIsRunning bool listenerFlagLock sync.Mutex - ebpfService ebpfMgr.Manager firewall Firewall - tcpDNATConfigured bool + // dnatRules holds the port 53 redirects that are installed and not yet + // removed, so a removal that fails can be retried. + dnatRules []dnatRule +} + +// dnatRule is a port 53 redirect as it was installed. The target is kept with +// the rule because the listener can come back on a different address or port, +// and a retried removal has to name the address and port the rule was added +// with, not the ones in use now. +type dnatRule struct { + protocol firewall.Protocol + ip netip.Addr + port uint16 } func newServiceViaListener(wgIface WGIface, customAddr *netip.AddrPort, fw Firewall) *serviceViaListener { @@ -112,34 +127,93 @@ func (s *serviceViaListener) Listen() error { } }() - // When eBPF redirects UDP port 53 to our listen port, TCP still needs - // a DNAT rule because eBPF only handles UDP. - if s.ebpfService != nil && s.firewall != nil && s.listenPort != DefaultPort { - if err := s.firewall.AddOutputDNAT(s.listenIP, firewall.ProtocolTCP, DefaultPort, s.listenPort); err != nil { - log.Warnf("failed to add DNS TCP DNAT rule, TCP DNS on port 53 will not work: %v", err) - } else { - s.tcpDNATConfigured = true - log.Infof("added DNS TCP DNAT rule: %s:%d -> %s:%d", s.listenIP, DefaultPort, s.listenIP, s.listenPort) - } + if s.listenPort != DefaultPort { + s.setupDNAT() } return nil } +// setupDNAT redirects port 53 to the port the DNS server actually listens on. +// Both protocols must be redirected or none: RuntimePort reports port 53 only +// while the full redirect is in place, so a half-configured redirect would +// advertise a resolver that answers over one protocol. +func (s *serviceViaListener) setupDNAT() { + if s.firewall == nil { + log.Errorf("no firewall manager available to redirect DNS port %d to %d, "+ + "clients pointed at %s will not reach the resolver", DefaultPort, s.listenPort, s.listenIP) + return + } + + // Clear whatever an earlier removal left behind first. Those rules can point + // at an address or port this listener no longer uses, and they are matched + // before anything added now, so adding a redirect on top of one would keep + // sending port 53 traffic to the previous listener while reporting the + // redirect as complete. The rules stay recorded for a later attempt. + if err := s.removeDNAT(); err != nil { + log.Errorf("failed to remove stale DNS DNAT rules, leaving port %d redirected to the previous listener: %v", + DefaultPort, err) + return + } + + for _, proto := range dnatProtocols { + if err := s.firewall.AddOutputDNAT(s.listenIP, proto, DefaultPort, s.listenPort); err != nil { + log.Errorf("failed to add DNS %s DNAT rule, DNS on port %d will not work: %v", + proto, DefaultPort, err) + if err := s.removeDNAT(); err != nil { + log.Warnf("failed to roll back DNS DNAT rules, retrying on stop: %v", err) + } + return + } + s.dnatRules = append(s.dnatRules, dnatRule{protocol: proto, ip: s.listenIP, port: s.listenPort}) + } + + log.Infof("added DNS DNAT rules: %s:%d -> %s:%d (UDP + TCP)", s.listenIP, DefaultPort, s.listenIP, s.listenPort) +} + +// removeDNAT removes every installed port 53 redirect. A rule whose removal +// fails stays recorded so a later setup or Stop retries it, rather than leaving +// port 53 pointing at a resolver that is no longer listening. +func (s *serviceViaListener) removeDNAT() error { + if s.firewall == nil { + return nil + } + + var merr *multierror.Error + var remaining []dnatRule + for _, rule := range s.dnatRules { + if err := s.firewall.RemoveOutputDNAT(rule.ip, rule.protocol, DefaultPort, rule.port); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove DNS %s DNAT rule for %s:%d: %w", + rule.protocol, rule.ip, rule.port, err)) + remaining = append(remaining, rule) + } + } + s.dnatRules = remaining + + return nberrors.FormatErrorOrNil(merr) +} + func (s *serviceViaListener) Stop() error { s.listenerFlagLock.Lock() defer s.listenerFlagLock.Unlock() + var merr *multierror.Error + + // Redirects are removed even when the listener is already stopped, so that + // a removal which failed earlier is retried instead of leaving port 53 + // pointing at a resolver that no longer listens. + if err := s.removeDNAT(); err != nil { + merr = multierror.Append(merr, err) + } + if !s.listenerIsRunning { - return nil + return nberrors.FormatErrorOrNil(merr) } s.listenerIsRunning = false ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - var merr *multierror.Error - if err := s.server.ShutdownContext(ctx); err != nil { merr = multierror.Append(merr, fmt.Errorf("stop DNS UDP server: %w", err)) } @@ -148,19 +222,6 @@ func (s *serviceViaListener) Stop() error { merr = multierror.Append(merr, fmt.Errorf("stop DNS TCP server: %w", err)) } - if s.tcpDNATConfigured && s.firewall != nil { - if err := s.firewall.RemoveOutputDNAT(s.listenIP, firewall.ProtocolTCP, DefaultPort, s.listenPort); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove DNS TCP DNAT rule: %w", err)) - } - s.tcpDNATConfigured = false - } - - if s.ebpfService != nil { - if err := s.ebpfService.FreeDNSFwd(); err != nil { - merr = multierror.Append(merr, fmt.Errorf("stop traffic forwarder: %w", err)) - } - } - return nberrors.FormatErrorOrNil(merr) } @@ -177,44 +238,54 @@ func (s *serviceViaListener) RuntimePort() int { s.listenerFlagLock.Lock() defer s.listenerFlagLock.Unlock() - if s.ebpfService != nil { + if s.redirectInstalled() { return DefaultPort - } else { - return int(s.listenPort) } + return int(s.listenPort) +} + +// redirectInstalled reports whether every protocol is redirected from port 53 +// to the address and port the listener currently serves. Rules left over from +// an earlier listener do not count. +func (s *serviceViaListener) redirectInstalled() bool { + for _, proto := range dnatProtocols { + current := dnatRule{protocol: proto, ip: s.listenIP, port: s.listenPort} + if !slices.Contains(s.dnatRules, current) { + return false + } + } + return true } func (s *serviceViaListener) RuntimeIP() netip.Addr { return s.listenIP } - -// evalListenAddress figure out the listen address for the DNS server -// first check the 53 port availability on WG interface or lo, if not success -// pick a random port on WG interface for eBPF, if not success -// check the 5053 port availability on WG interface or lo without eBPF usage, +// evalListenAddress figures out the listen address for the DNS server. +// IPv4-only: all peers have a v4 overlay address, and DNS config points to v4. +// Prefers port 53 on the overlay interface or lo, so no redirect is needed at +// all; when it is taken it falls back to port 5053 and then to a random free +// port, both of which need the port 53 redirect set up by setupDNAT. func (s *serviceViaListener) evalListenAddress() (netip.Addr, uint16, error) { if s.customAddr != nil { return s.customAddr.Addr(), s.customAddr.Port(), nil } - ip, ok := s.testFreePort(DefaultPort) - if ok { + if ip, ok := s.testFreePort(DefaultPort); ok { return ip, DefaultPort, nil } - ebpfSrv, port, ok := s.tryToUseeBPF() - if ok { - s.ebpfService = ebpfSrv - return s.wgInterface.Address().IP, port, nil - } - - ip, ok = s.testFreePort(customPort) - if ok { + if ip, ok := s.testFreePort(customPort); ok { return ip, customPort, nil } - return netip.Addr{}, 0, fmt.Errorf("failed to find a free port for DNS server") + ip := s.wgInterface.Address().IP + port, err := s.randomFreePort(ip) + if err != nil { + return netip.Addr{}, 0, fmt.Errorf("find a free port for DNS server: %w", err) + } + + return ip, port, nil } func (s *serviceViaListener) testFreePort(port int) (netip.Addr, bool) { @@ -261,50 +332,25 @@ func (s *serviceViaListener) tryToBind(ip netip.Addr, port int) bool { return true } -// tryToUseeBPF decides whether to apply eBPF program to capture DNS traffic on port 53. -// This is needed because on some operating systems if we start a DNS server not on a default port 53, -// the domain name resolution won't work. So, in case we are running on Linux and picked a free -// port we should fall back to the eBPF solution that will capture traffic on port 53 and forward -// it to a local DNS server running on the chosen port. -func (s *serviceViaListener) tryToUseeBPF() (ebpfMgr.Manager, uint16, bool) { - if runtime.GOOS != "linux" { - return nil, 0, false +// randomFreePort returns a port that is free on ip for both UDP and TCP, since +// the DNS server binds both. The probe listeners are closed again, so the port +// is only likely, not guaranteed, to still be free when the server binds it. +func (s *serviceViaListener) randomFreePort(ip netip.Addr) (uint16, error) { + for range randomPortAttempts { + probeListener, err := net.ListenUDP("udp4", &net.UDPAddr{}) + if err != nil { + return 0, fmt.Errorf("bind random port: %w", err) + } + + port := uint16(probeListener.LocalAddr().(*net.UDPAddr).Port) + if err := probeListener.Close(); err != nil { + return 0, fmt.Errorf("free up probed port: %w", err) + } + + if s.tryToBind(ip, int(port)) { + return port, nil + } } - port, err := s.generateFreePort() //nolint:staticcheck,unused - if err != nil { - log.Warnf("failed to generate a free port for eBPF DNS forwarder server: %s", err) - return nil, 0, false - } - - ebpfSrv := ebpf.GetEbpfManagerInstance() - err = ebpfSrv.LoadDNSFwd(s.wgInterface.Address().IP.String(), int(port)) - if err != nil { - log.Warnf("failed to load DNS forwarder eBPF program, error: %s", err) - return nil, 0, false - } - - return ebpfSrv, port, true -} - -func (s *serviceViaListener) generateFreePort() (uint16, error) { - ok := s.tryToBind(s.wgInterface.Address().IP, customPort) - if ok { - return customPort, nil - } - - udpAddr := net.UDPAddrFromAddrPort(netip.MustParseAddrPort("0.0.0.0:0")) - probeListener, err := net.ListenUDP("udp", udpAddr) - if err != nil { - log.Debugf("failed to bind random port for DNS: %s", err) - return 0, err - } - - addrPort := netip.MustParseAddrPort(probeListener.LocalAddr().String()) // might panic if address is incorrect - err = probeListener.Close() - if err != nil { - log.Debugf("failed to free up DNS port: %s", err) - return 0, err - } - return addrPort.Port(), nil + return 0, fmt.Errorf("no port free for UDP and TCP on %s after %d attempts", ip, randomPortAttempts) } diff --git a/client/internal/dns/service_listener_test.go b/client/internal/dns/service_listener_test.go index 90ef71d19..b158a79fd 100644 --- a/client/internal/dns/service_listener_test.go +++ b/client/internal/dns/service_listener_test.go @@ -1,6 +1,7 @@ package dns import ( + "errors" "fmt" "net" "net/netip" @@ -10,6 +11,8 @@ import ( "github.com/miekg/dns" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + firewall "github.com/netbirdio/netbird/client/firewall/manager" ) func TestServiceViaListener_TCPAndUDP(t *testing.T) { @@ -84,3 +87,133 @@ func TestServiceViaListener_TCPAndUDP(t *testing.T) { require.NotEmpty(t, tcpResp.Answer) assert.Contains(t, tcpResp.Answer[0].String(), "192.0.2.1", "TCP response should contain expected IP") } + +type dnatCall struct { + rule dnatRule + added bool +} + +// fakeFirewall records DNAT calls and fails the ones named in addErrs/removeErrs. +type fakeFirewall struct { + calls []dnatCall + addErrs map[firewall.Protocol]error + removeErrs map[firewall.Protocol]error +} + +func (f *fakeFirewall) AddOutputDNAT(ip netip.Addr, protocol firewall.Protocol, _, translatedPort uint16) error { + if err := f.addErrs[protocol]; err != nil { + return err + } + f.calls = append(f.calls, dnatCall{rule: dnatRule{protocol: protocol, ip: ip, port: translatedPort}, added: true}) + return nil +} + +func (f *fakeFirewall) RemoveOutputDNAT(ip netip.Addr, protocol firewall.Protocol, _, translatedPort uint16) error { + if err := f.removeErrs[protocol]; err != nil { + return err + } + f.calls = append(f.calls, dnatCall{rule: dnatRule{protocol: protocol, ip: ip, port: translatedPort}}) + return nil +} + +func newDNATTestService(fw Firewall) *serviceViaListener { + return &serviceViaListener{ + listenIP: netip.MustParseAddr("100.64.0.1"), + listenPort: customPort, + firewall: fw, + } +} + +func TestSetupDNAT_BothProtocols(t *testing.T) { + svc := newDNATTestService(&fakeFirewall{}) + + svc.setupDNAT() + + assert.Len(t, svc.dnatRules, len(dnatProtocols)) + assert.Equal(t, DefaultPort, svc.RuntimePort(), "port 53 is advertised once both redirects are installed") +} + +func TestSetupDNAT_RollsBackPartialRedirect(t *testing.T) { + fw := &fakeFirewall{addErrs: map[firewall.Protocol]error{firewall.ProtocolTCP: errors.New("nftables busy")}} + svc := newDNATTestService(fw) + + svc.setupDNAT() + + assert.Empty(t, svc.dnatRules, "the UDP redirect installed before the failure must be rolled back") + assert.Equal(t, int(svc.listenPort), svc.RuntimePort(), "an incomplete redirect must not advertise port 53") + udp := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: svc.listenPort} + assert.Contains(t, fw.calls, dnatCall{rule: udp}, "UDP removal should have been attempted") +} + +// A rollback that fails must keep the rule recorded, so port 53 is not left +// redirected to a resolver that no longer listens. +func TestStop_RetriesFailedDNATRemoval(t *testing.T) { + fw := &fakeFirewall{ + addErrs: map[firewall.Protocol]error{firewall.ProtocolTCP: errors.New("nftables busy")}, + removeErrs: map[firewall.Protocol]error{firewall.ProtocolUDP: errors.New("nftables busy")}, + } + svc := newDNATTestService(fw) + + svc.setupDNAT() + udp := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: svc.listenPort} + require.Equal(t, []dnatRule{udp}, svc.dnatRules, "a failed rollback keeps the rule for a later retry") + + require.Error(t, svc.Stop(), "the failing removal should be reported") + require.Equal(t, []dnatRule{udp}, svc.dnatRules) + + delete(fw.removeErrs, firewall.ProtocolUDP) + require.NoError(t, svc.Stop(), "a later stop retries the removal") + assert.Empty(t, svc.dnatRules) +} + +// A stale rule that cannot be removed is matched before anything added now, so +// no new redirect may be installed on top of it and port 53 must not be +// advertised as reaching this listener. +func TestSetupDNAT_AbortsWhileStaleRuleRemains(t *testing.T) { + fw := &fakeFirewall{removeErrs: map[firewall.Protocol]error{firewall.ProtocolUDP: errors.New("nftables busy")}} + svc := newDNATTestService(fw) + stalePort := svc.listenPort + + svc.setupDNAT() + require.Error(t, svc.Stop()) + staleUDP := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: stalePort} + require.Equal(t, []dnatRule{staleUDP}, svc.dnatRules) + + svc.listenPort = stalePort + 1 + fw.calls = nil + + svc.setupDNAT() + + assert.Equal(t, []dnatRule{staleUDP}, svc.dnatRules, "the stale rule stays recorded for a later attempt") + for _, call := range fw.calls { + assert.False(t, call.added, "no redirect may be installed while a stale one is still in place") + } + assert.Equal(t, int(svc.listenPort), svc.RuntimePort(), "port 53 must not be advertised") +} + +// A rule left behind by a failed removal must be removed with the address and +// port it was installed with, even when the listener has since moved to another +// port, and it must not count towards the redirect the new listener advertises. +func TestSetupDNAT_ClearsStaleRuleAfterPortChange(t *testing.T) { + fw := &fakeFirewall{removeErrs: map[firewall.Protocol]error{firewall.ProtocolUDP: errors.New("nftables busy")}} + svc := newDNATTestService(fw) + stalePort := svc.listenPort + + svc.setupDNAT() + require.Error(t, svc.Stop()) + staleUDP := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: stalePort} + require.Equal(t, []dnatRule{staleUDP}, svc.dnatRules) + + delete(fw.removeErrs, firewall.ProtocolUDP) + svc.listenPort = stalePort + 1 + fw.calls = nil + + svc.setupDNAT() + + assert.Contains(t, fw.calls, dnatCall{rule: staleUDP}, "the stale rule must be removed with its original port") + assert.Len(t, svc.dnatRules, len(dnatProtocols)) + assert.Equal(t, DefaultPort, svc.RuntimePort(), "the new listener is fully redirected") + for _, rule := range svc.dnatRules { + assert.Equal(t, svc.listenPort, rule.port, "only rules for the current listener remain") + } +} diff --git a/client/internal/dns/systemd_linux.go b/client/internal/dns/systemd_linux.go index d9854c033..bd301e177 100644 --- a/client/internal/dns/systemd_linux.go +++ b/client/internal/dns/systemd_linux.go @@ -8,6 +8,7 @@ import ( "fmt" "net" "net/netip" + "slices" "time" "github.com/godbus/dbus/v5" @@ -40,10 +41,17 @@ const ( ) type systemdDbusConfigurator struct { - dbusLinkObject dbus.ObjectPath - ifaceName string + dbusLinkObject dbus.ObjectPath + ifaceName string + wgIndex int + origNameservers []netip.Addr } +const ( + systemdDbusLinkDNSProperty = systemdDbusLinkInterface + ".DNS" + systemdDbusLinkDefaultRouteProperty = systemdDbusLinkInterface + ".DefaultRoute" +) + // the types below are based on dbus specification, each field is mapped to a dbus type // see https://dbus.freedesktop.org/doc/dbus-specification.html#basic-types for more details on dbus types // see https://www.freedesktop.org/software/systemd/man/org.freedesktop.resolve1.html on resolve1 input types @@ -79,10 +87,145 @@ func newSystemdDbusConfigurator(wgInterface string) (*systemdDbusConfigurator, e log.Debugf("got dbus Link interface: %s from net interface %s and index %d", s, iface.Name, iface.Index) - return &systemdDbusConfigurator{ + c := &systemdDbusConfigurator{ dbusLinkObject: dbus.ObjectPath(s), ifaceName: wgInterface, - }, nil + wgIndex: iface.Index, + } + + origNameservers, err := c.captureOriginalNameservers() + switch { + case err != nil: + log.Warnf("capture original nameservers from systemd-resolved: %v", err) + case len(origNameservers) == 0: + log.Warnf("no original nameservers captured from systemd-resolved default-route links; DNS fallback will be empty") + default: + log.Debugf("captured %d original nameservers from systemd-resolved default-route links: %v", len(origNameservers), origNameservers) + } + c.origNameservers = origNameservers + return c, nil +} + +// captureOriginalNameservers reads per-link DNS from systemd-resolved for +// every default-route link except our own WG link. Non-default-route links +// (VPNs, docker bridges) are skipped because their upstreams wouldn't +// actually serve host queries. +func (s *systemdDbusConfigurator) captureOriginalNameservers() ([]netip.Addr, error) { + ifaces, err := net.Interfaces() + if err != nil { + return nil, fmt.Errorf("list interfaces: %w", err) + } + + seen := make(map[netip.Addr]struct{}) + var out []netip.Addr + for _, iface := range ifaces { + if !s.isCandidateLink(iface) { + continue + } + linkPath, err := getSystemdLinkPath(iface.Index) + if err != nil || !isSystemdLinkDefaultRoute(linkPath) { + continue + } + for _, addr := range readSystemdLinkDNS(linkPath) { + addr = normalizeSystemdAddr(addr, iface.Name) + if !addr.IsValid() { + continue + } + if _, dup := seen[addr]; dup { + continue + } + seen[addr] = struct{}{} + out = append(out, addr) + } + } + return out, nil +} + +func (s *systemdDbusConfigurator) isCandidateLink(iface net.Interface) bool { + if iface.Index == s.wgIndex { + return false + } + if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 { + return false + } + return true +} + +// normalizeSystemdAddr unmaps v4-mapped-v6, drops unspecified, and reattaches +// the link's iface name as zone for link-local v6 (Link.DNS strips it). +// Returns the zero Addr to signal "skip this entry". +func normalizeSystemdAddr(addr netip.Addr, ifaceName string) netip.Addr { + addr = addr.Unmap() + if !addr.IsValid() || addr.IsUnspecified() { + return netip.Addr{} + } + if addr.IsLinkLocalUnicast() { + return addr.WithZone(ifaceName) + } + return addr +} + +func getSystemdLinkPath(ifIndex int) (dbus.ObjectPath, error) { + obj, closeConn, err := getDbusObject(systemdResolvedDest, systemdDbusObjectNode) + if err != nil { + return "", fmt.Errorf("dbus resolve1: %w", err) + } + defer closeConn() + var p string + if err := obj.Call(systemdDbusGetLinkMethod, dbusDefaultFlag, int32(ifIndex)).Store(&p); err != nil { + return "", err + } + return dbus.ObjectPath(p), nil +} + +func isSystemdLinkDefaultRoute(linkPath dbus.ObjectPath) bool { + obj, closeConn, err := getDbusObject(systemdResolvedDest, linkPath) + if err != nil { + return false + } + defer closeConn() + v, err := obj.GetProperty(systemdDbusLinkDefaultRouteProperty) + if err != nil { + return false + } + b, ok := v.Value().(bool) + return ok && b +} + +func readSystemdLinkDNS(linkPath dbus.ObjectPath) []netip.Addr { + obj, closeConn, err := getDbusObject(systemdResolvedDest, linkPath) + if err != nil { + return nil + } + defer closeConn() + v, err := obj.GetProperty(systemdDbusLinkDNSProperty) + if err != nil { + return nil + } + entries, ok := v.Value().([][]any) + if !ok { + return nil + } + var out []netip.Addr + for _, entry := range entries { + if len(entry) < 2 { + continue + } + raw, ok := entry[1].([]byte) + if !ok { + continue + } + addr, ok := netip.AddrFromSlice(raw) + if !ok { + continue + } + out = append(out, addr) + } + return out +} + +func (s *systemdDbusConfigurator) getOriginalNameservers() []netip.Addr { + return slices.Clone(s.origNameservers) } func (s *systemdDbusConfigurator) supportCustomPort() bool { @@ -90,8 +233,12 @@ func (s *systemdDbusConfigurator) supportCustomPort() bool { } func (s *systemdDbusConfigurator) applyDNSConfig(config HostDNSConfig, stateManager *statemanager.Manager) error { + family := int32(unix.AF_INET) + if config.ServerIP.Is6() { + family = unix.AF_INET6 + } defaultLinkInput := systemdDbusDNSInput{ - Family: unix.AF_INET, + Family: family, Address: config.ServerIP.AsSlice(), } if err := s.callLinkMethod(systemdDbusSetDNSMethodSuffix, []systemdDbusDNSInput{defaultLinkInput}); err != nil { diff --git a/client/internal/dns/unclean_shutdown_windows.go b/client/internal/dns/unclean_shutdown_windows.go index 24a9eca50..ab0b2cc63 100644 --- a/client/internal/dns/unclean_shutdown_windows.go +++ b/client/internal/dns/unclean_shutdown_windows.go @@ -5,9 +5,8 @@ import ( ) type ShutdownState struct { - Guid string - GPO bool - NRPTEntryCount int + Guid string + GPO bool } func (s *ShutdownState) Name() string { @@ -16,9 +15,8 @@ func (s *ShutdownState) Name() string { func (s *ShutdownState) Cleanup() error { manager := ®istryConfigurator{ - guid: s.Guid, - gpo: s.GPO, - nrptEntryCount: s.NRPTEntryCount, + guid: s.Guid, + gpo: s.GPO, } if err := manager.restoreUncleanShutdownDNS(); err != nil { diff --git a/client/internal/dns/upstream.go b/client/internal/dns/upstream.go index 746b73ca7..72fc0450c 100644 --- a/client/internal/dns/upstream.go +++ b/client/internal/dns/upstream.go @@ -1,3 +1,32 @@ +// Package dns implements the client-side DNS stack: listener/service on the +// peer's tunnel address, handler chain that routes questions by domain and +// priority, and upstream resolvers that forward what remains to configured +// nameservers. +// +// # Upstream resolution and the race model +// +// When two or more nameserver groups target the same domain, DefaultServer +// merges them into one upstream handler whose state is: +// +// upstreamResolverBase +// └── upstreamServers []upstreamRace // one entry per source NS group +// └── []netip.AddrPort // primary, fallback, ... +// +// Each source nameserver group contributes one upstreamRace. Within a race +// upstreams are tried in order: the next is used only on failure (timeout, +// SERVFAIL, REFUSED, no response). NXDOMAIN is a valid answer and stops +// the walk. When more than one race exists, ServeDNS fans out one +// goroutine per race and returns the first valid answer, cancelling the +// rest. A handler with a single race skips the fan-out. +// +// # Health projection +// +// Query outcomes are recorded per-upstream in UpstreamHealth. The server +// periodically merges these snapshots across handlers and projects them +// into peer.NSGroupState. There is no active probing: a group is marked +// unhealthy only when every seen upstream has a recent failure and none +// has a recent success. Healthy→unhealthy fires a single +// SystemEvent_WARNING; steady-state refreshes do not duplicate it. package dns import ( @@ -11,24 +40,50 @@ import ( "slices" "strings" "sync" - "sync/atomic" "time" - "github.com/cenkalti/backoff/v4" - "github.com/hashicorp/go-multierror" "github.com/miekg/dns" log "github.com/sirupsen/logrus" "golang.zx2c4.com/wireguard/tun/netstack" "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/internal/dns/resutil" "github.com/netbirdio/netbird/client/internal/dns/types" "github.com/netbirdio/netbird/client/internal/peer" - "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/domain" ) var currentMTU uint16 = iface.DefaultMTU +// nonRetryableEDECodes lists EDE info codes (RFC 8914) for which a SERVFAIL +// from one upstream means another upstream would return the same answer: +// DNSSEC validation outcomes and policy-based blocks. Transient errors +// (network, cached, not ready) are not included. +var nonRetryableEDECodes = map[uint16]struct{}{ + dns.ExtendedErrorCodeUnsupportedDNSKEYAlgorithm: {}, + dns.ExtendedErrorCodeUnsupportedDSDigestType: {}, + dns.ExtendedErrorCodeDNSSECIndeterminate: {}, + dns.ExtendedErrorCodeDNSBogus: {}, + dns.ExtendedErrorCodeSignatureExpired: {}, + dns.ExtendedErrorCodeSignatureNotYetValid: {}, + dns.ExtendedErrorCodeDNSKEYMissing: {}, + dns.ExtendedErrorCodeRRSIGsMissing: {}, + dns.ExtendedErrorCodeNoZoneKeyBitSet: {}, + dns.ExtendedErrorCodeNSECMissing: {}, + dns.ExtendedErrorCodeBlocked: {}, + dns.ExtendedErrorCodeCensored: {}, + dns.ExtendedErrorCodeFiltered: {}, + dns.ExtendedErrorCodeProhibited: {}, +} + +// privateClientIface is the subset of the WireGuard interface needed by GetClientPrivate. +type privateClientIface interface { + Name() string + Address() wgaddr.Address +} + func SetCurrentMTU(mtu uint16) { currentMTU = mtu } @@ -39,15 +94,17 @@ const ( // Set longer than UpstreamTimeout to ensure context timeout takes precedence ClientTimeout = 5 * time.Second - reactivatePeriod = 30 * time.Second - probeTimeout = 2 * time.Second - // ipv6HeaderSize + udpHeaderSize, used to derive the maximum DNS UDP // payload from the tunnel MTU. ipUDPHeaderSize = 60 + 8 -) -const testRecord = "com." + // raceMaxTotalTimeout caps the combined time spent walking all upstreams + // within one race, so a slow primary can't eat the whole race budget. + raceMaxTotalTimeout = 5 * time.Second + // raceMinPerUpstreamTimeout is the floor applied when dividing + // raceMaxTotalTimeout across upstreams within a race. + raceMinPerUpstreamTimeout = 2 * time.Second +) const ( protoUDP = "udp" @@ -56,6 +113,69 @@ const ( type dnsProtocolKey struct{} +type upstreamProtocolKey struct{} + +// upstreamProtocolResult holds the protocol used for the upstream exchange. +// Stored as a pointer in context so the exchange function can set it. +type upstreamProtocolResult struct { + protocol string +} + +type upstreamClient interface { + exchange(ctx context.Context, upstream string, r *dns.Msg) (*dns.Msg, time.Duration, error) +} + +type UpstreamResolver interface { + serveDNS(r *dns.Msg) (*dns.Msg, time.Duration, error) + upstreamExchange(upstream string, r *dns.Msg) (*dns.Msg, time.Duration, error) +} + +// upstreamRace is an ordered list of upstreams derived from one configured +// nameserver group. Order matters: the first upstream is tried first, the +// second only on failure, and so on. Multiple upstreamRace values coexist +// inside one resolver when overlapping nameserver groups target the same +// domain; those races run in parallel and the first valid answer wins. +type upstreamRace []netip.AddrPort + +// UpstreamHealth is the last query-path outcome for a single upstream, +// consumed by nameserver-group status projection. +type UpstreamHealth struct { + LastOk time.Time + LastFail time.Time + LastErr string +} + +type upstreamResolverBase struct { + ctx context.Context + cancel context.CancelFunc + upstreamClient upstreamClient + upstreamServers []upstreamRace + domain domain.Domain + upstreamTimeout time.Duration + + healthMu sync.RWMutex + health map[netip.AddrPort]*UpstreamHealth + + statusRecorder *peer.Status + // selectedRoutes returns the current set of client routes the admin + // has enabled. Called lazily from the query hot path when an upstream + // might need a tunnel-bound client (iOS) and from health projection. + selectedRoutes func() route.HAMap +} + +type upstreamFailure struct { + upstream netip.AddrPort + reason string +} + +type raceResult struct { + msg *dns.Msg + upstream netip.AddrPort + protocol string + ede string + failures []upstreamFailure +} + // contextWithDNSProtocol stores the inbound DNS protocol ("udp" or "tcp") in context. func contextWithDNSProtocol(ctx context.Context, network string) context.Context { return context.WithValue(ctx, dnsProtocolKey{}, network) @@ -72,16 +192,8 @@ func dnsProtocolFromContext(ctx context.Context) string { return "" } -type upstreamProtocolKey struct{} - -// upstreamProtocolResult holds the protocol used for the upstream exchange. -// Stored as a pointer in context so the exchange function can set it. -type upstreamProtocolResult struct { - protocol string -} - -// contextWithupstreamProtocolResult stores a mutable result holder in the context. -func contextWithupstreamProtocolResult(ctx context.Context) (context.Context, *upstreamProtocolResult) { +// contextWithUpstreamProtocolResult stores a mutable result holder in the context. +func contextWithUpstreamProtocolResult(ctx context.Context) (context.Context, *upstreamProtocolResult) { r := &upstreamProtocolResult{} return context.WithValue(ctx, upstreamProtocolKey{}, r), r } @@ -96,67 +208,37 @@ func setUpstreamProtocol(ctx context.Context, protocol string) { } } -type upstreamClient interface { - exchange(ctx context.Context, upstream string, r *dns.Msg) (*dns.Msg, time.Duration, error) -} - -type UpstreamResolver interface { - serveDNS(r *dns.Msg) (*dns.Msg, time.Duration, error) - upstreamExchange(upstream string, r *dns.Msg) (*dns.Msg, time.Duration, error) -} - -type upstreamResolverBase struct { - ctx context.Context - cancel context.CancelFunc - upstreamClient upstreamClient - upstreamServers []netip.AddrPort - domain string - disabled bool - successCount atomic.Int32 - mutex sync.Mutex - reactivatePeriod time.Duration - upstreamTimeout time.Duration - wg sync.WaitGroup - - deactivate func(error) - reactivate func() - statusRecorder *peer.Status - routeMatch func(netip.Addr) bool -} - -type upstreamFailure struct { - upstream netip.AddrPort - reason string -} - -func newUpstreamResolverBase(ctx context.Context, statusRecorder *peer.Status, domain string) *upstreamResolverBase { +func newUpstreamResolverBase(ctx context.Context, statusRecorder *peer.Status, d domain.Domain) *upstreamResolverBase { ctx, cancel := context.WithCancel(ctx) return &upstreamResolverBase{ - ctx: ctx, - cancel: cancel, - domain: domain, - upstreamTimeout: UpstreamTimeout, - reactivatePeriod: reactivatePeriod, - statusRecorder: statusRecorder, + ctx: ctx, + cancel: cancel, + domain: d, + upstreamTimeout: UpstreamTimeout, + statusRecorder: statusRecorder, } } // String returns a string representation of the upstream resolver func (u *upstreamResolverBase) String() string { - return fmt.Sprintf("Upstream %s", u.upstreamServers) + return fmt.Sprintf("Upstream %s", u.flatUpstreams()) } -// ID returns the unique handler ID +// ID returns the unique handler ID. Race groupings and within-race +// ordering are both part of the identity: [[A,B]] and [[A],[B]] query +// the same servers but with different semantics (serial fallback vs +// parallel race), so their handlers must not collide. func (u *upstreamResolverBase) ID() types.HandlerID { - servers := slices.Clone(u.upstreamServers) - slices.SortFunc(servers, func(a, b netip.AddrPort) int { return a.Compare(b) }) - hash := sha256.New() - hash.Write([]byte(u.domain + ":")) - for _, s := range servers { - hash.Write([]byte(s.String())) - hash.Write([]byte("|")) + hash.Write([]byte(u.domain.PunycodeString() + ":")) + for _, race := range u.upstreamServers { + hash.Write([]byte("[")) + for _, s := range race { + hash.Write([]byte(s.String())) + hash.Write([]byte("|")) + } + hash.Write([]byte("]")) } return types.HandlerID("upstream-" + hex.EncodeToString(hash.Sum(nil)[:8])) } @@ -166,13 +248,31 @@ func (u *upstreamResolverBase) MatchSubdomains() bool { } func (u *upstreamResolverBase) Stop() { - log.Debugf("stopping serving DNS for upstreams %s", u.upstreamServers) + log.Debugf("stopping serving DNS for upstreams %s", u.flatUpstreams()) u.cancel() +} - u.mutex.Lock() - u.wg.Wait() - u.mutex.Unlock() +// flatUpstreams is for logging and ID hashing only, not for dispatch. +func (u *upstreamResolverBase) flatUpstreams() []netip.AddrPort { + var out []netip.AddrPort + for _, g := range u.upstreamServers { + out = append(out, g...) + } + return out +} +// setSelectedRoutes swaps the accessor used to classify overlay-routed +// upstreams. Called when route sources are wired after the handler was +// built (permanent / iOS constructors). +func (u *upstreamResolverBase) setSelectedRoutes(selected func() route.HAMap) { + u.selectedRoutes = selected +} + +func (u *upstreamResolverBase) addRace(servers []netip.AddrPort) { + if len(servers) == 0 { + return + } + u.upstreamServers = append(u.upstreamServers, slices.Clone(servers)) } // ServeDNS handles a DNS request @@ -214,59 +314,213 @@ func (u *upstreamResolverBase) prepareRequest(r *dns.Msg) { } func (u *upstreamResolverBase) tryUpstreamServers(ctx context.Context, w dns.ResponseWriter, r *dns.Msg, logger *log.Entry) (bool, []upstreamFailure) { - timeout := u.upstreamTimeout - if len(u.upstreamServers) > 1 { - maxTotal := 5 * time.Second - minPerUpstream := 2 * time.Second - scaledTimeout := maxTotal / time.Duration(len(u.upstreamServers)) - if scaledTimeout > minPerUpstream { - timeout = scaledTimeout - } else { - timeout = minPerUpstream - } + groups := u.upstreamServers + switch len(groups) { + case 0: + return false, nil + case 1: + return u.tryOnlyRace(ctx, w, r, groups[0], logger) + default: + return u.raceAll(ctx, w, r, groups, logger) + } +} + +func (u *upstreamResolverBase) tryOnlyRace(ctx context.Context, w dns.ResponseWriter, r *dns.Msg, group upstreamRace, logger *log.Entry) (bool, []upstreamFailure) { + res := u.tryRace(ctx, r, group) + if res.msg == nil { + return false, res.failures + } + if res.ede != "" { + resutil.SetMeta(w, "ede", res.ede) + } + u.writeSuccessResponse(w, res.msg, res.upstream, r.Question[0].Name, res.protocol, logger) + return true, res.failures +} + +// raceAll runs one worker per group in parallel, taking the first valid +// answer and cancelling the rest. +func (u *upstreamResolverBase) raceAll(ctx context.Context, w dns.ResponseWriter, r *dns.Msg, groups []upstreamRace, logger *log.Entry) (bool, []upstreamFailure) { + raceCtx, cancel := context.WithCancel(ctx) + defer cancel() + + // Buffer sized to len(groups) so workers never block on send, even + // after the coordinator has returned. + results := make(chan raceResult, len(groups)) + for _, g := range groups { + // tryRace clones the request per attempt, so workers never share + // a *dns.Msg and concurrent EDNS0 mutations can't race. + go func(g upstreamRace) { + results <- u.tryRace(raceCtx, r, g) + }(g) } var failures []upstreamFailure - for _, upstream := range u.upstreamServers { - if failure := u.queryUpstream(ctx, w, r, upstream, timeout, logger); failure != nil { - failures = append(failures, *failure) - } else { - return true, failures + for range groups { + select { + case res := <-results: + failures = append(failures, res.failures...) + if res.msg != nil { + if res.ede != "" { + resutil.SetMeta(w, "ede", res.ede) + } + u.writeSuccessResponse(w, res.msg, res.upstream, r.Question[0].Name, res.protocol, logger) + return true, failures + } + case <-ctx.Done(): + return false, failures } } return false, failures } -// queryUpstream queries a single upstream server. Returns nil on success, or failure info to try next upstream. -func (u *upstreamResolverBase) queryUpstream(parentCtx context.Context, w dns.ResponseWriter, r *dns.Msg, upstream netip.AddrPort, timeout time.Duration, logger *log.Entry) *upstreamFailure { - var rm *dns.Msg - var t time.Duration - var err error - - var startTime time.Time - var upstreamProto *upstreamProtocolResult - func() { - ctx, cancel := context.WithTimeout(parentCtx, timeout) +func (u *upstreamResolverBase) tryRace(ctx context.Context, r *dns.Msg, group upstreamRace) raceResult { + timeout := u.upstreamTimeout + if len(group) > 1 { + // Cap the whole walk at raceMaxTotalTimeout: per-upstream timeouts + // still honor raceMinPerUpstreamTimeout as a floor for correctness + // on slow links, but the outer context ensures the combined walk + // cannot exceed the cap regardless of group size. + timeout = max(raceMaxTotalTimeout/time.Duration(len(group)), raceMinPerUpstreamTimeout) + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, raceMaxTotalTimeout) defer cancel() - ctx, upstreamProto = contextWithupstreamProtocolResult(ctx) - startTime = time.Now() - rm, t, err = u.upstreamClient.exchange(ctx, upstream.String(), r) - }() + } + + var failures []upstreamFailure + for _, upstream := range group { + if ctx.Err() != nil { + return raceResult{failures: failures} + } + // Clone the request per attempt: the exchange path mutates EDNS0 + // options in-place, so reusing the same *dns.Msg across sequential + // upstreams would carry those mutations (e.g. a reduced UDP size) + // into the next attempt. + res, failure := u.queryUpstream(ctx, r.Copy(), upstream, timeout) + if failure != nil { + failures = append(failures, *failure) + continue + } + res.failures = failures + return res + } + return raceResult{failures: failures} +} + +func (u *upstreamResolverBase) queryUpstream(parentCtx context.Context, r *dns.Msg, upstream netip.AddrPort, timeout time.Duration) (raceResult, *upstreamFailure) { + ctx, cancel := context.WithTimeout(parentCtx, timeout) + defer cancel() + ctx, upstreamProto := contextWithUpstreamProtocolResult(ctx) + + // Advertise EDNS0 so the upstream may include Extended DNS Errors + // (RFC 8914) in failure responses; we use those to short-circuit + // failover for definitive answers like DNSSEC validation failures. + // The caller already passed a per-attempt copy, so we can mutate r + // directly; hadEdns reflects the original client request's state and + // controls whether we strip the OPT from the response. + hadEdns := r.IsEdns0() != nil + if !hadEdns { + r.SetEdns0(upstreamUDPSize(), false) + } + + startTime := time.Now() + rm, _, err := u.upstreamClient.exchange(ctx, upstream.String(), r) if err != nil { - return u.handleUpstreamError(err, upstream, startTime) + // A parent cancellation (e.g., another race won and the coordinator + // cancelled the losers) is not an upstream failure. Check both the + // error chain and the parent context: a transport may surface the + // cancellation as a read/deadline error rather than context.Canceled. + if errors.Is(err, context.Canceled) || errors.Is(parentCtx.Err(), context.Canceled) { + return raceResult{}, &upstreamFailure{upstream: upstream, reason: "canceled"} + } + failure := u.handleUpstreamError(err, upstream, startTime) + u.markUpstreamFail(upstream, failure.reason) + return raceResult{}, failure } if rm == nil || !rm.Response { - return &upstreamFailure{upstream: upstream, reason: "no response"} + u.markUpstreamFail(upstream, "no response") + return raceResult{}, &upstreamFailure{upstream: upstream, reason: "no response"} + } + + // A valid response means the upstream is reachable, whatever the Rcode. + u.markUpstreamOk(upstream) + + proto := "" + if upstreamProto != nil { + proto = upstreamProto.protocol } if rm.Rcode == dns.RcodeServerFailure || rm.Rcode == dns.RcodeRefused { - return &upstreamFailure{upstream: upstream, reason: dns.RcodeToString[rm.Rcode]} + // SERVFAIL and REFUSED are per-question outcomes (DNSSEC-bogus names, + // refused zones, transient recursion errors), not reachability + // problems: fail over for a better answer but keep the upstream healthy. + if code, ok := nonRetryableEDE(rm); ok { + if !hadEdns { + resutil.StripOPT(rm) + } + return raceResult{msg: rm, upstream: upstream, protocol: proto, ede: edeName(code)}, nil + } + reason := dns.RcodeToString[rm.Rcode] + return raceResult{}, &upstreamFailure{upstream: upstream, reason: reason} } - u.writeSuccessResponse(w, rm, upstream, r.Question[0].Name, t, upstreamProto, logger) - return nil + if !hadEdns { + resutil.StripOPT(rm) + } + + return raceResult{msg: rm, upstream: upstream, protocol: proto}, nil +} + +// healthEntry returns the mutable health record for addr, lazily creating +// the map and the entry. Caller must hold u.healthMu. +func (u *upstreamResolverBase) healthEntry(addr netip.AddrPort) *UpstreamHealth { + if u.health == nil { + u.health = make(map[netip.AddrPort]*UpstreamHealth) + } + h := u.health[addr] + if h == nil { + h = &UpstreamHealth{} + u.health[addr] = h + } + return h +} + +func (u *upstreamResolverBase) markUpstreamOk(addr netip.AddrPort) { + u.healthMu.Lock() + defer u.healthMu.Unlock() + h := u.healthEntry(addr) + h.LastOk = time.Now() + h.LastFail = time.Time{} + h.LastErr = "" +} + +func (u *upstreamResolverBase) markUpstreamFail(addr netip.AddrPort, reason string) { + u.healthMu.Lock() + defer u.healthMu.Unlock() + h := u.healthEntry(addr) + h.LastFail = time.Now() + h.LastErr = reason +} + +// UpstreamHealth returns a snapshot of per-upstream query outcomes. +func (u *upstreamResolverBase) UpstreamHealth() map[netip.AddrPort]UpstreamHealth { + u.healthMu.RLock() + defer u.healthMu.RUnlock() + out := make(map[netip.AddrPort]UpstreamHealth, len(u.health)) + for k, v := range u.health { + out[k] = *v + } + return out +} + +// upstreamUDPSize returns the EDNS0 UDP buffer size we advertise to upstreams, +// derived from the tunnel MTU and bounded against underflow. +func upstreamUDPSize() uint16 { + if currentMTU > ipUDPHeaderSize { + return currentMTU - ipUDPHeaderSize + } + return dns.MinMsgSize } func (u *upstreamResolverBase) handleUpstreamError(err error, upstream netip.AddrPort, startTime time.Time) *upstreamFailure { @@ -282,12 +536,23 @@ func (u *upstreamResolverBase) handleUpstreamError(err error, upstream netip.Add return &upstreamFailure{upstream: upstream, reason: reason} } -func (u *upstreamResolverBase) writeSuccessResponse(w dns.ResponseWriter, rm *dns.Msg, upstream netip.AddrPort, domain string, t time.Duration, upstreamProto *upstreamProtocolResult, logger *log.Entry) bool { - u.successCount.Add(1) +func (u *upstreamResolverBase) debugUpstreamTimeout(upstream netip.AddrPort) string { + if u.statusRecorder == nil { + return "" + } + peerInfo := findPeerForIP(upstream.Addr(), u.statusRecorder) + if peerInfo == nil { + return "" + } + + return fmt.Sprintf("(routes through NetBird peer %s)", FormatPeerStatus(peerInfo)) +} + +func (u *upstreamResolverBase) writeSuccessResponse(w dns.ResponseWriter, rm *dns.Msg, upstream netip.AddrPort, domain string, proto string, logger *log.Entry) { resutil.SetMeta(w, "upstream", upstream.String()) - if upstreamProto != nil && upstreamProto.protocol != "" { - resutil.SetMeta(w, "upstream_protocol", upstreamProto.protocol) + if proto != "" { + resutil.SetMeta(w, "upstream_protocol", proto) } // Clear Zero bit from external responses to prevent upstream servers from @@ -296,14 +561,11 @@ func (u *upstreamResolverBase) writeSuccessResponse(w dns.ResponseWriter, rm *dn if err := w.WriteMsg(rm); err != nil { logger.Errorf("failed to write DNS response for question domain=%s: %s", domain, err) - return true } - - return true } func (u *upstreamResolverBase) logUpstreamFailures(domain string, failures []upstreamFailure, succeeded bool, logger *log.Entry) { - totalUpstreams := len(u.upstreamServers) + totalUpstreams := len(u.flatUpstreams()) failedCount := len(failures) failureSummary := formatFailures(failures) @@ -330,117 +592,32 @@ func formatFailures(failures []upstreamFailure) string { return strings.Join(parts, ", ") } -// ProbeAvailability tests all upstream servers simultaneously and -// disables the resolver if none work -func (u *upstreamResolverBase) ProbeAvailability(ctx context.Context) { - u.mutex.Lock() - defer u.mutex.Unlock() - - // avoid probe if upstreams could resolve at least one query - if u.successCount.Load() > 0 { - return +// nonRetryableEDE returns the first non-retryable EDE code carried in the +// response, if any. +func nonRetryableEDE(rm *dns.Msg) (uint16, bool) { + opt := rm.IsEdns0() + if opt == nil { + return 0, false } - - var success bool - var mu sync.Mutex - var wg sync.WaitGroup - - var errs *multierror.Error - for _, upstream := range u.upstreamServers { - wg.Add(1) - go func(upstream netip.AddrPort) { - defer wg.Done() - err := u.testNameserver(u.ctx, ctx, upstream, 500*time.Millisecond) - if err != nil { - mu.Lock() - errs = multierror.Append(errs, err) - mu.Unlock() - log.Warnf("probing upstream nameserver %s: %s", upstream, err) - return - } - - mu.Lock() - 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(errs.ErrorOrNil()) - - if u.statusRecorder == nil { - return + for _, o := range opt.Option { + ede, ok := o.(*dns.EDNS0_EDE) + if !ok { + continue + } + if _, ok := nonRetryableEDECodes[ede.InfoCode]; ok { + return ede.InfoCode, true } - - u.statusRecorder.PublishEvent( - proto.SystemEvent_WARNING, - proto.SystemEvent_DNS, - "All upstream servers failed (probe failed)", - "Unable to reach one or more DNS servers. This might affect your ability to connect to some services.", - map[string]string{"upstreams": u.upstreamServersString()}, - ) } + return 0, false } -// waitUntilResponse retries, in an exponential interval, querying the upstream servers until it gets a positive response -func (u *upstreamResolverBase) waitUntilResponse() { - exponentialBackOff := &backoff.ExponentialBackOff{ - InitialInterval: 500 * time.Millisecond, - RandomizationFactor: 0.5, - Multiplier: 1.1, - MaxInterval: u.reactivatePeriod, - MaxElapsedTime: 0, - Stop: backoff.Stop, - Clock: backoff.SystemClock, +// edeName returns a human-readable name for an EDE code, falling back to +// the numeric code when unknown. +func edeName(code uint16) string { + if name, ok := dns.ExtendedErrorCodeToString[code]; ok { + return name } - - operation := func() error { - select { - case <-u.ctx.Done(): - return backoff.Permanent(fmt.Errorf("exiting upstream retry loop for upstreams %s: parent context has been canceled", u.upstreamServersString())) - default: - } - - for _, upstream := range u.upstreamServers { - 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 - return nil - } - } - - log.Tracef("checking connectivity with upstreams %s failed. Retrying in %s", u.upstreamServersString(), exponentialBackOff.NextBackOff()) - return fmt.Errorf("upstream check call error") - } - - err := backoff.Retry(operation, backoff.WithContext(exponentialBackOff, u.ctx)) - if err != nil { - 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() + return fmt.Sprintf("EDE %d", code) } // isTimeout returns true if the given error is a network timeout error. @@ -454,45 +631,6 @@ func isTimeout(err error) bool { return false } -func (u *upstreamResolverBase) disable(err error) { - if u.disabled { - return - } - - log.Warnf("Upstream resolving is Disabled for %v", reactivatePeriod) - u.successCount.Store(0) - u.deactivate(err) - u.disabled = true - u.wg.Add(1) - go func() { - defer u.wg.Done() - u.waitUntilResponse() - }() -} - -func (u *upstreamResolverBase) upstreamServersString() string { - var servers []string - for _, server := range u.upstreamServers { - servers = append(servers, server.String()) - } - return strings.Join(servers, ", ") -} - -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(mergedCtx, server.String(), r) - return err -} - // clientUDPMaxSize returns the maximum UDP response size the client accepts. func clientUDPMaxSize(r *dns.Msg) int { if opt := r.IsEdns0(); opt != nil { @@ -504,13 +642,10 @@ func clientUDPMaxSize(r *dns.Msg) int { // ExchangeWithFallback exchanges a DNS message with the upstream server. // It first tries to use UDP, and if it is truncated, it falls back to TCP. // If the inbound request came over TCP (via context), it skips the UDP attempt. -// If the passed context is nil, this will use Exchange instead of ExchangeContext. func ExchangeWithFallback(ctx context.Context, client *dns.Client, r *dns.Msg, upstream string) (*dns.Msg, time.Duration, error) { // If the request came in over TCP, go straight to TCP upstream. if dnsProtocolFromContext(ctx) == protoTCP { - tcpClient := *client - tcpClient.Net = protoTCP - rm, t, err := tcpClient.ExchangeContext(ctx, r, upstream) + rm, t, err := toTCPClient(client).ExchangeContext(ctx, r, upstream) if err != nil { return nil, t, fmt.Errorf("with tcp: %w", err) } @@ -530,18 +665,7 @@ func ExchangeWithFallback(ctx context.Context, client *dns.Client, r *dns.Msg, u opt.SetUDPSize(maxUDPPayload) } - var ( - rm *dns.Msg - t time.Duration - err error - ) - - if ctx == nil { - rm, t, err = client.Exchange(r, upstream) - } else { - rm, t, err = client.ExchangeContext(ctx, r, upstream) - } - + rm, t, err := client.ExchangeContext(ctx, r, upstream) if err != nil { return nil, t, fmt.Errorf("with udp: %w", err) } @@ -555,15 +679,7 @@ func ExchangeWithFallback(ctx context.Context, client *dns.Client, r *dns.Msg, u // data than the client's buffer, we could truncate locally and skip // the TCP retry. - tcpClient := *client - tcpClient.Net = protoTCP - - if ctx == nil { - rm, t, err = tcpClient.Exchange(r, upstream) - } else { - rm, t, err = tcpClient.ExchangeContext(ctx, r, upstream) - } - + rm, t, err = toTCPClient(client).ExchangeContext(ctx, r, upstream) if err != nil { return nil, t, fmt.Errorf("with tcp: %w", err) } @@ -577,6 +693,25 @@ func ExchangeWithFallback(ctx context.Context, client *dns.Client, r *dns.Msg, u return rm, t, nil } +// toTCPClient returns a copy of c configured for TCP. If c's Dialer has a +// *net.UDPAddr bound as LocalAddr (iOS does this to keep the source IP on +// the tunnel interface), it is converted to the equivalent *net.TCPAddr +// so net.Dialer doesn't reject the TCP dial with "mismatched local +// address type". +func toTCPClient(c *dns.Client) *dns.Client { + tcp := *c + tcp.Net = protoTCP + if tcp.Dialer == nil { + return &tcp + } + d := *tcp.Dialer + if ua, ok := d.LocalAddr.(*net.UDPAddr); ok { + d.LocalAddr = &net.TCPAddr{IP: ua.IP, Port: ua.Port, Zone: ua.Zone} + } + tcp.Dialer = &d + return &tcp +} + // ExchangeWithNetstack performs a DNS exchange using netstack for dialing. // This is needed when netstack is enabled to reach peer IPs through the tunnel. func ExchangeWithNetstack(ctx context.Context, nsNet *netstack.Net, r *dns.Msg, upstream string) (*dns.Msg, error) { @@ -718,15 +853,36 @@ func findPeerForIP(ip netip.Addr, statusRecorder *peer.Status) *peer.State { return bestMatch } -func (u *upstreamResolverBase) debugUpstreamTimeout(upstream netip.AddrPort) string { - if u.statusRecorder == nil { - return "" +// haMapRouteCount returns the total number of routes across all HA +// groups in the map. route.HAMap is keyed by HAUniqueID with slices of +// routes per key, so len(hm) is the number of HA groups, not routes. +func haMapRouteCount(hm route.HAMap) int { + total := 0 + for _, routes := range hm { + total += len(routes) } + return total +} - peerInfo := findPeerForIP(upstream.Addr(), u.statusRecorder) - if peerInfo == nil { - return "" +// haMapContains checks whether ip is covered by any concrete prefix in +// the HA map. haveDynamic is reported separately: dynamic (domain-based) +// routes carry a placeholder Network that can't be prefix-checked, so we +// can't know at this point whether ip is reached through one. Callers +// decide how to interpret the unknown: health projection treats it as +// "possibly routed" to avoid emitting false-positive warnings during +// startup, while iOS dial selection requires a concrete match before +// binding to the tunnel. +func haMapContains(hm route.HAMap, ip netip.Addr) (matched, haveDynamic bool) { + for _, routes := range hm { + for _, r := range routes { + if r.IsDynamic() { + haveDynamic = true + continue + } + if r.Network.Contains(ip) { + return true, haveDynamic + } + } } - - return fmt.Sprintf("(routes through NetBird peer %s)", FormatPeerStatus(peerInfo)) + return false, haveDynamic } diff --git a/client/internal/dns/upstream_android.go b/client/internal/dns/upstream_android.go index ee1ca42fe..f7ab48b10 100644 --- a/client/internal/dns/upstream_android.go +++ b/client/internal/dns/upstream_android.go @@ -11,6 +11,7 @@ import ( "github.com/netbirdio/netbird/client/internal/peer" nbnet "github.com/netbirdio/netbird/client/net" + "github.com/netbirdio/netbird/shared/management/domain" ) type upstreamResolver struct { @@ -26,9 +27,9 @@ func newUpstreamResolver( _ WGIface, statusRecorder *peer.Status, hostsDNSHolder *hostsDNSHolder, - domain string, + d domain.Domain, ) (*upstreamResolver, error) { - upstreamResolverBase := newUpstreamResolverBase(ctx, statusRecorder, domain) + upstreamResolverBase := newUpstreamResolverBase(ctx, statusRecorder, d) c := &upstreamResolver{ upstreamResolverBase: upstreamResolverBase, hostsDNSHolder: hostsDNSHolder, @@ -86,7 +87,7 @@ func (u *upstreamResolver) isLocalResolver(upstream string) bool { return false } -func GetClientPrivate(ip netip.Addr, interfaceName string, dialTimeout time.Duration) (*dns.Client, error) { +func GetClientPrivate(_ privateClientIface, _ netip.Addr, dialTimeout time.Duration) (*dns.Client, error) { return &dns.Client{ Timeout: dialTimeout, Net: "udp", diff --git a/client/internal/dns/upstream_general.go b/client/internal/dns/upstream_general.go index 1143b6c51..dc841757b 100644 --- a/client/internal/dns/upstream_general.go +++ b/client/internal/dns/upstream_general.go @@ -12,6 +12,7 @@ import ( "golang.zx2c4.com/wireguard/tun/netstack" "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/shared/management/domain" ) type upstreamResolver struct { @@ -24,9 +25,9 @@ func newUpstreamResolver( wgIface WGIface, statusRecorder *peer.Status, _ *hostsDNSHolder, - domain string, + d domain.Domain, ) (*upstreamResolver, error) { - upstreamResolverBase := newUpstreamResolverBase(ctx, statusRecorder, domain) + upstreamResolverBase := newUpstreamResolverBase(ctx, statusRecorder, d) nonIOS := &upstreamResolver{ upstreamResolverBase: upstreamResolverBase, nsNet: wgIface.GetNet(), @@ -52,7 +53,7 @@ func (u *upstreamResolver) exchange(ctx context.Context, upstream string, r *dns return ExchangeWithFallback(ctx, client, r, upstream) } -func GetClientPrivate(ip netip.Addr, interfaceName string, dialTimeout time.Duration) (*dns.Client, error) { +func GetClientPrivate(_ privateClientIface, _ netip.Addr, dialTimeout time.Duration) (*dns.Client, error) { return &dns.Client{ Timeout: dialTimeout, Net: "udp", diff --git a/client/internal/dns/upstream_ios.go b/client/internal/dns/upstream_ios.go index 02c11173b..793d87fca 100644 --- a/client/internal/dns/upstream_ios.go +++ b/client/internal/dns/upstream_ios.go @@ -15,13 +15,12 @@ import ( "golang.org/x/sys/unix" "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/shared/management/domain" ) type upstreamResolverIOS struct { *upstreamResolverBase - lIP netip.Addr - lNet netip.Prefix - interfaceName string + wgIface WGIface } func newUpstreamResolver( @@ -29,15 +28,13 @@ func newUpstreamResolver( wgIface WGIface, statusRecorder *peer.Status, _ *hostsDNSHolder, - domain string, + d domain.Domain, ) (*upstreamResolverIOS, error) { - upstreamResolverBase := newUpstreamResolverBase(ctx, statusRecorder, domain) + upstreamResolverBase := newUpstreamResolverBase(ctx, statusRecorder, d) ios := &upstreamResolverIOS{ upstreamResolverBase: upstreamResolverBase, - lIP: wgIface.Address().IP, - lNet: wgIface.Address().Network, - interfaceName: wgIface.Name(), + wgIface: wgIface, } ios.upstreamClient = ios @@ -65,39 +62,55 @@ func (u *upstreamResolverIOS) exchange(ctx context.Context, upstream string, r * } else { upstreamIP = upstreamIP.Unmap() } - needsPrivate := u.lNet.Contains(upstreamIP) || - (u.routeMatch != nil && u.routeMatch(upstreamIP)) + addr := u.wgIface.Address() + var routed bool + if u.selectedRoutes != nil { + // Only a concrete prefix match binds to the tunnel: dialing + // through a private client for an upstream we can't prove is + // routed would break public resolvers. + routed, _ = haMapContains(u.selectedRoutes(), upstreamIP) + } + needsPrivate := addr.Network.Contains(upstreamIP) || + addr.IPv6Net.Contains(upstreamIP) || + routed if needsPrivate { log.Debugf("using private client to query %s via upstream %s", r.Question[0].Name, upstream) - client, err = GetClientPrivate(u.lIP, u.interfaceName, timeout) + client, err = GetClientPrivate(u.wgIface, upstreamIP, timeout) if err != nil { return nil, 0, fmt.Errorf("create private client: %s", err) } } - // Cannot use client.ExchangeContext because it overwrites our Dialer - return ExchangeWithFallback(nil, client, r, upstream) + return ExchangeWithFallback(ctx, client, r, upstream) } -// GetClientPrivate returns a new DNS client bound to the local IP address of the Netbird interface -// This method is needed for iOS -func GetClientPrivate(ip netip.Addr, interfaceName string, dialTimeout time.Duration) (*dns.Client, error) { - index, err := getInterfaceIndex(interfaceName) +// GetClientPrivate returns a new DNS client bound to the local IP of the Netbird interface. +// It selects the v6 bind address when the upstream is IPv6 and the interface has one, otherwise v4. +func GetClientPrivate(iface privateClientIface, upstreamIP netip.Addr, dialTimeout time.Duration) (*dns.Client, error) { + index, err := getInterfaceIndex(iface.Name()) if err != nil { - log.Debugf("unable to get interface index for %s: %s", interfaceName, err) + log.Debugf("unable to get interface index for %s: %s", iface.Name(), err) return nil, err } + addr := iface.Address() + bindIP := addr.IP + if upstreamIP.Is6() && addr.HasIPv6() { + bindIP = addr.IPv6 + } + + proto, opt := unix.IPPROTO_IP, unix.IP_BOUND_IF + if bindIP.Is6() { + proto, opt = unix.IPPROTO_IPV6, unix.IPV6_BOUND_IF + } + dialer := &net.Dialer{ - LocalAddr: &net.UDPAddr{ - IP: ip.AsSlice(), - Port: 0, // Let the OS pick a free port - }, - Timeout: dialTimeout, + LocalAddr: net.UDPAddrFromAddrPort(netip.AddrPortFrom(bindIP, 0)), + Timeout: dialTimeout, Control: func(network, address string, c syscall.RawConn) error { var operr error fn := func(s uintptr) { - operr = unix.SetsockoptInt(int(s), unix.IPPROTO_IP, unix.IP_BOUND_IF, index) + operr = unix.SetsockoptInt(int(s), proto, opt, index) } if err := c.Control(fn); err != nil { @@ -117,8 +130,3 @@ func GetClientPrivate(ip netip.Addr, interfaceName string, dialTimeout time.Dura } return client, nil } - -func getInterfaceIndex(interfaceName string) (int, error) { - iface, err := net.InterfaceByName(interfaceName) - return iface.Index, err -} diff --git a/client/internal/dns/upstream_test.go b/client/internal/dns/upstream_test.go index 1797fdad8..4c2784545 100644 --- a/client/internal/dns/upstream_test.go +++ b/client/internal/dns/upstream_test.go @@ -6,6 +6,7 @@ import ( "net" "net/netip" "strings" + "sync/atomic" "testing" "time" @@ -73,7 +74,7 @@ func TestUpstreamResolver_ServeDNS(t *testing.T) { servers = append(servers, netip.AddrPortFrom(addrPort.Addr().Unmap(), addrPort.Port())) } } - resolver.upstreamServers = servers + resolver.addRace(servers) resolver.upstreamTimeout = testCase.timeout if testCase.cancelCTX { cancel() @@ -132,20 +133,10 @@ func (m *mockNetstackProvider) GetInterfaceGUIDString() (string, error) { return "", nil } -type mockUpstreamResolver struct { - r *dns.Msg - rtt time.Duration - err error -} - -// exchange mock implementation of exchange from upstreamResolver -func (c mockUpstreamResolver) exchange(_ context.Context, _ string, _ *dns.Msg) (*dns.Msg, time.Duration, error) { - return c.r, c.rtt, c.err -} - type mockUpstreamResponse struct { - msg *dns.Msg - err error + msg *dns.Msg + err error + delay time.Duration } type mockUpstreamResolverPerServer struct { @@ -153,63 +144,19 @@ type mockUpstreamResolverPerServer struct { rtt time.Duration } -func (c mockUpstreamResolverPerServer) exchange(_ context.Context, upstream string, _ *dns.Msg) (*dns.Msg, time.Duration, error) { - if r, ok := c.responses[upstream]; ok { - return r.msg, c.rtt, r.err +func (c mockUpstreamResolverPerServer) exchange(ctx context.Context, upstream string, _ *dns.Msg) (*dns.Msg, time.Duration, error) { + r, ok := c.responses[upstream] + if !ok { + return nil, c.rtt, fmt.Errorf("no mock response for %s", upstream) } - return nil, c.rtt, fmt.Errorf("no mock response for %s", upstream) -} - -func TestUpstreamResolver_DeactivationReactivation(t *testing.T) { - mockClient := &mockUpstreamResolver{ - err: dns.ErrTime, - r: new(dns.Msg), - rtt: time.Millisecond, - } - - resolver := &upstreamResolverBase{ - ctx: context.TODO(), - upstreamClient: mockClient, - upstreamTimeout: UpstreamTimeout, - reactivatePeriod: time.Microsecond * 100, - } - addrPort, _ := netip.ParseAddrPort("0.0.0.0:1") // Use valid port for parsing, test will still fail on connection - resolver.upstreamServers = []netip.AddrPort{netip.AddrPortFrom(addrPort.Addr().Unmap(), addrPort.Port())} - - failed := false - resolver.deactivate = func(error) { - failed = true - // After deactivation, make the mock client work again - mockClient.err = nil - } - - reactivated := false - resolver.reactivate = func() { - reactivated = true - } - - resolver.ProbeAvailability(context.TODO()) - - if !failed { - t.Errorf("expected that resolving was deactivated") - return - } - - if !resolver.disabled { - t.Errorf("resolver should be Disabled") - return - } - - time.Sleep(time.Millisecond * 200) - - if !reactivated { - t.Errorf("expected that resolving was reactivated") - return - } - - if resolver.disabled { - t.Errorf("should be enabled") + if r.delay > 0 { + select { + case <-time.After(r.delay): + case <-ctx.Done(): + return nil, c.rtt, ctx.Err() + } } + return r.msg, c.rtt, r.err } func TestUpstreamResolver_Failover(t *testing.T) { @@ -339,9 +286,9 @@ func TestUpstreamResolver_Failover(t *testing.T) { resolver := &upstreamResolverBase{ ctx: ctx, upstreamClient: trackingClient, - upstreamServers: []netip.AddrPort{upstream1, upstream2}, upstreamTimeout: UpstreamTimeout, } + resolver.addRace([]netip.AddrPort{upstream1, upstream2}) var responseMSG *dns.Msg responseWriter := &test.MockResponseWriter{ @@ -421,9 +368,9 @@ func TestUpstreamResolver_SingleUpstreamFailure(t *testing.T) { resolver := &upstreamResolverBase{ ctx: ctx, upstreamClient: mockClient, - upstreamServers: []netip.AddrPort{upstream}, upstreamTimeout: UpstreamTimeout, } + resolver.addRace([]netip.AddrPort{upstream}) var responseMSG *dns.Msg responseWriter := &test.MockResponseWriter{ @@ -440,6 +387,208 @@ func TestUpstreamResolver_SingleUpstreamFailure(t *testing.T) { assert.Equal(t, dns.RcodeServerFailure, responseMSG.Rcode, "single upstream SERVFAIL should return SERVFAIL") } +// TestUpstreamResolver_RaceAcrossGroups covers two nameserver groups +// configured for the same domain, with one broken group. The merge+race +// path should answer as fast as the working group and not pay the timeout +// of the broken one on every query. +func TestUpstreamResolver_RaceAcrossGroups(t *testing.T) { + broken := netip.MustParseAddrPort("192.0.2.1:53") + working := netip.MustParseAddrPort("192.0.2.2:53") + successAnswer := "192.0.2.100" + timeoutErr := &net.OpError{Op: "read", Err: fmt.Errorf("i/o timeout")} + + mockClient := &mockUpstreamResolverPerServer{ + responses: map[string]mockUpstreamResponse{ + // Force the broken upstream to only unblock via timeout / + // cancellation so the assertion below can't pass if races + // were run serially. + broken.String(): {err: timeoutErr, delay: 500 * time.Millisecond}, + working.String(): {msg: buildMockResponse(dns.RcodeSuccess, successAnswer)}, + }, + rtt: time.Millisecond, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + resolver := &upstreamResolverBase{ + ctx: ctx, + upstreamClient: mockClient, + upstreamTimeout: 250 * time.Millisecond, + } + resolver.addRace([]netip.AddrPort{broken}) + resolver.addRace([]netip.AddrPort{working}) + + var responseMSG *dns.Msg + responseWriter := &test.MockResponseWriter{ + WriteMsgFunc: func(m *dns.Msg) error { + responseMSG = m + return nil + }, + } + + inputMSG := new(dns.Msg).SetQuestion("example.com.", dns.TypeA) + start := time.Now() + resolver.ServeDNS(responseWriter, inputMSG) + elapsed := time.Since(start) + + require.NotNil(t, responseMSG, "should write a response") + assert.Equal(t, dns.RcodeSuccess, responseMSG.Rcode) + require.NotEmpty(t, responseMSG.Answer) + assert.Contains(t, responseMSG.Answer[0].String(), successAnswer) + // Working group answers in a single RTT; the broken group's + // timeout (100ms) must not block the response. + assert.Less(t, elapsed, 100*time.Millisecond, "race must not wait for broken group's timeout") +} + +// TestUpstreamResolver_AllGroupsFail checks that when every group fails the +// resolver returns SERVFAIL rather than leaking a partial response. +func TestUpstreamResolver_AllGroupsFail(t *testing.T) { + a := netip.MustParseAddrPort("192.0.2.1:53") + b := netip.MustParseAddrPort("192.0.2.2:53") + + mockClient := &mockUpstreamResolverPerServer{ + responses: map[string]mockUpstreamResponse{ + a.String(): {msg: buildMockResponse(dns.RcodeServerFailure, "")}, + b.String(): {msg: buildMockResponse(dns.RcodeServerFailure, "")}, + }, + rtt: time.Millisecond, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + resolver := &upstreamResolverBase{ + ctx: ctx, + upstreamClient: mockClient, + upstreamTimeout: UpstreamTimeout, + } + resolver.addRace([]netip.AddrPort{a}) + resolver.addRace([]netip.AddrPort{b}) + + var responseMSG *dns.Msg + responseWriter := &test.MockResponseWriter{ + WriteMsgFunc: func(m *dns.Msg) error { + responseMSG = m + return nil + }, + } + + resolver.ServeDNS(responseWriter, new(dns.Msg).SetQuestion("example.com.", dns.TypeA)) + require.NotNil(t, responseMSG) + assert.Equal(t, dns.RcodeServerFailure, responseMSG.Rcode) +} + +// TestUpstreamResolver_HealthTracking verifies that query-path results are +// recorded into per-upstream health, which is what projects back to +// NSGroupState for status reporting. +func TestUpstreamResolver_HealthTracking(t *testing.T) { + ok := netip.MustParseAddrPort("192.0.2.10:53") + bad := netip.MustParseAddrPort("192.0.2.11:53") + + mockClient := &mockUpstreamResolverPerServer{ + responses: map[string]mockUpstreamResponse{ + ok.String(): {msg: buildMockResponse(dns.RcodeSuccess, "192.0.2.100")}, + bad.String(): {msg: buildMockResponse(dns.RcodeServerFailure, "")}, + }, + rtt: time.Millisecond, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + resolver := &upstreamResolverBase{ + ctx: ctx, + upstreamClient: mockClient, + upstreamTimeout: UpstreamTimeout, + } + resolver.addRace([]netip.AddrPort{ok, bad}) + + responseWriter := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { return nil }} + resolver.ServeDNS(responseWriter, new(dns.Msg).SetQuestion("example.com.", dns.TypeA)) + + health := resolver.UpstreamHealth() + require.Contains(t, health, ok) + assert.False(t, health[ok].LastOk.IsZero(), "ok upstream should have LastOk set") + assert.Empty(t, health[ok].LastErr) + + // bad upstream was never tried because ok answered first; its health + // should remain unset. + assert.NotContains(t, health, bad, "sibling upstream should not be queried when primary answers") +} + +// TestUpstreamResolver_HealthTracking_ResponseMeansReachable verifies that an +// upstream which answers with SERVFAIL or REFUSED is recorded as healthy: +// those are per-question outcomes from a reachable server and must not mark +// the upstream unhealthy. Only transport failures (timeouts) do. +func TestUpstreamResolver_HealthTracking_ResponseMeansReachable(t *testing.T) { + a := netip.MustParseAddrPort("192.0.2.10:53") + b := netip.MustParseAddrPort("192.0.2.11:53") + timeoutErr := &net.OpError{Op: "read", Err: fmt.Errorf("i/o timeout")} + + tests := []struct { + name string + respA mockUpstreamResponse + respB mockUpstreamResponse + wantHealthy bool + }{ + { + name: "both SERVFAIL are reachable", + respA: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeServerFailure, "")}, + respB: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeServerFailure, "")}, + wantHealthy: true, + }, + { + name: "both REFUSED are reachable", + respA: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeRefused, "")}, + respB: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeRefused, "")}, + wantHealthy: true, + }, + { + name: "timeout marks unhealthy", + respA: mockUpstreamResponse{err: timeoutErr}, + respB: mockUpstreamResponse{err: timeoutErr}, + wantHealthy: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mockClient := &mockUpstreamResolverPerServer{ + responses: map[string]mockUpstreamResponse{ + a.String(): tc.respA, + b.String(): tc.respB, + }, + rtt: time.Millisecond, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + resolver := &upstreamResolverBase{ + ctx: ctx, + upstreamClient: mockClient, + upstreamTimeout: UpstreamTimeout, + } + resolver.addRace([]netip.AddrPort{a, b}) + + responseWriter := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { return nil }} + resolver.ServeDNS(responseWriter, new(dns.Msg).SetQuestion("example.com.", dns.TypeA)) + + health := resolver.UpstreamHealth() + require.Contains(t, health, a, "primary upstream should have a health record") + if tc.wantHealthy { + assert.False(t, health[a].LastOk.IsZero(), "responding upstream should have LastOk set") + assert.True(t, health[a].LastFail.IsZero(), "responding upstream should not be marked failed") + assert.Empty(t, health[a].LastErr, "responding upstream should have no error") + } else { + assert.False(t, health[a].LastFail.IsZero(), "timed-out upstream should be marked failed") + assert.NotEmpty(t, health[a].LastErr, "timed-out upstream should record an error") + } + }) + } +} + func TestFormatFailures(t *testing.T) { testCases := []struct { name string @@ -665,10 +814,10 @@ func TestExchangeWithFallback_EDNS0Capped(t *testing.T) { // Verify that a client EDNS0 larger than our MTU-derived limit gets // capped in the outgoing request so the upstream doesn't send a // response larger than our read buffer. - var receivedUDPSize uint16 + var receivedUDPSize atomic.Uint32 udpHandler := dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) { if opt := r.IsEdns0(); opt != nil { - receivedUDPSize = opt.UDPSize() + receivedUDPSize.Store(uint32(opt.UDPSize())) } m := new(dns.Msg) m.SetReply(r) @@ -699,7 +848,7 @@ func TestExchangeWithFallback_EDNS0Capped(t *testing.T) { require.NotNil(t, rm) expectedMax := uint16(currentMTU - ipUDPHeaderSize) - assert.Equal(t, expectedMax, receivedUDPSize, + assert.Equal(t, expectedMax, uint16(receivedUDPSize.Load()), "upstream should see capped EDNS0, not the client's 4096") } @@ -770,3 +919,119 @@ func TestExchangeWithFallback_TCPTruncatesToClientSize(t *testing.T) { assert.Less(t, len(rm2.Answer), 20, "small EDNS0 client should get fewer records") assert.True(t, rm2.Truncated, "response should be truncated for small buffer client") } + +func msgWithEDE(rcode int, codes ...uint16) *dns.Msg { + m := new(dns.Msg) + m.Response = true + m.Rcode = rcode + if len(codes) == 0 { + return m + } + opt := &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}} + opt.SetUDPSize(dns.MinMsgSize) + for _, c := range codes { + opt.Option = append(opt.Option, &dns.EDNS0_EDE{InfoCode: c}) + } + m.Extra = append(m.Extra, opt) + return m +} + +func TestNonRetryableEDE(t *testing.T) { + tests := []struct { + name string + msg *dns.Msg + wantOK bool + wantCode uint16 + }{ + {name: "no edns0", msg: msgWithEDE(dns.RcodeServerFailure)}, + { + name: "opt without ede", + msg: func() *dns.Msg { + m := msgWithEDE(dns.RcodeServerFailure) + opt := &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}} + opt.Option = append(opt.Option, &dns.EDNS0_NSID{Code: dns.EDNS0NSID}) + m.Extra = []dns.RR{opt} + return m + }(), + }, + {name: "ede dnsbogus", msg: msgWithEDE(dns.RcodeServerFailure, dns.ExtendedErrorCodeDNSBogus), wantOK: true, wantCode: dns.ExtendedErrorCodeDNSBogus}, + {name: "ede signature expired", msg: msgWithEDE(dns.RcodeServerFailure, dns.ExtendedErrorCodeSignatureExpired), wantOK: true, wantCode: dns.ExtendedErrorCodeSignatureExpired}, + {name: "ede blocked", msg: msgWithEDE(dns.RcodeServerFailure, dns.ExtendedErrorCodeBlocked), wantOK: true, wantCode: dns.ExtendedErrorCodeBlocked}, + {name: "ede prohibited", msg: msgWithEDE(dns.RcodeRefused, dns.ExtendedErrorCodeProhibited), wantOK: true, wantCode: dns.ExtendedErrorCodeProhibited}, + {name: "ede cached error retryable", msg: msgWithEDE(dns.RcodeServerFailure, dns.ExtendedErrorCodeCachedError)}, + {name: "ede network error retryable", msg: msgWithEDE(dns.RcodeServerFailure, dns.ExtendedErrorCodeNetworkError)}, + {name: "ede not ready retryable", msg: msgWithEDE(dns.RcodeServerFailure, dns.ExtendedErrorCodeNotReady)}, + { + name: "first non-retryable wins", + msg: msgWithEDE(dns.RcodeServerFailure, dns.ExtendedErrorCodeNetworkError, dns.ExtendedErrorCodeDNSBogus), + wantOK: true, + wantCode: dns.ExtendedErrorCodeDNSBogus, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + code, ok := nonRetryableEDE(tc.msg) + assert.Equal(t, tc.wantOK, ok, "ok should match") + if tc.wantOK { + assert.Equal(t, tc.wantCode, code, "code should match") + } + }) + } +} + +func TestEDEName(t *testing.T) { + assert.Equal(t, "DNSSEC Bogus", edeName(dns.ExtendedErrorCodeDNSBogus)) + assert.Equal(t, "Signature Expired", edeName(dns.ExtendedErrorCodeSignatureExpired)) + assert.Equal(t, "EDE 9999", edeName(9999), "unknown code falls back to numeric") +} + +func TestUpstreamResolver_NonRetryableEDEShortCircuits(t *testing.T) { + upstream1 := netip.MustParseAddrPort("192.0.2.1:53") + upstream2 := netip.MustParseAddrPort("192.0.2.2:53") + + servfailWithEDE := msgWithEDE(dns.RcodeServerFailure, dns.ExtendedErrorCodeDNSBogus) + successResp := buildMockResponse(dns.RcodeSuccess, "192.0.2.100") + + var queried []string + tracking := &trackingMockClient{ + inner: &mockUpstreamResolverPerServer{ + responses: map[string]mockUpstreamResponse{ + upstream1.String(): {msg: servfailWithEDE}, + upstream2.String(): {msg: successResp}, + }, + rtt: time.Millisecond, + }, + queriedUpstreams: &queried, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + resolver := &upstreamResolverBase{ + ctx: ctx, + upstreamClient: tracking, + upstreamServers: []upstreamRace{{upstream1, upstream2}}, + upstreamTimeout: UpstreamTimeout, + } + + var written *dns.Msg + w := &test.MockResponseWriter{ + WriteMsgFunc: func(m *dns.Msg) error { + written = m + return nil + }, + } + + // Client query without EDNS0 must not see an OPT in the response. + q := new(dns.Msg).SetQuestion("example.com.", dns.TypeA) + resolver.ServeDNS(w, q) + + require.NotNil(t, written, "response must be written") + assert.Equal(t, dns.RcodeServerFailure, written.Rcode, "SERVFAIL must propagate") + assert.Len(t, queried, 1, "only first upstream should be queried") + assert.Equal(t, upstream1.String(), queried[0]) + for _, rr := range written.Extra { + _, isOPT := rr.(*dns.OPT) + assert.False(t, isOPT, "synthetic OPT must not leak to a non-EDNS0 client") + } +} diff --git a/client/internal/dns_peer_activator.go b/client/internal/dns_peer_activator.go new file mode 100644 index 000000000..c283d6251 --- /dev/null +++ b/client/internal/dns_peer_activator.go @@ -0,0 +1,76 @@ +package internal + +import ( + "context" + "net/netip" + "time" + + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/peerstore" +) + +const dnsActivationPollInterval = 50 * time.Millisecond + +// dnsPeerActivator wakes lazy-connection peers from the DNS resolution path. It +// implements dns/local.PeerActivator. DNS queries run on their own goroutines, +// so it only touches state that is safe for concurrent use — ConnMgr.ActivatePeer, +// peerstore.Store and peer.Status — and never takes the engine's syncMsgMux, +// keeping DNS resolution from contending with network-map processing. +type dnsPeerActivator struct { + connMgr *ConnMgr + peerStore *peerstore.Store + status *peer.Status + // ctx is the engine's long-lived context. The connection dial is tied to it + // (not the per-query DNS wait budget) so a handshake that outlasts the wait + // still completes in the background rather than being cancelled at the deadline. + ctx context.Context +} + +// ActivatePeersByIP triggers wake-up for the peer(s) owning addrs and waits +// until one is connected or ctx (the per-query DNS wait budget) expires. +// Activation itself is tied to the engine's long-lived context so the dial +// survives a wait that times out. Unknown or already-connected addresses are +// skipped, so the steady-state (warm) path adds no latency. +func (a *dnsPeerActivator) ActivatePeersByIP(ctx context.Context, addrs []netip.Addr) { + if a == nil || a.connMgr == nil { + return + } + + var pending []string + for _, addr := range addrs { + ip := addr.String() + st, ok := a.status.PeerStateByIP(ip) + if !ok || st.ConnStatus == peer.StatusConnected { + continue + } + conn, ok := a.peerStore.PeerConn(st.PubKey) + if !ok { + continue + } + a.connMgr.ActivatePeer(a.ctx, conn) + pending = append(pending, ip) + } + + if len(pending) == 0 { + return + } + a.waitConnected(ctx, pending) +} + +// waitConnected blocks until any of ips reports a connected peer or ctx expires. +func (a *dnsPeerActivator) waitConnected(ctx context.Context, ips []string) { + ticker := time.NewTicker(dnsActivationPollInterval) + defer ticker.Stop() + for { + for _, ip := range ips { + if st, ok := a.status.PeerStateByIP(ip); ok && st.ConnStatus == peer.StatusConnected { + return + } + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} diff --git a/client/internal/dns_peer_activator_test.go b/client/internal/dns_peer_activator_test.go new file mode 100644 index 000000000..8c3b75e59 --- /dev/null +++ b/client/internal/dns_peer_activator_test.go @@ -0,0 +1,129 @@ +package internal + +import ( + "context" + "net/netip" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/peerstore" +) + +func newTestPeerConn(t *testing.T, key string) *peer.Conn { + t.Helper() + conn, err := peer.NewConn(peer.ConnConfig{ + Key: key, + LocalKey: "local", + WgConfig: peer.WgConfig{ + AllowedIps: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")}, + }, + }, peer.ServiceDependencies{}) + require.NoError(t, err) + return conn +} + +func newTestDNSPeerActivator(t *testing.T) (*dnsPeerActivator, *peer.Status, *peerstore.Store) { + t.Helper() + status := peer.NewRecorder("https://mgm") + store := peerstore.NewConnStore() + // ConnMgr without Start: the lazy manager is nil, so ActivatePeer is a + // no-op — these tests exercise the activator's skip/wait logic. + connMgr := NewConnMgr(&EngineConfig{}, status, store, nil) + return &dnsPeerActivator{ + connMgr: connMgr, + peerStore: store, + status: status, + ctx: context.Background(), + }, status, store +} + +func TestDNSPeerActivator_NilSafe(t *testing.T) { + var a *dnsPeerActivator + a.ActivatePeersByIP(context.Background(), []netip.Addr{netip.MustParseAddr("100.64.0.1")}) +} + +// TestDNSPeerActivator_SkipsUnknownAndConnectedPeers verifies the steady-state +// (warm) path adds no latency: already-connected and unknown addresses never +// enter the wait loop. +func TestDNSPeerActivator_SkipsUnknownAndConnectedPeers(t *testing.T) { + a, status, store := newTestDNSPeerActivator(t) + + require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", "fd00::1")) + require.NoError(t, status.UpdatePeerState(peer.State{PubKey: "peerA", ConnStatus: peer.StatusConnected})) + store.AddPeerConn("peerA", newTestPeerConn(t, "peerA")) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + start := time.Now() + a.ActivatePeersByIP(ctx, []netip.Addr{ + netip.MustParseAddr("100.64.0.1"), // known, connected -> skipped + netip.MustParseAddr("fd00::1"), // known via IPv6, connected -> skipped + netip.MustParseAddr("100.64.0.99"), // unknown -> skipped + }) + require.Less(t, time.Since(start), time.Second, "no pending peer must mean no wait") +} + +// TestDNSPeerActivator_WaitsForPendingPeerToConnect verifies the wait loop +// returns as soon as a pending peer reports connected, well before the +// per-query budget expires. +func TestDNSPeerActivator_WaitsForPendingPeerToConnect(t *testing.T) { + a, status, store := newTestDNSPeerActivator(t) + + require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", "")) + store.AddPeerConn("peerA", newTestPeerConn(t, "peerA")) + + go func() { + time.Sleep(150 * time.Millisecond) + _ = status.UpdatePeerState(peer.State{PubKey: "peerA", ConnStatus: peer.StatusConnected}) + }() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + start := time.Now() + a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")}) + elapsed := time.Since(start) + + require.GreaterOrEqual(t, elapsed, 100*time.Millisecond, "must wait for the pending peer") + require.Less(t, elapsed, 5*time.Second, "must return on connect, not at the deadline") +} + +// TestDNSPeerActivator_ReturnsAtBudgetWhenPeerStaysIdle verifies a peer that +// never connects releases the DNS response at the per-query budget instead of +// blocking it indefinitely. +func TestDNSPeerActivator_ReturnsAtBudgetWhenPeerStaysIdle(t *testing.T) { + a, status, store := newTestDNSPeerActivator(t) + + require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", "")) + store.AddPeerConn("peerA", newTestPeerConn(t, "peerA")) + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + start := time.Now() + a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")}) + elapsed := time.Since(start) + + require.GreaterOrEqual(t, elapsed, 250*time.Millisecond, "must wait out the budget for a pending peer") + require.Less(t, elapsed, 5*time.Second, "must not block past the budget") +} + +// TestDNSPeerActivator_NoWaitWithoutPeerConn verifies a known-but-idle peer +// with no connection object in the store is not waited on: there is nothing to +// activate, so waiting could only ever time out. +func TestDNSPeerActivator_NoWaitWithoutPeerConn(t *testing.T) { + a, status, _ := newTestDNSPeerActivator(t) + + require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", "")) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + start := time.Now() + a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")}) + require.Less(t, time.Since(start), time.Second, "peer without a conn must not be waited on") +} diff --git a/client/internal/dns_test.go b/client/internal/dns_test.go new file mode 100644 index 000000000..031431efe --- /dev/null +++ b/client/internal/dns_test.go @@ -0,0 +1,225 @@ +package internal + +import ( + "net/netip" + "testing" + + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/iface/wgaddr" + nbdns "github.com/netbirdio/netbird/dns" + mgmProto "github.com/netbirdio/netbird/shared/management/proto" +) + +func TestCreatePTRRecord_IPv4(t *testing.T) { + record := nbdns.SimpleRecord{ + Name: "peer1.netbird.cloud.", + Type: int(dns.TypeA), + Class: nbdns.DefaultClass, + TTL: 300, + RData: "100.64.0.5", + } + prefix := netip.MustParsePrefix("100.64.0.0/16") + + ptr, ok := createPTRRecord(record, prefix) + require.True(t, ok) + assert.Equal(t, "5.0.64.100.in-addr.arpa.", ptr.Name) + assert.Equal(t, int(dns.TypePTR), ptr.Type) + assert.Equal(t, "peer1.netbird.cloud.", ptr.RData) +} + +func TestCreatePTRRecord_IPv6(t *testing.T) { + record := nbdns.SimpleRecord{ + Name: "peer1.netbird.cloud.", + Type: int(dns.TypeAAAA), + Class: nbdns.DefaultClass, + TTL: 300, + RData: "fd00:1234:5678::1", + } + prefix := netip.MustParsePrefix("fd00:1234:5678::/48") + + ptr, ok := createPTRRecord(record, prefix) + require.True(t, ok) + assert.Equal(t, "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.7.6.5.4.3.2.1.0.0.d.f.ip6.arpa.", ptr.Name) + assert.Equal(t, int(dns.TypePTR), ptr.Type) + assert.Equal(t, "peer1.netbird.cloud.", ptr.RData) +} + +func TestCreatePTRRecord_OutOfRange(t *testing.T) { + record := nbdns.SimpleRecord{ + Name: "peer1.netbird.cloud.", + Type: int(dns.TypeA), + RData: "10.0.0.1", + } + prefix := netip.MustParsePrefix("100.64.0.0/16") + + _, ok := createPTRRecord(record, prefix) + assert.False(t, ok) +} + +func TestGenerateReverseZoneName_IPv4(t *testing.T) { + tests := []struct { + prefix string + expected string + }{ + {"100.64.0.0/16", "64.100.in-addr.arpa."}, + {"10.0.0.0/8", "10.in-addr.arpa."}, + {"192.168.1.0/24", "1.168.192.in-addr.arpa."}, + } + + for _, tt := range tests { + t.Run(tt.prefix, func(t *testing.T) { + zone, err := generateReverseZoneName(netip.MustParsePrefix(tt.prefix)) + require.NoError(t, err) + assert.Equal(t, tt.expected, zone) + }) + } +} + +func TestGenerateReverseZoneName_IPv6(t *testing.T) { + tests := []struct { + prefix string + expected string + }{ + {"fd00:1234:5678::/48", "8.7.6.5.4.3.2.1.0.0.d.f.ip6.arpa."}, + {"fd00::/16", "0.0.d.f.ip6.arpa."}, + {"fd12:3456:789a:bcde::/64", "e.d.c.b.a.9.8.7.6.5.4.3.2.1.d.f.ip6.arpa."}, + } + + for _, tt := range tests { + t.Run(tt.prefix, func(t *testing.T) { + zone, err := generateReverseZoneName(netip.MustParsePrefix(tt.prefix)) + require.NoError(t, err) + assert.Equal(t, tt.expected, zone) + }) + } +} + +func TestCollectPTRRecords_BothFamilies(t *testing.T) { + config := &nbdns.Config{ + CustomZones: []nbdns.CustomZone{ + { + Domain: "netbird.cloud.", + Records: []nbdns.SimpleRecord{ + {Name: "peer1.netbird.cloud.", Type: int(dns.TypeA), RData: "100.64.0.1"}, + {Name: "peer1.netbird.cloud.", Type: int(dns.TypeAAAA), RData: "fd00::1"}, + {Name: "peer2.netbird.cloud.", Type: int(dns.TypeA), RData: "100.64.0.2"}, + }, + }, + }, + } + + v4Records := collectPTRRecords(config, netip.MustParsePrefix("100.64.0.0/16")) + assert.Len(t, v4Records, 2, "should collect 2 A record PTRs for the v4 prefix") + + v6Records := collectPTRRecords(config, netip.MustParsePrefix("fd00::/64")) + assert.Len(t, v6Records, 1, "should collect 1 AAAA record PTR for the v6 prefix") +} + +func TestAddReverseZone_IPv6(t *testing.T) { + config := &nbdns.Config{ + CustomZones: []nbdns.CustomZone{ + { + Domain: "netbird.cloud.", + Records: []nbdns.SimpleRecord{ + {Name: "peer1.netbird.cloud.", Type: int(dns.TypeAAAA), RData: "fd00:1234:5678::1"}, + }, + }, + }, + } + + addReverseZone(config, netip.MustParsePrefix("fd00:1234:5678::/48")) + + require.Len(t, config.CustomZones, 2) + reverseZone := config.CustomZones[1] + assert.Equal(t, "8.7.6.5.4.3.2.1.0.0.d.f.ip6.arpa.", reverseZone.Domain) + assert.Len(t, reverseZone.Records, 1) + assert.Equal(t, int(dns.TypePTR), reverseZone.Records[0].Type) +} + +// TestToDNSConfig_ZoneFlagsPreserved pins the per-zone NonAuthoritative flag +// through the legacy DNSConfig path. A non-authoritative zone is match-only: +// the local resolver falls through to the upstream for an in-zone name it does +// not define. The built-in peer zone is the authoritative one and must stay +// that way, so the flag has to travel per zone rather than be derived. +func TestToDNSConfig_ZoneFlagsPreserved(t *testing.T) { + config := toDNSConfig(&mgmProto.DNSConfig{ + ServiceEnable: true, + CustomZones: []*mgmProto.CustomZone{ + { + Domain: "netbird.cloud.", + Records: []*mgmProto.SimpleRecord{ + {Name: "peer1.netbird.cloud.", Type: int64(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.1"}, + }, + }, + { + Domain: "corp.internal.", + NonAuthoritative: true, + SearchDomainDisabled: true, + Records: []*mgmProto.SimpleRecord{ + {Name: "db.corp.internal.", Type: int64(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.10.0.5"}, + }, + }, + }, + }, wgaddr.Address{ + IP: netip.MustParseAddr("100.64.0.1"), + Network: netip.MustParsePrefix("100.64.0.0/16"), + }) + + zones := make(map[string]nbdns.CustomZone, len(config.CustomZones)) + for _, zone := range config.CustomZones { + zones[zone.Domain] = zone + } + + peerZone, ok := zones["netbird.cloud."] + require.True(t, ok, "peer zone must survive") + assert.False(t, peerZone.NonAuthoritative, "the built-in peer zone owns the account domain and stays authoritative") + + accountZone, ok := zones["corp.internal."] + require.True(t, ok, "account zone must survive") + assert.True(t, accountZone.NonAuthoritative, "an account zone stays match-only, else undefined in-zone names get black-holed") + assert.True(t, accountZone.SearchDomainDisabled) +} + +// TestToDNSConfig_SingleZoneForcedAuthoritative pins the compatibility clause +// in toDNSConfig: a config carrying exactly one zone is treated as +// authoritative no matter what the server said, because servers that predate +// the NonAuthoritative field send only the peer FQDN zone. +// +// The clause can only ever downgrade an explicit true to false, so a server +// that legitimately sends a single non-authoritative zone — an account whose +// only zone is a custom one, with no peer records to build the built-in zone +// from — gets that zone's whole apex black-holed on the client. Real accounts +// always carry the peer zone alongside, which is why this is latent. Narrowing +// it needs a way to tell "unset" from "false" on the wire, or the account +// domain passed down here; until then this test states the contract so a +// change to it is deliberate. +func TestToDNSConfig_SingleZoneForcedAuthoritative(t *testing.T) { + config := toDNSConfig(&mgmProto.DNSConfig{ + ServiceEnable: true, + CustomZones: []*mgmProto.CustomZone{ + { + Domain: "corp.internal.", + NonAuthoritative: true, + Records: []*mgmProto.SimpleRecord{ + {Name: "db.corp.internal.", Type: int64(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.10.0.5"}, + }, + }, + }, + }, wgaddr.Address{ + IP: netip.MustParseAddr("100.64.0.1"), + Network: netip.MustParsePrefix("100.64.0.0/16"), + }) + + require.NotEmpty(t, config.CustomZones) + assert.Equal(t, "corp.internal.", config.CustomZones[0].Domain) + assert.False(t, config.CustomZones[0].NonAuthoritative, + "a lone zone is forced authoritative for pre-NonAuthoritative servers") + + // The reverse zone the config gains afterwards must not feed back into the + // decision: the compat gate counts the zones the server sent. + require.Len(t, config.CustomZones, 2, "a reverse zone is appended for the overlay prefix") + assert.Equal(t, "64.100.in-addr.arpa.", config.CustomZones[1].Domain) +} diff --git a/client/internal/dnsfwd/forwarder.go b/client/internal/dnsfwd/forwarder.go index 2e8ef84ab..e3cb597be 100644 --- a/client/internal/dnsfwd/forwarder.go +++ b/client/internal/dnsfwd/forwarder.go @@ -26,8 +26,23 @@ import ( const errResolveFailed = "failed to resolve query for domain=%s: %v" const upstreamTimeout = 15 * time.Second +// EDE info codes the forwarder emits on upstream failures so the querying +// client can see the reason without inspecting this peer's logs. They live in +// the RFC 8914 Private Use range (49152-65535); the Go resolver never exposes a +// real upstream EDE here, so these cannot collide with a genuine code. +const ( + edeNetbirdUpstreamTimeout uint16 = 49152 + edeNetbirdUpstreamFailure uint16 = 49153 +) + type resolver interface { LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error) + LookupMX(ctx context.Context, name string) ([]*net.MX, error) + LookupTXT(ctx context.Context, name string) ([]string, error) + LookupNS(ctx context.Context, name string) ([]*net.NS, error) + LookupSRV(ctx context.Context, service, proto, name string) (string, []*net.SRV, error) + LookupCNAME(ctx context.Context, host string) (string, error) + LookupAddr(ctx context.Context, addr string) ([]string, error) } type firewaller interface { @@ -39,12 +54,20 @@ type DNSForwarder struct { ttl uint32 statusRecorder *peer.Status - dnsServer *dns.Server - mux *dns.ServeMux - tcpServer *dns.Server - tcpMux *dns.ServeMux + mux *dns.ServeMux + tcpMux *dns.ServeMux - mutex sync.RWMutex + mutex sync.RWMutex + // closed records that Close has run, so a Listen still in flight does not + // go on to serve sockets nobody will shut down. + closed bool + // The sockets are kept alongside the servers because closing them is the + // only stop that always works: a server whose ActivateAndServe has not run + // yet refuses to shut down, and would otherwise start serving afterwards. + udpConn net.PacketConn + tcpLn net.Listener + dnsServer *dns.Server + tcpServer *dns.Server fwdEntries []*ForwarderEntry firewall firewaller resolver resolver @@ -91,7 +114,7 @@ func (f *DNSForwarder) Listen(entries []*ForwarderEntry) error { mux := dns.NewServeMux() f.mux = mux mux.HandleFunc(".", f.handleDNSQueryUDP) - f.dnsServer = &dns.Server{ + dnsServer := &dns.Server{ PacketConn: udpLn, Handler: mux, } @@ -99,22 +122,32 @@ func (f *DNSForwarder) Listen(entries []*ForwarderEntry) error { tcpMux := dns.NewServeMux() f.tcpMux = tcpMux tcpMux.HandleFunc(".", f.handleDNSQueryTCP) - f.tcpServer = &dns.Server{ + tcpServer := &dns.Server{ Listener: tcpLn, Handler: tcpMux, } - f.UpdateDomains(entries) + if !f.publish(udpLn, tcpLn, dnsServer, tcpServer, entries) { + log.Infof("DNS forwarder on %s was closed before it started serving", addrDesc) + if err := udpLn.Close(); err != nil { + log.Debugf("close UDP listener of a closed forwarder: %v", err) + } + if err := tcpLn.Close(); err != nil { + log.Debugf("close TCP listener of a closed forwarder: %v", err) + } + return nil + } + log.Debugf("DNS forwarder serving %d domains", len(entries)) errCh := make(chan error, 2) go func() { log.Infof("DNS UDP listener running on %s", addrDesc) - errCh <- f.dnsServer.ActivateAndServe() + errCh <- dnsServer.ActivateAndServe() }() go func() { log.Infof("DNS TCP listener running on %s", addrDesc) - errCh <- f.tcpServer.ActivateAndServe() + errCh <- tcpServer.ActivateAndServe() }() return <-errCh @@ -136,6 +169,46 @@ func (f *DNSForwarder) createTCPListener(netstackNet *netstack.Net) (net.Listene return net.ListenTCP("tcp", net.TCPAddrFromAddrPort(f.listenAddress)) } +// publish hands the sockets, servers and entries to the forwarder so Close can +// reach them and Domains can report them, and says whether serving may begin. +// Listen runs on its own goroutine, so a Close can arrive before it gets this +// far; false means the caller must close what it created instead of serving on +// it. +// +// The entries go in under the same lock rather than afterwards. Anything that +// reads them in between would otherwise see a forwarder that is listening and +// serves no domain, which for a caller rebuilding one means it comes back +// refusing every routed query. +func (f *DNSForwarder) publish( + udpConn net.PacketConn, + tcpLn net.Listener, + dnsServer, tcpServer *dns.Server, + entries []*ForwarderEntry, +) bool { + f.mutex.Lock() + defer f.mutex.Unlock() + + if f.closed { + return false + } + + f.udpConn = udpConn + f.tcpLn = tcpLn + f.dnsServer = dnsServer + f.tcpServer = tcpServer + f.fwdEntries = entries + return true +} + +// Domains returns the entries currently being served. The slice is replaced +// wholesale by UpdateDomains rather than mutated, so the caller may read it but +// must not write to it. +func (f *DNSForwarder) Domains() []*ForwarderEntry { + f.mutex.RLock() + defer f.mutex.RUnlock() + return f.fwdEntries +} + func (f *DNSForwarder) UpdateDomains(entries []*ForwarderEntry) { f.mutex.Lock() defer f.mutex.Unlock() @@ -174,19 +247,45 @@ func (f *DNSForwarder) removeStaleCacheEntries(oldEntries, newEntries []*Forward } func (f *DNSForwarder) Close(ctx context.Context) error { + // Marked closed under the lock so a Listen that has not published its + // servers yet gives up instead of racing this shutdown. The shutdowns + // themselves block, so they run outside it. + f.mutex.Lock() + f.closed = true + dnsServer, tcpServer := f.dnsServer, f.tcpServer + udpConn, tcpLn := f.udpConn, f.tcpLn + f.mutex.Unlock() + var result *multierror.Error - if f.dnsServer != nil { - if err := f.dnsServer.ShutdownContext(ctx); err != nil { + if dnsServer != nil { + if err := shutdownServer(ctx, dnsServer); err != nil { result = multierror.Append(result, fmt.Errorf("UDP shutdown: %w", err)) } } - if f.tcpServer != nil { - if err := f.tcpServer.ShutdownContext(ctx); err != nil { + if tcpServer != nil { + if err := shutdownServer(ctx, tcpServer); err != nil { result = multierror.Append(result, fmt.Errorf("TCP shutdown: %w", err)) } } + // The sockets are closed even when the shutdowns above reported nothing to + // do. A server that has been published but has not reached + // ActivateAndServe refuses to shut down, and closing what it was about to + // serve on is what stops it: the alternative is a listener still answering + // on an interface that has gone away. A shutdown that did run has already + // closed these, so the second close is expected to fail. + if udpConn != nil { + if err := udpConn.Close(); err != nil { + log.Debugf("close UDP socket of the DNS forwarder: %v", err) + } + } + if tcpLn != nil { + if err := tcpLn.Close(); err != nil { + log.Debugf("close TCP socket of the DNS forwarder: %v", err) + } + } + return nberrors.FormatErrorOrNil(result) } @@ -201,12 +300,6 @@ func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, q qname, dns.TypeToString[question.Qtype], dns.ClassToString[question.Qclass]) resp := query.SetReply(query) - network := resutil.NetworkForQtype(question.Qtype) - if network == "" { - resp.Rcode = dns.RcodeNotImplemented - f.writeResponse(logger, w, resp, qname, startTime) - return - } mostSpecificResId, matchingEntries := f.getMatchingEntries(strings.TrimSuffix(qname, ".")) if mostSpecificResId == "" { @@ -218,9 +311,46 @@ func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, q ctx, cancel := context.WithTimeout(context.Background(), upstreamTimeout) defer cancel() + reqHasEdns := query.IsEdns0() != nil + + switch question.Qtype { + case dns.TypeA, dns.TypeAAAA: + f.handleAddressQuery(ctx, logger, w, resp, mostSpecificResId, matchingEntries, reqHasEdns, startTime) + case dns.TypeMX, dns.TypeTXT, dns.TypeNS, dns.TypeSRV, dns.TypeCNAME, dns.TypePTR: + f.handleRecordQuery(ctx, logger, w, resp, startTime) + default: + // The domain is routed here, so any other type is answered NODATA + // (NOERROR, empty answer) rather than falling back to a resolver that + // would poison the name with NXDOMAIN. The Extended DNS Error lets a + // client tell this capability-driven NODATA apart from an + // authoritative one. The OPT pseudo-record must not appear unless the + // query advertised EDNS0. + if reqHasEdns { + attachEDE(resp, dns.ExtendedErrorCodeNotSupported, "netbird forwarder: unsupported query type") + } + f.writeResponse(logger, w, resp, qname, startTime) + } +} + +// handleAddressQuery resolves A/AAAA queries, programs the firewall sets and +// resolved-IP state, and caches the answer for resilience on upstream failure. +func (f *DNSForwarder) handleAddressQuery( + ctx context.Context, + logger *log.Entry, + w dns.ResponseWriter, + resp *dns.Msg, + mostSpecificResId route.ResID, + matchingEntries []*ForwarderEntry, + reqHasEdns bool, + startTime time.Time, +) { + question := resp.Question[0] + qname := strings.ToLower(question.Name) + + network := resutil.NetworkForQtype(question.Qtype) result := resutil.LookupIP(ctx, f.resolver, network, qname, question.Qtype) if result.Err != nil { - f.handleDNSError(ctx, logger, w, question, resp, qname, result, startTime) + f.handleDNSError(ctx, logger, w, question, resp, qname, result, reqHasEdns, startTime) return } @@ -231,6 +361,25 @@ func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, q f.writeResponse(logger, w, resp, qname, startTime) } +// handleRecordQuery resolves non-address record types (MX, TXT, NS, SRV, +// CNAME, PTR) through the host resolver. Missing records are answered NODATA so +// the routed name is never poisoned with NXDOMAIN. +func (f *DNSForwarder) handleRecordQuery( + ctx context.Context, + logger *log.Entry, + w dns.ResponseWriter, + resp *dns.Msg, + startTime time.Time, +) { + question := resp.Question[0] + qname := strings.ToLower(question.Name) + + records, rcode := resutil.LookupRecords(ctx, f.resolver, qname, question.Qtype, f.ttl) + resp.Rcode = rcode + resp.Answer = append(resp.Answer, records...) + 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) @@ -333,6 +482,7 @@ func (f *DNSForwarder) handleDNSError( resp *dns.Msg, domain string, result resutil.LookupResult, + reqHasEdns bool, startTime time.Time, ) { qType := question.Qtype @@ -374,6 +524,10 @@ func (f *DNSForwarder) handleDNSError( logger.Warnf(errResolveFailed, domain, result.Err) } + if reqHasEdns { + attachEDE(resp, edeCodeFor(dnsErr), edeText(dnsErr)) + } + f.writeResponse(logger, w, resp, domain, startTime) } @@ -414,3 +568,46 @@ func (f *DNSForwarder) getMatchingEntries(domain string) (route.ResID, []*Forwar return selectedResId, matches } + +// edeCodeFor maps an upstream lookup error to the NetBird EDE info code. +func edeCodeFor(dnsErr *net.DNSError) uint16 { + if dnsErr != nil && dnsErr.IsTimeout { + return edeNetbirdUpstreamTimeout + } + return edeNetbirdUpstreamFailure +} + +// edeText builds the EDE extra-text describing the class of upstream failure. +// It deliberately omits the upstream server address, which may be an internal +// resolver and is exposed to any client permitted to use the route; the full +// detail stays in the forwarder's local log. +func edeText(dnsErr *net.DNSError) string { + if dnsErr != nil && dnsErr.IsTimeout { + return "netbird forwarder: upstream timeout" + } + return "netbird forwarder: upstream failure" +} + +// attachEDE adds an Extended DNS Error (RFC 8914) option to the response, +// creating the OPT pseudo-record if the response does not already carry one. +func attachEDE(resp *dns.Msg, code uint16, text string) { + opt := resp.IsEdns0() + if opt == nil { + resp.SetEdns0(dns.DefaultMsgSize, false) + opt = resp.IsEdns0() + } + opt.Option = append(opt.Option, &dns.EDNS0_EDE{InfoCode: code, ExtraText: text}) +} + +// shutdownServer shuts a server down gracefully, treating "never started" as +// success. A server that was published but has not reached ActivateAndServe +// has nothing to wind down, and the caller closes its socket regardless, which +// is what actually stops it. dns exports no sentinel for this, so the message +// is all there is to match on. +func shutdownServer(ctx context.Context, server *dns.Server) error { + err := server.ShutdownContext(ctx) + if err == nil || strings.Contains(err.Error(), "server not started") { + return nil + } + return err +} diff --git a/client/internal/dnsfwd/forwarder_test.go b/client/internal/dnsfwd/forwarder_test.go index 7325ef8a7..a64ba80e7 100644 --- a/client/internal/dnsfwd/forwarder_test.go +++ b/client/internal/dnsfwd/forwarder_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/require" firewall "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/internal/dns/resutil" "github.com/netbirdio/netbird/client/internal/dns/test" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/route" @@ -132,6 +133,41 @@ func (m *MockResolver) LookupNetIP(ctx context.Context, network, host string) ([ return args.Get(0).([]netip.Addr), args.Error(1) } +func (m *MockResolver) LookupMX(ctx context.Context, name string) ([]*net.MX, error) { + args := m.Called(ctx, name) + recs, _ := args.Get(0).([]*net.MX) + return recs, args.Error(1) +} + +func (m *MockResolver) LookupTXT(ctx context.Context, name string) ([]string, error) { + args := m.Called(ctx, name) + recs, _ := args.Get(0).([]string) + return recs, args.Error(1) +} + +func (m *MockResolver) LookupNS(ctx context.Context, name string) ([]*net.NS, error) { + args := m.Called(ctx, name) + recs, _ := args.Get(0).([]*net.NS) + return recs, args.Error(1) +} + +func (m *MockResolver) LookupSRV(ctx context.Context, service, proto, name string) (string, []*net.SRV, error) { + args := m.Called(ctx, service, proto, name) + recs, _ := args.Get(1).([]*net.SRV) + return args.String(0), recs, args.Error(2) +} + +func (m *MockResolver) LookupCNAME(ctx context.Context, host string) (string, error) { + args := m.Called(ctx, host) + return args.String(0), args.Error(1) +} + +func (m *MockResolver) LookupAddr(ctx context.Context, addr string) ([]string, error) { + args := m.Called(ctx, addr) + recs, _ := args.Get(0).([]string) + return recs, args.Error(1) +} + func TestDNSForwarder_SubdomainAccessLogic(t *testing.T) { tests := []struct { name string @@ -544,12 +580,15 @@ func TestDNSForwarder_MultipleIPsInSingleUpdate(t *testing.T) { } func TestDNSForwarder_ResponseCodes(t *testing.T) { + // A type with no net.Resolver Lookup method (CAA) must answer NODATA + // (NOERROR, empty) rather than NXDOMAIN/NOTIMP to avoid poisoning the name. tests := []struct { name string queryType uint16 queryDomain string configured string expectedCode int + expectEDE bool description string }{ { @@ -561,28 +600,13 @@ func TestDNSForwarder_ResponseCodes(t *testing.T) { description: "RFC compliant REFUSED for unauthorized queries", }, { - name: "unsupported query type returns NOTIMP", - queryType: dns.TypeMX, + name: "unsupported query type returns NODATA", + queryType: dns.TypeCAA, queryDomain: "example.com", configured: "example.com", - expectedCode: dns.RcodeNotImplemented, - description: "RFC compliant NOTIMP for unsupported types", - }, - { - name: "CNAME query returns NOTIMP", - queryType: dns.TypeCNAME, - queryDomain: "example.com", - configured: "example.com", - expectedCode: dns.RcodeNotImplemented, - description: "CNAME queries not supported", - }, - { - name: "TXT query returns NOTIMP", - queryType: dns.TypeTXT, - queryDomain: "example.com", - configured: "example.com", - expectedCode: dns.RcodeNotImplemented, - description: "TXT queries not supported", + expectedCode: dns.RcodeSuccess, + expectEDE: true, + description: "Unsupported types answer NODATA, not NXDOMAIN/NOTIMP", }, } @@ -598,6 +622,7 @@ func TestDNSForwarder_ResponseCodes(t *testing.T) { query := &dns.Msg{} query.SetQuestion(dns.Fqdn(tt.queryDomain), tt.queryType) + query.SetEdns0(dns.DefaultMsgSize, false) // Capture the written response var writtenResp *dns.Msg @@ -613,6 +638,288 @@ func TestDNSForwarder_ResponseCodes(t *testing.T) { // Check the response written to the writer require.NotNil(t, writtenResp, "Expected response to be written") assert.Equal(t, tt.expectedCode, writtenResp.Rcode, tt.description) + assert.Empty(t, writtenResp.Answer, "Non-address response should carry no answers") + + if tt.expectEDE { + require.NotNil(t, writtenResp.IsEdns0(), "EDNS0 client should get an OPT in the reply") + assert.True(t, hasEDE(writtenResp, dns.ExtendedErrorCodeNotSupported), + "unsupported type NODATA should carry EDE Not Supported") + } + }) + } +} + +func hasEDE(m *dns.Msg, code uint16) bool { + opt := m.IsEdns0() + if opt == nil { + return false + } + for _, o := range opt.Option { + if ede, ok := o.(*dns.EDNS0_EDE); ok && ede.InfoCode == code { + return true + } + } + return false +} + +func TestDNSForwarder_RecordQueries(t *testing.T) { + notFound := &net.DNSError{IsNotFound: true, Name: "example.com"} + + t.Run("MX records are forwarded", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "example.com") + + mockResolver.On("LookupMX", mock.Anything, "example.com."). + Return([]*net.MX{{Host: "mail.example.com.", Pref: 10}}, nil).Once() + + resp := runRecordQuery(t, forwarder, "example.com", dns.TypeMX) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + mx, ok := resp.Answer[0].(*dns.MX) + require.True(t, ok, "answer should be an MX record") + assert.Equal(t, uint16(10), mx.Preference) + assert.Equal(t, "mail.example.com.", mx.Mx) + mockResolver.AssertExpectations(t) + }) + + t.Run("missing MX is NODATA not NXDOMAIN", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "example.com") + + // A not-found cannot prove the name is absent (it may exist with only + // other record types), so it must answer NODATA, never NXDOMAIN. + mockResolver.On("LookupMX", mock.Anything, "example.com."). + Return(nil, notFound).Once() + + resp := runRecordQuery(t, forwarder, "example.com", dns.TypeMX) + assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "missing record must be NODATA") + assert.Empty(t, resp.Answer) + mockResolver.AssertExpectations(t) + }) + + t.Run("NS records are forwarded", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "example.com") + + mockResolver.On("LookupNS", mock.Anything, "example.com."). + Return([]*net.NS{{Host: "ns1.example.com."}}, nil).Once() + + resp := runRecordQuery(t, forwarder, "example.com", dns.TypeNS) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + ns, ok := resp.Answer[0].(*dns.NS) + require.True(t, ok, "answer should be an NS record") + assert.Equal(t, "ns1.example.com.", ns.Ns) + mockResolver.AssertExpectations(t) + }) + + t.Run("missing NS is NODATA", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "example.com") + + mockResolver.On("LookupNS", mock.Anything, "example.com."). + Return(nil, notFound).Once() + + resp := runRecordQuery(t, forwarder, "example.com", dns.TypeNS) + assert.Equal(t, dns.RcodeSuccess, resp.Rcode) + assert.Empty(t, resp.Answer) + mockResolver.AssertExpectations(t) + }) + + t.Run("SRV records are forwarded", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "_sip._tcp.example.com") + + mockResolver.On("LookupSRV", mock.Anything, "", "", "_sip._tcp.example.com."). + Return("", []*net.SRV{{Target: "sip.example.com.", Port: 5060, Priority: 10, Weight: 5}}, nil).Once() + + resp := runRecordQuery(t, forwarder, "_sip._tcp.example.com", dns.TypeSRV) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + srv, ok := resp.Answer[0].(*dns.SRV) + require.True(t, ok, "answer should be an SRV record") + assert.Equal(t, "sip.example.com.", srv.Target) + assert.Equal(t, uint16(5060), srv.Port) + assert.Equal(t, uint16(10), srv.Priority) + mockResolver.AssertExpectations(t) + }) + + t.Run("missing SRV is NODATA", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "_sip._tcp.example.com") + + mockResolver.On("LookupSRV", mock.Anything, "", "", "_sip._tcp.example.com."). + Return("", nil, notFound).Once() + + resp := runRecordQuery(t, forwarder, "_sip._tcp.example.com", dns.TypeSRV) + assert.Equal(t, dns.RcodeSuccess, resp.Rcode) + assert.Empty(t, resp.Answer) + mockResolver.AssertExpectations(t) + }) + + t.Run("TXT records are forwarded", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "example.com") + + mockResolver.On("LookupTXT", mock.Anything, "example.com."). + Return([]string{"v=spf1 -all"}, nil).Once() + + resp := runRecordQuery(t, forwarder, "example.com", dns.TypeTXT) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + txt, ok := resp.Answer[0].(*dns.TXT) + require.True(t, ok, "answer should be a TXT record") + assert.Equal(t, []string{"v=spf1 -all"}, txt.Txt) + mockResolver.AssertExpectations(t) + }) + + t.Run("CNAME record is forwarded", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "www.example.com") + + mockResolver.On("LookupCNAME", mock.Anything, "www.example.com."). + Return("target.example.com.", nil).Once() + + resp := runRecordQuery(t, forwarder, "www.example.com", dns.TypeCNAME) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + cname, ok := resp.Answer[0].(*dns.CNAME) + require.True(t, ok, "answer should be a CNAME record") + assert.Equal(t, "target.example.com.", cname.Target) + mockResolver.AssertExpectations(t) + }) + + t.Run("CNAME equal to the name is NODATA", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "example.com") + + // No CNAME exists: LookupCNAME echoes the queried name back. + mockResolver.On("LookupCNAME", mock.Anything, "example.com."). + Return("example.com.", nil).Once() + + resp := runRecordQuery(t, forwarder, "example.com", dns.TypeCNAME) + assert.Equal(t, dns.RcodeSuccess, resp.Rcode) + assert.Empty(t, resp.Answer, "self-referential CNAME means no CNAME record") + mockResolver.AssertExpectations(t) + }) + + t.Run("PTR record is forwarded", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "*.in-addr.arpa") + + // The reverse name is parsed back to the address LookupAddr expects. + mockResolver.On("LookupAddr", mock.Anything, "1.2.3.4"). + Return([]string{"host.example.com."}, nil).Once() + + resp := runRecordQuery(t, forwarder, "4.3.2.1.in-addr.arpa", dns.TypePTR) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + ptr, ok := resp.Answer[0].(*dns.PTR) + require.True(t, ok, "answer should be a PTR record") + assert.Equal(t, "host.example.com.", ptr.Ptr) + mockResolver.AssertExpectations(t) + }) +} + +func newRecordTestForwarder(t *testing.T, r resolver, configured string) *DNSForwarder { + t.Helper() + forwarder := NewDNSForwarder(netip.MustParseAddrPort("127.0.0.1:0"), 300, nil, &peer.Status{}, nil) + forwarder.resolver = r + + d, err := domain.FromString(configured) + require.NoError(t, err) + forwarder.UpdateDomains([]*ForwarderEntry{{Domain: d, ResID: "test-res"}}) + return forwarder +} + +func runRecordQuery(t *testing.T, forwarder *DNSForwarder, qname string, qtype uint16) *dns.Msg { + t.Helper() + query := &dns.Msg{} + query.SetQuestion(dns.Fqdn(qname), qtype) + + mockWriter := &test.MockResponseWriter{} + forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query, time.Now()) + + resp := mockWriter.GetLastResponse() + require.NotNil(t, resp, "expected response to be written") + return resp +} + +func TestDNSForwarder_UpstreamFailureEDE(t *testing.T) { + tests := []struct { + name string + lookupErr error + reqEdns bool + wantEDE bool + wantCode uint16 + wantTextHas string + }{ + { + name: "timeout with edns0", + lookupErr: &net.DNSError{Err: "i/o timeout", Server: "10.0.0.53:53", IsTimeout: true}, + reqEdns: true, + wantEDE: true, + wantCode: edeNetbirdUpstreamTimeout, + wantTextHas: "netbird forwarder: upstream timeout", + }, + { + name: "server failure with edns0", + lookupErr: &net.DNSError{Err: "server misbehaving", Server: "10.0.0.53:53"}, + reqEdns: true, + wantEDE: true, + wantCode: edeNetbirdUpstreamFailure, + wantTextHas: "netbird forwarder: upstream failure", + }, + { + name: "no edns0 in request omits ede", + lookupErr: &net.DNSError{Err: "server misbehaving", Server: "10.0.0.53:53"}, + reqEdns: false, + wantEDE: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := NewDNSForwarder(netip.MustParseAddrPort("127.0.0.1:0"), 300, nil, &peer.Status{}, nil) + forwarder.resolver = mockResolver + + d, err := domain.FromString("example.com") + require.NoError(t, err) + forwarder.UpdateDomains([]*ForwarderEntry{{Domain: d, ResID: "test-res"}}) + + mockResolver.On("LookupNetIP", mock.Anything, "ip4", "example.com."). + Return([]netip.Addr(nil), tt.lookupErr).Once() + + query := &dns.Msg{} + query.SetQuestion("example.com.", dns.TypeA) + if tt.reqEdns { + query.SetEdns0(dns.DefaultMsgSize, false) + } + + var writtenResp *dns.Msg + mockWriter := &test.MockResponseWriter{ + WriteMsgFunc: func(m *dns.Msg) error { + writtenResp = m + return nil + }, + } + + forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query, time.Now()) + mockResolver.AssertExpectations(t) + + require.NotNil(t, writtenResp, "expected a response") + assert.Equal(t, dns.RcodeServerFailure, writtenResp.Rcode, "upstream failure must be SERVFAIL") + + ede, ok := resutil.ExtractEDE(writtenResp) + if !tt.wantEDE { + assert.False(t, ok, "response must not carry EDE") + return + } + require.True(t, ok, "response must carry EDE") + assert.Equal(t, tt.wantCode, ede.InfoCode, "EDE info code") + assert.Contains(t, ede.ExtraText, tt.wantTextHas, "EDE extra-text") + assert.NotContains(t, ede.ExtraText, "10.0.0.53", "must not leak upstream server address") }) } } @@ -931,3 +1238,55 @@ func TestDNSForwarder_EmptyQuery(t *testing.T) { assert.Nil(t, mockWriter.GetLastResponse(), "Should not write response for empty query") } + +// TestDNSForwarder_ClosedBeforeItServes covers Listen reaching the point of +// serving after the forwarder has already been closed. Listen runs on its own +// goroutine, so it can get there late, and a socket it starts serving then is +// one nothing will ever close: on Android it keeps answering on an interface +// that has been replaced. The close is sequenced first here rather than raced, +// which pins the same state deterministically. +func TestDNSForwarder_ClosedBeforeItServes(t *testing.T) { + f := NewDNSForwarder(netip.MustParseAddrPort("127.0.0.1:0"), 60, nil, nil, nil) + + require.NoError(t, f.Close(context.Background()), "closing a forwarder that never started") + + done := make(chan error, 1) + go func() { done <- f.Listen(nil) }() + + select { + case err := <-done: + assert.NoError(t, err, "a closed forwarder should give up quietly, not serve") + case <-time.After(5 * time.Second): + t.Fatal("Listen went on to serve after the forwarder was closed") + } +} + +// TestDNSForwarder_CloseStopsUnactivatedServers covers the window between +// Listen publishing its servers and reaching ActivateAndServe. A server that +// has not been activated refuses to shut down, so Close has to close the +// sockets itself or they are left serving. +func TestDNSForwarder_CloseStopsUnactivatedServers(t *testing.T) { + f := NewDNSForwarder(netip.MustParseAddrPort("127.0.0.1:0"), 60, nil, nil, nil) + + udpConn, err := f.createUDPListener(nil) + require.NoError(t, err, "create UDP listener") + tcpLn, err := f.createTCPListener(nil) + require.NoError(t, err, "create TCP listener") + + // Published but deliberately never activated, which is the state Listen is + // in for the moment before it starts serving. + require.True(t, f.publish(udpConn, tcpLn, &dns.Server{PacketConn: udpConn}, &dns.Server{Listener: tcpLn}, nil), + "publishing to an open forwarder") + + tcpAddr := tcpLn.Addr().String() + require.NoError(t, f.Close(context.Background()), "close should report no error for servers it could not shut down") + + _, err = tcpLn.Accept() + assert.Error(t, err, "the TCP socket should be closed after Close") + + conn, err := net.DialTimeout("tcp", tcpAddr, time.Second) + if err == nil { + _ = conn.Close() + t.Fatal("the forwarder is still accepting connections after Close") + } +} diff --git a/client/internal/dnsfwd/manager.go b/client/internal/dnsfwd/manager.go index 58b88d9ef..1c62e908d 100644 --- a/client/internal/dnsfwd/manager.go +++ b/client/internal/dnsfwd/manager.go @@ -3,7 +3,6 @@ package dnsfwd import ( "context" "fmt" - "net" "net/netip" "os" "strconv" @@ -80,6 +79,7 @@ func (m *Manager) Start(fwdEntries []*ForwarderEntry) error { return err } + // IPv4-only: peers reach the forwarder via its v4 overlay address. localAddr := m.wgIface.Address().IP if localAddr.IsValid() && m.firewall != nil { @@ -100,7 +100,7 @@ func (m *Manager) Start(fwdEntries []*ForwarderEntry) error { m.dnsForwarder = NewDNSForwarder(listenAddress, dnsTTL, m.firewall, m.statusRecorder, m.wgIface) go func() { - if err := m.dnsForwarder.Listen(fwdEntries); err != nil { + if err := m.dnsForwarder.Listen(fwdEntries); err != nil { //nolint:staticcheck // todo handle close error if it is exists log.Errorf("failed to start DNS forwarder, err: %v", err) } @@ -117,6 +117,16 @@ func (m *Manager) UpdateDomains(entries []*ForwarderEntry) { m.dnsForwarder.UpdateDomains(entries) } +// Domains returns the entries currently being served, or nil when the +// forwarder is not running. +func (m *Manager) Domains() []*ForwarderEntry { + if m.dnsForwarder == nil { + return nil + } + + return m.dnsForwarder.Domains() +} + func (m *Manager) Stop(ctx context.Context) error { if m.dnsForwarder == nil { return nil @@ -159,12 +169,13 @@ func (m *Manager) allowDNSFirewall() error { return nil } - dnsRules, err := m.firewall.AddPeerFiltering(nil, net.IP{0, 0, 0, 0}, firewall.ProtocolUDP, nil, dport, firewall.ActionAccept, "") + anyV4 := []netip.Prefix{netip.PrefixFrom(netip.IPv4Unspecified(), 0)} + dnsRule, err := m.firewall.AddFilterRule(nil, anyV4, firewall.Network{}, firewall.ProtocolUDP, nil, dport, firewall.ActionAccept) if err != nil { return fmt.Errorf("add udp firewall rule: %w", err) } - tcpRules, err := m.firewall.AddPeerFiltering(nil, net.IP{0, 0, 0, 0}, firewall.ProtocolTCP, nil, dport, firewall.ActionAccept, "") + tcpRule, err := m.firewall.AddFilterRule(nil, anyV4, firewall.Network{}, firewall.ProtocolTCP, nil, dport, firewall.ActionAccept) if err != nil { return fmt.Errorf("add tcp firewall rule: %w", err) } @@ -173,8 +184,12 @@ func (m *Manager) allowDNSFirewall() error { return fmt.Errorf("flush: %w", err) } - m.fwRules = dnsRules - m.tcpRules = tcpRules + if dnsRule != nil { + m.fwRules = []firewall.Rule{dnsRule} + } + if tcpRule != nil { + m.tcpRules = []firewall.Rule{tcpRule} + } m.registerNetstackServices() @@ -208,12 +223,12 @@ func (m *Manager) unregisterNetstackServices() { func (m *Manager) dropDNSFirewall() error { var mErr *multierror.Error for _, rule := range m.fwRules { - if err := m.firewall.DeletePeerRule(rule); err != nil { + if err := m.firewall.DeleteFilterRule(rule); err != nil { mErr = multierror.Append(mErr, fmt.Errorf("failed to delete DNS router rules, err: %v", err)) } } for _, rule := range m.tcpRules { - if err := m.firewall.DeletePeerRule(rule); err != nil { + if err := m.firewall.DeleteFilterRule(rule); err != nil { mErr = multierror.Append(mErr, fmt.Errorf("failed to delete DNS router rules, err: %v", err)) } } diff --git a/client/internal/ebpf/ebpf/bpf_bpfeb.go b/client/internal/ebpf/ebpf/bpf_bpfeb.go index 04b19883b..4b6230217 100644 --- a/client/internal/ebpf/ebpf/bpf_bpfeb.go +++ b/client/internal/ebpf/ebpf/bpf_bpfeb.go @@ -1,5 +1,5 @@ // Code generated by bpf2go; DO NOT EDIT. -//go:build arm64be || armbe || mips || mips64 || mips64p32 || ppc64 || s390 || s390x || sparc || sparc64 +//go:build mips || mips64 || ppc64 || s390x package ebpf @@ -47,9 +47,10 @@ func loadBpfObjects(obj interface{}, opts *ebpf.CollectionOptions) error { type bpfSpecs struct { bpfProgramSpecs bpfMapSpecs + bpfVariableSpecs } -// bpfSpecs contains programs before they are loaded into the kernel. +// bpfProgramSpecs contains programs before they are loaded into the kernel. // // It can be passed ebpf.CollectionSpec.Assign. type bpfProgramSpecs struct { @@ -61,17 +62,28 @@ type bpfProgramSpecs struct { // It can be passed ebpf.CollectionSpec.Assign. type bpfMapSpecs struct { NbFeatures *ebpf.MapSpec `ebpf:"nb_features"` - NbMapDnsIp *ebpf.MapSpec `ebpf:"nb_map_dns_ip"` - NbMapDnsPort *ebpf.MapSpec `ebpf:"nb_map_dns_port"` NbWgProxySettingsMap *ebpf.MapSpec `ebpf:"nb_wg_proxy_settings_map"` } +// bpfVariableSpecs contains global variables before they are loaded into the kernel. +// +// It can be passed ebpf.CollectionSpec.Assign. +type bpfVariableSpecs struct { + FlagFeatureWgProxy *ebpf.VariableSpec `ebpf:"flag_feature_wg_proxy"` + MapKeyFeatures *ebpf.VariableSpec `ebpf:"map_key_features"` + MapKeyProxyPort *ebpf.VariableSpec `ebpf:"map_key_proxy_port"` + MapKeyWgPort *ebpf.VariableSpec `ebpf:"map_key_wg_port"` + ProxyPort *ebpf.VariableSpec `ebpf:"proxy_port"` + WgPort *ebpf.VariableSpec `ebpf:"wg_port"` +} + // bpfObjects contains all objects after they have been loaded into the kernel. // // It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. type bpfObjects struct { bpfPrograms bpfMaps + bpfVariables } func (o *bpfObjects) Close() error { @@ -86,20 +98,28 @@ func (o *bpfObjects) Close() error { // It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. type bpfMaps struct { NbFeatures *ebpf.Map `ebpf:"nb_features"` - NbMapDnsIp *ebpf.Map `ebpf:"nb_map_dns_ip"` - NbMapDnsPort *ebpf.Map `ebpf:"nb_map_dns_port"` NbWgProxySettingsMap *ebpf.Map `ebpf:"nb_wg_proxy_settings_map"` } func (m *bpfMaps) Close() error { return _BpfClose( m.NbFeatures, - m.NbMapDnsIp, - m.NbMapDnsPort, m.NbWgProxySettingsMap, ) } +// bpfVariables contains all global variables after they have been loaded into the kernel. +// +// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. +type bpfVariables struct { + FlagFeatureWgProxy *ebpf.Variable `ebpf:"flag_feature_wg_proxy"` + MapKeyFeatures *ebpf.Variable `ebpf:"map_key_features"` + MapKeyProxyPort *ebpf.Variable `ebpf:"map_key_proxy_port"` + MapKeyWgPort *ebpf.Variable `ebpf:"map_key_wg_port"` + ProxyPort *ebpf.Variable `ebpf:"proxy_port"` + WgPort *ebpf.Variable `ebpf:"wg_port"` +} + // bpfPrograms contains all programs after they have been loaded into the kernel. // // It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. diff --git a/client/internal/ebpf/ebpf/bpf_bpfeb.o b/client/internal/ebpf/ebpf/bpf_bpfeb.o index 6e9cda44a..b435d4964 100644 Binary files a/client/internal/ebpf/ebpf/bpf_bpfeb.o and b/client/internal/ebpf/ebpf/bpf_bpfeb.o differ diff --git a/client/internal/ebpf/ebpf/bpf_bpfel.go b/client/internal/ebpf/ebpf/bpf_bpfel.go index 03b494aa2..f56efc901 100644 --- a/client/internal/ebpf/ebpf/bpf_bpfel.go +++ b/client/internal/ebpf/ebpf/bpf_bpfel.go @@ -1,5 +1,5 @@ // Code generated by bpf2go; DO NOT EDIT. -//go:build 386 || amd64 || amd64p32 || arm || arm64 || loong64 || mips64le || mips64p32le || mipsle || ppc64le || riscv64 +//go:build 386 || amd64 || arm || arm64 || loong64 || mips64le || mipsle || ppc64le || riscv64 || wasm package ebpf @@ -47,9 +47,10 @@ func loadBpfObjects(obj interface{}, opts *ebpf.CollectionOptions) error { type bpfSpecs struct { bpfProgramSpecs bpfMapSpecs + bpfVariableSpecs } -// bpfSpecs contains programs before they are loaded into the kernel. +// bpfProgramSpecs contains programs before they are loaded into the kernel. // // It can be passed ebpf.CollectionSpec.Assign. type bpfProgramSpecs struct { @@ -61,17 +62,28 @@ type bpfProgramSpecs struct { // It can be passed ebpf.CollectionSpec.Assign. type bpfMapSpecs struct { NbFeatures *ebpf.MapSpec `ebpf:"nb_features"` - NbMapDnsIp *ebpf.MapSpec `ebpf:"nb_map_dns_ip"` - NbMapDnsPort *ebpf.MapSpec `ebpf:"nb_map_dns_port"` NbWgProxySettingsMap *ebpf.MapSpec `ebpf:"nb_wg_proxy_settings_map"` } +// bpfVariableSpecs contains global variables before they are loaded into the kernel. +// +// It can be passed ebpf.CollectionSpec.Assign. +type bpfVariableSpecs struct { + FlagFeatureWgProxy *ebpf.VariableSpec `ebpf:"flag_feature_wg_proxy"` + MapKeyFeatures *ebpf.VariableSpec `ebpf:"map_key_features"` + MapKeyProxyPort *ebpf.VariableSpec `ebpf:"map_key_proxy_port"` + MapKeyWgPort *ebpf.VariableSpec `ebpf:"map_key_wg_port"` + ProxyPort *ebpf.VariableSpec `ebpf:"proxy_port"` + WgPort *ebpf.VariableSpec `ebpf:"wg_port"` +} + // bpfObjects contains all objects after they have been loaded into the kernel. // // It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. type bpfObjects struct { bpfPrograms bpfMaps + bpfVariables } func (o *bpfObjects) Close() error { @@ -86,20 +98,28 @@ func (o *bpfObjects) Close() error { // It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. type bpfMaps struct { NbFeatures *ebpf.Map `ebpf:"nb_features"` - NbMapDnsIp *ebpf.Map `ebpf:"nb_map_dns_ip"` - NbMapDnsPort *ebpf.Map `ebpf:"nb_map_dns_port"` NbWgProxySettingsMap *ebpf.Map `ebpf:"nb_wg_proxy_settings_map"` } func (m *bpfMaps) Close() error { return _BpfClose( m.NbFeatures, - m.NbMapDnsIp, - m.NbMapDnsPort, m.NbWgProxySettingsMap, ) } +// bpfVariables contains all global variables after they have been loaded into the kernel. +// +// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. +type bpfVariables struct { + FlagFeatureWgProxy *ebpf.Variable `ebpf:"flag_feature_wg_proxy"` + MapKeyFeatures *ebpf.Variable `ebpf:"map_key_features"` + MapKeyProxyPort *ebpf.Variable `ebpf:"map_key_proxy_port"` + MapKeyWgPort *ebpf.Variable `ebpf:"map_key_wg_port"` + ProxyPort *ebpf.Variable `ebpf:"proxy_port"` + WgPort *ebpf.Variable `ebpf:"wg_port"` +} + // bpfPrograms contains all programs after they have been loaded into the kernel. // // It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. diff --git a/client/internal/ebpf/ebpf/bpf_bpfel.o b/client/internal/ebpf/ebpf/bpf_bpfel.o index 6338f4774..a388b6d6d 100644 Binary files a/client/internal/ebpf/ebpf/bpf_bpfel.o and b/client/internal/ebpf/ebpf/bpf_bpfel.o differ diff --git a/client/internal/ebpf/ebpf/dns_fwd_linux.go b/client/internal/ebpf/ebpf/dns_fwd_linux.go deleted file mode 100644 index 93797da76..000000000 --- a/client/internal/ebpf/ebpf/dns_fwd_linux.go +++ /dev/null @@ -1,51 +0,0 @@ -package ebpf - -import ( - "encoding/binary" - "net" - - log "github.com/sirupsen/logrus" -) - -const ( - mapKeyDNSIP uint32 = 0 - mapKeyDNSPort uint32 = 1 -) - -func (tf *GeneralManager) LoadDNSFwd(ip string, dnsPort int) error { - log.Debugf("load eBPF DNS forwarder, watching addr: %s:53, redirect to port: %d", ip, dnsPort) - tf.lock.Lock() - defer tf.lock.Unlock() - - err := tf.loadXdp() - if err != nil { - return err - } - - err = tf.bpfObjs.NbMapDnsIp.Put(mapKeyDNSIP, ip2int(ip)) - if err != nil { - return err - } - - err = tf.bpfObjs.NbMapDnsPort.Put(mapKeyDNSPort, uint16(dnsPort)) - if err != nil { - return err - } - - tf.setFeatureFlag(featureFlagDnsForwarder) - err = tf.bpfObjs.NbFeatures.Put(mapKeyFeatures, tf.featureFlags) - if err != nil { - return err - } - return nil -} - -func (tf *GeneralManager) FreeDNSFwd() error { - log.Debugf("free ebpf DNS forwarder") - return tf.unsetFeatureFlag(featureFlagDnsForwarder) -} - -func ip2int(ipString string) uint32 { - ip := net.ParseIP(ipString) - return binary.BigEndian.Uint32(ip.To4()) -} diff --git a/client/internal/ebpf/ebpf/manager_linux.go b/client/internal/ebpf/ebpf/manager_linux.go index 7520a6387..a13f5f19a 100644 --- a/client/internal/ebpf/ebpf/manager_linux.go +++ b/client/internal/ebpf/ebpf/manager_linux.go @@ -15,8 +15,7 @@ import ( const ( mapKeyFeatures uint32 = 0 - featureFlagWGProxy = 0b00000001 - featureFlagDnsForwarder = 0b00000010 + featureFlagWGProxy = 0b00000001 ) var ( @@ -28,9 +27,9 @@ var ( // GeneralManager is used to load multiple eBPF programs with a custom check (if then) done in prog.c // The manager simply adds a feature (byte) of each program to a map that is shared between the userspace and kernel. -// When packet arrives, the C code checks for each feature (if it is set) and executes each enabled program (e.g., dns_fwd.c and wg_proxy.c). +// When packet arrives, the C code checks for each feature (if it is set) and executes each enabled program (e.g., wg_proxy.c). // -//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang-14 bpf src/prog.c -- -I /usr/x86_64-linux-gnu/include +//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang-14 bpf src/prog.c -- -I /usr/x86_64-linux-gnu/include -include src/bpf_map_def.h type GeneralManager struct { lock sync.Mutex link link.Link diff --git a/client/internal/ebpf/ebpf/manager_linux_test.go b/client/internal/ebpf/ebpf/manager_linux_test.go index 5664a4565..e09fcb977 100644 --- a/client/internal/ebpf/ebpf/manager_linux_test.go +++ b/client/internal/ebpf/ebpf/manager_linux_test.go @@ -7,33 +7,24 @@ import ( func TestManager_setFeatureFlag(t *testing.T) { mgr := GeneralManager{} mgr.setFeatureFlag(featureFlagWGProxy) - if mgr.featureFlags != 1 { + if mgr.featureFlags != featureFlagWGProxy { t.Errorf("invalid feature state") } - mgr.setFeatureFlag(featureFlagDnsForwarder) - if mgr.featureFlags != 3 { - t.Errorf("invalid feature state") + mgr.setFeatureFlag(featureFlagWGProxy) + if mgr.featureFlags != featureFlagWGProxy { + t.Errorf("setting a flag twice must be idempotent, got: %d", mgr.featureFlags) } } func TestManager_unsetFeatureFlag(t *testing.T) { mgr := GeneralManager{} mgr.setFeatureFlag(featureFlagWGProxy) - mgr.setFeatureFlag(featureFlagDnsForwarder) err := mgr.unsetFeatureFlag(featureFlagWGProxy) if err != nil { t.Errorf("unexpected error: %s", err) } - if mgr.featureFlags != 2 { - t.Errorf("invalid feature state, expected: %d, got: %d", 2, mgr.featureFlags) - } - - err = mgr.unsetFeatureFlag(featureFlagDnsForwarder) - if err != nil { - t.Errorf("unexpected error: %s", err) - } if mgr.featureFlags != 0 { t.Errorf("invalid feature state, expected: %d, got: %d", 0, mgr.featureFlags) } diff --git a/client/internal/ebpf/ebpf/src/bpf_map_def.h b/client/internal/ebpf/ebpf/src/bpf_map_def.h new file mode 100644 index 000000000..9528fb592 --- /dev/null +++ b/client/internal/ebpf/ebpf/src/bpf_map_def.h @@ -0,0 +1,16 @@ +// libbpf 1.0 removed struct bpf_map_def, but the programs here keep the legacy +// map definitions: they load on kernels built without BTF, which BTF-style +// (SEC(".maps")) definitions do not. Define the struct ourselves so the +// programs compile against current libbpf headers. +#ifndef NB_BPF_MAP_DEF_H +#define NB_BPF_MAP_DEF_H + +struct bpf_map_def { + unsigned int type; + unsigned int key_size; + unsigned int value_size; + unsigned int max_entries; + unsigned int map_flags; +}; + +#endif diff --git a/client/internal/ebpf/ebpf/src/dns_fwd.c b/client/internal/ebpf/ebpf/src/dns_fwd.c deleted file mode 100644 index 5f3fbcc32..000000000 --- a/client/internal/ebpf/ebpf/src/dns_fwd.c +++ /dev/null @@ -1,64 +0,0 @@ -const __u32 map_key_dns_ip = 0; -const __u32 map_key_dns_port = 1; - -struct bpf_map_def SEC("maps") nb_map_dns_ip = { - .type = BPF_MAP_TYPE_ARRAY, - .key_size = sizeof(__u32), - .value_size = sizeof(__u32), - .max_entries = 10, -}; - -struct bpf_map_def SEC("maps") nb_map_dns_port = { - .type = BPF_MAP_TYPE_ARRAY, - .key_size = sizeof(__u32), - .value_size = sizeof(__u16), - .max_entries = 10, -}; - -__be32 dns_ip = 0; -__be16 dns_port = 0; - -// 13568 is 53 in big endian -__be16 GENERAL_DNS_PORT = 13568; - -bool read_settings() { - __u16 *port_value; - __u32 *ip_value; - - // read dns ip - ip_value = bpf_map_lookup_elem(&nb_map_dns_ip, &map_key_dns_ip); - if(!ip_value) { - return false; - } - dns_ip = htonl(*ip_value); - - // read dns port - port_value = bpf_map_lookup_elem(&nb_map_dns_port, &map_key_dns_port); - if (!port_value) { - return false; - } - dns_port = htons(*port_value); - return true; -} - -int xdp_dns_fwd(struct iphdr *ip, struct udphdr *udp) { - if (dns_port == 0) { - if(!read_settings()){ - return XDP_PASS; - } - // bpf_printk("dns port: %d", ntohs(dns_port)); - // bpf_printk("dns ip: %d", ntohl(dns_ip)); - } - - if (udp->dest == GENERAL_DNS_PORT && ip->daddr == dns_ip) { - udp->dest = dns_port; - return XDP_PASS; - } - - if (udp->source == dns_port && ip->saddr == dns_ip) { - udp->source = GENERAL_DNS_PORT; - return XDP_PASS; - } - - return XDP_PASS; -} diff --git a/client/internal/ebpf/ebpf/src/prog.c b/client/internal/ebpf/ebpf/src/prog.c index f32103f28..44ee53458 100644 --- a/client/internal/ebpf/ebpf/src/prog.c +++ b/client/internal/ebpf/ebpf/src/prog.c @@ -5,11 +5,9 @@ #include #include #include -#include "dns_fwd.c" #include "wg_proxy.c" const __u16 flag_feature_wg_proxy = 0b01; -const __u16 flag_feature_dns_fwd = 0b10; const __u32 map_key_features = 0; struct bpf_map_def SEC("maps") nb_features = { @@ -48,10 +46,6 @@ int nb_xdp_prog(struct xdp_md *ctx) { return XDP_PASS; } - if (*features & flag_feature_dns_fwd) { - xdp_dns_fwd(ip, udp); - } - if (*features & flag_feature_wg_proxy) { xdp_wg_proxy(ip, udp); } diff --git a/client/internal/ebpf/ebpf/src/readme.md b/client/internal/ebpf/ebpf/src/readme.md index 0ab393dd4..aa47847da 100644 --- a/client/internal/ebpf/ebpf/src/readme.md +++ b/client/internal/ebpf/ebpf/src/readme.md @@ -1,8 +1,18 @@ -# DNS forwarder +# XDP programs -The agent attach the XDP program to the lo device. We can not use fake address in eBPF because the -traffic does not appear in the eBPF program. The program capture the traffic on wg_ip:53 and -overwrite in it the destination port to 5053. +`prog.c` is attached to the `lo` device and dispatches to the features enabled in the +`nb_features` map. The only feature is the WireGuard proxy (`wg_proxy.c`): it rewrites +loopback UDP sent from the WireGuard listen port so it reaches the userspace relay proxy +port instead, and swaps the peer endpoint port into the source so the proxy can tell +peers apart. + +Maps use the legacy `struct bpf_map_def` form, defined in `bpf_map_def.h` because libbpf +1.0 removed it. They load on kernels built without BTF, which BTF-style (`SEC(".maps")`) +definitions do not. + +Regenerate the objects with `go generate ./client/internal/ebpf/ebpf/`; it needs +`clang-14`. Loading a regenerated object needs root, attaching it needs `bpf_link` +(kernel >= 5.7), and only one XDP program can own `lo` at a time. # Debug diff --git a/client/internal/ebpf/ebpf/src/wg_proxy.c b/client/internal/ebpf/ebpf/src/wg_proxy.c index 88fea65cf..5e7474928 100644 --- a/client/internal/ebpf/ebpf/src/wg_proxy.c +++ b/client/internal/ebpf/ebpf/src/wg_proxy.c @@ -50,5 +50,11 @@ int xdp_wg_proxy(struct iphdr *ip, struct udphdr *udp) { __be16 new_dst_port = htons(proxy_port); udp->dest = new_dst_port; udp->source = new_src_port; + + // The ports are covered by the UDP checksum. This is an IPv4 loopback hop + // and the payload is already integrity-protected, so clear the checksum (a + // zero UDP checksum means "not computed" for IPv4) rather than leave a + // stale value the kernel would drop as UDP_CSUM. + udp->check = 0; return XDP_PASS; } diff --git a/client/internal/ebpf/manager/manager.go b/client/internal/ebpf/manager/manager.go index af10142d5..fdc5d8d82 100644 --- a/client/internal/ebpf/manager/manager.go +++ b/client/internal/ebpf/manager/manager.go @@ -1,9 +1,7 @@ package manager -// Manager is used to load multiple eBPF programs. E.g., current DNS programs and WireGuard proxy +// Manager is used to load multiple eBPF programs. E.g., the WireGuard proxy type Manager interface { - LoadDNSFwd(ip string, dnsPort int) error - FreeDNSFwd() error LoadWgProxy(proxyPort, wgPort int) error FreeWGProxy() error } diff --git a/client/internal/elevate/elevate.go b/client/internal/elevate/elevate.go new file mode 100644 index 000000000..aa5a5da78 --- /dev/null +++ b/client/internal/elevate/elevate.go @@ -0,0 +1,74 @@ +// Package elevate re-runs this very executable under the operating system's own +// privilege-elevation mechanism and waits for it to finish. +// +// It exists so that a change the daemon restricts to root/administrator can be +// authorized from the GUI, by the user, at the moment they ask for it: Windows +// shows the UAC consent dialog, macOS the system authentication dialog, and +// Linux/FreeBSD the session's polkit agent. The credentials, where any are +// asked for, are collected by the operating system and never pass through +// NetBird. +// +// What the elevated process then does is the caller's business: it is the same +// binary, in a one-shot mode, and it is authorized by the daemon exactly like +// any other privileged caller, from the identity the kernel reports on the +// control channel. Nothing here grants privilege, and the daemon gains no new +// way to be talked into something: elevation only changes who is calling it. +package elevate + +import ( + "context" + "errors" + + log "github.com/sirupsen/logrus" +) + +// AppliedMarker is what the elevated process prints on standard output once it has +// done what it was run for. +// +// macOS's AuthorizationExecuteWithPrivileges reports no exit status and does not +// say which process it started, so there this line is the only evidence that the +// change was applied. The other platforms have an exit code and ignore it. +const AppliedMarker = "netbird-elevated: applied" + +var ( + // ErrDeclined reports that the user dismissed the prompt or did not + // authenticate. Nothing happened and nothing is wrong: a caller undoes its + // optimistic update and stays quiet. + ErrDeclined = errors.New("authorization declined") + + // ErrUnavailable reports that this host has no elevation mechanism we can + // drive: no polkit on a Unix desktop, or an executable we decline to run as + // root. A caller falls back to telling the user which command to run. + ErrUnavailable = errors.New("no privilege elevation mechanism available") +) + +// Run runs this executable with args under the platform's elevation mechanism +// and waits for it to exit. A non-zero exit is returned as an error, so the +// caller can treat a completed Run as the operation having succeeded. +// +// The args are the caller's own command line, so they cross no privilege +// boundary: only a user who has just authenticated as an administrator can get +// them run at all. +func Run(ctx context.Context, args ...string) error { + self, err := trustedSelf() + if err != nil { + return err + } + return run(ctx, self, args) +} + +// Available reports whether Run has a mechanism to use on this host, so a caller +// can offer the prompt only when there is one and otherwise fall back to +// guidance the user can act on. It answers from what is installed, not from what +// the user is allowed to do: an administrator's password may still be required +// and may still not be given, which is ErrDeclined from Run. +func Available() bool { + if _, err := trustedSelf(); err != nil { + // Worth a line: this is also what a build run from a group-writable + // directory hits, and there is nothing in the UI to say why the offer is + // missing. + log.Debugf("not offering privilege elevation: %v", err) + return false + } + return mechanismAvailable() +} diff --git a/client/internal/elevate/output.go b/client/internal/elevate/output.go new file mode 100644 index 000000000..6e1646bd3 --- /dev/null +++ b/client/internal/elevate/output.go @@ -0,0 +1,18 @@ +package elevate + +import "strings" + +// noOutput stands in for a process that said nothing, so that a report of what it +// said still reads as a sentence. +const noOutput = "no output" + +func firstLine(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return noOutput + } + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} diff --git a/client/internal/elevate/output_test.go b/client/internal/elevate/output_test.go new file mode 100644 index 000000000..3faacf53a --- /dev/null +++ b/client/internal/elevate/output_test.go @@ -0,0 +1,21 @@ +package elevate + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFirstLine(t *testing.T) { + tests := []struct{ in, want string }{ + {in: "", want: noOutput}, + {in: " \n ", want: noOutput}, + {in: "one line", want: "one line"}, + {in: "first\nsecond", want: "first"}, + {in: "\nsecond\n", want: "second"}, + } + + for _, tt := range tests { + assert.Equal(t, tt.want, firstLine(tt.in), "input %q", tt.in) + } +} diff --git a/client/internal/elevate/run_darwin.go b/client/internal/elevate/run_darwin.go new file mode 100644 index 000000000..6b0e4fc0d --- /dev/null +++ b/client/internal/elevate/run_darwin.go @@ -0,0 +1,359 @@ +package elevate + +import ( + "context" + "errors" + "fmt" + "os" + "runtime" + "strings" + "sync" + "syscall" + "unsafe" + + "github.com/ebitengine/purego" + log "github.com/sirupsen/logrus" +) + +// Authorization Services, reached through purego rather than cgo so the released +// binaries keep building with CGO_ENABLED=0. +// +// The prompt belongs to this process, which is what makes it carry the +// application's name and our own explanation. Going through osascript instead puts +// the very same trampoline behind a dialog attributed to osascript, and means +// handing a shell a command line to re-parse. +// +// # On AuthorizationExecuteWithPrivileges +// +// It is deprecated, and Apple's guidance (Quinn, "BSD Privilege Escalation on +// macOS", developer.apple.com/forums/thread/708765) is "while it still works, it's +// been deprecated for many years. Do not use it in a widely distributed product." +// It is used here anyway, knowingly, because the alternatives Apple offers are for +// *obtaining* ongoing privileges — an installer package, SMAppService, SMJobBless — +// and NetBird already has what they would install: a launchd daemon running as +// root. What is missing is only a way for an unprivileged client to ask it to act. +// +// The way to that without a deprecated call is to authorize the client instead of +// elevating one: the app takes the right with AuthorizationCreate, passes the +// AuthorizationExternalForm to the daemon, and the daemon checks it with +// AuthorizationCopyRights before acting — none of which is deprecated. It is the +// better design and it is where this should end up. It also means the daemon +// accepting an authorization over its control socket, which is a new way to be +// asked for privileged work and wants reviewing as such, so it is deliberately not +// bundled in with the rest of this. +// +// Until then, three things keep the deprecation from being a trap. Every symbol is +// resolved with an error rather than a panic, so a macOS that has dropped this +// function leaves the app offering the user a command instead of crashing on the +// way to a prompt. A failure to run the tool is reported as ErrUnavailable, so the +// fallback is the same one an agent-less Linux session gets. And the whole path +// runs under guard, which turns a panic out of the FFI layer into that same +// fallback. +// +// The trampoline passes on the environment it was given, so what it starts as root +// must be an executable this user's peers cannot influence: that is what +// trustedSelf refuses, and what signing the binary settles for the loader. + +const ( + securityFramework = "/System/Library/Frameworks/Security.framework/Security" + libSystem = "/usr/lib/libSystem.B.dylib" + + // trampoline is what the framework hands the tool to. Present on every macOS, + // and worth confirming before offering a prompt rather than mid-prompt. + trampoline = "/usr/libexec/security_authtrampoline" +) + +// rightExecute is the right an administrator holds, and what +// AuthorizationExecuteWithPrivileges requires of us. +const rightExecute = "system.privilege.admin" + +// promptKey is kAuthorizationEnvironmentPrompt, which puts a sentence of ours above +// the system's in the dialog. It is about the change rather than the mechanism. +const ( + promptKey = "prompt" + promptText = "NetBird needs to change a setting that grants SSH access to this computer." +) + +// OSStatus values from SecBase.h that mean something to us; anything else is +// reported as it comes. +const ( + errAuthorizationSuccess = 0 + errAuthorizationDenied = -60005 + errAuthorizationCanceled = -60006 + errAuthorizationInteractionNotAllowed = -60007 + errAuthorizationToolExecuteFailure = -60031 + errAuthorizationToolEnvironmentError = -60032 +) + +// AuthorizationFlags from Authorization.h. +const ( + flagDefaults = 0 + flagInteractionAllowed = 1 << 0 + flagExtendRights = 1 << 1 + flagDestroyRights = 1 << 3 + flagPreAuthorize = 1 << 4 +) + +// authorizationItem mirrors AuthorizationItem: a name, and a value the name gives +// meaning to. 32 bytes on both amd64 and arm64. +type authorizationItem struct { + name *byte + valueLength uintptr + value unsafe.Pointer + // flags is reserved by the API and always zero. Declared because the layout + // is the contract: without it the struct is 24 bytes where C reads 32. + flags uint32 //nolint:unused // part of the C layout +} + +// authorizationItemSet mirrors AuthorizationItemSet, which serves as both an +// AuthorizationRights and an AuthorizationEnvironment. +type authorizationItemSet struct { + count uint32 + items *authorizationItem +} + +var ( + authorizationCreate func(rights, environment *authorizationItemSet, flags uint32, authorization *uintptr) int32 + authorizationExecuteWithPrivileges func(authorization uintptr, pathToTool string, options uint32, arguments *uintptr, communicationsPipe *uintptr) int32 + authorizationFree func(authorization uintptr, flags uint32) int32 + fileno func(stream uintptr) int32 + fclose func(stream uintptr) int32 + + loadOnce sync.Once + loadErr error +) + +// load resolves the functions once. A framework that cannot be opened, or a symbol +// that is no longer there, leaves the host without a mechanism rather than taking +// the process down with it: see the note on deprecation above. +func load() error { + loadOnce.Do(func() { loadErr = guard("loading Security.framework", resolve) }) + return loadErr +} + +// guard turns a panic out of the FFI layer into an error, so an API that has +// changed under us costs the user a prompt rather than the window they were +// clicking in. purego panics on a signature it cannot map, and this is the one +// place in the client that calls a deprecated system function. +// +// It catches Go panics, which is what purego raises. A fault inside the framework +// itself is not a panic and not recoverable; the layout the tests pin down is what +// stands between us and that. +func guard(what string, fn func() error) (err error) { + defer func() { + r := recover() + if r == nil { + return + } + log.Errorf("%s panicked: %v", what, r) + err = fmt.Errorf("%w: %s: %v", ErrUnavailable, what, r) + }() + return fn() +} + +func resolve() error { + security, err := purego.Dlopen(securityFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL) + if err != nil { + return fmt.Errorf("open %s: %w", securityFramework, err) + } + system, err := purego.Dlopen(libSystem, purego.RTLD_LAZY|purego.RTLD_GLOBAL) + if err != nil { + return fmt.Errorf("open %s: %w", libSystem, err) + } + + // purego.RegisterLibFunc panics on a symbol it cannot find, which is not how a + // deprecated function's disappearance should reach the user. + for _, fn := range []struct { + ptr any + handle uintptr + name string + }{ + {&authorizationCreate, security, "AuthorizationCreate"}, + {&authorizationExecuteWithPrivileges, security, "AuthorizationExecuteWithPrivileges"}, + {&authorizationFree, security, "AuthorizationFree"}, + {&fileno, system, "fileno"}, + {&fclose, system, "fclose"}, + } { + symbol, err := purego.Dlsym(fn.handle, fn.name) + if err != nil { + return fmt.Errorf("resolve %s: %w", fn.name, err) + } + if symbol == 0 { + return fmt.Errorf("resolve %s: not present on this system", fn.name) + } + purego.RegisterFunc(fn.ptr, symbol) + } + return nil +} + +// run asks the system to run self as root: first for the right, which is what puts +// up the authentication dialog and collects the password or takes the Touch ID, +// then for the tool. The credentials go to the system's authorization trampoline +// and never to us. +// +// The context bounds only our own waiting; the dialog belongs to the system and +// closes when the user answers it. +func run(ctx context.Context, self string, args []string) error { + if err := load(); err != nil { + return fmt.Errorf("%w: %v", ErrUnavailable, err) + } + + return guard("asking for privileges", func() error { + authorization, err := authorize() + if err != nil { + return err + } + defer authorizationFree(authorization, flagDestroyRights) + + return execute(ctx, authorization, self, args) + }) +} + +func mechanismAvailable() bool { + if err := load(); err != nil { + return false + } + info, err := os.Stat(trampoline) + return err == nil && !info.IsDir() +} + +// authorize obtains the right, prompting for it. A dismissed dialog comes back as +// errAuthorizationCanceled and a password given up on as errAuthorizationDenied; +// both are the user's answer rather than a failure. +func authorize() (uintptr, error) { + var pinner runtime.Pinner + defer pinner.Unpin() + + rights := itemSet(&pinner, authorizationItem{name: cString(&pinner, rightExecute)}) + environment := itemSet(&pinner, promptItem(&pinner)) + + var authorization uintptr + status := authorizationCreate(rights, environment, + flagDefaults|flagInteractionAllowed|flagPreAuthorize|flagExtendRights, &authorization) + + switch status { + case errAuthorizationSuccess: + return authorization, nil + case errAuthorizationCanceled, errAuthorizationDenied: + return 0, ErrDeclined + case errAuthorizationInteractionNotAllowed: + // Nowhere to put a dialog, so there is nobody to ask: a launch daemon, or + // a session with no window server. + return 0, fmt.Errorf("%w: this session cannot show an authorization prompt", ErrUnavailable) + default: + return 0, fmt.Errorf("request %s: OSStatus %d", rightExecute, status) + } +} + +// execute runs the tool with the right in hand and waits for it by reading the pipe +// it is given until the tool closes it. +// +// AuthorizationExecuteWithPrivileges reports no exit status and does not say what +// process it started, which is why the one-shot says so itself: what it prints is +// the only evidence that the change was applied. +func execute(ctx context.Context, authorization uintptr, self string, args []string) error { + var pinner runtime.Pinner + defer pinner.Unpin() + + argv := make([]uintptr, 0, len(args)+1) + for _, arg := range args { + argv = append(argv, uintptr(unsafe.Pointer(cString(&pinner, arg)))) + } + argv = append(argv, 0) + pinner.Pin(&argv[0]) + + var pipe uintptr + status := authorizationExecuteWithPrivileges(authorization, self, flagDefaults, &argv[0], &pipe) + switch status { + case errAuthorizationSuccess: + case errAuthorizationCanceled: + return ErrDeclined + case errAuthorizationToolExecuteFailure, errAuthorizationToolEnvironmentError: + // The right was granted and the tool still did not start. Nothing the user + // can do about it from here, so point them at the command instead. + return fmt.Errorf("%w: the system would not run %s elevated (OSStatus %d)", ErrUnavailable, self, status) + default: + return fmt.Errorf("run %s elevated: OSStatus %d", self, status) + } + + out, err := readPipe(ctx, pipe) + if err != nil { + return err + } + return checkApplied(out) +} + +// checkApplied reads the one-shot's report, which stands in for the exit status +// there is no way to ask for here. A run that said nothing did not apply the +// change, whatever else went on. +func checkApplied(out string) error { + if !strings.Contains(out, AppliedMarker) { + return fmt.Errorf("elevated netbird did not report the change as applied: %s", firstLine(out)) + } + return nil +} + +// readPipe drains the tool's output, which ends when the tool exits and is +// therefore also how we wait for it. +func readPipe(ctx context.Context, pipe uintptr) (string, error) { + if pipe == 0 { + return "", nil + } + defer fclose(pipe) + + fd := int(fileno(pipe)) + if fd < 0 { + return "", nil + } + + var out strings.Builder + buf := make([]byte, 4096) + for { + if err := ctx.Err(); err != nil { + return out.String(), err + } + n, err := syscall.Read(fd, buf) + if n > 0 { + out.Write(buf[:n]) + } + switch { + case errors.Is(err, syscall.EINTR): + // A signal landed mid-read, which says nothing about the tool. + continue + case err != nil: + log.Debugf("read the elevated process's output: %v", err) + return out.String(), nil + case n <= 0: + // End of file: the tool closed the pipe, which is how it exiting + // reaches us. + return out.String(), nil + } + } +} + +// itemSet builds an AuthorizationItemSet over items, pinned for the call. +func itemSet(pinner *runtime.Pinner, items ...authorizationItem) *authorizationItemSet { + pinner.Pin(&items[0]) + set := &authorizationItemSet{count: uint32(len(items)), items: &items[0]} + pinner.Pin(set) + return set +} + +// promptItem is the environment entry carrying our sentence for the dialog. +func promptItem(pinner *runtime.Pinner) authorizationItem { + value := []byte(promptText) + pinner.Pin(&value[0]) + return authorizationItem{ + name: cString(pinner, promptKey), + valueLength: uintptr(len(value)), + value: unsafe.Pointer(&value[0]), + } +} + +// cString returns a NUL-terminated copy of s, pinned so the C side may hold it for +// the duration of the call. +func cString(pinner *runtime.Pinner, s string) *byte { + b := append([]byte(s), 0) + pinner.Pin(&b[0]) + return &b[0] +} diff --git a/client/internal/elevate/run_darwin_test.go b/client/internal/elevate/run_darwin_test.go new file mode 100644 index 000000000..f6c58c8cb --- /dev/null +++ b/client/internal/elevate/run_darwin_test.go @@ -0,0 +1,111 @@ +package elevate + +import ( + "errors" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The framework has to load and the symbols have to resolve, or nothing else here +// means anything. +func TestSecurityFrameworkLoads(t *testing.T) { + require.NoError(t, load(), "Security.framework must open") + + for name, fn := range map[string]any{ + "AuthorizationCreate": authorizationCreate, + "AuthorizationExecuteWithPrivileges": authorizationExecuteWithPrivileges, + "AuthorizationFree": authorizationFree, + "fileno": fileno, + "fclose": fclose, + } { + assert.NotNil(t, fn, "%s must resolve", name) + } +} + +// A request with no interaction allowed exercises the whole call — the rights and +// environment structs, and the OSStatus that comes back — without a dialog anybody +// has to answer. What the system decides is its business; that it decides at all is +// what this asserts. +func TestAuthorizationCreateWithoutInteraction(t *testing.T) { + if err := load(); err != nil { + t.Skipf("Security.framework did not open: %v", err) + } + + var pinner runtime.Pinner + defer pinner.Unpin() + + rights := itemSet(&pinner, authorizationItem{name: cString(&pinner, rightExecute)}) + environment := itemSet(&pinner, promptItem(&pinner)) + require.EqualValues(t, 1, rights.count, "the rights struct layout must match the C one") + + var authorization uintptr + status := authorizationCreate(rights, environment, flagDefaults|flagExtendRights, &authorization) + + switch status { + case errAuthorizationSuccess: + // Credentials were already cached for this session. + authorizationFree(authorization, flagDestroyRights) + case errAuthorizationDenied, errAuthorizationInteractionNotAllowed: + // The expected answers when nobody may be asked. + default: + require.Failf(t, "unknown OSStatus", "AuthorizationCreate returned %d, want a status we recognise", status) + } +} + +// Asking with a right nobody has must not be mistaken for a declined prompt: the +// caller would report nothing at all. +func TestAuthorizeUnknownRightIsNotDeclined(t *testing.T) { + if err := load(); err != nil { + t.Skipf("Security.framework did not open: %v", err) + } + + var pinner runtime.Pinner + defer pinner.Unpin() + + rights := itemSet(&pinner, authorizationItem{name: cString(&pinner, "io.netbird.right.that.does.not.exist")}) + + var authorization uintptr + status := authorizationCreate(rights, nil, flagDefaults|flagExtendRights, &authorization) + if status == errAuthorizationSuccess { + authorizationFree(authorization, flagDestroyRights) + } + assert.NotEqual(t, int32(errAuthorizationSuccess), status, "a right that does not exist must not be granted") +} + +func TestMechanismAvailable(t *testing.T) { + assert.True(t, mechanismAvailable(), "the trampoline exists on every macOS") +} + +// The one-shot's report is what stands in for an exit status here, so a run that +// says nothing must not read as success. +func TestCheckApplied(t *testing.T) { + require.NoError(t, checkApplied(AppliedMarker+"\n"), "the report the one-shot prints") + require.NoError(t, checkApplied("some warning\n"+AppliedMarker+"\n"), "the report after other output") + + assert.Error(t, checkApplied(""), "a run that printed nothing did not apply the change") + assert.Error(t, checkApplied("dyld: library not loaded\n"), "output that is not the report") +} + +// A panic out of the FFI layer has to reach the caller as "no mechanism", which is +// the outcome that offers the user the command instead of taking the window down. +func TestGuardTurnsAPanicIntoUnavailable(t *testing.T) { + err := guard("pretending to call something", func() error { + panic("purego: signature it cannot map") + }) + + require.ErrorIs(t, err, ErrUnavailable, "a panic must read as a missing mechanism") + assert.Contains(t, err.Error(), "pretending to call something", "what panicked") +} + +// guard wraps every darwin path, so what a caller switches on has to survive it. +func TestGuardPassesErrorsThrough(t *testing.T) { + sentinel := errors.New("the call itself failed") + assert.ErrorIs(t, guard("calling", func() error { return sentinel }), sentinel, + "the error it was given") + assert.ErrorIs(t, guard("calling", func() error { return ErrDeclined }), ErrDeclined, + "a declined prompt stays declined") + assert.NoError(t, guard("calling", func() error { return nil }), "a call that worked") +} diff --git a/client/internal/elevate/run_unix.go b/client/internal/elevate/run_unix.go new file mode 100644 index 000000000..b2de09a49 --- /dev/null +++ b/client/internal/elevate/run_unix.go @@ -0,0 +1,117 @@ +//go:build linux + +package elevate + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" +) + +// pkexec exit codes that are about the authorization rather than about the program +// we asked it to run. The manual page reserves both. +const ( + // exitDismissed is returned when the user dismissed the authentication + // dialog. + exitDismissed = 126 + // exitNotAuthorized is returned when the authorization was not obtained. That + // covers the user saying no as well as pkexec having had nobody to ask: see + // noAgentMarkers. + exitNotAuthorized = 127 +) + +// exitNotAuthorized covers three different endings that only pkexec's own words +// tell apart, so they are matched here. Read with LC_ALL=C so the words are the +// ones written below. +// +// refusedMarker is a refusal: the user said no, gave up on the password, or holds +// an account that may not elevate at all. +const refusedMarker = "Not authorized" + +// noAgentMarkers say pkexec had no way to ask: no agent registered for the +// session, and no controlling terminal for the textual agent it falls back to. +var noAgentMarkers = []string{"authentication agent", "controlling terminal"} + +// run asks polkit to run self as root. pkexec hands the request to the session's +// polkit agent, which is what prompts and what collects any password; we see only +// its verdict. +// +// The environment is otherwise deliberately not passed through: pkexec clears it +// bar a small allowlist, and the one-shot needs nothing from it. +func run(ctx context.Context, self string, args []string) error { + pkexec, err := exec.LookPath("pkexec") + if err != nil { + return fmt.Errorf("%w: pkexec is not installed", ErrUnavailable) + } + + cmd := exec.CommandContext(ctx, pkexec, append([]string{self}, args...)...) + // C locale so pkexec's own diagnostics are the ones noAgentMarkers knows. + cmd.Env = append(os.Environ(), "LC_ALL=C") + var stderr strings.Builder + cmd.Stderr = &stderr + // The one-shot reports itself on stdout for macOS's sake, where there is no + // exit status to read. Here there is one, so that line is noise. + cmd.Stdout = io.Discard + + err = cmd.Run() + if err == nil { + return nil + } + + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + return fmt.Errorf("run pkexec: %w", err) + } + + // Matched against everything pkexec said, reported as one line: a complaint + // that is not the first thing printed still has to be recognised, and reading + // it as a refusal would swallow it. + full := stderr.String() + out := firstLine(full) + + switch exitErr.ExitCode() { + case exitDismissed: + return ErrDeclined + case exitNotAuthorized: + return notAuthorized(full, out) + default: + return fmt.Errorf("elevated netbird exited with %d: %s", exitErr.ExitCode(), out) + } +} + +// notAuthorized sorts out the three endings pkexec reports as exitNotAuthorized. +// +// It also returns that code when the authorization succeeded and it then could +// not run the program, so a refusal has to be recognised rather than assumed: +// reading every one of these as "the user said no" would revert the control in +// silence on a host where elevation is broken. +func notAuthorized(full, out string) error { + switch { + case hasAny(full, noAgentMarkers): + return fmt.Errorf("%w: polkit had no way to ask: %s", ErrUnavailable, out) + case out == noOutput, strings.Contains(full, refusedMarker): + // The user said no, which needs no message; that an account barred from + // elevating altogether lands here too is why the reason is kept. + return fmt.Errorf("%w: %s", ErrDeclined, out) + default: + return fmt.Errorf("pkexec could not run elevated netbird: %s", out) + } +} + +func hasAny(s string, markers []string) bool { + for _, marker := range markers { + if strings.Contains(s, marker) { + return true + } + } + return false +} + +func mechanismAvailable() bool { + _, err := exec.LookPath("pkexec") + return err == nil +} diff --git a/client/internal/elevate/run_unix_test.go b/client/internal/elevate/run_unix_test.go new file mode 100644 index 000000000..c868f9a74 --- /dev/null +++ b/client/internal/elevate/run_unix_test.go @@ -0,0 +1,110 @@ +//go:build linux + +package elevate + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakePkexec puts a pkexec on PATH that exits with the given code, so the +// mapping from polkit's exit codes onto our errors can be exercised without a +// polkit agent. +func fakePkexec(t *testing.T, exitCode int, stderr string) { + t.Helper() + + dir := t.TempDir() + script := fmt.Sprintf("#!/bin/sh\necho %s >&2\nexit %d\n", shellQuote(stderr), exitCode) + require.NoError(t, os.WriteFile(filepath.Join(dir, "pkexec"), []byte(script), 0o700), "write the fake pkexec") + t.Setenv("PATH", dir) +} + +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +func TestRunMapsPkexecExitCodes(t *testing.T) { + tests := []struct { + name string + exitCode int + stderr string + wantErr error + }{ + {name: "applied", exitCode: 0}, + { + name: "dialog dismissed", + exitCode: exitDismissed, + stderr: "Error executing command as another user: Request dismissed", + wantErr: ErrDeclined, + }, + { + // What a graphical agent reports for a cancelled prompt. Not a + // failure: the user was asked and answered. + name: "prompt cancelled", + exitCode: exitNotAuthorized, + stderr: "Error executing command as another user: Not authorized", + wantErr: ErrDeclined, + }, + { + // The same status, but pkexec never got to ask anybody. + name: "no agent and no terminal to fall back on", + exitCode: exitNotAuthorized, + stderr: "Error creating textual authentication agent: Error opening current controlling terminal for the process (`/dev/tty'): No such device or address", + wantErr: ErrUnavailable, + }, + { + // And the same status again once the authorization succeeded and + // pkexec could not run what it had been authorized to run. Reading + // that as a refusal would revert the control in silence on a host + // where elevation is broken. + name: "authorized but not runnable", + exitCode: exitNotAuthorized, + stderr: "Error executing command as another user: No such file or directory", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fakePkexec(t, tt.exitCode, tt.stderr) + + err := run(context.Background(), "/nonexistent/netbird-ui", []string{"--flag"}) + switch { + case tt.wantErr != nil: + require.ErrorIs(t, err, tt.wantErr, "exit %d said %q", tt.exitCode, tt.stderr) + case tt.exitCode == 0: + require.NoError(t, err, "a pkexec that exited cleanly applied the change") + default: + require.Error(t, err, "exit %d said %q", tt.exitCode, tt.stderr) + assert.NotErrorIs(t, err, ErrDeclined, "not the user's answer") + assert.NotErrorIs(t, err, ErrUnavailable, "not a missing mechanism") + } + }) + } +} + +// An exit code that is not polkit's is the one-shot's own failure, and has to +// stay distinguishable from a declined prompt: the caller reports it. +func TestRunReportsOneShotFailure(t *testing.T) { + fakePkexec(t, 3, "the one-shot said no") + + err := run(context.Background(), "/nonexistent/netbird-ui", nil) + + require.Error(t, err, "a one-shot that failed is not a prompt that was answered") + assert.NotErrorIs(t, err, ErrDeclined, "not the user's answer") + assert.NotErrorIs(t, err, ErrUnavailable, "not a missing mechanism") +} + +func TestRunWithoutPkexecIsUnavailable(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + + err := run(context.Background(), "/nonexistent/netbird-ui", nil) + require.ErrorIs(t, err, ErrUnavailable, "no pkexec means no mechanism") + assert.False(t, mechanismAvailable(), "mechanismAvailable without pkexec on PATH") +} diff --git a/client/internal/elevate/run_unsupported.go b/client/internal/elevate/run_unsupported.go new file mode 100644 index 000000000..d1daf3184 --- /dev/null +++ b/client/internal/elevate/run_unsupported.go @@ -0,0 +1,19 @@ +//go:build !windows && !darwin && !linux + +package elevate + +import "context" + +// run reports that this platform has no elevation prompt to drive. +// +// The desktop app is the only caller and is not built for any of these: mobile +// and WASM have no local user to ask, and the FreeBSD client ships without a UI. +// pkexec would be the mechanism there, and run_unix.go is what to widen if that +// changes. +func run(context.Context, string, []string) error { + return ErrUnavailable +} + +func mechanismAvailable() bool { + return false +} diff --git a/client/internal/elevate/run_windows.go b/client/internal/elevate/run_windows.go new file mode 100644 index 000000000..eef4c23ce --- /dev/null +++ b/client/internal/elevate/run_windows.go @@ -0,0 +1,187 @@ +package elevate + +import ( + "context" + "errors" + "fmt" + "runtime" + "unsafe" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" +) + +const ( + // seeMaskNoCloseProcess keeps the started process's handle open in + // hProcess so we can wait for it. + seeMaskNoCloseProcess = 0x00000040 + // seeMaskNoAsync makes ShellExecuteExW finish its work before returning, + // which it must when the calling thread does not pump messages. + seeMaskNoAsync = 0x00000100 + // seeMaskFlagNoUI suppresses the shell's own error dialogs; the UAC consent + // dialog is not one of them and still appears. + seeMaskFlagNoUI = 0x00000400 + + // swHide: the one-shot has no window to show. + swHide = 0 +) + +// shellExecuteInfoW mirrors SHELLEXECUTEINFOW. The field order and Go's own +// padding match the C layout on both 386 and amd64. +type shellExecuteInfoW struct { + cbSize uint32 + fMask uint32 + hwnd windows.HWND + lpVerb *uint16 + lpFile *uint16 + lpParameters *uint16 + lpDirectory *uint16 + nShow int32 + hInstApp windows.Handle + lpIDList uintptr + lpClass *uint16 + hkeyClass windows.Handle + dwHotKey uint32 + hIconOrMonitor windows.Handle + hProcess windows.Handle +} + +var ( + shell32 = windows.NewLazySystemDLL("shell32.dll") + procShellExecuteEx = shell32.NewProc("ShellExecuteExW") +) + +// run starts self elevated with the "runas" verb, which is what raises the UAC +// consent dialog, and waits for it to finish. Windows decides whether consent is +// enough or an administrator's credentials are needed, and collects them itself. +func run(ctx context.Context, self string, args []string) error { + verb, err := windows.UTF16PtrFromString("runas") + if err != nil { + return fmt.Errorf("encode verb: %w", err) + } + file, err := windows.UTF16PtrFromString(self) + if err != nil { + return fmt.Errorf("encode %s: %w", self, err) + } + params, err := windows.UTF16PtrFromString(windows.ComposeCommandLine(args)) + if err != nil { + return fmt.Errorf("encode arguments: %w", err) + } + + info := shellExecuteInfoW{ + fMask: seeMaskNoCloseProcess | seeMaskNoAsync | seeMaskFlagNoUI, + hwnd: ownerWindow(), + lpVerb: verb, + lpFile: file, + lpParameters: params, + nShow: swHide, + } + info.cbSize = uint32(unsafe.Sizeof(info)) + + process, err := shellExecute(&info) + if err != nil { + return err + } + defer func() { + if err := windows.CloseHandle(process); err != nil { + log.Debugf("close elevated process handle: %v", err) + } + }() + + return waitForProcess(ctx, process) +} + +// shellExecute performs the call itself. ShellExecuteExW wants COM initialised on +// the calling thread, so the goroutine is pinned to one for the duration and COM +// is set up on it; an "already initialised, different mode" answer is fine, +// because then somebody else has done it for us. +func shellExecute(info *shellExecuteInfoW) (windows.Handle, error) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + switch err := windows.CoInitializeEx(0, windows.COINIT_APARTMENTTHREADED); { + case err == nil, isHResult(err, windows.S_FALSE): + // Ours, or already initialised in the same mode: either way this call + // counts and has to be balanced. + defer windows.CoUninitialize() + case isHResult(err, windows.RPC_E_CHANGED_MODE): + // The thread is already in the other apartment model. ShellExecuteExW + // works there too, and there is nothing of ours to balance. + default: + return 0, fmt.Errorf("initialise COM: %w", err) + } + + ret, _, lastErr := procShellExecuteEx.Call(uintptr(unsafe.Pointer(info))) + if ret != 0 { + return info.hProcess, nil + } + + if errors.Is(lastErr, windows.ERROR_CANCELLED) { + return 0, ErrDeclined + } + return 0, fmt.Errorf("run elevated: %w", lastErr) +} + +// ownerWindow returns this process's foreground window, and 0 when the window in +// front belongs to somebody else or cannot be attributed. ShellExecuteExW takes it +// as the parent for the UI it raises, which is what keeps the consent dialog in +// front of the window the user was just clicking in instead of behind it. It is +// also what a remote-desktop session needs to place the dialog at all when the +// secure desktop is switched off. +func ownerWindow() windows.HWND { + hwnd := windows.GetForegroundWindow() + if hwnd == 0 { + return 0 + } + + var pid uint32 + if _, err := windows.GetWindowThreadProcessId(hwnd, &pid); err != nil { + log.Debugf("cannot attribute the foreground window, raising the prompt without an owner: %v", err) + return 0 + } + if pid != windows.GetCurrentProcessId() { + return 0 + } + return hwnd +} + +// isHResult reports whether err carries the given HRESULT. CoInitializeEx +// returns its HRESULT as an Errno, so the comparison is on the raw value. +func isHResult(err error, hresult windows.Handle) bool { + var errno windows.Errno + return errors.As(err, &errno) && uintptr(errno) == uintptr(hresult) +} + +func waitForProcess(ctx context.Context, process windows.Handle) error { + // The wait is interruptible so a cancelled context stops us waiting on a + // consent dialog nobody is going to answer. The elevated process is not + // ours to kill, and it either applies the change or does not. + for { + event, err := windows.WaitForSingleObject(process, 250) + if err != nil { + return fmt.Errorf("wait for the elevated process: %w", err) + } + if event == uint32(windows.WAIT_OBJECT_0) { + break + } + if err := ctx.Err(); err != nil { + return err + } + } + + var code uint32 + if err := windows.GetExitCodeProcess(process, &code); err != nil { + return fmt.Errorf("read the elevated process's exit code: %w", err) + } + if code != 0 { + return fmt.Errorf("elevated netbird exited with %d", code) + } + return nil +} + +// mechanismAvailable is true on Windows: UAC prompts for consent when the user +// is an administrator and for an administrator's credentials when they are not, +// so there is always something to ask. +func mechanismAvailable() bool { + return true +} diff --git a/client/internal/elevate/trusted.go b/client/internal/elevate/trusted.go new file mode 100644 index 000000000..c11054c45 --- /dev/null +++ b/client/internal/elevate/trusted.go @@ -0,0 +1,40 @@ +package elevate + +import ( + "fmt" + "os" + "path/filepath" +) + +// trustedSelf returns the path of this executable, provided it is one we are +// willing to have run as root. +// +// The check is what keeps elevation from becoming a way to launder someone +// else's code into a root process: the user consents to NetBird being elevated, +// having been shown NetBird's name, so what runs must be the file NetBird was +// installed as and not something a third party could have swapped for it. An +// executable only its owner can write is that; anything wider is refused, and +// the caller falls back to showing the command instead. +// +// The owner writing to their own executable is not part of that threat: code +// running as the user can already prompt them for anything, and could just as +// well ask them to run the command by hand. What matters is that no *other* +// unprivileged account can reach it. +func trustedSelf() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", fmt.Errorf("locate this executable: %w", err) + } + + // Resolve symlinks so the checks below apply to the file that would actually + // be executed, not to a link somebody else may control. + resolved, err := filepath.EvalSymlinks(exe) + if err != nil { + return "", fmt.Errorf("resolve %s: %w", exe, err) + } + + if err := checkOnlyOwnerWritable(resolved); err != nil { + return "", fmt.Errorf("%w: %s cannot be trusted to run as root: %w", ErrUnavailable, resolved, err) + } + return resolved, nil +} diff --git a/client/internal/elevate/trusted_group_darwin.go b/client/internal/elevate/trusted_group_darwin.go new file mode 100644 index 000000000..a4b387ec4 --- /dev/null +++ b/client/internal/elevate/trusted_group_darwin.go @@ -0,0 +1,10 @@ +package elevate + +// adminWriteGIDs are the groups whose write access to an executable does not +// widen who could authorize elevating it. +// +// macOS installs applications as root:admin, mode 0775, /Applications included, +// so requiring owner-only write would reject every normal install. Group admin +// (gid 80) is exactly the set of accounts that can answer the authentication +// dialog, so its write access grants nothing the prompt would not. +var adminWriteGIDs = []uint32{0, 80} diff --git a/client/internal/elevate/trusted_group_unix.go b/client/internal/elevate/trusted_group_unix.go new file mode 100644 index 000000000..7aa336423 --- /dev/null +++ b/client/internal/elevate/trusted_group_unix.go @@ -0,0 +1,9 @@ +//go:build !windows && !darwin + +package elevate + +// adminWriteGIDs are the groups whose write access to an executable does not +// widen who could authorize elevating it. Only root's own group qualifies here: +// a distribution installs into root-owned directories, and there is no +// system-wide administrators group that both writes them and answers polkit. +var adminWriteGIDs = []uint32{0} diff --git a/client/internal/elevate/trusted_unix.go b/client/internal/elevate/trusted_unix.go new file mode 100644 index 000000000..f9d1a1b7e --- /dev/null +++ b/client/internal/elevate/trusted_unix.go @@ -0,0 +1,119 @@ +//go:build !windows + +package elevate + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strconv" + "syscall" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/getent" +) + +// checkOnlyOwnerWritable reports an error unless path, and every directory leading +// to it, is owned by either root or this user and writable by nobody who could not +// already act as its owner. A writable directory is as good as a writable file, +// since anything in it can be replaced, so the whole chain is checked. +func checkOnlyOwnerWritable(path string) error { + self := uint32(os.Getuid()) + + for dir := path; ; dir = filepath.Dir(dir) { + info, err := os.Lstat(dir) + if err != nil { + return fmt.Errorf("stat %s: %w", dir, err) + } + + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return errors.New("file ownership is unavailable on this platform") + } + if stat.Uid != 0 && stat.Uid != self { + return fmt.Errorf("%s is owned by uid %d, neither root nor this user", dir, stat.Uid) + } + + if err := checkWriteBits(dir, info, stat.Uid, stat.Gid); err != nil { + return err + } + + if parent := filepath.Dir(dir); parent == dir { + return nil + } + } +} + +func checkWriteBits(path string, info os.FileInfo, uid, gid uint32) error { + // On a directory the sticky bit stands in for the write bits: whoever may + // write there still cannot replace an entry they do not own, which is the + // only thing that would matter to us. /tmp is the usual example. + sticky := info.IsDir() && info.Mode()&os.ModeSticky != 0 + + return writeBitsAllow(path, info.Mode().Perm(), sticky, groupWriteAllowed(uid, gid)) +} + +// writeBitsAllow decides on the permission bits alone, given whether the group's +// write access has been vouched for. +func writeBitsAllow(path string, perm os.FileMode, sticky, groupAllowed bool) error { + if sticky { + return nil + } + if perm&0o020 != 0 && !groupAllowed { + return fmt.Errorf("%s is writable by a group with members other than its owner (%v)", path, perm) + } + if perm&0o002 != 0 { + return fmt.Errorf("%s is world-writable (%v)", path, perm) + } + return nil +} + +// groupWriteAllowed reports whether a group's write access to a file owned by uid +// puts it in reach of anyone who could not already act as that owner. +// +// Two ways it does not. A group in adminWriteGIDs holds the accounts that can +// answer the elevation prompt anyway. And a user private group is how Debian, +// Ubuntu and Fedora ship: their umask of 002 makes a home directory and +// everything built in it group-writable, so refusing that would refuse every +// build not installed from a package. +func groupWriteAllowed(uid, gid uint32) bool { + if slices.Contains(adminWriteGIDs, gid) { + return true + } + + group, err := getent.LookupGroupID(strconv.FormatUint(uint64(gid), 10)) + if err != nil { + log.Debugf("cannot look up group %d, treating it as shared: %v", gid, err) + return false + } + owner, err := getent.LookupUserID(strconv.FormatUint(uint64(uid), 10)) + if err != nil { + log.Debugf("cannot look up uid %d, treating its group as shared: %v", uid, err) + return false + } + + if group.Name != owner.Username { + return false + } + return !groupHasOtherMembers(group.Name, owner.Username) +} + +// groupHasOtherMembers reports whether the group lists a member besides owner. +// +// Sharing the owner's name is what a user private group is recognised by, and it +// says nothing about who is in it: a group that has since gained a member is +// still named that way, and that member can write whatever the group can. So the +// membership is read rather than assumed. A group whose members cannot be +// listed, because no source on this host describes it, is treated as shared: +// the name alone cannot vouch for who writes through it. +func groupHasOtherMembers(name, owner string) bool { + members, err := getent.GroupMembers(name) + if err != nil { + log.Debugf("cannot list the members of group %q, treating it as shared: %v", name, err) + return true + } + return slices.ContainsFunc(members, func(member string) bool { return member != owner }) +} diff --git a/client/internal/elevate/trusted_unix_test.go b/client/internal/elevate/trusted_unix_test.go new file mode 100644 index 000000000..7c0c5a966 --- /dev/null +++ b/client/internal/elevate/trusted_unix_test.go @@ -0,0 +1,148 @@ +//go:build !windows + +package elevate + +import ( + "os" + "os/user" + "path/filepath" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ownerOnlyDir is t.TempDir() with the write bits tightened. testing creates its +// numbered directory with 0777 minus the umask, so under the common 002 umask it +// is group-writable and would fail the check under test on its own. +func ownerOnlyDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.Chmod(dir, 0o755), "tighten the temporary directory") + return dir +} + +// writeExecutable creates a plain executable file, the shape trustedSelf checks. +func writeExecutable(t *testing.T, dir string) string { + t.Helper() + path := filepath.Join(dir, "netbird-ui") + require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755), "write the executable") + require.NoError(t, os.Chmod(path, 0o755), "set the executable's mode") + return path +} + +func TestCheckOnlyOwnerWritableAcceptsOwnerOnly(t *testing.T) { + err := checkOnlyOwnerWritable(writeExecutable(t, ownerOnlyDir(t))) + assert.NoError(t, err, "an owner-only writable executable is trustworthy") +} + +func TestCheckOnlyOwnerWritableRejectsWorldWritableFile(t *testing.T) { + path := writeExecutable(t, ownerOnlyDir(t)) + require.NoError(t, os.Chmod(path, 0o777), "make the executable world-writable") + + assert.Error(t, checkOnlyOwnerWritable(path), "a world-writable executable must be refused") +} + +// The permission policy on its own, without a filesystem to arrange: whether the +// group has been vouched for is the only thing that makes group write acceptable. +func TestWriteBitsAllow(t *testing.T) { + tests := []struct { + name string + perm os.FileMode + sticky bool + groupAllowed bool + wantErr bool + }{ + {name: "owner only", perm: 0o755}, + {name: "group write in a private group", perm: 0o775, groupAllowed: true}, + {name: "group write in a shared group", perm: 0o775, wantErr: true}, + {name: "world write", perm: 0o777, groupAllowed: true, wantErr: true}, + {name: "world write on a sticky directory", perm: 0o777, sticky: true}, + {name: "group write on a sticky directory", perm: 0o775, sticky: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := writeBitsAllow("/path", tt.perm, tt.sticky, tt.groupAllowed) + if tt.wantErr { + assert.Error(t, err, "perm %v, sticky %v, group allowed %v", tt.perm, tt.sticky, tt.groupAllowed) + return + } + assert.NoError(t, err, "perm %v, sticky %v, group allowed %v", tt.perm, tt.sticky, tt.groupAllowed) + }) + } +} + +// A build under a home directory on a distribution with a 002 umask, which is what +// a locally built or tarball-installed binary looks like. Its group has no members +// but its owner, so it is as good as owner-only. +// +// Whether this host is such a distribution is read from the environment rather than +// from groupWriteAllowed: asking the function under test whether to run would let +// it skip its own coverage away if it regressed to refusing everything. +func TestCheckOnlyOwnerWritableAcceptsOwnPrivateGroup(t *testing.T) { + requirePrivatePrimaryGroup(t) + + dir := ownerOnlyDir(t) + path := writeExecutable(t, dir) + require.NoError(t, os.Chmod(dir, 0o775), "make the directory group-writable") + require.NoError(t, os.Chmod(path, 0o775), "make the executable group-writable") + + err := checkOnlyOwnerWritable(path) + assert.NoError(t, err, "group write in the owner's own private group reaches nobody else") +} + +// A group whose membership no source can answer for is treated as shared: the +// private-group allowance must not stand on a name nobody can vouch for. The +// membership listing itself lives in the getent package and is tested there. +func TestGroupHasOtherMembersRejectsAnUnknownGroup(t *testing.T) { + assert.True(t, groupHasOtherMembers("nonexistent_group_xyzzy_12345", "vma"), + "a group no source describes") +} + +// A writable directory is as good as a writable file: whoever can write the +// directory can put a different binary at the same path. +func TestCheckOnlyOwnerWritableRejectsWritableDirectory(t *testing.T) { + dir := filepath.Join(ownerOnlyDir(t), "bin") + require.NoError(t, os.Mkdir(dir, 0o755), "create the directory") + path := writeExecutable(t, dir) + require.NoError(t, os.Chmod(dir, 0o777), "make the directory world-writable") + + assert.Error(t, checkOnlyOwnerWritable(path), "an executable in a world-writable directory must be refused") +} + +// A sticky world-writable directory is exempt: the sticky bit is what stops one +// user replacing another's entries. /tmp is why this matters. +func TestCheckOnlyOwnerWritableAcceptsStickyDirectory(t *testing.T) { + dir := filepath.Join(ownerOnlyDir(t), "sticky") + require.NoError(t, os.Mkdir(dir, 0o755), "create the directory") + path := writeExecutable(t, dir) + require.NoError(t, os.Chmod(dir, 0o777|os.ModeSticky), "make the directory sticky and world-writable") + + err := checkOnlyOwnerWritable(path) + assert.NoError(t, err, "the sticky bit stops another user replacing the executable") +} + +func TestCheckOnlyOwnerWritableRejectsMissingFile(t *testing.T) { + err := checkOnlyOwnerWritable(filepath.Join(ownerOnlyDir(t), "absent")) + assert.Error(t, err, "an executable that is not there must be refused") +} + +// requirePrivatePrimaryGroup skips unless this user's primary group is their own, +// which is what the user-private-group allowance is about. +func requirePrivatePrimaryGroup(t *testing.T) { + t.Helper() + + self, err := user.Current() + require.NoError(t, err, "look up the test user") + group, err := user.LookupGroupId(strconv.Itoa(os.Getgid())) + require.NoError(t, err, "look up the test user's primary group") + + if group.Name != self.Username { + t.Skipf("the test user's primary group is %q, not their own, so there is nothing to assert here", group.Name) + } + if groupHasOtherMembers(group.Name, self.Username) { + t.Skipf("group %q has other members, so it is not a private group", group.Name) + } +} diff --git a/client/internal/elevate/trusted_windows.go b/client/internal/elevate/trusted_windows.go new file mode 100644 index 000000000..8fb05fd88 --- /dev/null +++ b/client/internal/elevate/trusted_windows.go @@ -0,0 +1,215 @@ +package elevate + +import ( + "errors" + "fmt" + "path/filepath" + "slices" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + // fileDeleteChild is FILE_DELETE_CHILD, which x/sys does not define: the + // right to delete an entry of a directory without holding DELETE on it. + fileDeleteChild = 0x00000040 + + // accessAllowedCallbackACEType is an allow ACE with a condition appended to + // the ACCESS_ALLOWED_ACE layout, so its trustee is still at SidStart. + accessAllowedCallbackACEType = 0x9 + + // The allow ACE types that carry object GUIDs ahead of the trustee, so the + // SID is not at SidStart. They occur on directory-service objects rather + // than files, and are refused rather than skipped: see aceTrustee. + accessAllowedObjectACEType = 0x5 + accessAllowedCallbackObjectACEType = 0xB +) + +// fileWriteAccess are the rights that let a trustee rewrite or replace a file, +// or take it over and then do so. +const fileWriteAccess = windows.FILE_WRITE_DATA | windows.FILE_APPEND_DATA | + windows.DELETE | windows.WRITE_DAC | windows.WRITE_OWNER | + windows.GENERIC_WRITE | windows.GENERIC_ALL + +// dirWriteAccess are the rights over a directory that let a trustee replace an +// entry somebody else owns. Creating a new entry is not one of them, which is +// what the Unix sticky bit says in one bit: the root of every volume grants +// BUILTIN\Users the right to add directories under it, and that reaches nothing +// already there. +const dirWriteAccess = fileDeleteChild | windows.DELETE | + windows.WRITE_DAC | windows.WRITE_OWNER | windows.GENERIC_ALL + +// trustedInstallerSID owns much of what Windows itself installs. x/sys has no +// well-known constant for it. +const trustedInstallerSID = "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464" + +// checkOnlyOwnerWritable reports an error unless path, and every directory +// leading to it, is owned by an account that can elevate (or by this user) and +// grants write access to nobody else. A writable directory is as good as a +// writable file, since an entry in it can be replaced, so the whole chain is +// checked. +func checkOnlyOwnerWritable(path string) error { + owners, err := trustedOwners() + if err != nil { + return err + } + writers, err := trustedWriters(owners) + if err != nil { + return err + } + + writeAccess := windows.ACCESS_MASK(fileWriteAccess) + for target := path; ; target = filepath.Dir(target) { + if err := checkSecurity(target, writeAccess, owners, writers); err != nil { + return err + } + if parent := filepath.Dir(target); parent == target { + return nil + } + writeAccess = dirWriteAccess + } +} + +// trustedOwners are the accounts we accept as the owner of the executable and of +// the directories above it: the ones that can already answer the UAC prompt, +// plus this user, whose own executable is theirs to write. Code running as the +// user could prompt them for anything anyway; what matters is that no *other* +// unprivileged account can reach it. +func trustedOwners() ([]*windows.SID, error) { + self, err := currentUserSID() + if err != nil { + return nil, err + } + + owners := []*windows.SID{self} + for _, wellKnown := range []windows.WELL_KNOWN_SID_TYPE{ + windows.WinLocalSystemSid, + windows.WinBuiltinAdministratorsSid, + } { + sid, err := windows.CreateWellKnownSid(wellKnown) + if err != nil { + return nil, fmt.Errorf("build well-known SID %d: %w", wellKnown, err) + } + owners = append(owners, sid) + } + + installer, err := windows.StringToSid(trustedInstallerSID) + if err != nil { + return nil, fmt.Errorf("parse TrustedInstaller SID: %w", err) + } + return append(owners, installer), nil +} + +// trustedWriters are the trustees whose write access does not widen who could +// decide what runs behind the prompt. The owners, and CREATOR OWNER, which +// resolves to the object's owner and is therefore already vetted. +func trustedWriters(owners []*windows.SID) ([]*windows.SID, error) { + creatorOwner, err := windows.CreateWellKnownSid(windows.WinCreatorOwnerSid) + if err != nil { + return nil, fmt.Errorf("build the CREATOR OWNER SID: %w", err) + } + return append(slices.Clone(owners), creatorOwner), nil +} + +func checkSecurity(path string, writeAccess windows.ACCESS_MASK, owners, writers []*windows.SID) error { + sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, + windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION) + if err != nil { + return fmt.Errorf("read security descriptor of %s: %w", path, err) + } + + owner, _, err := sd.Owner() + if err != nil { + return fmt.Errorf("read owner of %s: %w", path, err) + } + if !containsSID(owners, owner) { + return fmt.Errorf("%s is owned by %s, which is neither this user nor an account that can elevate", path, owner) + } + + dacl, _, err := sd.DACL() + if err != nil { + return fmt.Errorf("read DACL of %s: %w", path, err) + } + // A NULL DACL grants everyone everything; only an absent security + // descriptor would have got us here without one, and neither is trustworthy. + if dacl == nil { + return fmt.Errorf("%s has no DACL, so it grants write access to everyone", path) + } + + return checkDACL(path, dacl, writeAccess, writers) +} + +// checkDACL refuses an ACL that grants write access to a trustee outside +// writers. +// +// An allowlist, because the trustees that must not have it cannot be listed: an +// ACE naming an ordinary user account hands that account the same power as one +// naming Everyone, and only the accounts that may hold it are knowable. +func checkDACL(path string, dacl *windows.ACL, writeAccess windows.ACCESS_MASK, writers []*windows.SID) error { + for i := uint32(0); i < uint32(dacl.AceCount); i++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(dacl, i, &ace); err != nil { + return fmt.Errorf("read ACE %d of %s: %w", i, path, err) + } + // An inherit-only ACE says what children of this object get, not what + // this object grants. + if ace.Header.AceFlags&windows.INHERIT_ONLY_ACE != 0 { + continue + } + if ace.Mask&writeAccess == 0 { + continue + } + // Only an allow ACE grants anything; a deny ACE narrows what one gave. + if !isAllowACE(ace.Header.AceType) { + continue + } + + trustee, err := aceTrustee(ace) + if err != nil { + return fmt.Errorf("read the trustee of ACE %d of %s: %w", i, path, err) + } + if !containsSID(writers, trustee) { + return fmt.Errorf("%s grants write access to %s", path, trustee) + } + } + return nil +} + +// isAllowACE reports whether an ACE type grants rights, rather than denying, +// auditing or labelling them. +func isAllowACE(aceType uint8) bool { + switch aceType { + case windows.ACCESS_ALLOWED_ACE_TYPE, accessAllowedCallbackACEType, + accessAllowedObjectACEType, accessAllowedCallbackObjectACEType: + return true + default: + return false + } +} + +// aceTrustee returns who an allow ACE grants its rights to. An ACE whose trustee +// cannot be located is an error rather than something to skip past: being unable +// to read who is being given write access is a refusal. +func aceTrustee(ace *windows.ACCESS_ALLOWED_ACE) (*windows.SID, error) { + switch ace.Header.AceType { + case windows.ACCESS_ALLOWED_ACE_TYPE, accessAllowedCallbackACEType: + //nolint:gosec // SidStart is the first uint32 of the variable-length SID that follows the ACE header. + return (*windows.SID)(unsafe.Pointer(&ace.SidStart)), nil + default: + return nil, errors.New("an object-type allow ACE does not carry its trustee where we can read it") + } +} + +func containsSID(sids []*windows.SID, sid *windows.SID) bool { + return slices.ContainsFunc(sids, sid.Equals) +} + +func currentUserSID() (*windows.SID, error) { + token := windows.GetCurrentProcessToken() + user, err := token.GetTokenUser() + if err != nil { + return nil, fmt.Errorf("read this process's user: %w", err) + } + return user.User.Sid, nil +} diff --git a/client/internal/elevate/trusted_windows_test.go b/client/internal/elevate/trusted_windows_test.go new file mode 100644 index 000000000..946e7b7c8 --- /dev/null +++ b/client/internal/elevate/trusted_windows_test.go @@ -0,0 +1,126 @@ +package elevate + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +// A file the test user created under their own profile, which is what a per-user +// install looks like. The whole chain up to the volume root is walked, so this is +// also what says the walk does not refuse an ordinary Windows installation: the +// root of every volume grants BUILTIN\Users rights that are not ours to worry +// about. +func TestCheckOnlyOwnerWritableAcceptsOwnFile(t *testing.T) { + err := checkOnlyOwnerWritable(writeExecutable(t)) + assert.NoError(t, err, "a file the test user owns, under directories only administrators can write") +} + +// Write access held by an account that cannot answer the UAC prompt means that +// account decides what runs behind it, whoever the ACE names. The trustees that +// must not have it cannot be listed, so the check names the ones that may. +func TestCheckOnlyOwnerWritableRejectsUntrustedWriters(t *testing.T) { + tests := []struct { + name string + wellKnown windows.WELL_KNOWN_SID_TYPE + }{ + {name: "everyone", wellKnown: windows.WinWorldSid}, + {name: "authenticated users", wellKnown: windows.WinAuthenticatedUserSid}, + {name: "builtin users", wellKnown: windows.WinBuiltinUsersSid}, + // A service account, which no denylist of the obvious groups would name + // and which cannot elevate any more than Everyone can. + {name: "local service", wellKnown: windows.WinLocalServiceSid}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := writeExecutable(t) + grantWrite(t, path, tt.wellKnown) + + assert.Error(t, checkOnlyOwnerWritable(path), + "write access for %s must be refused", tt.name) + }) + } +} + +// The masks are the policy: on a file any write reaches its contents, while on a +// directory only deleting or taking over an entry reaches something already +// there. Adding an entry does not, which is why the walk survives a volume root. +func TestWriteAccessMasks(t *testing.T) { + assert.NotZero(t, fileWriteAccess&windows.FILE_WRITE_DATA, "writing a file's data reaches its contents") + assert.NotZero(t, fileWriteAccess&windows.FILE_APPEND_DATA, "appending to a file reaches its contents") + + assert.Zero(t, dirWriteAccess&windows.FILE_WRITE_DATA, "adding a file to a directory replaces nothing") + assert.Zero(t, dirWriteAccess&windows.FILE_APPEND_DATA, "adding a subdirectory replaces nothing") + assert.NotZero(t, dirWriteAccess&fileDeleteChild, "deleting an entry replaces it") + assert.NotZero(t, dirWriteAccess&windows.DELETE, "deleting the directory takes its entries with it") +} + +func TestIsAllowACE(t *testing.T) { + tests := []struct { + name string + aceType uint8 + want bool + }{ + {name: "allowed", aceType: windows.ACCESS_ALLOWED_ACE_TYPE, want: true}, + {name: "allowed callback", aceType: accessAllowedCallbackACEType, want: true}, + {name: "allowed object", aceType: accessAllowedObjectACEType, want: true}, + {name: "allowed callback object", aceType: accessAllowedCallbackObjectACEType, want: true}, + {name: "denied", aceType: windows.ACCESS_DENIED_ACE_TYPE}, + // SYSTEM_AUDIT_ACE_TYPE, which x/sys does not define: an ACE that records + // access rather than granting it. + {name: "audit", aceType: 0x2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isAllowACE(tt.aceType), "ACE type %#x", tt.aceType) + }) + } +} + +// writeExecutable creates a plain file under the test's own directory, the shape +// trustedSelf checks. +func writeExecutable(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "netbird-ui.exe") + require.NoError(t, os.WriteFile(path, []byte("MZ"), 0o755), "write the executable") + return path +} + +// grantWrite replaces the file's DACL with one that grants a well-known trustee +// everything, keeping the test user's own access so the file stays deletable. +func grantWrite(t *testing.T, path string, wellKnown windows.WELL_KNOWN_SID_TYPE) { + t.Helper() + + trustee, err := windows.CreateWellKnownSid(wellKnown) + require.NoError(t, err, "build the trustee SID") + self, err := currentUserSID() + require.NoError(t, err, "read the test user's SID") + + acl, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{ + fullControl(self, windows.TRUSTEE_IS_USER), + fullControl(trustee, windows.TRUSTEE_IS_WELL_KNOWN_GROUP), + }, nil) + require.NoError(t, err, "build the ACL") + + require.NoError(t, windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, + nil, nil, acl, nil), "set the DACL") +} + +func fullControl(sid *windows.SID, trusteeType uint32) windows.EXPLICIT_ACCESS { + return windows.EXPLICIT_ACCESS{ + AccessPermissions: windows.GENERIC_ALL, + AccessMode: windows.GRANT_ACCESS, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_TYPE(trusteeType), + TrusteeValue: windows.TrusteeValueFromSID(sid), + }, + } +} diff --git a/client/internal/engine.go b/client/internal/engine.go index 8d7e02bd5..f8b65f7d8 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -22,16 +22,18 @@ import ( log "github.com/sirupsen/logrus" "golang.zx2c4.com/wireguard/tun/netstack" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" - "google.golang.org/protobuf/proto" + "github.com/netbirdio/netbird/client/anonymize" nberrors "github.com/netbirdio/netbird/client/errors" "github.com/netbirdio/netbird/client/firewall" "github.com/netbirdio/netbird/client/firewall/firewalld" firewallManager "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/firewall/uspfilter/forwarder" "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/iface/wgaddr" "github.com/netbirdio/netbird/client/internal/acl" "github.com/netbirdio/netbird/client/internal/debug" "github.com/netbirdio/netbird/client/internal/dns" @@ -39,6 +41,7 @@ import ( "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/lazyconn" "github.com/netbirdio/netbird/client/internal/metrics" "github.com/netbirdio/netbird/client/internal/netflow" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" @@ -52,22 +55,29 @@ import ( "github.com/netbirdio/netbird/client/internal/relay" "github.com/netbirdio/netbird/client/internal/rosenpass" "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/syncstore" "github.com/netbirdio/netbird/client/internal/updater" "github.com/netbirdio/netbird/client/jobexec" + "github.com/netbirdio/netbird/client/netevents" cProto "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/system" nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/route" mgm "github.com/netbirdio/netbird/shared/management/client" "github.com/netbirdio/netbird/shared/management/domain" + sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc" + nbnetworkmap "github.com/netbirdio/netbird/shared/management/networkmap" mgmProto "github.com/netbirdio/netbird/shared/management/proto" + types "github.com/netbirdio/netbird/shared/management/types" + "github.com/netbirdio/netbird/shared/netiputil" auth "github.com/netbirdio/netbird/shared/relay/auth/hmac" relayClient "github.com/netbirdio/netbird/shared/relay/client" signal "github.com/netbirdio/netbird/shared/signal/client" sProto "github.com/netbirdio/netbird/shared/signal/proto" "github.com/netbirdio/netbird/util" + "github.com/netbirdio/netbird/util/capture" + "github.com/netbirdio/netbird/version" ) // PeerConnectionTimeoutMax is a timeout of an initial connection attempt to a remote peer. @@ -78,16 +88,32 @@ const ( PeerConnectionTimeoutMax = 45000 // ms PeerConnectionTimeoutMin = 30000 // ms disableAutoUpdate = "disabled" + + // systemInfoTimeout bounds how long the sync loop waits for system info / posture + // check gathering. The gathering runs uncancellable system calls (process scan, + // exec, os.Stat); without this bound a single stuck call freezes handleSync, and + // thus syncMsgMux, for as long as the call hangs (observed multi-minute freezes). + systemInfoTimeout = 15 * time.Second + + // dnsForwarderStopTimeout bounds how long stopping the DNS forwarder waits + // for the queries still in flight. One waiting on an unresponsive upstream + // would otherwise hold the stop for the whole upstream timeout, and the + // stop runs with syncMsgMux held. The sockets are closed either way, so + // giving up costs a query that was already failing. + dnsForwarderStopTimeout = 2 * time.Second ) var ErrResetConnection = fmt.Errorf("reset connection") +var ErrEngineAlreadyStarted = errors.New("engine already started") + type EngineConfig struct { WgPort int WgIfaceName string - // WgAddr is a Wireguard local address (Netbird Network IP) - WgAddr string + // WgAddr is the Wireguard local address (Netbird Network IP). + // Contains both v4 and optional v6 overlay addresses. + WgAddr wgaddr.Address // WgPrivateKey is a Wireguard private key of our peer (it MUST never leave the machine) WgPrivateKey wgtypes.Key @@ -118,6 +144,7 @@ type EngineConfig struct { RosenpassPermissive bool ServerSSHAllowed bool + RemoteJobsAllowed bool EnableSSHRoot *bool EnableSSHSFTP *bool EnableSSHLocalPortForwarding *bool @@ -132,8 +159,12 @@ type EngineConfig struct { DisableFirewall bool BlockLANAccess bool BlockInbound bool + DisableIPv6 bool + SyncMessageVersion *int - LazyConnectionEnabled bool + // LazyConnection is the MDM-sourced lazy-connection override; StateUnset defers to + // the env var and management feature flag. + LazyConnection lazyconn.State MTU uint16 @@ -142,6 +173,10 @@ type EngineConfig struct { LogPath string TempDir string + + // StateDir is the directory holding the state file. The sync response + // (network map) is serialized here on platforms that persist it to disk. + StateDir string } // EngineServices holds the external service dependencies required by the Engine. @@ -154,6 +189,10 @@ type EngineServices struct { StateManager *statemanager.Manager UpdateManager *updater.Manager ClientMetrics *metrics.ClientMetrics + MetricsCtx context.Context + // NetMgr gates the reconnection loops on OS-reported network + // availability; nil disables gating. + NetMgr *netevents.Manager } // Engine is a mechanism responsible for reacting on Signal and Management stream events and managing connections to the remote peers. @@ -177,6 +216,10 @@ type Engine struct { config *EngineConfig mobileDep MobileDependency + // netMgr gates the peer reconnection guards on OS-reported network + // availability; nil disables gating. + netMgr *netevents.Manager + // STUNs is a list of STUN servers used by ICE STUNs []*stun.URI // TURNs is a list of STUN servers used by ICE @@ -189,6 +232,8 @@ type Engine struct { ctx context.Context cancel context.CancelFunc + started bool + wgInterface WGIface udpMux *udpmux.UniversalUDPMuxDefault @@ -196,6 +241,13 @@ type Engine struct { // networkSerial is the latest CurrentSerial (state ID) of the network sent by the Management service networkSerial uint64 + // latestComponents is the most-recent NetworkMapComponents decoded from + // a NetworkMapEnvelope (capability=3 peers only). Held alongside the + // NetworkMap that Calculate() produced from it so future incremental + // updates have a base to apply changes against. nil for legacy-format + // peers. Guarded by syncMsgMux. + latestComponents *types.NetworkMapComponents + networkMonitor *networkmonitor.NetworkMonitor sshServer sshServer @@ -213,16 +265,25 @@ type Engine struct { // checks are the client-applied posture checks that need to be evaluated on the client checks []*mgmProto.Checks + infoSource system.InfoSource + relayManager *relayClient.Manager stateManager *statemanager.Manager portForwardManager *portforward.Manager srWatcher *guard.SRWatcher - // Sync response persistence (protected by syncRespMux) - syncRespMux sync.RWMutex - persistSyncResponse bool - latestSyncResponse *mgmProto.SyncResponse - flowManager nftypes.FlowManager + afpacketCapture *capture.AFPacketCapture + + // Sync response persistence (protected by syncRespMux). + // syncStore is nil unless persistence has been enabled; its presence is + // what marks persistence as active. The backend (disk or memory) is + // selected per-platform; see the syncstore package. syncStoreDir is where + // a disk-backed store serializes to. + syncRespMux sync.RWMutex + syncStore syncstore.Store + syncStoreDir string + + flowManager nftypes.FlowManager // auto-update updateManager *updater.Manager @@ -237,11 +298,26 @@ type Engine struct { // clientMetrics collects and pushes metrics clientMetrics *metrics.ClientMetrics + metricsCtx context.Context jobExecutor *jobexec.Executor jobExecutorWG sync.WaitGroup exposeManager *expose.Manager + + sessionWatcher sessionDeadlineWatcher +} + +// sessionDeadlineWatcher is the engine-facing surface of the SSO session +// expiry watcher. The concrete implementation (sessionwatch.Watcher) is wired +// in via newSessionWatcher, which is build-tagged so the js/wasm build links a +// no-op stub instead of pulling the full sessionwatch package (and its timer +// machinery) into the binary — the wasm client never runs the engine's +// session-warning flow. +type sessionDeadlineWatcher interface { + Update(deadline time.Time) error + Dismiss() + Close() } // Peer is an instance of the Connection Peer @@ -254,6 +330,10 @@ type localIpUpdater interface { UpdateLocalIPs() error } +// overlayRebind rebuilds one subsystem's sockets on the current interface. The +// error it returns names its own subsystem, since the caller can only log it. +type overlayRebind func() error + // NewEngine creates a new Connection Engine with probes attached func NewEngine( clientCtx context.Context, @@ -262,9 +342,15 @@ func NewEngine( services EngineServices, mobileDep MobileDependency, ) *Engine { + // The engine is single-use: a fresh instance is built per connection + // cycle (see Client.run), so the run context is created once here rather + // than in Start. + ctx, cancel := context.WithCancel(clientCtx) engine := &Engine{ clientCtx: clientCtx, clientCancel: clientCancel, + ctx: ctx, + cancel: cancel, signal: services.SignalClient, signaler: peer.NewSignaler(services.SignalClient, config.WgPrivateKey), mgmClient: services.MgmClient, @@ -273,6 +359,7 @@ func NewEngine( syncMsgMux: &sync.Mutex{}, config: config, mobileDep: mobileDep, + netMgr: services.NetMgr, STUNs: []*stun.URI{}, TURNs: []*stun.URI{}, networkSerial: 0, @@ -283,8 +370,21 @@ func NewEngine( probeStunTurn: relay.NewStunTurnProbe(relay.DefaultCacheTTL), jobExecutor: jobexec.NewExecutor(), clientMetrics: services.ClientMetrics, + metricsCtx: services.MetricsCtx, updateManager: services.UpdateManager, + syncStoreDir: config.StateDir, } + // sessionWatcher keeps the SubscribeStatus consumers in sync with the + // session expiry deadline. Deadline-change ticks come for free via + // Status.SetSessionExpiresAt; the watcher exists to push a wake-up at + // T-WarningLead and T-FinalWarningLead so the UI repaints the remaining + // time / warning state even when nothing else changed, and to publish + // two SystemEvents (the warning composition lives in sessionwatch so + // the wire format stays owned by one package): + // - T-WarningLead → interactive "Extend now / Dismiss" notification + // - T-FinalWarningLead → auto-opened SessionAboutToExpire dialog, + // suppressed when the user dismissed the earlier warning + engine.sessionWatcher = newSessionWatcher(engine.statusRecorder) log.Infof("I am: %s", config.WgPrivateKey.PublicKey().String()) return engine @@ -296,8 +396,34 @@ func (e *Engine) Stop() error { log.Debugf("tried stopping engine that is nil") return nil } + e.cancel() e.syncMsgMux.Lock() + e.stopLocked() + + e.syncMsgMux.Unlock() + + timeout := e.calculateShutdownTimeout() + log.Debugf("waiting for goroutines to finish with timeout: %v", timeout) + shutdownCtx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + if err := waitWithContext(shutdownCtx, &e.shutdownWg); err != nil { + log.Warnf("shutdown timeout exceeded after %v, some goroutines may still be running", timeout) + } + + log.Infof("stopped Netbird Engine") + + return nil +} + +// stopLocked tears down everything Start may have brought up, in the order +// teardown requires (DNS before the interface goes down, flow manager after). +// The caller must hold syncMsgMux. It is shared by Stop and by Start's failure +// path, so a partially-initialized engine is cleaned up the same way; every +// step is nil-guarded. It does not wait on shutdownWg — the caller does that +// after releasing the lock, since the goroutines also take syncMsgMux. +func (e *Engine) stopLocked() { if e.connMgr != nil { e.connMgr.Close() } @@ -325,6 +451,10 @@ func (e *Engine) Stop() error { e.srWatcher.Close() } + if e.sessionWatcher != nil { + e.sessionWatcher.Close() + } + if e.updateManager != nil { e.updateManager.SetDownloadOnly() } @@ -348,10 +478,6 @@ func (e *Engine) Stop() error { // so dbus and friends don't complain because of a missing interface e.stopDNSServer() - if e.cancel != nil { - e.cancel() - } - e.jobExecutorWG.Wait() // block until job goroutines finish e.close() @@ -370,21 +496,6 @@ func (e *Engine) Stop() error { if err := e.stateManager.PersistState(context.Background()); err != nil { log.Errorf("failed to persist state: %v", err) } - - e.syncMsgMux.Unlock() - - timeout := e.calculateShutdownTimeout() - log.Debugf("waiting for goroutines to finish with timeout: %v", timeout) - shutdownCtx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - if err := waitWithContext(shutdownCtx, &e.shutdownWg); err != nil { - log.Warnf("shutdown timeout exceeded after %v, some goroutines may still be running", timeout) - } - - log.Infof("stopped Netbird Engine") - - return nil } // calculateShutdownTimeout returns shutdown timeout: 10s base + 100ms per peer, capped at 30s. @@ -422,18 +533,38 @@ func waitWithContext(ctx context.Context, wg *sync.WaitGroup) error { // Start creates a new WireGuard tunnel interface and listens to events from Signal and Management services // Connections to remote peers are not established here. // However, they will be established once an event with a list of peers to connect to will be received from Management Service -func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) error { +func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) (err error) { e.syncMsgMux.Lock() defer e.syncMsgMux.Unlock() - if err := iface.ValidateMTU(e.config.MTU); err != nil { + // The engine is single-use. Reject a duplicate start and a start on an + // already-stopped engine (run context cancelled). + if e.started { + return ErrEngineAlreadyStarted + } + + if ctxErr := e.ctx.Err(); ctxErr != nil { + return fmt.Errorf("engine already stopped: %w", ctxErr) + } + + e.started = true + + // Tear down any partially-initialized state on a failed start. Cancel the + // run context first so goroutines started before the failure (connMgr, + // srWatcher, monitors) unwind, then stopLocked mirrors Stop's teardown (we + // already hold syncMsgMux), cleaning up route/DNS/flow/state managers too, + // not just what close() covers. + defer func() { + if err != nil { + e.cancel() + e.stopLocked() + } + }() + + if err = iface.ValidateMTU(e.config.MTU); err != nil { return fmt.Errorf("invalid MTU configuration: %w", err) } - if e.cancel != nil { - e.cancel() - } - e.ctx, e.cancel = context.WithCancel(e.clientCtx) e.exposeManager = expose.NewManager(e.ctx, e.mgmClient) wgIface, err := e.newWgIface() @@ -455,7 +586,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) } else { log.Infof("running rosenpass in strict mode") } - e.rpManager, err = rosenpass.NewManager(e.config.PreSharedKey, e.config.WgIfaceName) + e.rpManager, err = rosenpass.NewManager(e.config.PreSharedKey, e.config.WgIfaceName, publicKey) if err != nil { return fmt.Errorf("create rosenpass manager: %w", err) } @@ -465,15 +596,8 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) } e.stateManager.Start() - initialRoutes, dnsConfig, dnsFeatureFlag, err := e.readInitialSettings() + dnsServer, err := e.newDnsServer() if err != nil { - e.close() - return fmt.Errorf("read initial settings: %w", err) - } - - dnsServer, err := e.newDnsServer(dnsConfig) - if err != nil { - e.close() return fmt.Errorf("create dns server: %w", err) } e.dnsServer = dnsServer @@ -490,10 +614,8 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) WGInterface: e.wgInterface, StatusRecorder: e.statusRecorder, RelayManager: e.relayManager, - InitialRoutes: initialRoutes, StateManager: e.stateManager, DNSServer: dnsServer, - DNSFeatureFlag: dnsFeatureFlag, PeerStore: e.peerStore, DisableClientRoutes: e.config.DisableClientRoutes, DisableServerRoutes: e.config.DisableServerRoutes, @@ -504,25 +626,18 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) e.routeManager.SetRouteChangeListener(e.mobileDep.NetworkChangeListener) - e.dnsServer.SetRouteChecker(func(ip netip.Addr) bool { - for _, routes := range e.routeManager.GetSelectedClientRoutes() { - for _, r := range routes { - if r.Network.Contains(ip) { - return true - } - } - } - return false - }) + e.dnsServer.SetRouteSources(e.routeManager.GetSelectedClientRoutes, e.routeManager.GetActiveClientRoutes) if err = e.wgInterfaceCreate(); err != nil { log.Errorf("failed creating tunnel interface %s: [%s]", e.config.WgIfaceName, err.Error()) - e.close() return fmt.Errorf("create wg interface: %w", err) } + if filteredDevice := e.wgInterface.GetDevice(); filteredDevice != nil { + filteredDevice.SetPanicHandler(e.triggerClientRestart) + } + if err := e.createFirewall(); err != nil { - e.close() return err } @@ -534,7 +649,6 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) e.udpMux, err = e.wgInterface.Up() if err != nil { log.Errorf("failed to pull up wgInterface [%s]: %s", e.wgInterface.Name(), err.Error()) - e.close() return fmt.Errorf("up wg interface: %w", err) } @@ -559,21 +673,37 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) e.acl = acl.NewDefaultManager(e.firewall) } - err = e.dnsServer.Initialize() - if err != nil { - e.close() + if err := e.dnsServer.Initialize(); err != nil { return fmt.Errorf("initialize dns server: %w", err) } iceCfg := e.createICEConfig() e.connMgr = NewConnMgr(e.config, e.statusRecorder, e.peerStore, wgIface) + e.connMgr.SetRoutedIPsReconciler(func(peerKey string) error { + if e.routeManager == nil { + return nil + } + return e.routeManager.ReconcilePeerAllowedIPs(peerKey) + }) e.connMgr.Start(e.ctx) + // Wire DNS-time lazy-connection warm-up now that the connection manager + // exists (it does not at DNS-server construction time). A DNS answer that + // points at an idle peer then wakes it before the client's first request. + e.dnsServer.SetPeerActivator(&dnsPeerActivator{ + connMgr: e.connMgr, + peerStore: e.peerStore, + status: e.statusRecorder, + ctx: e.ctx, + }) + e.srWatcher = guard.NewSRWatcher(e.signal, e.relayManager, e.mobileDep.IFaceDiscover, iceCfg) e.srWatcher.Start(peer.IsForceRelayed()) - e.receiveSignalEvents() + if err = e.receiveSignalEvents(); err != nil { + return err + } e.receiveManagementEvents() e.receiveJobEvents() @@ -625,10 +755,14 @@ func (e *Engine) createFirewall() error { func (e *Engine) initFirewall() error { if err := e.routeManager.SetFirewall(e.firewall); err != nil { - e.close() return fmt.Errorf("set firewall: %w", err) } + // TODO: the firewall backends dedup filter rules by content, so a + // management route ACL with identical content would collapse onto the + // untracked drop rules installed here, and a later management delete + // could remove them. Needs backend refcounting or per-consumer key + // namespacing. if e.config.BlockLANAccess { e.blockLanAccess() } @@ -640,15 +774,15 @@ func (e *Engine) initFirewall() error { rosenpassPort := e.rpManager.GetAddress().Port port := firewallManager.Port{Values: []uint16{uint16(rosenpassPort)}} - // this rule is static and will be torn down on engine down by the firewall manager - if _, err := e.firewall.AddPeerFiltering( + // IPv4-only: rosenpass peers connect via AllowedIps[0] which is always v4. + if _, err := e.firewall.AddFilterRule( nil, - net.IP{0, 0, 0, 0}, + []netip.Prefix{netip.PrefixFrom(netip.IPv4Unspecified(), 0)}, + firewallManager.Network{}, firewallManager.ProtocolUDP, nil, &port, firewallManager.ActionAccept, - "", ); err != nil { log.Errorf("failed to allow rosenpass interface traffic: %v", err) return nil @@ -692,10 +826,15 @@ func (e *Engine) blockLanAccess() { log.Infof("blocking route LAN access for networks: %v", toBlock) v4 := netip.PrefixFrom(netip.IPv4Unspecified(), 0) + v6 := netip.PrefixFrom(netip.IPv6Unspecified(), 0) for _, network := range toBlock { - if _, err := e.firewall.AddRouteFiltering( + source := v4 + if network.Addr().Is6() { + source = v6 + } + if _, err := e.firewall.AddFilterRule( nil, - []netip.Prefix{v4}, + []netip.Prefix{source}, firewallManager.Network{Prefix: network}, firewallManager.ProtocolALL, nil, @@ -733,7 +872,7 @@ func (e *Engine) modifyPeers(peersUpdate []*mgmProto.RemotePeerConfig) error { if !ok { continue } - if !compareNetIPLists(allowedIPs, p.GetAllowedIps()) { + if !compareNetIPLists(allowedIPs, e.filterAllowedIPs(p.GetAllowedIps())) { modified = append(modified, p) continue } @@ -752,8 +891,7 @@ func (e *Engine) modifyPeers(peersUpdate []*mgmProto.RemotePeerConfig) error { } // third, add the peer connections again for _, p := range modified { - err := e.addNewPeer(p) - if err != nil { + if err := e.addNewPeer(p); err != nil { return err } } @@ -846,6 +984,16 @@ func (e *Engine) handleAutoUpdateVersion(autoUpdateSettings *mgmProto.AutoUpdate e.updateManager.SetVersion(autoUpdateSettings.Version, autoUpdateSettings.AlwaysUpdate) } +// phase times a sync sub-phase: it returns a function that records the elapsed +// duration when called. Starting the timer at the call site keeps inter-phase +// glue code out of the measurement. +func (e *Engine) phase(name string) func() { + start := time.Now() + return func() { + e.clientMetrics.RecordSyncPhase(e.ctx, name, time.Since(start)) + } +} + func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { started := time.Now() defer func() { @@ -861,68 +1009,87 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { return e.ctx.Err() } - if update.NetworkMap != nil && update.NetworkMap.PeerConfig != nil { - e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate) + e.ApplySessionDeadline(update.GetSessionExpiresAt()) + + // Envelope sync responses carry PeerConfig at the top level; legacy + // NetworkMap syncs carry it under NetworkMap.PeerConfig. + if pc := update.GetPeerConfig(); pc != nil { + e.handleAutoUpdateVersion(pc.GetAutoUpdate()) + } else if nm := update.GetNetworkMap(); nm != nil && nm.GetPeerConfig() != nil { + e.handleAutoUpdateVersion(nm.GetPeerConfig().GetAutoUpdate()) } - if update.GetNetbirdConfig() != nil { - wCfg := update.GetNetbirdConfig() - err := e.updateTURNs(wCfg.GetTurns()) - if err != nil { - return fmt.Errorf("update TURNs: %w", err) - } - - err = e.updateSTUNs(wCfg.GetStuns()) - if err != nil { - return fmt.Errorf("update STUNs: %w", err) - } - - var stunTurn []*stun.URI - stunTurn = append(stunTurn, e.STUNs...) - stunTurn = append(stunTurn, e.TURNs...) - e.stunTurn.Store(stunTurn) - - err = e.handleRelayUpdate(wCfg.GetRelay()) - if err != nil { - return err - } - - err = e.handleFlowUpdate(wCfg.GetFlow()) - if err != nil { - return fmt.Errorf("handle the flow configuration: %w", err) - } - - if err := e.PopulateNetbirdConfig(wCfg, nil); err != nil { - log.Warnf("Failed to update DNS server config: %v", err) - } - - // todo update signal - } - - if err := e.updateChecksIfNew(update.Checks); err != nil { + done := e.phase("netbird_config") + err := e.updateNetbirdConfig(update.GetNetbirdConfig()) + done() + if err != nil { return err } - nm := update.GetNetworkMap() + // Decode the network map from either the components envelope or the + // legacy proto.NetworkMap before the posture-check gating below, so the + // "is there a network map" decision covers both wire shapes. + var ( + nm *mgmProto.NetworkMap + components *types.NetworkMapComponents + ) + if version := update.GetVersion(); version == int32(sharedgrpc.ComponentNetworkMap) { + // Components-format peer: decode the envelope back to typed + // components, run Calculate() locally, and convert to the wire + // NetworkMap shape the rest of the engine consumes. Components are + // retained so future incremental updates can apply deltas instead + // of doing a full reconstruction. + envelope := update.GetNetworkMapEnvelope() + if envelope == nil { + return fmt.Errorf("received a SyncReponse indicating use of components network map, but components are missing") + } + + localKey := e.config.WgPrivateKey.PublicKey().String() + dnsName := "" + if pc := update.GetPeerConfig(); pc != nil { + // PeerConfig.Fqdn = "." — extract the + // shared domain by stripping the peer's own label prefix. Falls + // back to empty if the FQDN doesn't have the expected shape. + dnsName = extractDNSDomainFromFQDN(pc.GetFqdn()) + } + result, err := nbnetworkmap.EnvelopeToNetworkMap(e.ctx, envelope, localKey, dnsName) + if err != nil { + return fmt.Errorf("decode network map envelope: %w", err) + } + nm = result.NetworkMap + components = result.Components + } else { + nm = update.GetNetworkMap() + } + + // Posture checks are bound to the network map presence: + // NetworkMap != nil, checks present -> apply the received checks + // NetworkMap != nil, checks nil -> posture checks were removed, clear them + // NetworkMap == nil -> config-only update (e.g. relay token rotation), + // leave the previously applied checks untouched if nm == nil { return nil } - // Persist sync response under the dedicated lock (syncRespMux), not under syncMsgMux. - // Read the storage-enabled flag under the syncRespMux too. - e.syncRespMux.RLock() - enabled := e.persistSyncResponse - e.syncRespMux.RUnlock() - - // Store sync response if persistence is enabled - if enabled { - e.syncRespMux.Lock() - e.latestSyncResponse = update - e.syncRespMux.Unlock() - - log.Debugf("sync response persisted with serial %d", nm.GetSerial()) + done = e.phase("checks") + err = e.updateChecksIfNew(update.Checks) + done() + if err != nil { + return err } + done = e.phase("persist") + // Only retain the components view when the server sent the envelope + // path. A legacy proto.NetworkMap means components == nil; writing it + // here would clobber a previously-cached snapshot, breaking the + // incremental-delta base on a future envelope sync. + if components != nil { + e.latestComponents = components + } + + e.persistSyncResponse(update) + done() + // only apply new changes and ignore old ones if err := e.updateNetworkMap(nm); err != nil { return err @@ -933,6 +1100,79 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { return nil } +// extractDNSDomainFromFQDN returns the trailing dotted domain part of the +// receiving peer's FQDN — the same value the management server fills as +// dnsName when it builds the legacy NetworkMap. "peer42.netbird.cloud" → +// "netbird.cloud". An empty string is returned for unrecognized formats. +func extractDNSDomainFromFQDN(fqdn string) string { + for i := 0; i < len(fqdn); i++ { + if fqdn[i] == '.' && i+1 < len(fqdn) { + return fqdn[i+1:] + } + } + return "" +} + +// updateNetbirdConfig applies the management-provided NetBird configuration: +// STUN/TURN and relay servers, flow logging and DNS settings. A nil config is a no-op, +// which is the case for sync updates carrying only a network map. +func (e *Engine) updateNetbirdConfig(wCfg *mgmProto.NetbirdConfig) error { + if wCfg == nil { + return nil + } + + if err := e.updateTURNs(wCfg.GetTurns()); err != nil { + return fmt.Errorf("update TURNs: %w", err) + } + + if err := e.updateSTUNs(wCfg.GetStuns()); err != nil { + return fmt.Errorf("update STUNs: %w", err) + } + + var stunTurn []*stun.URI + stunTurn = append(stunTurn, e.STUNs...) + stunTurn = append(stunTurn, e.TURNs...) + e.stunTurn.Store(stunTurn) + + if err := e.handleRelayUpdate(wCfg.GetRelay()); err != nil { + return err + } + + if err := e.handleFlowUpdate(wCfg.GetFlow()); err != nil { + return fmt.Errorf("handle the flow configuration: %w", err) + } + + e.handleMetricsUpdate(wCfg.GetMetrics()) + + if err := e.PopulateNetbirdConfig(wCfg, nil); err != nil { + log.Warnf("Failed to update DNS server config: %v", err) + } + + // todo update signal + + return nil +} + +// persistSyncResponse stores the full sync response so it can be restored on the next +// startup. Persistence is enabled only when syncStore is set. The dedicated syncRespMux +// (not syncMsgMux) is held for the whole Set so the store cannot be cleared (disabled / +// engine close) mid-call and have this write resurrect a file that was just removed. +func (e *Engine) persistSyncResponse(update *mgmProto.SyncResponse) { + e.syncRespMux.RLock() + defer e.syncRespMux.RUnlock() + + if e.syncStore == nil { + return + } + + if err := e.syncStore.Set(update); err != nil { + log.Errorf("failed to persist sync response: %v", err) + return + } + + log.Debugf("sync response persisted with serial %d", update.GetNetworkMap().GetSerial()) +} + func (e *Engine) handleRelayUpdate(update *mgmProto.RelayConfig) error { if update != nil { // when we receive token we expect valid address list too @@ -944,7 +1184,12 @@ func (e *Engine) handleRelayUpdate(update *mgmProto.RelayConfig) error { return fmt.Errorf("update relay token: %w", err) } - e.relayManager.UpdateServerURLs(update.Urls) + urls := update.Urls + if override, ok := peer.OverrideRelayURLs(); ok { + log.Infof("overriding relay URLs from %s: %v", peer.EnvKeyNBHomeRelayServers, override) + urls = override + } + e.relayManager.UpdateServerURLs(urls) // Just in case the agent started with an MGM server where the relay was disabled but was later enabled. // We can ignore all errors because the guard will manage the reconnection retries. @@ -968,6 +1213,14 @@ func (e *Engine) handleFlowUpdate(config *mgmProto.FlowConfig) error { return e.flowManager.Update(flowConfig) } +func (e *Engine) handleMetricsUpdate(config *mgmProto.MetricsConfig) { + if config == nil { + return + } + log.Infof("received metrics configuration from management: enabled=%v", config.GetEnabled()) + e.clientMetrics.UpdatePushFromMgm(e.metricsCtx, config.GetEnabled()) +} + func toFlowLoggerConfig(config *mgmProto.FlowConfig) (*nftypes.FlowConfig, error) { if config.GetInterval() == nil { return nil, errors.New("flow interval is nil") @@ -990,13 +1243,23 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error { if isChecksEqual(e.checks, checks) { return nil } - e.checks = checks - - info, err := system.GetInfoWithChecks(e.ctx, checks) - if err != nil { - log.Warnf("failed to get system info with checks: %v", err) - info = system.GetInfo(e.ctx) + info, ok := e.infoSource.Refresh(e.ctx, systemInfoTimeout, checks, e.overlayAddresses()...) + if !ok { + // Gathering timed out; skip the meta sync this cycle rather than blocking the + // sync loop (and syncMsgMux) on a stuck system call. A later sync will retry. + return nil } + e.applyInfoFlags(info) + + if err := e.mgmClient.SyncMeta(info); err != nil { + return fmt.Errorf("could not sync meta: error %s", err) + } + e.checks = checks + return nil +} + +// applyInfoFlags sets the engine's config-derived feature flags on the gathered system info. +func (e *Engine) applyInfoFlags(info *system.Info) { info.SetFlags( e.config.RosenpassEnabled, e.config.RosenpassPermissive, @@ -1007,19 +1270,51 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error { e.config.DisableFirewall, e.config.BlockLANAccess, e.config.BlockInbound, - e.config.LazyConnectionEnabled, + e.config.DisableIPv6, + e.config.SyncMessageVersion, e.config.EnableSSHRoot, e.config.EnableSSHSFTP, e.config.EnableSSHLocalPortForwarding, e.config.EnableSSHRemotePortForwarding, e.config.DisableSSHAuth, + &e.config.RemoteJobsAllowed, ) +} - if err := e.mgmClient.SyncMeta(info); err != nil { - log.Errorf("could not sync meta: error %s", err) - return err +func (e *Engine) currentSystemInfo(ctx context.Context) *system.Info { + info := e.infoSource.Current(ctx, e.overlayAddresses()...) + e.applyInfoFlags(info) + return info +} + +// syncInfoFunc returns the info callback for the management sync stream. The +// first connect sends the info refreshed right before it instead of gathering +// again; every reconnect gathers a fresh one. The stream retry loop calls the +// callback sequentially, so the handoff needs no synchronization. +func (e *Engine) syncInfoFunc(refreshed *system.Info) func(ctx context.Context) *system.Info { + return func(ctx context.Context) *system.Info { + if refreshed == nil { + return e.currentSystemInfo(ctx) + } + info := refreshed + refreshed = nil + e.applyInfoFlags(info) + return info } - return nil +} + +// overlayAddresses returns our own WireGuard overlay address (v4 and v6) so it +// can be excluded from the reported network addresses; the interface coming and +// going otherwise churns the peer meta on the management server. +func (e *Engine) overlayAddresses() []netip.Addr { + var ips []netip.Addr + if e.config.WgAddr.IP.IsValid() { + ips = append(ips, e.config.WgAddr.IP) + } + if e.config.WgAddr.HasIPv6() { + ips = append(ips, e.config.WgAddr.IPv6) + } + return ips } func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error { @@ -1034,6 +1329,13 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error { return ErrResetConnection } + if !e.config.DisableIPv6 && e.hasIPv6Changed(conf) { + log.Infof("peer IPv6 address changed, restarting client") + _ = CtxGetState(e.ctx).Wrap(ErrResetConnection) + e.clientCancel() + return ErrResetConnection + } + if conf.GetSshConfig() != nil { if err := e.updateSSH(conf.GetSshConfig()); err != nil { log.Warnf("failed handling SSH server setup: %v", err) @@ -1042,14 +1344,38 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error { state := e.statusRecorder.GetLocalPeerState() state.IP = e.wgInterface.Address().String() + state.IPv6 = e.wgInterface.Address().IPv6String() state.PubKey = e.config.WgPrivateKey.PublicKey().String() state.KernelInterface = !e.wgInterface.IsUserspaceBind() state.FQDN = conf.GetFqdn() + state.WgPort = e.config.WgPort e.statusRecorder.UpdateLocalPeerState(state) return nil } + +// hasIPv6Changed reports whether the IPv6 overlay address in the peer config +// differs from the configured address (added, removed, or changed). +// Compares against e.config.WgAddr (not the interface address, which may have +// been cleared by ClearIPv6 if OS assignment failed). +func (e *Engine) hasIPv6Changed(conf *mgmProto.PeerConfig) bool { + current := e.config.WgAddr + raw := conf.GetAddressV6() + + if len(raw) == 0 { + return current.HasIPv6() + } + + prefix, err := netiputil.DecodePrefix(raw) + if err != nil { + log.Errorf("decode v6 overlay address: %v", err) + return false + } + + return !current.HasIPv6() || current.IPv6 != prefix.Addr() || current.IPv6Net != prefix.Masked() +} + func (e *Engine) receiveJobEvents() { e.jobExecutorWG.Add(1) go func() { @@ -1059,6 +1385,13 @@ func (e *Engine) receiveJobEvents() { ID: msg.ID, Status: mgmProto.JobStatus_failed, } + // Remote jobs are an explicit opt-in. When not enabled on this + // peer, every job is refused before any work is done. + if !e.config.RemoteJobsAllowed { + log.Warnf("refusing remote job: remote jobs are not enabled on this peer (enable with --allow-remote-jobs)") + resp.Reason = []byte("remote jobs are not enabled on this peer") + return &resp + } switch params := msg.WorkloadParameters.(type) { case *mgmProto.JobRequest_Bundle: bundleResult, err := e.handleBundle(params.Bundle) @@ -1088,7 +1421,25 @@ func (e *Engine) receiveJobEvents() { } func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobResponse_Bundle, error) { - log.Infof("handle remote debug bundle request: %s", params.String()) + // The upload URL can carry a host, credentials, or query tokens, so it is + // kept out of the info-level line; the full parameters stay available at + // debug level for troubleshooting. + log.Infof("handle remote debug bundle request: anonymize=%v anonymize_level=%q log_file_count=%d bundle_for=%v bundle_for_time=%d", + params.GetAnonymize(), params.GetAnonymizeLevel(), params.GetLogFileCount(), params.GetBundleFor(), params.GetBundleForTime()) + log.Debugf("remote debug bundle request parameters: %s", params.String()) + + // Resolve the upload destination: an MDM override, when set, takes + // precedence over the management-supplied URL. Both are validated the same + // way; an empty result falls back to the default upload server downstream. + uploadURL := params.GetUploadUrl() + if override := e.config.ProfileConfig.DebugBundleUploadURL; override != "" { + log.Infof("using MDM debug bundle upload URL override instead of the management-supplied value") + uploadURL = override + } + if err := validateBundleUploadURL(uploadURL); err != nil { + return nil, err + } + syncResponse, err := e.GetLatestSyncResponse() if err != nil { log.Warnf("get latest sync response: %v", err) @@ -1101,20 +1452,22 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR LogPath: e.config.LogPath, TempDir: e.config.TempDir, ClientMetrics: e.clientMetrics, + DaemonVersion: version.NetbirdVersion(), RefreshStatus: func() { - e.RunHealthProbes(true) + e.RunHealthProbes(e.ctx, true) }, } bundleJobParams := debug.BundleConfig{ Anonymize: params.Anonymize, + AnonymizeLevel: anonymize.ParseLevel(params.AnonymizeLevel), IncludeSystemInfo: true, LogFileCount: uint32(params.LogFileCount), } waitFor := time.Duration(params.BundleForTime) * time.Minute - uploadKey, err := e.jobExecutor.BundleJob(e.ctx, bundleDeps, bundleJobParams, waitFor, e.config.ProfileConfig.ManagementURL.String()) + uploadKey, err := e.jobExecutor.BundleJob(e.ctx, bundleDeps, bundleJobParams, waitFor, e.config.ProfileConfig.ManagementURL.String(), uploadURL) if err != nil { return nil, err } @@ -1127,36 +1480,27 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR return response, nil } +// validateBundleUploadURL sanity-checks a management-supplied upload URL for a +// remote debug bundle job. It delegates to profilemanager.ValidateBundleUploadURL +// so the executor and the MDM policy override share one definition of the rule +// (empty accepted; otherwise a well-formed https URL with a host) and cannot +// drift. The host is deliberately left unconstrained pending a decision on +// management-directed uploads. +func validateBundleUploadURL(raw string) error { + return profilemanager.ValidateBundleUploadURL(raw) +} + // receiveManagementEvents connects to the Management Service event stream to receive updates from the management service // E.g. when a new peer has been registered and we are allowed to connect to it. func (e *Engine) receiveManagementEvents() { e.shutdownWg.Add(1) go func() { defer e.shutdownWg.Done() - info, err := system.GetInfoWithChecks(e.ctx, e.checks) - if err != nil { - log.Warnf("failed to get system info with checks: %v", err) - info = system.GetInfo(e.ctx) + info, ok := e.infoSource.Refresh(e.ctx, systemInfoTimeout, e.checks, e.overlayAddresses()...) + if !ok { + log.Warnf("posture checks not refreshed before the sync connect, sending the previous results") } - info.SetFlags( - e.config.RosenpassEnabled, - e.config.RosenpassPermissive, - &e.config.ServerSSHAllowed, - e.config.DisableClientRoutes, - e.config.DisableServerRoutes, - e.config.DisableDNS, - e.config.DisableFirewall, - e.config.BlockLANAccess, - e.config.BlockInbound, - e.config.LazyConnectionEnabled, - e.config.EnableSSHRoot, - e.config.EnableSSHSFTP, - e.config.EnableSSHLocalPortForwarding, - e.config.EnableSSHRemotePortForwarding, - e.config.DisableSSHAuth, - ) - - err = e.mgmClient.Sync(e.ctx, info, e.handleSync) + err := e.mgmClient.Sync(e.ctx, e.syncInfoFunc(info), e.handleSync) if err != nil { // happens if management is unavailable for a long time. // We want to cancel the operation of the whole client @@ -1222,8 +1566,12 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error { return nil } - if err := e.connMgr.UpdatedRemoteFeatureFlag(e.ctx, networkMap.GetPeerConfig().GetLazyConnectionEnabled()); err != nil { - log.Errorf("failed to update lazy connection feature flag: %v", err) + // Only update the flag when the sync carries a peer config; a nil peer config + // (e.g. a partial update) must not reset the cached flag to false. + if peerConfig := networkMap.GetPeerConfig(); peerConfig != nil { + if err := e.connMgr.UpdatedRemoteFeatureFlag(e.ctx, peerConfig.GetLazyConnectionEnabled()); err != nil { + log.Errorf("failed to update lazy connection feature flag: %v", err) + } } if e.firewall != nil { @@ -1247,15 +1595,18 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error { protoDNSConfig = &mgmProto.DNSConfig{} } - dnsConfig := toDNSConfig(protoDNSConfig, e.wgInterface.Address().Network) + dnsConfig := toDNSConfig(protoDNSConfig, e.wgInterface.Address()) + done := e.phase("dns_server") if err := e.dnsServer.UpdateDNSServer(serial, dnsConfig); err != nil { log.Errorf("failed to update dns server, err: %v", err) } + done() e.routeManager.SetDNSForwarderPort(dnsConfig.ForwarderPort) // apply routes first, route related actions might depend on routing being enabled + done = e.phase("routes_classify") routes := toRoutes(networkMap.GetRoutes()) serverRoutes, clientRoutes := e.routeManager.ClassifyRoutes(routes) @@ -1264,29 +1615,58 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error { e.connMgr.UpdateRouteHAMap(clientRoutes) log.Debugf("updated lazy connection manager with %d HA groups", len(clientRoutes)) } + done() + done = e.phase("routes_apply") dnsRouteFeatureFlag := toDNSFeatureFlag(networkMap) if err := e.routeManager.UpdateRoutes(serial, serverRoutes, clientRoutes, dnsRouteFeatureFlag); err != nil { log.Errorf("failed to update routes: %v", err) } + done() + done = e.phase("filtering") if e.acl != nil { e.acl.ApplyFiltering(networkMap, dnsRouteFeatureFlag) } + done() + done = e.phase("dns_forwarder") fwdEntries := toRouteDomains(e.config.WgPrivateKey.PublicKey().String(), routes) e.updateDNSForwarder(dnsRouteFeatureFlag, fwdEntries) + done() // Ingress forward rules - forwardingRules, err := e.updateForwardRules(networkMap.GetForwardingRules()) - if err != nil { + done = e.phase("forward_rules") + if _, err := e.updateForwardRules(networkMap.GetForwardingRules()); err != nil { log.Errorf("failed to update forward rules, err: %v", err) } + done() log.Debugf("got peers update from Management Service, total peers to connect to = %d", len(networkMap.GetRemotePeers())) + done = e.phase("offline_peers") e.updateOfflinePeers(networkMap.GetOfflinePeers()) + done() + remotePeers, err := e.reconcilePeers(networkMap) + if err != nil { + return err + } + + // must set the exclude list after the peers are added. Without it the manager can not figure out the peers parameters from the store + done = e.phase("lazy_exclude") + e.connMgr.SetExcludeList(e.ctx, e.toExcludedLazyPeers(remotePeers)) + done() + + e.networkSerial = serial + + return nil +} + +// reconcilePeers applies the remote peer list from the network map (removing, +// modifying and adding peers, then updating SSH config) and returns the remote +// peers with our own peer filtered out, for use by later sync steps. +func (e *Engine) reconcilePeers(networkMap *mgmProto.NetworkMap) ([]*mgmProto.RemotePeerConfig, error) { // Filter out own peer from the remote peers list localPubKey := e.config.WgPrivateKey.PublicKey().String() remotePeers := make([]*mgmProto.RemotePeerConfig, 0, len(networkMap.GetRemotePeers())) @@ -1301,45 +1681,43 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error { err := e.removeAllPeers() e.statusRecorder.FinishPeerListModifications() if err != nil { - return err + return nil, err } - } else { - err := e.removePeers(remotePeers) - if err != nil { - return err - } - - err = e.modifyPeers(remotePeers) - if err != nil { - return err - } - - err = e.addNewPeers(remotePeers) - if err != nil { - return err - } - - e.statusRecorder.FinishPeerListModifications() - - e.updatePeerSSHHostKeys(remotePeers) - - if err := e.updateSSHClientConfig(remotePeers); err != nil { - log.Warnf("failed to update SSH client config: %v", err) - } - - e.updateSSHServerAuth(networkMap.GetSshAuth()) + return remotePeers, nil } - // must set the exclude list after the peers are added. Without it the manager can not figure out the peers parameters from the store - excludedLazyPeers := e.toExcludedLazyPeers(forwardingRules, remotePeers) - e.connMgr.SetExcludeList(e.ctx, excludedLazyPeers) + done := e.phase("removed_peers") + err := e.removePeers(remotePeers) + done() + if err != nil { + return nil, err + } - e.networkSerial = serial + done = e.phase("modified_peers") + err = e.modifyPeers(remotePeers) + done() + if err != nil { + return nil, err + } - // 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. - go e.dnsServer.ProbeAvailability() - return nil + done = e.phase("added_peers") + err = e.addNewPeers(remotePeers) + done() + if err != nil { + return nil, err + } + + e.statusRecorder.FinishPeerListModifications() + + e.updatePeerSSHHostKeys(remotePeers) + + if err := e.updateSSHClientConfig(remotePeers); err != nil { + log.Warnf("failed to update SSH client config: %v", err) + } + + e.updateSSHServerAuth(networkMap.GetSshAuth()) + + return remotePeers, nil } func toDNSFeatureFlag(networkMap *mgmProto.NetworkMap) bool { @@ -1402,7 +1780,9 @@ func toRouteDomains(myPubKey string, routes []*route.Route) []*dnsfwd.ForwarderE return entries } -func toDNSConfig(protoDNSConfig *mgmProto.DNSConfig, network netip.Prefix) nbdns.Config { +func toDNSConfig(protoDNSConfig *mgmProto.DNSConfig, addr wgaddr.Address) nbdns.Config { + network := addr.Network + networkV6 := addr.IPv6Net //nolint forwarderPort := uint16(protoDNSConfig.GetForwarderPort()) if forwarderPort == 0 { @@ -1459,6 +1839,9 @@ func toDNSConfig(protoDNSConfig *mgmProto.DNSConfig, network netip.Prefix) nbdns if len(dnsUpdate.CustomZones) > 0 { addReverseZone(&dnsUpdate, network) + if networkV6.IsValid() { + addReverseZone(&dnsUpdate, networkV6) + } } return dnsUpdate @@ -1468,8 +1851,10 @@ func (e *Engine) updateOfflinePeers(offlinePeers []*mgmProto.RemotePeerConfig) { replacement := make([]peer.State, len(offlinePeers)) for i, offlinePeer := range offlinePeers { log.Debugf("added offline peer %s", offlinePeer.Fqdn) + v4, v6 := overlayAddrsFromAllowedIPs(offlinePeer.GetAllowedIps(), e.wgInterface.Address().IPv6Net) replacement[i] = peer.State{ - IP: strings.Join(offlinePeer.GetAllowedIps(), ","), + IP: addrToString(v4), + IPv6: addrToString(v6), PubKey: offlinePeer.GetWgPubKey(), FQDN: offlinePeer.GetFqdn(), ConnStatus: peer.StatusIdle, @@ -1480,18 +1865,49 @@ func (e *Engine) updateOfflinePeers(offlinePeers []*mgmProto.RemotePeerConfig) { e.statusRecorder.ReplaceOfflinePeers(replacement) } +// overlayAddrsFromAllowedIPs extracts the peer's v4 and v6 overlay addresses +// from AllowedIPs strings. Only host routes (/32, /128) are considered; v6 must +// fall within ourV6Net to distinguish overlay addresses from routed prefixes. +func overlayAddrsFromAllowedIPs(allowedIPs []string, ourV6Net netip.Prefix) (v4, v6 netip.Addr) { + for _, cidr := range allowedIPs { + prefix, err := netip.ParsePrefix(cidr) + if err != nil { + log.Warnf("failed to parse AllowedIP %q: %v", cidr, err) + continue + } + addr := prefix.Addr().Unmap() + switch { + case addr.Is4() && prefix.Bits() == 32 && !v4.IsValid(): + v4 = addr + case addr.Is6() && prefix.Bits() == 128 && ourV6Net.Contains(addr) && !v6.IsValid(): + v6 = addr + } + if v4.IsValid() && v6.IsValid() { + break + } + } + return +} + +func addrToString(addr netip.Addr) string { + if !addr.IsValid() { + return "" + } + return addr.String() +} + // addNewPeers adds peers that were not know before but arrived from the Management service with the update func (e *Engine) addNewPeers(peersUpdate []*mgmProto.RemotePeerConfig) error { for _, p := range peersUpdate { - err := e.addNewPeer(p) - if err != nil { + if err := e.addNewPeer(p); err != nil { return err } } return nil } -// addNewPeer add peer if connection doesn't exist +// addNewPeer add peer if connection doesn't exist. A peer that is not lazy by +// policy gets an always-active connection instead. func (e *Engine) addNewPeer(peerConfig *mgmProto.RemotePeerConfig) error { peerKey := peerConfig.GetWgPubKey() peerIPs := make([]netip.Prefix, 0, len(peerConfig.GetAllowedIps())) @@ -1505,20 +1921,29 @@ func (e *Engine) addNewPeer(peerConfig *mgmProto.RemotePeerConfig) error { log.Errorf("failed to parse allowedIPS: %v", err) return err } + if allowedNetIP.Addr().Is6() && !e.wgInterface.Address().HasIPv6() { + continue + } peerIPs = append(peerIPs, allowedNetIP) } + if len(peerIPs) == 0 { + return fmt.Errorf("peer %s has no usable AllowedIPs", peerKey) + } + conn, err := e.createPeerConn(peerKey, peerIPs, peerConfig.AgentVersion) if err != nil { return fmt.Errorf("create peer connection: %w", err) } - err = e.statusRecorder.AddPeer(peerKey, peerConfig.Fqdn, peerIPs[0].Addr().String()) + peerV4, peerV6 := overlayAddrsFromAllowedIPs(peerConfig.GetAllowedIps(), e.wgInterface.Address().IPv6Net) + err = e.statusRecorder.AddPeer(peerKey, peerConfig.Fqdn, addrToString(peerV4), addrToString(peerV6)) if err != nil { log.Warnf("error adding peer %s to status recorder, got error: %v", peerKey, err) } - if exists := e.connMgr.AddPeerConn(e.ctx, peerKey, conn); exists { + permanent := !e.connMgr.PeerLazyDefault(peerConfig.GetLazyState()) + if exists := e.connMgr.AddPeerConn(e.ctx, peerKey, conn, permanent); exists { conn.Close(false) return fmt.Errorf("peer already exists: %s", peerKey) } @@ -1552,6 +1977,7 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV PermissiveMode: e.config.RosenpassPermissive, }, ICEConfig: e.createICEConfig(), + NetMgr: e.netMgr, } serviceDependencies := peer.ServiceDependencies{ @@ -1578,7 +2004,7 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV } // receiveSignalEvents connects to the Signal Service event stream to negotiate connection with remote peers -func (e *Engine) receiveSignalEvents() { +func (e *Engine) receiveSignalEvents() error { e.shutdownWg.Add(1) go func() { defer e.shutdownWg.Done() @@ -1594,6 +2020,13 @@ func (e *Engine) receiveSignalEvents() { return e.ctx.Err() } + // Self-addressed heartbeat: the signal client's receive watchdog + // round-trips this through the server to confirm the receive stream + // is delivering. Liveness is already recorded before this handler. + if msg.GetBody().GetType() == sProto.Body_HEARTBEAT { + return nil + } + conn, ok := e.peerStore.PeerConn(msg.Key) if !ok { return fmt.Errorf("wrongly addressed message %s", msg.Key) @@ -1642,7 +2075,12 @@ func (e *Engine) receiveSignalEvents() { } }() - e.signal.WaitStreamConnected() + // todo: consider to remove this blocker. I do not see benefit to block the Start operations + e.signal.WaitStreamConnected(e.ctx) + if err := e.ctx.Err(); err != nil { + return fmt.Errorf("wait for signal stream: %w", err) + } + return nil } func (e *Engine) parseNATExternalIPMappings() []string { @@ -1698,6 +2136,11 @@ func (e *Engine) parseNATExternalIPMappings() []string { } func (e *Engine) close() { + if e.afpacketCapture != nil { + e.afpacketCapture.Stop() + e.afpacketCapture = nil + } + log.Debugf("removing Netbird interface %s", e.config.WgIfaceName) if e.wgInterface != nil { @@ -1724,41 +2167,18 @@ func (e *Engine) close() { if err := e.portForwardManager.GracefullyStop(ctx); err != nil { log.Warnf("failed to gracefully stop port forwarding manager: %s", err) } -} -func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, error) { - if runtime.GOOS != "android" { - // nolint:nilnil - return nil, nil, false, nil + // Drop any persisted sync response so its network map does not linger on + // disk after the engine stops (and cannot leak into a later run). + e.syncRespMux.Lock() + store := e.syncStore + e.syncStore = nil + e.syncRespMux.Unlock() + if store != nil { + if err := store.Clear(); err != nil { + log.Warnf("failed to clear persisted sync response on close: %v", err) + } } - - info := system.GetInfo(e.ctx) - info.SetFlags( - e.config.RosenpassEnabled, - e.config.RosenpassPermissive, - &e.config.ServerSSHAllowed, - e.config.DisableClientRoutes, - e.config.DisableServerRoutes, - e.config.DisableDNS, - e.config.DisableFirewall, - e.config.BlockLANAccess, - e.config.BlockInbound, - e.config.LazyConnectionEnabled, - e.config.EnableSSHRoot, - e.config.EnableSSHSFTP, - e.config.EnableSSHLocalPortForwarding, - e.config.EnableSSHRemotePortForwarding, - e.config.DisableSSHAuth, - ) - - netMap, err := e.mgmClient.GetNetworkMap(info) - if err != nil { - return nil, nil, false, err - } - routes := toRoutes(netMap.GetRoutes()) - dnsCfg := toDNSConfig(netMap.GetDNSConfig(), e.wgInterface.Address().Network) - dnsFeatureFlag := toDNSFeatureFlag(netMap) - return routes, &dnsCfg, dnsFeatureFlag, nil } func (e *Engine) newWgIface() (*iface.WGIface, error) { @@ -1774,7 +2194,6 @@ func (e *Engine) newWgIface() (*iface.WGIface, error) { WGPrivKey: e.config.WgPrivateKey.String(), MTU: e.config.MTU, TransportNet: transportNet, - FilterFn: e.addrViaRoutes, DisableDNS: e.config.DisableDNS, } @@ -1796,9 +2215,12 @@ func (e *Engine) newWgIface() (*iface.WGIface, error) { func (e *Engine) wgInterfaceCreate() (err error) { switch runtime.GOOS { case "android": - err = e.wgInterface.CreateOnAndroid(e.routeManager.InitialRouteRange(), e.dnsServer.DnsIP().String(), e.dnsServer.SearchDomains()) + err = e.wgInterface.CreateOnAndroid(e.routeManager.CurrentRouteRange(), e.dnsServer.DnsIP().String(), e.dnsServer.SearchDomains()) case "ios": - e.mobileDep.NetworkChangeListener.SetInterfaceIP(e.config.WgAddr) + e.mobileDep.NetworkChangeListener.SetInterfaceIP(e.config.WgAddr.String()) + if e.config.WgAddr.HasIPv6() { + e.mobileDep.NetworkChangeListener.SetInterfaceIPv6(e.config.WgAddr.IPv6String()) + } err = e.wgInterface.Create() default: err = e.wgInterface.Create() @@ -1806,7 +2228,7 @@ func (e *Engine) wgInterfaceCreate() (err error) { return err } -func (e *Engine) newDnsServer(dnsConfig *nbdns.Config) (dns.Server, error) { +func (e *Engine) newDnsServer() (dns.Server, error) { // due to tests where we are using a mocked version of the DNS server if e.dnsServer != nil { return e.dnsServer, nil @@ -1818,7 +2240,7 @@ func (e *Engine) newDnsServer(dnsConfig *nbdns.Config) (dns.Server, error) { e.ctx, e.wgInterface, e.mobileDep.HostDNSAddresses, - *dnsConfig, + nbdns.Config{}, e.mobileDep.NetworkChangeListener, e.statusRecorder, e.config.DisableDNS, @@ -1827,7 +2249,7 @@ func (e *Engine) newDnsServer(dnsConfig *nbdns.Config) (dns.Server, error) { return dnsServer, nil case "ios": - dnsServer := dns.NewDefaultServerIos(e.ctx, e.wgInterface, e.mobileDep.DnsManager, e.mobileDep.HostDNSAddresses, e.statusRecorder, e.config.DisableDNS) + dnsServer := dns.NewDefaultServerIos(e.ctx, e.wgInterface, e.mobileDep.DnsManager, e.statusRecorder, e.config.DisableDNS) return dnsServer, nil default: @@ -1874,6 +2296,29 @@ func (e *Engine) GetClientMetrics() *metrics.ClientMetrics { return e.clientMetrics } +// Performance bundles runtime-adjustable tunnel pool knobs. +// See Engine.SetPerformance. Nil fields are ignored. +type Performance struct { + PreallocatedBuffersPerPool *uint32 +} + +// SetPerformance applies the given tuning to this engine's live Device. +func (e *Engine) SetPerformance(t Performance) error { + e.syncMsgMux.Lock() + defer e.syncMsgMux.Unlock() + if e.wgInterface == nil { + return fmt.Errorf("wg interface not initialized") + } + dev := e.wgInterface.GetWGDevice() + if dev == nil { + return fmt.Errorf("wg device not initialized") + } + if t.PreallocatedBuffersPerPool != nil { + dev.SetPreallocatedBuffersPerPool(*t.PreallocatedBuffersPerPool) + } + return nil +} + func findIPFromInterfaceName(ifaceName string) (net.IP, error) { iface, err := net.InterfaceByName(ifaceName) if err != nil { @@ -1911,7 +2356,20 @@ func (e *Engine) getRosenpassAddr() string { // RunHealthProbes executes health checks for Signal, Management, Relay, and WireGuard services // and updates the status recorder with the latest states. -func (e *Engine) RunHealthProbes(waitForResult bool) bool { +// +// ctx scopes the (potentially slow) STUN/TURN probing: a caller that gives up — +// e.g. a Status RPC whose client disconnected — cancels its ctx and the probe +// returns instead of running to its per-component timeout. The engine's own +// lifetime ctx still applies independently, so an engine shutdown aborts the +// probe even if the caller's ctx is context.Background(). +func (e *Engine) RunHealthProbes(ctx context.Context, waitForResult bool) bool { + // Tie the caller's ctx to the engine lifetime: either cancelling aborts + // the probe below. + ctx, cancel := context.WithCancel(ctx) + defer cancel() + stop := context.AfterFunc(e.ctx, cancel) + defer stop() + e.syncMsgMux.Lock() signalHealthy := e.signal.IsHealthy() @@ -1934,9 +2392,9 @@ func (e *Engine) RunHealthProbes(waitForResult bool) bool { if runtime.GOOS != "js" { var results []relay.ProbeResult if waitForResult { - results = e.probeStunTurn.ProbeAllWaitResult(e.ctx, stuns, turns) + results = e.probeStunTurn.ProbeAllWaitResult(ctx, stuns, turns) } else { - results = e.probeStunTurn.ProbeAll(e.ctx, stuns, turns) + results = e.probeStunTurn.ProbeAll(ctx, stuns, turns) } e.statusRecorder.UpdateRelayStates(results) @@ -1996,21 +2454,6 @@ func (e *Engine) startNetworkMonitor() { }() } -func (e *Engine) addrViaRoutes(addr netip.Addr) (bool, netip.Prefix, error) { - var vpnRoutes []netip.Prefix - for _, routes := range e.routeManager.GetClientRoutes() { - if len(routes) > 0 && routes[0] != nil { - vpnRoutes = append(vpnRoutes, routes[0].Network) - } - } - - if isVpn, prefix := systemops.IsAddrRouted(addr, vpnRoutes); isVpn { - return true, prefix, nil - } - - return false, netip.Prefix{}, nil -} - func (e *Engine) stopDNSServer() { if e.dnsServer == nil { return @@ -2026,45 +2469,42 @@ func (e *Engine) stopDNSServer() { e.statusRecorder.UpdateDNSStates(nsGroupStates) } -// SetSyncResponsePersistence enables or disables sync response persistence +// SetSyncResponsePersistence enables or disables sync response persistence. +// The store is only instantiated while persistence is enabled; construction +// itself drops any stale data left over from an earlier run (see syncstore). func (e *Engine) SetSyncResponsePersistence(enabled bool) { e.syncRespMux.Lock() defer e.syncRespMux.Unlock() - if enabled == e.persistSyncResponse { + if enabled == (e.syncStore != nil) { return } - e.persistSyncResponse = enabled log.Debugf("Sync response persistence is set to %t", enabled) if !enabled { - e.latestSyncResponse = nil + if err := e.syncStore.Clear(); err != nil { + log.Warnf("failed to clear persisted sync response: %v", err) + } + e.syncStore = nil + return } + + e.syncStore = syncstore.New(e.syncStoreDir) } // GetLatestSyncResponse returns the stored sync response if persistence is enabled func (e *Engine) GetLatestSyncResponse() (*mgmProto.SyncResponse, error) { + // Hold the lock for the whole Get so the store cannot be cleared + // (disabled / engine close) mid-call. e.syncRespMux.RLock() - enabled := e.persistSyncResponse - latest := e.latestSyncResponse - e.syncRespMux.RUnlock() + defer e.syncRespMux.RUnlock() - if !enabled { + if e.syncStore == nil { return nil, errors.New("sync response persistence is disabled") } - if latest == nil { - //nolint:nilnil - return nil, nil - } - - log.Debugf("Retrieving latest sync response with size %d bytes", proto.Size(latest)) - sr, ok := proto.Clone(latest).(*mgmProto.SyncResponse) - if !ok { - return nil, fmt.Errorf("failed to clone sync response") - } - - return sr, nil + //nolint:nilnil + return e.syncStore.Get() } // GetWgAddr returns the wireguard address @@ -2075,6 +2515,14 @@ func (e *Engine) GetWgAddr() netip.Addr { return e.wgInterface.Address().IP } +// GetWgV6Addr returns the IPv6 overlay address of the WireGuard interface. +func (e *Engine) GetWgV6Addr() netip.Addr { + if e.wgInterface == nil { + return netip.Addr{} + } + return e.wgInterface.Address().IPv6 +} + func (e *Engine) RenewTun(fd int) error { e.syncMsgMux.Lock() wgInterface := e.wgInterface @@ -2084,7 +2532,72 @@ func (e *Engine) RenewTun(fd int) error { return fmt.Errorf("wireguard interface not initialized") } - return wgInterface.RenewTun(fd) + if err := wgInterface.RenewTun(fd); err != nil { + return err + } + + e.rebindOverlayListeners() + return nil +} + +// rebindOverlayListeners gives the servers that listen on an overlay address +// sockets on the interface as it is now. +// +// A socket belongs to the interface generation it was created on. Renewing the +// TUN builds a new interface and moves the overlay addresses to it, which +// leaves the old sockets in LISTEN with the uspfilter still logging packets +// arriving for them, while every accept fails with EINVAL for the life of the +// socket: from the outside the server looks alive and answers nothing. On +// Android this happens during a normal startup, where the first TUN is +// established before the routes are known and replaced once they arrive. +// +// Rebinding costs whatever those sockets were carrying, which the renewal has +// already broken. Errors are logged rather than returned: the renewal itself +// succeeded, and failing it would hand the caller a working interface and an +// error. +func (e *Engine) rebindOverlayListeners() { + e.syncMsgMux.Lock() + defer e.syncMsgMux.Unlock() + + for _, rebind := range e.overlayRebinds() { + if err := rebind(); err != nil { + log.Errorf("after TUN renewal: %v", err) + } + } +} + +// overlayRebinds is every subsystem of this engine that holds sockets bound to +// an overlay address, and how to rebuild each one's. +// +// A subsystem that starts listening on an overlay address belongs in this list. +// Leaving it out costs nothing that review would notice and produces a listener +// that stays in LISTEN, is logged as receiving packets, and refuses every +// connection for the life of the process. +func (e *Engine) overlayRebinds() []overlayRebind { + return []overlayRebind{ + e.restartSSHListeners, + e.restartDNSForwarder, + } +} + +// restartDNSForwarder rebuilds the DNS forwarder serving the same domains. +// No-op when it is not running. See Engine.rebindOverlayListeners. +func (e *Engine) restartDNSForwarder() error { + if e.dnsForwardMgr == nil { + return nil + } + // Read from the forwarder before it goes away, so the replacement serves + // the domains in force now rather than a copy kept somewhere else. + entries := e.dnsForwardMgr.Domains() + e.stopDNSForwarder() + // Both halves log their own failures, so the only thing left to report is + // the outcome: a start that failed left the manager nil, and the forwarder + // is now down rather than merely rebound. + e.startDNSForwarder(entries) + if e.dnsForwardMgr == nil { + return errors.New("rebind DNS forwarder: it did not come back up") + } + return nil } // updateDNSForwarder start or stop the DNS forwarder based on the domains and the feature flag @@ -2092,7 +2605,7 @@ func (e *Engine) updateDNSForwarder( enabled bool, fwdEntries []*dnsfwd.ForwarderEntry, ) { - if e.config.DisableServerRoutes { + if e.config.DisableServerRoutes || e.config.BlockInbound { return } @@ -2130,7 +2643,14 @@ func (e *Engine) stopDNSForwarder() { return } - if err := e.dnsForwardMgr.Stop(context.Background()); err != nil { + // Bounded because the shutdown waits for queries still in flight, and one + // waiting on an unresponsive upstream holds it for as long as that lookup + // is allowed to take. This runs with syncMsgMux held, so that wait is one + // the whole engine spends. + ctx, cancel := context.WithTimeout(context.Background(), dnsForwarderStopTimeout) + defer cancel() + + if err := e.dnsForwardMgr.Stop(ctx); err != nil { log.Errorf("failed to stop DNS forward: %v", err) } @@ -2163,6 +2683,62 @@ func (e *Engine) Address() (netip.Addr, error) { return e.wgInterface.Address().IP, nil } +// SetCapture sets or clears packet capture on the WireGuard device. +// On userspace WireGuard, it taps the FilteredDevice directly. +// On kernel WireGuard (Linux), it falls back to AF_PACKET raw socket capture. +// Pass nil to disable capture. +func (e *Engine) SetCapture(pc device.PacketCapture) error { + e.syncMsgMux.Lock() + defer e.syncMsgMux.Unlock() + + intf := e.wgInterface + if intf == nil { + return errors.New("wireguard interface not initialized") + } + + if e.afpacketCapture != nil { + e.afpacketCapture.Stop() + e.afpacketCapture = nil + } + + dev := intf.GetDevice() + if dev != nil { + dev.SetCapture(pc) + e.setForwarderCapture(pc) + return nil + } + + // Kernel mode: no FilteredDevice. Use AF_PACKET on Linux. + if pc == nil { + return nil + } + sess, ok := pc.(*capture.Session) + if !ok { + return errors.New("filtered device not available and AF_PACKET requires *capture.Session") + } + + afc := capture.NewAFPacketCapture(intf.Name(), sess) + if err := afc.Start(); err != nil { //nolint:staticcheck // always errors on non-Linux builds + return fmt.Errorf("start AF_PACKET capture on %s: %w", intf.Name(), err) + } + e.afpacketCapture = afc + return nil +} + +// setForwarderCapture propagates capture to the USP filter's forwarder endpoint. +// This captures outbound response packets that bypass the FilteredDevice in netstack mode. +func (e *Engine) setForwarderCapture(pc device.PacketCapture) { + if e.firewall == nil { + return + } + type forwarderCapturer interface { + SetPacketCapture(pc forwarder.PacketCapture) + } + if fc, ok := e.firewall.(forwarderCapturer); ok { + fc.SetPacketCapture(pc) + } +} + func (e *Engine) updateForwardRules(rules []*mgmProto.ForwardingRule) ([]firewallManager.ForwardRule, error) { if e.firewall == nil { log.Warn("firewall is disabled, not updating forwarding rules") @@ -2189,7 +2765,7 @@ func (e *Engine) updateForwardRules(rules []*mgmProto.ForwardingRule) ([]firewal var merr *multierror.Error forwardingRules := make([]firewallManager.ForwardRule, 0, len(rules)) for _, rule := range rules { - proto, err := convertToFirewallProtocol(rule.GetProtocol()) + proto, err := acl.ConvertToFirewallProtocol(rule.GetProtocol()) if err != nil { merr = multierror.Append(merr, fmt.Errorf("failed to convert protocol '%s': %w", rule.GetProtocol(), err)) continue @@ -2231,21 +2807,16 @@ func (e *Engine) updateForwardRules(rules []*mgmProto.ForwardingRule) ([]firewal return forwardingRules, nberrors.FormatErrorOrNil(merr) } -func (e *Engine) toExcludedLazyPeers(rules []firewallManager.ForwardRule, peers []*mgmProto.RemotePeerConfig) map[string]bool { +// toExcludedLazyPeers returns the peers that must have an always-active +// connection: those that are not lazy by policy (the per-peer lazy state or the +// account flag, subject to the local override). +func (e *Engine) toExcludedLazyPeers(peers []*mgmProto.RemotePeerConfig) map[string]bool { excludedPeers := make(map[string]bool) - for _, r := range rules { - ip := r.TranslatedAddress - for _, p := range peers { - for _, allowedIP := range p.GetAllowedIps() { - if allowedIP != ip.String() { - continue - } - log.Infof("exclude forwarder peer from lazy connection: %s", p.GetWgPubKey()) - excludedPeers[p.GetWgPubKey()] = true - } + for _, p := range peers { + if !e.connMgr.PeerLazyDefault(p.GetLazyState()) { + excludedPeers[p.GetWgPubKey()] = true } } - return excludedPeers } @@ -2300,8 +2871,7 @@ func getInterfacePrefixes() ([]netip.Prefix, error) { prefix := netip.PrefixFrom(addr.Unmap(), ones).Masked() ip := prefix.Addr() - // TODO: add IPv6 - if !ip.Is4() || ip.IsLoopback() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + if ip.IsLoopback() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { continue } @@ -2312,6 +2882,24 @@ func getInterfacePrefixes() ([]netip.Prefix, error) { return prefixes, nberrors.FormatErrorOrNil(merr) } +// filterAllowedIPs strips IPv6 entries when the local interface has no v6 address. +// This covers both the explicit --disable-ipv6 flag (v6 never assigned) and the +// case where OS v6 assignment failed (ClearIPv6). Without this, WireGuard would +// accept v6 traffic that the native firewall cannot filter. +func (e *Engine) filterAllowedIPs(ips []string) []string { + if e.wgInterface.Address().HasIPv6() { + return ips + } + filtered := make([]string, 0, len(ips)) + for _, s := range ips { + p, err := netip.ParsePrefix(s) + if err != nil || !p.Addr().Is6() { + filtered = append(filtered, s) + } + } + return filtered +} + // compareNetIPLists compares a list of netip.Prefix with a list of strings. // return true if both lists are equal, false otherwise. func compareNetIPLists(list1 []netip.Prefix, list2 []string) bool { @@ -2384,6 +2972,8 @@ func convertToOfferAnswer(msg *sProto.Message) (*peer.OfferAnswer, error) { } } + relayIP := decodeRelayIP(msg.GetBody().GetRelayServerIP()) + offerAnswer := peer.OfferAnswer{ IceCredentials: peer.IceCredentials{ UFrag: remoteCred.UFrag, @@ -2394,7 +2984,23 @@ func convertToOfferAnswer(msg *sProto.Message) (*peer.OfferAnswer, error) { RosenpassPubKey: rosenpassPubKey, RosenpassAddr: rosenpassAddr, RelaySrvAddress: msg.GetBody().GetRelayServerAddress(), + RelaySrvIP: relayIP, SessionID: sessionID, } return &offerAnswer, nil } + +// decodeRelayIP decodes the proto relayServerIP bytes (4 or 16) into a +// netip.Addr. Returns the zero value for empty input and logs a warning +// for malformed payloads. +func decodeRelayIP(b []byte) netip.Addr { + if len(b) == 0 { + return netip.Addr{} + } + ip, ok := netip.AddrFromSlice(b) + if !ok { + log.Warnf("invalid relayServerIP in signal message (%d bytes), ignoring", len(b)) + return netip.Addr{} + } + return ip.Unmap() +} diff --git a/client/internal/engine_authsession.go b/client/internal/engine_authsession.go new file mode 100644 index 000000000..725c0903f --- /dev/null +++ b/client/internal/engine_authsession.go @@ -0,0 +1,108 @@ +package internal + +import ( + "context" + "errors" + "fmt" + "time" + + log "github.com/sirupsen/logrus" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/netbirdio/netbird/client/internal/auth/sessionwatch" + cProto "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/client/system" +) + +// ApplySessionDeadline propagates the absolute SSO session deadline carried on +// LoginResponse / SyncResponse to both the watcher (for the edge-triggered +// warning) and the status recorder (for the SubscribeStatus / Status RPC +// snapshot the UI consumes). +// +// The wire field is 3-state: +// - nil → snapshot carries no info; keep the +// previously-anchored deadline (no-op) +// - explicit zero (s=0, n=0) → peer is not SSO-registered or expiry is +// disabled; clear both sinks +// - valid timestamp → new deadline; arm watcher, expose on +// status recorder +// +// Deadline sanity-checks live in sessionwatch.Watcher.Update. Any rejected +// value is treated as a clear on both sinks: the alternative — leaving the +// previously-known deadline in place — risks the UI confidently displaying +// a stale "expires in X" while the server has actually invalidated it. +func (e *Engine) ApplySessionDeadline(ts *timestamppb.Timestamp) { + if ts == nil { + return + } + var deadline time.Time + // Explicit zero (seconds=0 AND nanos=0) is the sentinel for "disabled". + // Everything else flows through Watcher.Update, whose sanity-checks + // reject out-of-range / pre-epoch / far-future / too-stale values and + // clear on rejection. + if ts.GetSeconds() != 0 || ts.GetNanos() != 0 { + deadline = ts.AsTime().UTC() + } + if e.sessionWatcher == nil { + return + } + // Watcher.Update owns the propagation to the status recorder (the + // SubscribeStatus / Status snapshot the UI reads): a set writes the + // deadline, a clear or a sanity-check rejection writes the zero value. + // Keeping a single writer is what stops the recorder from drifting out + // of sync with the warning timers. + if err := e.sessionWatcher.Update(deadline); err != nil { + log.Errorf("auth session deadline rejected: %v, clearing", err) + e.statusRecorder.PublishEvent( + cProto.SystemEvent_ERROR, + cProto.SystemEvent_AUTHENTICATION, + "session deadline rejected", + "", + map[string]string{sessionwatch.MetaSessionDeadlineRejected: err.Error()}, + ) + } +} + +// DismissSessionWarning records the user's "Dismiss" click on the +// T-WarningLead interactive notification and suppresses the upcoming +// T-FinalWarningLead fallback for the current deadline. No-op when the +// watcher is not running or holds no deadline. +func (e *Engine) DismissSessionWarning() { + if e.sessionWatcher == nil { + return + } + e.sessionWatcher.Dismiss() +} + +// ExtendAuthSession asks the management server to refresh the SSO session +// expiry deadline using the supplied JWT, then mirrors the new deadline into +// the daemon's state. The tunnel is untouched; no resync, no reconnect. +// +// Returns the new absolute UTC deadline (or zero time when the server +// reports the peer is not eligible for extension). +func (e *Engine) ExtendAuthSession(ctx context.Context, jwtToken string) (time.Time, error) { + if jwtToken == "" { + return time.Time{}, errors.New("jwt token is required") + } + if e.mgmClient == nil { + return time.Time{}, errors.New("management client is not initialised") + } + + info, err := system.GetInfoWithChecks(ctx, e.checks) + if err != nil { + log.Warnf("failed to collect system info for session extend: %v", err) + info = system.GetInfo(ctx) + } + + resp, err := e.mgmClient.ExtendAuthSession(info, jwtToken) + if err != nil { + return time.Time{}, fmt.Errorf("extend auth session on management: %w", err) + } + + e.ApplySessionDeadline(resp.GetSessionExpiresAt()) + + if resp.GetSessionExpiresAt().IsValid() { + return resp.GetSessionExpiresAt().AsTime().UTC(), nil + } + return time.Time{}, nil +} diff --git a/client/internal/engine_bundle_test.go b/client/internal/engine_bundle_test.go new file mode 100644 index 000000000..20b40a8a6 --- /dev/null +++ b/client/internal/engine_bundle_test.go @@ -0,0 +1,36 @@ +package internal + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestValidateBundleUploadURL covers the sanity check applied to a +// management-supplied upload URL before a remote debug bundle is generated. +func TestValidateBundleUploadURL(t *testing.T) { + for _, tc := range []struct { + name string + raw string + wantErr bool + }{ + {name: "empty falls back to default", raw: ""}, + {name: "https with host", raw: "https://upload.debug.netbird.io/upload"}, + {name: "https self-hosted host", raw: "https://upload.example.com"}, + {name: "plaintext rejected", raw: "http://upload.example.com", wantErr: true}, + {name: "missing host rejected", raw: "https:///upload", wantErr: true}, + {name: "port-only authority rejected", raw: "https://:443", wantErr: true}, + {name: "non-url scheme rejected", raw: "ftp://upload.example.com", wantErr: true}, + {name: "garbage rejected", raw: "://not a url", wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + err := validateBundleUploadURL(tc.raw) + if tc.wantErr { + require.Error(t, err, "an invalid upload URL must be rejected") + return + } + assert.NoError(t, err, "a valid or empty upload URL must be accepted") + }) + } +} diff --git a/client/internal/engine_privileged_test.go b/client/internal/engine_privileged_test.go new file mode 100644 index 000000000..181457ba9 --- /dev/null +++ b/client/internal/engine_privileged_test.go @@ -0,0 +1,565 @@ +//go:build privileged + +package internal + +import ( + "context" + "fmt" + "net" + "runtime" + "strings" + "sync" + "testing" + "time" + + "github.com/google/uuid" + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + "go.uber.org/mock/gomock" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + "google.golang.org/grpc" + "google.golang.org/grpc/keepalive" + + "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/device" + "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/internal/dns" + "github.com/netbirdio/netbird/client/internal/peer" + nbssh "github.com/netbirdio/netbird/client/ssh" + "github.com/netbirdio/netbird/client/system" + nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" + "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel" + "github.com/netbirdio/netbird/management/internals/modules/peers" + "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager" + "github.com/netbirdio/netbird/management/internals/modules/permissions" + "github.com/netbirdio/netbird/management/internals/server/config" + nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + "github.com/netbirdio/netbird/management/server" + "github.com/netbirdio/netbird/management/server/activity" + nbcache "github.com/netbirdio/netbird/management/server/cache" + "github.com/netbirdio/netbird/management/server/groups" + "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator" + "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" + "github.com/netbirdio/netbird/management/server/job" + "github.com/netbirdio/netbird/management/server/settings" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/telemetry" + "github.com/netbirdio/netbird/management/server/types" + mgmt "github.com/netbirdio/netbird/shared/management/client" + mgmtProto "github.com/netbirdio/netbird/shared/management/proto" + relayClient "github.com/netbirdio/netbird/shared/relay/client" + signal "github.com/netbirdio/netbird/shared/signal/client" + "github.com/netbirdio/netbird/shared/signal/proto" + signalServer "github.com/netbirdio/netbird/signal/server" + "github.com/netbirdio/netbird/util" +) + +func TestEngine_SSH(t *testing.T) { + key, err := wgtypes.GeneratePrivateKey() + if err != nil { + t.Fatal(err) + return + } + + sshKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + if err != nil { + t.Fatal(err) + return + } + + ctx, cancel := context.WithCancel(CtxInitState(context.Background())) + defer cancel() + + relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) + engine := NewEngine( + ctx, cancel, + &EngineConfig{ + WgIfaceName: "utun101", + WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"), + WgPrivateKey: key, + WgPort: 33100, + ServerSSHAllowed: true, + MTU: iface.DefaultMTU, + SSHKey: sshKey, + }, + EngineServices{ + SignalClient: &signal.MockClient{}, + MgmClient: &mgmt.MockClient{}, + RelayManager: relayMgr, + StatusRecorder: peer.NewRecorder("https://mgm"), + }, + MobileDependency{}, + ) + + engine.dnsServer = &dns.MockServer{ + UpdateDNSServerFunc: func(serial uint64, update nbdns.Config) error { return nil }, + } + + err = engine.Start(nil, nil) + require.NoError(t, err) + + defer func() { + err := engine.Stop() + if err != nil { + return + } + }() + + peerWithSSH := &mgmtProto.RemotePeerConfig{ + WgPubKey: "MNHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=", + AllowedIps: []string{"100.64.0.21/24"}, + SshConfig: &mgmtProto.SSHConfig{ + SshPubKey: []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFATYCqaQw/9id1Qkq3n16JYhDhXraI6Pc1fgB8ynEfQ"), + }, + } + + // SSH server is not enabled so SSH config of a remote peer should be ignored + networkMap := &mgmtProto.NetworkMap{ + Serial: 6, + PeerConfig: nil, + RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH}, + RemotePeersIsEmpty: false, + } + + err = engine.updateNetworkMap(networkMap) + require.NoError(t, err) + + assert.Nil(t, engine.sshServer) + + // SSH server is enabled, therefore SSH config should be applied + networkMap = &mgmtProto.NetworkMap{ + Serial: 7, + PeerConfig: &mgmtProto.PeerConfig{Address: "100.64.0.1/24", + SshConfig: &mgmtProto.SSHConfig{ + SshEnabled: true, + JwtConfig: &mgmtProto.JWTConfig{ + Issuer: "test-issuer", + Audience: "test-audience", + KeysLocation: "test-keys", + MaxTokenAge: 3600, + }, + }}, + RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH}, + RemotePeersIsEmpty: false, + } + + err = engine.updateNetworkMap(networkMap) + require.NoError(t, err) + + time.Sleep(250 * time.Millisecond) + assert.NotNil(t, engine.sshServer) + + // now remove peer + networkMap = &mgmtProto.NetworkMap{ + Serial: 8, + RemotePeers: []*mgmtProto.RemotePeerConfig{}, + RemotePeersIsEmpty: false, + } + + err = engine.updateNetworkMap(networkMap) + require.NoError(t, err) + + // time.Sleep(250 * time.Millisecond) + assert.NotNil(t, engine.sshServer) + + // now disable SSH server + networkMap = &mgmtProto.NetworkMap{ + Serial: 9, + PeerConfig: &mgmtProto.PeerConfig{Address: "100.64.0.1/24", + SshConfig: &mgmtProto.SSHConfig{SshEnabled: false}}, + RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH}, + RemotePeersIsEmpty: false, + } + + err = engine.updateNetworkMap(networkMap) + require.NoError(t, err) + + assert.Nil(t, engine.sshServer) +} + +func TestEngine_Sync(t *testing.T) { + key, err := wgtypes.GeneratePrivateKey() + if err != nil { + t.Fatal(err) + return + } + + ctx, cancel := context.WithCancel(CtxInitState(context.Background())) + defer cancel() + + // feed updates to Engine via mocked Management client + updates := make(chan *mgmtProto.SyncResponse) + defer close(updates) + syncFunc := func(ctx context.Context, _ func(context.Context) *system.Info, msgHandler func(msg *mgmtProto.SyncResponse) error) error { + for msg := range updates { + err := msgHandler(msg) + if err != nil { + t.Fatal(err) + } + } + return nil + } + relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) + engine := NewEngine(ctx, cancel, &EngineConfig{ + WgIfaceName: "utun103", + WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"), + WgPrivateKey: key, + WgPort: 33100, + MTU: iface.DefaultMTU, + }, EngineServices{ + SignalClient: &signal.MockClient{}, + MgmClient: &mgmt.MockClient{SyncFunc: syncFunc}, + RelayManager: relayMgr, + StatusRecorder: peer.NewRecorder("https://mgm"), + }, MobileDependency{}) + engine.ctx = ctx + + engine.dnsServer = &dns.MockServer{ + UpdateDNSServerFunc: func(serial uint64, update nbdns.Config) error { return nil }, + } + + defer func() { + err := engine.Stop() + if err != nil { + return + } + }() + + err = engine.Start(nil, nil) + if err != nil { + t.Fatal(err) + return + } + + peer1 := &mgmtProto.RemotePeerConfig{ + WgPubKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=", + AllowedIps: []string{"100.64.0.10/24"}, + } + peer2 := &mgmtProto.RemotePeerConfig{ + WgPubKey: "LLHf3Ma6z6mdLbriAJbqhX9+nM/B71lgw2+91q3LlhU=", + AllowedIps: []string{"100.64.0.11/24"}, + } + peer3 := &mgmtProto.RemotePeerConfig{ + WgPubKey: "GGHf3Ma6z6mdLbriAJbqhX9+nM/B71lgw2+91q3LlhU=", + AllowedIps: []string{"100.64.0.12/24"}, + } + // 1st update with just 1 peer and serial larger than the current serial of the engine => apply update + updates <- &mgmtProto.SyncResponse{ + NetworkMap: &mgmtProto.NetworkMap{ + Serial: 10, + PeerConfig: nil, + RemotePeers: []*mgmtProto.RemotePeerConfig{peer1, peer2, peer3}, + RemotePeersIsEmpty: false, + }, + } + + timeout := time.After(time.Second * 2) + for { + select { + case <-timeout: + t.Fatalf("timeout while waiting for test to finish") + return + default: + } + + if getPeers(engine) == 3 && engine.networkSerial == 10 { + break + } + } +} + +func TestEngine_MultiplePeers(t *testing.T) { + // log.SetLevel(log.DebugLevel) + + ctx, cancel := context.WithCancel(CtxInitState(context.Background())) + defer cancel() + + sigServer, signalAddr, err := startSignal(t) + if err != nil { + t.Fatal(err) + return + } + defer sigServer.Stop() + mgmtServer, mgmtAddr, err := startManagement(t, t.TempDir(), "../testdata/store.sql") + if err != nil { + t.Fatal(err) + return + } + defer mgmtServer.GracefulStop() + + setupKey := "A2C8E62B-38F5-4553-B31E-DD66C696CEBB" + + mu := sync.Mutex{} + engines := []*Engine{} + numPeers := 10 + wg := sync.WaitGroup{} + wg.Add(numPeers) + // create and start peers + for i := 0; i < numPeers; i++ { + j := i + go func() { + engine, err := createEngine(ctx, cancel, setupKey, j, mgmtAddr, signalAddr) + if err != nil { + wg.Done() + t.Errorf("unable to create the engine for peer %d with error %v", j, err) + return + } + engine.dnsServer = &dns.MockServer{} + mu.Lock() + defer mu.Unlock() + guid := fmt.Sprintf("{%s}", uuid.New().String()) + device.CustomWindowsGUIDString = strings.ToLower(guid) + err = engine.Start(nil, nil) + if err != nil { + t.Errorf("unable to start engine for peer %d with error %v", j, err) + wg.Done() + return + } + engines = append(engines, engine) + wg.Done() + }() + } + + // wait until all have been created and started + wg.Wait() + if len(engines) != numPeers { + t.Fatal("not all peers were started") + } + // check whether all the peer have expected peers connected + + expectedConnected := numPeers * (numPeers - 1) + + // adjust according to timeouts + timeout := 50 * time.Second + timeoutChan := time.After(timeout) + ticker := time.NewTicker(time.Second) + defer ticker.Stop() +loop: + for { + select { + case <-timeoutChan: + t.Fatalf("waiting for expected connections timeout after %s", timeout.String()) + break loop + case <-ticker.C: + totalConnected := 0 + for _, engine := range engines { + totalConnected += getConnectedPeers(engine) + } + if totalConnected == expectedConnected { + log.Infof("total connected=%d", totalConnected) + break loop + } + log.Infof("total connected=%d", totalConnected) + } + } + // cleanup test + for n, peerEngine := range engines { + t.Logf("stopping peer with interface %s from multipeer test, loopIndex %d", peerEngine.wgInterface.Name(), n) + errStop := peerEngine.mgmClient.Close() + if errStop != nil { + log.Infoln("got error trying to close management clients from engine: ", errStop) + } + errStop = peerEngine.Stop() + if errStop != nil { + log.Infoln("got error trying to close testing peers engine: ", errStop) + } + } +} + +var ( + kaep = keepalive.EnforcementPolicy{ + MinTime: 15 * time.Second, + PermitWithoutStream: true, + } + + kasp = keepalive.ServerParameters{ + MaxConnectionIdle: 15 * time.Second, + MaxConnectionAgeGrace: 5 * time.Second, + Time: 5 * time.Second, + Timeout: 2 * time.Second, + } +) + +func createEngine(ctx context.Context, cancel context.CancelFunc, setupKey string, i int, mgmtAddr string, signalAddr string) (*Engine, error) { + key, err := wgtypes.GeneratePrivateKey() + if err != nil { + return nil, err + } + mgmtClient, err := mgmt.NewClient(ctx, mgmtAddr, key, false) + if err != nil { + return nil, err + } + signalClient, err := signal.NewClient(ctx, signalAddr, key, false) + if err != nil { + return nil, err + } + + info := system.GetInfo(ctx) + resp, err := mgmtClient.Register(setupKey, "", info, nil, nil) + if err != nil { + return nil, err + } + + var ifaceName string + if runtime.GOOS == "darwin" { + ifaceName = fmt.Sprintf("utun1%d", i) + } else { + ifaceName = fmt.Sprintf("wt%d", i) + } + + wgPort := 33100 + i + conf := &EngineConfig{ + WgIfaceName: ifaceName, + WgAddr: wgaddr.MustParseWGAddress(resp.PeerConfig.Address), + WgPrivateKey: key, + WgPort: wgPort, + MTU: iface.DefaultMTU, + } + + relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) + 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 +} + +func startSignal(t *testing.T) (*grpc.Server, string, error) { + t.Helper() + + s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp)) + + lis, err := net.Listen("tcp", "localhost:0") + if err != nil { + log.Fatalf("failed to listen: %v", err) + } + + srv, err := signalServer.NewServer(context.Background(), otel.Meter("")) + require.NoError(t, err) + proto.RegisterSignalExchangeServer(s, srv) + + go func() { + if err = s.Serve(lis); err != nil { + log.Fatalf("failed to serve: %v", err) + } + }() + + return s, lis.Addr().String(), nil +} + +func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, string, error) { + t.Helper() + + config := &config.Config{ + Stuns: []*config.Host{}, + TURNConfig: &config.TURNConfig{}, + Relay: &config.Relay{ + Addresses: []string{"127.0.0.1:1234"}, + CredentialsTTL: util.Duration{Duration: time.Hour}, + Secret: "222222222222222222", + }, + Signal: &config.Host{ + Proto: "http", + URI: "localhost:10000", + }, + Datadir: dataDir, + HttpConfig: nil, + } + + lis, err := net.Listen("tcp", "localhost:0") + if err != nil { + return nil, "", err + } + s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp)) + + store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), testFile, config.Datadir) + if err != nil { + return nil, "", err + } + t.Cleanup(cleanUp) + + eventStore := &activity.InMemoryEventStore{} + if err != nil { + return nil, "", err + } + + permissionsManager := permissions.NewManager(store) + peersManager := peers.NewManager(store) + jobManager := job.NewJobManager(nil, store, peersManager) + + cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100) + if err != nil { + return nil, "", err + } + + ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore) + + metrics, err := telemetry.NewDefaultAppMetrics(context.Background()) + require.NoError(t, err) + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + settingsMockManager := settings.NewMockManager(ctrl) + settingsMockManager.EXPECT(). + GetSettings(gomock.Any(), gomock.Any(), gomock.Any()). + Return(&types.Settings{}, nil). + AnyTimes() + settingsMockManager.EXPECT(). + GetExtraSettings(gomock.Any(), gomock.Any()). + Return(&types.ExtraSettings{}, nil). + AnyTimes() + + groupsManager := groups.NewManagerMock() + + updateManager := update_channel.NewPeersUpdateManager(metrics) + requestBuffer := server.NewAccountRequestBuffer(context.Background(), store) + networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config, nil) + accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + if err != nil { + return nil, "", err + } + + secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager) + if err != nil { + return nil, "", err + } + mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil, nil) + if err != nil { + return nil, "", err + } + mgmtProto.RegisterManagementServiceServer(s, mgmtServer) + go func() { + if err = s.Serve(lis); err != nil { + log.Fatalf("failed to serve: %v", err) + } + }() + + return s, lis.Addr().String(), nil +} + +// getConnectedPeers returns a connection Status or nil if peer connection wasn't found +func getConnectedPeers(e *Engine) int { + e.syncMsgMux.Lock() + defer e.syncMsgMux.Unlock() + i := 0 + for _, id := range e.peerStore.PeersPubKey() { + conn, _ := e.peerStore.PeerConn(id) + if conn.IsConnected() { + i++ + } + } + return i +} + +func getPeers(e *Engine) int { + e.syncMsgMux.Lock() + defer e.syncMsgMux.Unlock() + + return len(e.peerStore.PeersPubKey()) +} diff --git a/client/internal/engine_session_deadline_test.go b/client/internal/engine_session_deadline_test.go new file mode 100644 index 000000000..5a67f103a --- /dev/null +++ b/client/internal/engine_session_deadline_test.go @@ -0,0 +1,88 @@ +package internal + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/netbirdio/netbird/client/internal/auth/sessionwatch" + "github.com/netbirdio/netbird/client/internal/peer" +) + +// TestApplySessionDeadline_ThreeState pins down the 3-state semantics of the +// wire field carried on LoginResponse / SyncResponse: +// +// - nil pointer → no info; previously-anchored deadline survives +// - explicit zero value → "expiry disabled" sentinel; both sinks cleared +// - valid future timestamp → new deadline propagated to both sinks +func TestApplySessionDeadline_ThreeState(t *testing.T) { + newEngine := func() *Engine { + recorder := peer.NewRecorder("") + return &Engine{ + statusRecorder: recorder, + sessionWatcher: sessionwatch.New(recorder), + } + } + + t.Run("valid timestamp sets deadline on both sinks", func(t *testing.T) { + e := newEngine() + deadline := time.Now().Add(time.Hour).UTC().Truncate(time.Second) + + e.ApplySessionDeadline(timestamppb.New(deadline)) + + require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(deadline), + "status recorder should hold the new deadline") + }) + + t.Run("nil is a no-op and preserves previous deadline", func(t *testing.T) { + e := newEngine() + seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second) + e.ApplySessionDeadline(timestamppb.New(seeded)) + require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded)) + + e.ApplySessionDeadline(nil) + + require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded), + "nil snapshot must not disturb the existing deadline") + }) + + t.Run("explicit zero clears a previously-anchored deadline", func(t *testing.T) { + e := newEngine() + seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second) + e.ApplySessionDeadline(timestamppb.New(seeded)) + require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded)) + + // Explicit zero Timestamp{} (seconds=0, nanos=0) is the + // "expiry disabled / not SSO" sentinel. + e.ApplySessionDeadline(×tamppb.Timestamp{}) + + require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(), + "explicit zero sentinel must clear the deadline") + }) + + t.Run("invalid timestamp clears the deadline", func(t *testing.T) { + e := newEngine() + seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second) + e.ApplySessionDeadline(timestamppb.New(seeded)) + require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded)) + + // Out-of-range nanos → IsValid()==false; same-meaning as the + // disabled sentinel for downstream sinks. + e.ApplySessionDeadline(×tamppb.Timestamp{Seconds: 1, Nanos: -1}) + + require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(), + "invalid timestamp must clear the deadline") + }) + + t.Run("recently expired timestamp stays visible as expired", func(t *testing.T) { + e := newEngine() + expired := time.Now().Add(-5 * time.Minute).UTC().Truncate(time.Second) + + e.ApplySessionDeadline(timestamppb.New(expired)) + + require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(expired), + "recently-expired deadline must stay on the recorder so consumers render it as expired") + }) +} diff --git a/client/internal/engine_sessionwatch.go b/client/internal/engine_sessionwatch.go new file mode 100644 index 000000000..a46d73f87 --- /dev/null +++ b/client/internal/engine_sessionwatch.go @@ -0,0 +1,16 @@ +//go:build !js + +package internal + +import ( + "github.com/netbirdio/netbird/client/internal/auth/sessionwatch" + "github.com/netbirdio/netbird/client/internal/peer" +) + +// newSessionWatcher returns the real SSO session expiry watcher for every +// non-wasm build. The js/wasm build gets a no-op stub from +// engine_sessionwatch_js.go so the sessionwatch package (and its timer +// machinery) never links into the wasm binary. +func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher { + return sessionwatch.New(recorder) +} diff --git a/client/internal/engine_sessionwatch_js.go b/client/internal/engine_sessionwatch_js.go new file mode 100644 index 000000000..50e148ab9 --- /dev/null +++ b/client/internal/engine_sessionwatch_js.go @@ -0,0 +1,44 @@ +//go:build js + +package internal + +import ( + "time" + + "github.com/netbirdio/netbird/client/internal/peer" +) + +// noopSessionWatcher is the js/wasm stand-in for sessionwatch.Watcher. The +// wasm client never runs the engine's session-warning flow (the interactive +// T-WarningLead notification and the T-FinalWarningLead fallback dialog live +// in the desktop UI), so linking the full sessionwatch package (timers, event +// composition) would only bloat the binary. +// +// It still mirrors the deadline into the status recorder so the SubscribeStatus +// / Status snapshot the UI consumes stays correct — only the timer-driven +// warnings are dropped. +type noopSessionWatcher struct { + recorder *peer.Status +} + +func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher { + return noopSessionWatcher{recorder: recorder} +} + +// Update mirrors the real watcher's recorder propagation without the timers or +// sanity-check sentinels: a valid deadline is exposed on the status snapshot, +// the zero time clears it. +func (w noopSessionWatcher) Update(deadline time.Time) error { + if w.recorder != nil { + w.recorder.SetSessionExpiresAt(deadline) + } + return nil +} + +func (noopSessionWatcher) Dismiss() { + // No-op: only suppresses the timer-driven final-warning, which this stub never arms. +} + +func (noopSessionWatcher) Close() { + // No-op: no timers to stop and no state to unwind; the recorder is cleared via Update(zero). +} diff --git a/client/internal/engine_ssh.go b/client/internal/engine_ssh.go index 1419bc262..60bdfd806 100644 --- a/client/internal/engine_ssh.go +++ b/client/internal/engine_ssh.go @@ -24,6 +24,8 @@ type sshServer interface { Stop() error GetStatus() (bool, []sshserver.SessionInfo) UpdateSSHAuth(config *sshauth.Config) + JWTConfig() *sshserver.JWTConfig + AuthConfig() *sshauth.Config } func (e *Engine) setupSSHPortRedirection() error { @@ -41,6 +43,14 @@ func (e *Engine) setupSSHPortRedirection() error { } log.Infof("SSH port redirection enabled: %s:22 -> %s:22022", localAddr, localAddr) + if v6 := e.wgInterface.Address().IPv6; v6.IsValid() { + if err := e.firewall.AddInboundDNAT(v6, firewallManager.ProtocolTCP, 22, 22022); err != nil { + log.Warnf("failed to add IPv6 SSH port redirection: %v", err) + } else { + log.Infof("SSH port redirection enabled: [%s]:22 -> [%s]:22022", v6, v6) + } + } + return nil } @@ -69,7 +79,7 @@ func (e *Engine) updateSSH(sshConf *mgmProto.SSHConfig) error { if e.config.DisableSSHAuth != nil && *e.config.DisableSSHAuth { log.Info("starting SSH server without JWT authentication (authentication disabled by config)") - return e.startSSHServer(nil) + return e.startSSHServer(nil, nil) } if protoJWT := sshConf.GetJwtConfig(); protoJWT != nil { @@ -87,7 +97,7 @@ func (e *Engine) updateSSH(sshConf *mgmProto.SSHConfig) error { MaxTokenAge: protoJWT.GetMaxTokenAge(), } - return e.startSSHServer(jwtConfig) + return e.startSSHServer(jwtConfig, nil) } return errors.New("SSH server requires valid JWT configuration") @@ -137,12 +147,13 @@ func (e *Engine) extractPeerSSHInfo(remotePeers []*mgmProto.RemotePeerConfig) [] continue } - peerIP := e.extractPeerIP(peerConfig) + peerV4, peerV6 := overlayAddrsFromAllowedIPs(peerConfig.GetAllowedIps(), e.wgInterface.Address().IPv6Net) hostname := e.extractHostname(peerConfig) peerInfo = append(peerInfo, sshconfig.PeerSSHInfo{ Hostname: hostname, - IP: peerIP, + IP: peerV4, + IPv6: peerV6, FQDN: peerConfig.GetFqdn(), }) } @@ -150,18 +161,6 @@ func (e *Engine) extractPeerSSHInfo(remotePeers []*mgmProto.RemotePeerConfig) [] return peerInfo } -// extractPeerIP extracts IP address from peer's allowed IPs -func (e *Engine) extractPeerIP(peerConfig *mgmProto.RemotePeerConfig) string { - if len(peerConfig.GetAllowedIps()) == 0 { - return "" - } - - if prefix, err := netip.ParsePrefix(peerConfig.GetAllowedIps()[0]); err == nil { - return prefix.Addr().String() - } - return "" -} - // extractHostname extracts short hostname from FQDN func (e *Engine) extractHostname(peerConfig *mgmProto.RemotePeerConfig) string { fqdn := peerConfig.GetFqdn() @@ -208,7 +207,7 @@ func (e *Engine) GetPeerSSHKey(peerAddress string) ([]byte, bool) { fullStatus := statusRecorder.GetFullStatus() for _, peerState := range fullStatus.Peers { - if peerState.IP == peerAddress || peerState.FQDN == peerAddress { + if peerState.IP == peerAddress || peerState.FQDN == peerAddress || peerState.IPv6 == peerAddress { if len(peerState.SSHHostKey) > 0 { return peerState.SSHHostKey, true } @@ -234,8 +233,33 @@ func (e *Engine) cleanupSSHConfig() { } } -// startSSHServer initializes and starts the SSH server with proper configuration. -func (e *Engine) startSSHServer(jwtConfig *sshserver.JWTConfig) error { +// restartSSHListeners rebuilds the SSH server so it listens on new sockets, on +// the same terms it was started with. No-op when it is not running. See +// Engine.rebindOverlayListeners for why this is needed. +func (e *Engine) restartSSHListeners() error { + if e.sshServer == nil { + return nil + } + // Read from the server before it goes away. A rebuilt one starts with an + // empty authorizer, which fails closed, so without carrying the + // authorization over every JWT login is refused until the next network map + // happens to bring one. + jwtConfig, authConfig := e.sshServer.JWTConfig(), e.sshServer.AuthConfig() + if err := e.stopSSHServer(); err != nil { + return fmt.Errorf("rebind SSH listeners: %w", err) + } + if err := e.startSSHServer(jwtConfig, authConfig); err != nil { + return fmt.Errorf("rebind SSH listeners: %w", err) + } + return nil +} + +// startSSHServer initializes and starts the SSH server with proper +// configuration. authConfig is the fine-grained authorization to open with, and +// is applied before the server accepts anything: a server that starts listening +// with an empty authorizer refuses the logins that arrive in the meantime. +// Nil leaves it as management has not sent one yet. +func (e *Engine) startSSHServer(jwtConfig *sshserver.JWTConfig, authConfig *sshauth.Config) error { if e.wgInterface == nil { return errors.New("wg interface not initialized") } @@ -243,6 +267,7 @@ func (e *Engine) startSSHServer(jwtConfig *sshserver.JWTConfig) error { serverConfig := &sshserver.Config{ HostKeyPEM: e.config.SSHKey, JWT: jwtConfig, + Auth: authConfig, } server := sshserver.New(serverConfig) @@ -262,6 +287,13 @@ func (e *Engine) startSSHServer(jwtConfig *sshserver.JWTConfig) error { return fmt.Errorf("start SSH server: %w", err) } + if v6 := wgAddr.IPv6; v6.IsValid() { + v6Addr := netip.AddrPortFrom(v6, sshserver.InternalSSHPort) + if err := server.AddListener(e.ctx, v6Addr); err != nil { + log.Warnf("failed to add IPv6 SSH listener: %v", err) + } + } + e.sshServer = server if netstackNet := e.wgInterface.GetNet(); netstackNet != nil { @@ -330,6 +362,12 @@ func (e *Engine) cleanupSSHPortRedirection() error { } log.Debugf("SSH port redirection removed: %s:22 -> %s:22022", localAddr, localAddr) + if v6 := e.wgInterface.Address().IPv6; v6.IsValid() { + if err := e.firewall.RemoveInboundDNAT(v6, firewallManager.ProtocolTCP, 22, 22022); err != nil { + log.Debugf("failed to remove IPv6 SSH port redirection: %v", err) + } + } + return nil } diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go index b13bfa7b4..ec388ac94 100644 --- a/client/internal/engine_test.go +++ b/client/internal/engine_test.go @@ -2,42 +2,23 @@ package internal import ( "context" + "errors" "fmt" "net" "net/netip" "os" - "runtime" "strings" "sync" "testing" "time" - "github.com/golang/mock/gomock" - "github.com/google/uuid" - log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.opentelemetry.io/otel" wgdevice "golang.zx2c4.com/wireguard/device" "golang.zx2c4.com/wireguard/tun/netstack" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" - "google.golang.org/grpc" - "google.golang.org/grpc/keepalive" "github.com/netbirdio/netbird/client/internal/stdnet" - "github.com/netbirdio/netbird/management/internals/modules/permissions" - "github.com/netbirdio/netbird/management/server/job" - - "github.com/netbirdio/management-integrations/integrations" - - "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" - "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel" - "github.com/netbirdio/netbird/management/internals/modules/peers" - "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager" - nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" - - "github.com/netbirdio/netbird/management/internals/server/config" - "github.com/netbirdio/netbird/management/server/groups" "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/iface/configurer" @@ -51,42 +32,18 @@ import ( icemaker "github.com/netbirdio/netbird/client/internal/peer/ice" "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/internal/routemanager" - nbssh "github.com/netbirdio/netbird/client/ssh" "github.com/netbirdio/netbird/client/system" nbdns "github.com/netbirdio/netbird/dns" - "github.com/netbirdio/netbird/management/server" - "github.com/netbirdio/netbird/management/server/activity" - nbcache "github.com/netbirdio/netbird/management/server/cache" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" - "github.com/netbirdio/netbird/management/server/settings" - "github.com/netbirdio/netbird/management/server/store" - "github.com/netbirdio/netbird/management/server/telemetry" - "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/monotime" "github.com/netbirdio/netbird/route" mgmt "github.com/netbirdio/netbird/shared/management/client" mgmtProto "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/shared/netiputil" relayClient "github.com/netbirdio/netbird/shared/relay/client" signal "github.com/netbirdio/netbird/shared/signal/client" - "github.com/netbirdio/netbird/shared/signal/proto" - signalServer "github.com/netbirdio/netbird/signal/server" "github.com/netbirdio/netbird/util" ) -var ( - kaep = keepalive.EnforcementPolicy{ - MinTime: 15 * time.Second, - PermitWithoutStream: true, - } - - kasp = keepalive.ServerParameters{ - MaxConnectionIdle: 15 * time.Second, - MaxConnectionAgeGrace: 5 * time.Second, - Time: 5 * time.Second, - Timeout: 2 * time.Second, - } -) - type MockWGIface struct { CreateFunc func() error CreateOnAndroidFunc func(routeRange []string, ip string, domains []string) error @@ -95,7 +52,7 @@ type MockWGIface struct { AddressFunc func() wgaddr.Address ToInterfaceFunc func() *net.Interface UpFunc func() (*udpmux.UniversalUDPMuxDefault, error) - UpdateAddrFunc func(newAddr string) error + UpdateAddrFunc func(newAddr wgaddr.Address) error UpdatePeerFunc func(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error RemovePeerFunc func(peerKey string) error AddAllowedIPFunc func(peerKey string, allowedIP netip.Prefix) error @@ -157,7 +114,7 @@ func (m *MockWGIface) Up() (*udpmux.UniversalUDPMuxDefault, error) { return m.UpFunc() } -func (m *MockWGIface) UpdateAddr(newAddr string) error { +func (m *MockWGIface) UpdateAddr(newAddr wgaddr.Address) error { return m.UpdateAddrFunc(newAddr) } @@ -223,6 +180,10 @@ func (m *MockWGIface) LastActivities() map[string]monotime.Time { return nil } +func (m *MockWGIface) MTU() uint16 { + return 1280 +} + func (m *MockWGIface) SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error { return nil } @@ -233,129 +194,6 @@ func TestMain(m *testing.M) { os.Exit(code) } -func TestEngine_SSH(t *testing.T) { - key, err := wgtypes.GeneratePrivateKey() - if err != nil { - t.Fatal(err) - return - } - - sshKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) - if err != nil { - t.Fatal(err) - return - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) - engine := NewEngine( - ctx, cancel, - &EngineConfig{ - WgIfaceName: "utun101", - WgAddr: "100.64.0.1/24", - WgPrivateKey: key, - WgPort: 33100, - ServerSSHAllowed: true, - MTU: iface.DefaultMTU, - SSHKey: sshKey, - }, - EngineServices{ - SignalClient: &signal.MockClient{}, - MgmClient: &mgmt.MockClient{}, - RelayManager: relayMgr, - StatusRecorder: peer.NewRecorder("https://mgm"), - }, - MobileDependency{}, - ) - - engine.dnsServer = &dns.MockServer{ - UpdateDNSServerFunc: func(serial uint64, update nbdns.Config) error { return nil }, - } - - err = engine.Start(nil, nil) - require.NoError(t, err) - - defer func() { - err := engine.Stop() - if err != nil { - return - } - }() - - peerWithSSH := &mgmtProto.RemotePeerConfig{ - WgPubKey: "MNHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=", - AllowedIps: []string{"100.64.0.21/24"}, - SshConfig: &mgmtProto.SSHConfig{ - SshPubKey: []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFATYCqaQw/9id1Qkq3n16JYhDhXraI6Pc1fgB8ynEfQ"), - }, - } - - // SSH server is not enabled so SSH config of a remote peer should be ignored - networkMap := &mgmtProto.NetworkMap{ - Serial: 6, - PeerConfig: nil, - RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH}, - RemotePeersIsEmpty: false, - } - - err = engine.updateNetworkMap(networkMap) - require.NoError(t, err) - - assert.Nil(t, engine.sshServer) - - // SSH server is enabled, therefore SSH config should be applied - networkMap = &mgmtProto.NetworkMap{ - Serial: 7, - PeerConfig: &mgmtProto.PeerConfig{Address: "100.64.0.1/24", - SshConfig: &mgmtProto.SSHConfig{ - SshEnabled: true, - JwtConfig: &mgmtProto.JWTConfig{ - Issuer: "test-issuer", - Audience: "test-audience", - KeysLocation: "test-keys", - MaxTokenAge: 3600, - }, - }}, - RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH}, - RemotePeersIsEmpty: false, - } - - err = engine.updateNetworkMap(networkMap) - require.NoError(t, err) - - time.Sleep(250 * time.Millisecond) - assert.NotNil(t, engine.sshServer) - - // now remove peer - networkMap = &mgmtProto.NetworkMap{ - Serial: 8, - RemotePeers: []*mgmtProto.RemotePeerConfig{}, - RemotePeersIsEmpty: false, - } - - err = engine.updateNetworkMap(networkMap) - require.NoError(t, err) - - // time.Sleep(250 * time.Millisecond) - assert.NotNil(t, engine.sshServer) - - // now disable SSH server - networkMap = &mgmtProto.NetworkMap{ - Serial: 9, - PeerConfig: &mgmtProto.PeerConfig{Address: "100.64.0.1/24", - SshConfig: &mgmtProto.SSHConfig{SshEnabled: false}}, - RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH}, - RemotePeersIsEmpty: false, - } - - err = engine.updateNetworkMap(networkMap) - require.NoError(t, err) - - assert.Nil(t, engine.sshServer) -} - func TestEngine_SSHUpdateLogic(t *testing.T) { // Test that SSH server start/stop logic works based on config engine := &Engine{ @@ -417,6 +255,118 @@ func TestEngine_SSHServerConsistency(t *testing.T) { }) } +func TestEngine_FirstSyncInfoCarriesLoginChecks(t *testing.T) { + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) + + exe, err := os.Executable() + require.NoError(t, err) + + ctx, cancel := context.WithCancel(CtxInitState(context.Background())) + defer cancel() + + infos := make(chan *system.Info, 1) + mgmClient := &mgmt.MockClient{ + SyncFunc: func(ctx context.Context, getInfo func(context.Context) *system.Info, _ func(*mgmtProto.SyncResponse) error) error { + infos <- getInfo(ctx) + return nil + }, + } + + relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) + engine := NewEngine(ctx, cancel, &EngineConfig{ + WgIfaceName: "utun104", + WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"), + WgPrivateKey: key, + WgPort: 33100, + MTU: iface.DefaultMTU, + }, EngineServices{ + SignalClient: &signal.MockClient{}, + MgmClient: mgmClient, + RelayManager: relayMgr, + StatusRecorder: peer.NewRecorder("https://mgm"), + Checks: []*mgmtProto.Checks{{Files: []string{exe}}}, + }, MobileDependency{}) + + engine.receiveManagementEvents() + + select { + case info := <-infos: + require.Len(t, info.Files, 1) + assert.Equal(t, exe, info.Files[0].Path) + assert.True(t, info.Files[0].Exist) + case <-time.After(20 * time.Second): + t.Fatal("timeout waiting for the first sync info") + } + engine.shutdownWg.Wait() +} + +func TestEngine_SyncInfoFuncReusesRefreshedInfoOnce(t *testing.T) { + engine := &Engine{config: &EngineConfig{}} + + refreshed := &system.Info{Hostname: "from-refresh"} + getInfo := engine.syncInfoFunc(refreshed) + + first := getInfo(context.Background()) + assert.Same(t, refreshed, first, "the first connect should send the refreshed info instead of gathering again") + + second := getInfo(context.Background()) + assert.NotSame(t, refreshed, second, "the reconnect should gather a fresh info") + assert.NotEqual(t, "from-refresh", second.Hostname, "the fresh info should not carry the refreshed hostname") +} + +func TestEngine_SyncInfoFuncGathersWhenRefreshFailed(t *testing.T) { + engine := &Engine{config: &EngineConfig{}} + + info := engine.syncInfoFunc(nil)(context.Background()) + require.NotNil(t, info, "a failed refresh should fall back to gathering the info") + assert.NotEmpty(t, info.Hostname, "the gathered info should carry the hostname") +} + +func TestEngine_UpdateChecksIfNewRetriesAfterFailedSyncMeta(t *testing.T) { + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) + + exe, err := os.Executable() + require.NoError(t, err) + + ctx, cancel := context.WithCancel(CtxInitState(context.Background())) + defer cancel() + + syncMetaCalls := 0 + mgmClient := &mgmt.MockClient{ + SyncMetaFunc: func(*system.Info) error { + syncMetaCalls++ + if syncMetaCalls == 1 { + return errors.New("management unavailable") + } + return nil + }, + } + + relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) + engine := NewEngine(ctx, cancel, &EngineConfig{ + WgIfaceName: "utun105", + WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"), + WgPrivateKey: key, + WgPort: 33100, + MTU: iface.DefaultMTU, + }, EngineServices{ + SignalClient: &signal.MockClient{}, + MgmClient: mgmClient, + RelayManager: relayMgr, + StatusRecorder: peer.NewRecorder("https://mgm"), + }, MobileDependency{}) + + checks := []*mgmtProto.Checks{{Files: []string{exe}}} + + require.Error(t, engine.updateChecksIfNew(checks)) + require.NoError(t, engine.updateChecksIfNew(checks)) + require.NoError(t, engine.updateChecksIfNew(checks)) + + assert.Equal(t, 2, syncMetaCalls) +} + func TestEngine_UpdateNetworkMap(t *testing.T) { // test setup key, err := wgtypes.GeneratePrivateKey() @@ -425,13 +375,13 @@ func TestEngine_UpdateNetworkMap(t *testing.T) { return } - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(CtxInitState(context.Background())) defer cancel() relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) engine := NewEngine(ctx, cancel, &EngineConfig{ WgIfaceName: "utun102", - WgAddr: "100.64.0.1/24", + WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"), WgPrivateKey: key, WgPort: 33100, MTU: iface.DefaultMTU, @@ -443,7 +393,8 @@ func TestEngine_UpdateNetworkMap(t *testing.T) { }, MobileDependency{}) wgIface := &MockWGIface{ - NameFunc: func() string { return "utun102" }, + NameFunc: func() string { return "utun102" }, + IsUserspaceBindFunc: func() bool { return true }, RemovePeerFunc: func(peerKey string) error { return nil }, @@ -630,97 +581,6 @@ func TestEngine_UpdateNetworkMap(t *testing.T) { } } -func TestEngine_Sync(t *testing.T) { - key, err := wgtypes.GeneratePrivateKey() - if err != nil { - t.Fatal(err) - return - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // feed updates to Engine via mocked Management client - updates := make(chan *mgmtProto.SyncResponse) - defer close(updates) - syncFunc := func(ctx context.Context, info *system.Info, msgHandler func(msg *mgmtProto.SyncResponse) error) error { - for msg := range updates { - err := msgHandler(msg) - if err != nil { - t.Fatal(err) - } - } - return nil - } - relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) - engine := NewEngine(ctx, cancel, &EngineConfig{ - WgIfaceName: "utun103", - WgAddr: "100.64.0.1/24", - WgPrivateKey: key, - WgPort: 33100, - MTU: iface.DefaultMTU, - }, EngineServices{ - SignalClient: &signal.MockClient{}, - MgmClient: &mgmt.MockClient{SyncFunc: syncFunc}, - RelayManager: relayMgr, - StatusRecorder: peer.NewRecorder("https://mgm"), - }, MobileDependency{}) - engine.ctx = ctx - - engine.dnsServer = &dns.MockServer{ - UpdateDNSServerFunc: func(serial uint64, update nbdns.Config) error { return nil }, - } - - defer func() { - err := engine.Stop() - if err != nil { - return - } - }() - - err = engine.Start(nil, nil) - if err != nil { - t.Fatal(err) - return - } - - peer1 := &mgmtProto.RemotePeerConfig{ - WgPubKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=", - AllowedIps: []string{"100.64.0.10/24"}, - } - peer2 := &mgmtProto.RemotePeerConfig{ - WgPubKey: "LLHf3Ma6z6mdLbriAJbqhX9+nM/B71lgw2+91q3LlhU=", - AllowedIps: []string{"100.64.0.11/24"}, - } - peer3 := &mgmtProto.RemotePeerConfig{ - WgPubKey: "GGHf3Ma6z6mdLbriAJbqhX9+nM/B71lgw2+91q3LlhU=", - AllowedIps: []string{"100.64.0.12/24"}, - } - // 1st update with just 1 peer and serial larger than the current serial of the engine => apply update - updates <- &mgmtProto.SyncResponse{ - NetworkMap: &mgmtProto.NetworkMap{ - Serial: 10, - PeerConfig: nil, - RemotePeers: []*mgmtProto.RemotePeerConfig{peer1, peer2, peer3}, - RemotePeersIsEmpty: false, - }, - } - - timeout := time.After(time.Second * 2) - for { - select { - case <-timeout: - t.Fatalf("timeout while waiting for test to finish") - return - default: - } - - if getPeers(engine) == 3 && engine.networkSerial == 10 { - break - } - } -} - func TestEngine_UpdateNetworkMapWithRoutes(t *testing.T) { testCases := []struct { name string @@ -816,7 +676,7 @@ func TestEngine_UpdateNetworkMapWithRoutes(t *testing.T) { return } - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(CtxInitState(context.Background())) defer cancel() wgIfaceName := fmt.Sprintf("utun%d", 104+n) @@ -825,7 +685,7 @@ func TestEngine_UpdateNetworkMapWithRoutes(t *testing.T) { relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) engine := NewEngine(ctx, cancel, &EngineConfig{ WgIfaceName: wgIfaceName, - WgAddr: wgAddr, + WgAddr: wgaddr.MustParseWGAddress(wgAddr), WgPrivateKey: key, WgPort: 33100, MTU: iface.DefaultMTU, @@ -843,7 +703,7 @@ func TestEngine_UpdateNetworkMapWithRoutes(t *testing.T) { opts := iface.WGIFaceOpts{ IFaceName: wgIfaceName, - Address: wgAddr, + Address: wgaddr.MustParseWGAddress(wgAddr), WGPort: engine.config.WgPort, WGPrivKey: key.String(), MTU: iface.DefaultMTU, @@ -1023,7 +883,7 @@ func TestEngine_UpdateNetworkMapWithDNSUpdate(t *testing.T) { return } - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(CtxInitState(context.Background())) defer cancel() wgIfaceName := fmt.Sprintf("utun%d", 104+n) @@ -1032,7 +892,7 @@ func TestEngine_UpdateNetworkMapWithDNSUpdate(t *testing.T) { relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) engine := NewEngine(ctx, cancel, &EngineConfig{ WgIfaceName: wgIfaceName, - WgAddr: wgAddr, + WgAddr: wgaddr.MustParseWGAddress(wgAddr), WgPrivateKey: key, WgPort: 33100, MTU: iface.DefaultMTU, @@ -1050,7 +910,7 @@ func TestEngine_UpdateNetworkMapWithDNSUpdate(t *testing.T) { } opts := iface.WGIFaceOpts{ IFaceName: wgIfaceName, - Address: wgAddr, + Address: wgaddr.MustParseWGAddress(wgAddr), WGPort: 33100, WGPrivKey: key.String(), MTU: iface.DefaultMTU, @@ -1104,104 +964,6 @@ func TestEngine_UpdateNetworkMapWithDNSUpdate(t *testing.T) { } } -func TestEngine_MultiplePeers(t *testing.T) { - // log.SetLevel(log.DebugLevel) - - ctx, cancel := context.WithCancel(CtxInitState(context.Background())) - defer cancel() - - sigServer, signalAddr, err := startSignal(t) - if err != nil { - t.Fatal(err) - return - } - defer sigServer.Stop() - mgmtServer, mgmtAddr, err := startManagement(t, t.TempDir(), "../testdata/store.sql") - if err != nil { - t.Fatal(err) - return - } - defer mgmtServer.GracefulStop() - - setupKey := "A2C8E62B-38F5-4553-B31E-DD66C696CEBB" - - mu := sync.Mutex{} - engines := []*Engine{} - numPeers := 10 - wg := sync.WaitGroup{} - wg.Add(numPeers) - // create and start peers - for i := 0; i < numPeers; i++ { - j := i - go func() { - engine, err := createEngine(ctx, cancel, setupKey, j, mgmtAddr, signalAddr) - if err != nil { - wg.Done() - t.Errorf("unable to create the engine for peer %d with error %v", j, err) - return - } - engine.dnsServer = &dns.MockServer{} - mu.Lock() - defer mu.Unlock() - guid := fmt.Sprintf("{%s}", uuid.New().String()) - device.CustomWindowsGUIDString = strings.ToLower(guid) - err = engine.Start(nil, nil) - if err != nil { - t.Errorf("unable to start engine for peer %d with error %v", j, err) - wg.Done() - return - } - engines = append(engines, engine) - wg.Done() - }() - } - - // wait until all have been created and started - wg.Wait() - if len(engines) != numPeers { - t.Fatal("not all peers was started") - } - // check whether all the peer have expected peers connected - - expectedConnected := numPeers * (numPeers - 1) - - // adjust according to timeouts - timeout := 50 * time.Second - timeoutChan := time.After(timeout) - ticker := time.NewTicker(time.Second) - defer ticker.Stop() -loop: - for { - select { - case <-timeoutChan: - t.Fatalf("waiting for expected connections timeout after %s", timeout.String()) - break loop - case <-ticker.C: - totalConnected := 0 - for _, engine := range engines { - totalConnected += getConnectedPeers(engine) - } - if totalConnected == expectedConnected { - log.Infof("total connected=%d", totalConnected) - break loop - } - log.Infof("total connected=%d", totalConnected) - } - } - // cleanup test - for n, peerEngine := range engines { - t.Logf("stopping peer with interface %s from multipeer test, loopIndex %d", peerEngine.wgInterface.Name(), n) - errStop := peerEngine.mgmClient.Close() - if errStop != nil { - log.Infoln("got error trying to close management clients from engine: ", errStop) - } - errStop = peerEngine.Stop() - if errStop != nil { - log.Infoln("got error trying to close testing peers engine: ", errStop) - } - } -} - func Test_ParseNATExternalIPMappings(t *testing.T) { ifaceList, err := net.Interfaces() if err != nil { @@ -1525,183 +1287,223 @@ func TestCompareNetIPLists(t *testing.T) { } } -func createEngine(ctx context.Context, cancel context.CancelFunc, setupKey string, i int, mgmtAddr string, signalAddr string) (*Engine, error) { - key, err := wgtypes.GeneratePrivateKey() - if err != nil { - return nil, err - } - mgmtClient, err := mgmt.NewClient(ctx, mgmtAddr, key, false) - if err != nil { - return nil, err - } - signalClient, err := signal.NewClient(ctx, signalAddr, key, false) - if err != nil { - return nil, err - } - - info := system.GetInfo(ctx) - resp, err := mgmtClient.Register(setupKey, "", info, nil, nil) - if err != nil { - return nil, err - } - - var ifaceName string - if runtime.GOOS == "darwin" { - ifaceName = fmt.Sprintf("utun1%d", i) - } else { - ifaceName = fmt.Sprintf("wt%d", i) - } - - wgPort := 33100 + i - conf := &EngineConfig{ - WgIfaceName: ifaceName, - WgAddr: resp.PeerConfig.Address, - WgPrivateKey: key, - WgPort: wgPort, - MTU: iface.DefaultMTU, - } - - relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) - 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 -} - -func startSignal(t *testing.T) (*grpc.Server, string, error) { +func mustEncodePrefix(t *testing.T, p netip.Prefix) []byte { t.Helper() - - s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp)) - - lis, err := net.Listen("tcp", "localhost:0") - if err != nil { - log.Fatalf("failed to listen: %v", err) - } - - srv, err := signalServer.NewServer(context.Background(), otel.Meter("")) + b, err := netiputil.EncodePrefix(p) require.NoError(t, err) - proto.RegisterSignalExchangeServer(s, srv) - - go func() { - if err = s.Serve(lis); err != nil { - log.Fatalf("failed to serve: %v", err) - } - }() - - return s, lis.Addr().String(), nil + return b } -func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, string, error) { - t.Helper() +func TestEngine_hasIPv6Changed(t *testing.T) { + v4Only := wgaddr.MustParseWGAddress("100.64.0.1/16") - config := &config.Config{ - Stuns: []*config.Host{}, - TURNConfig: &config.TURNConfig{}, - Relay: &config.Relay{ - Addresses: []string{"127.0.0.1:1234"}, - CredentialsTTL: util.Duration{Duration: time.Hour}, - Secret: "222222222222222222", + v4v6 := wgaddr.MustParseWGAddress("100.64.0.1/16") + v4v6.IPv6 = netip.MustParseAddr("fd00::1") + v4v6.IPv6Net = netip.MustParsePrefix("fd00::1/64").Masked() + + tests := []struct { + name string + current wgaddr.Address + confV6 []byte + expected bool + }{ + { + name: "no v6 before, no v6 now", + current: v4Only, + confV6: nil, + expected: false, }, - Signal: &config.Host{ - Proto: "http", - URI: "localhost:10000", + { + name: "no v6 before, v6 added", + current: v4Only, + confV6: mustEncodePrefix(t, netip.MustParsePrefix("fd00::1/64")), + expected: true, + }, + { + name: "had v6, now removed", + current: v4v6, + confV6: nil, + expected: true, + }, + { + name: "had v6, same v6", + current: v4v6, + confV6: mustEncodePrefix(t, netip.MustParsePrefix("fd00::1/64")), + expected: false, + }, + { + name: "had v6, different v6", + current: v4v6, + confV6: mustEncodePrefix(t, netip.MustParsePrefix("fd00::2/64")), + expected: true, + }, + { + name: "same v6 addr, different prefix length", + current: v4v6, + confV6: mustEncodePrefix(t, netip.MustParsePrefix("fd00::1/80")), + expected: true, + }, + { + name: "decode error keeps status quo", + current: v4Only, + confV6: []byte{1, 2, 3}, + expected: false, }, - Datadir: dataDir, - HttpConfig: nil, } - lis, err := net.Listen("tcp", "localhost:0") - if err != nil { - return nil, "", err + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + engine := &Engine{ + config: &EngineConfig{WgAddr: tt.current}, + } + conf := &mgmtProto.PeerConfig{ + AddressV6: tt.confV6, + } + assert.Equal(t, tt.expected, engine.hasIPv6Changed(conf)) + }) } - s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp)) - - store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), testFile, config.Datadir) - if err != nil { - return nil, "", err - } - t.Cleanup(cleanUp) - - eventStore := &activity.InMemoryEventStore{} - if err != nil { - return nil, "", err - } - - permissionsManager := permissions.NewManager(store) - peersManager := peers.NewManager(store) - jobManager := job.NewJobManager(nil, store, peersManager) - - cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100) - if err != nil { - return nil, "", err - } - - ia, _ := integrations.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore) - - metrics, err := telemetry.NewDefaultAppMetrics(context.Background()) - require.NoError(t, err) - - ctrl := gomock.NewController(t) - t.Cleanup(ctrl.Finish) - settingsMockManager := settings.NewMockManager(ctrl) - settingsMockManager.EXPECT(). - GetSettings(gomock.Any(), gomock.Any(), gomock.Any()). - Return(&types.Settings{}, nil). - AnyTimes() - settingsMockManager.EXPECT(). - GetExtraSettings(gomock.Any(), gomock.Any()). - Return(&types.ExtraSettings{}, nil). - AnyTimes() - - groupsManager := groups.NewManagerMock() - - updateManager := update_channel.NewPeersUpdateManager(metrics) - requestBuffer := server.NewAccountRequestBuffer(context.Background(), store) - networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config) - accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) - if err != nil { - return nil, "", err - } - - secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager) - if err != nil { - return nil, "", err - } - mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil) - if err != nil { - return nil, "", err - } - mgmtProto.RegisterManagementServiceServer(s, mgmtServer) - go func() { - if err = s.Serve(lis); err != nil { - log.Fatalf("failed to serve: %v", err) - } - }() - - return s, lis.Addr().String(), nil } -// getConnectedPeers returns a connection Status or nil if peer connection wasn't found -func getConnectedPeers(e *Engine) int { - e.syncMsgMux.Lock() - defer e.syncMsgMux.Unlock() - i := 0 - for _, id := range e.peerStore.PeersPubKey() { - conn, _ := e.peerStore.PeerConn(id) - if conn.IsConnected() { - i++ - } +func TestFilterAllowedIPs(t *testing.T) { + v4v6Addr := wgaddr.MustParseWGAddress("100.64.0.1/16") + v4v6Addr.IPv6 = netip.MustParseAddr("fd00::1") + v4v6Addr.IPv6Net = netip.MustParsePrefix("fd00::1/64").Masked() + + v4OnlyAddr := wgaddr.MustParseWGAddress("100.64.0.1/16") + + tests := []struct { + name string + addr wgaddr.Address + input []string + expected []string + }{ + { + name: "interface has v6, keep all", + addr: v4v6Addr, + input: []string{"100.64.0.1/32", "fd00::1/128"}, + expected: []string{"100.64.0.1/32", "fd00::1/128"}, + }, + { + name: "no v6, strip v6", + addr: v4OnlyAddr, + input: []string{"100.64.0.1/32", "fd00::1/128"}, + expected: []string{"100.64.0.1/32"}, + }, + { + name: "no v6, only v4", + addr: v4OnlyAddr, + input: []string{"100.64.0.1/32", "10.0.0.0/8"}, + expected: []string{"100.64.0.1/32", "10.0.0.0/8"}, + }, + { + name: "no v6, only v6 input", + addr: v4OnlyAddr, + input: []string{"fd00::1/128", "::/0"}, + expected: []string{}, + }, + { + name: "no v6, invalid prefix preserved", + addr: v4OnlyAddr, + input: []string{"100.64.0.1/32", "garbage"}, + expected: []string{"100.64.0.1/32", "garbage"}, + }, + { + name: "no v6, empty input", + addr: v4OnlyAddr, + input: []string{}, + expected: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + addr := tt.addr + engine := &Engine{ + config: &EngineConfig{}, + wgInterface: &MockWGIface{ + AddressFunc: func() wgaddr.Address { return addr }, + }, + } + result := engine.filterAllowedIPs(tt.input) + assert.Equal(t, tt.expected, result) + }) } - return i } -func getPeers(e *Engine) int { - e.syncMsgMux.Lock() - defer e.syncMsgMux.Unlock() +func TestOverlayAddrsFromAllowedIPs(t *testing.T) { + ourV6Net := netip.MustParsePrefix("fd00:1234:5678:abcd::/64") - return len(e.peerStore.PeersPubKey()) + tests := []struct { + name string + allowedIPs []string + ourV6Net netip.Prefix + wantV4 string + wantV6 string + }{ + { + name: "v4 only", + allowedIPs: []string{"100.64.0.1/32"}, + ourV6Net: ourV6Net, + wantV4: "100.64.0.1", + wantV6: "", + }, + { + name: "v4 and v6 overlay", + allowedIPs: []string{"100.64.0.1/32", "fd00:1234:5678:abcd::1/128"}, + ourV6Net: ourV6Net, + wantV4: "100.64.0.1", + wantV6: "fd00:1234:5678:abcd::1", + }, + { + name: "v4, routed v6, overlay v6", + allowedIPs: []string{"100.64.0.1/32", "2001:db8::1/128", "fd00:1234:5678:abcd::1/128"}, + ourV6Net: ourV6Net, + wantV4: "100.64.0.1", + wantV6: "fd00:1234:5678:abcd::1", + }, + { + name: "routed v6 /128 outside our subnet is ignored", + allowedIPs: []string{"100.64.0.1/32", "2001:db8::1/128"}, + ourV6Net: ourV6Net, + wantV4: "100.64.0.1", + wantV6: "", + }, + { + name: "routed v6 prefix is ignored", + allowedIPs: []string{"100.64.0.1/32", "fd00:1234:5678:abcd::/64"}, + ourV6Net: ourV6Net, + wantV4: "100.64.0.1", + wantV6: "", + }, + { + name: "no v6 subnet configured", + allowedIPs: []string{"100.64.0.1/32", "fd00:1234:5678:abcd::1/128"}, + ourV6Net: netip.Prefix{}, + wantV4: "100.64.0.1", + wantV6: "", + }, + { + name: "v4 /24 route is ignored", + allowedIPs: []string{"100.64.0.0/24", "100.64.0.1/32"}, + ourV6Net: ourV6Net, + wantV4: "100.64.0.1", + wantV6: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v4, v6 := overlayAddrsFromAllowedIPs(tt.allowedIPs, tt.ourV6Net) + if tt.wantV4 == "" { + assert.False(t, v4.IsValid(), "expected no v4") + } else { + assert.Equal(t, tt.wantV4, v4.String(), "v4") + } + if tt.wantV6 == "" { + assert.False(t, v6.IsValid(), "expected no v6") + } else { + assert.Equal(t, tt.wantV6, v6.String(), "v6") + } + }) + } } diff --git a/client/internal/engine_tunsettings.go b/client/internal/engine_tunsettings.go new file mode 100644 index 000000000..34a59671a --- /dev/null +++ b/client/internal/engine_tunsettings.go @@ -0,0 +1,20 @@ +package internal + +func (e *Engine) TunSettings() ([]string, []string) { + e.syncMsgMux.Lock() + routeManager := e.routeManager + dnsServer := e.dnsServer + e.syncMsgMux.Unlock() + + var routes []string + if routeManager != nil { + routes = routeManager.CurrentRouteRange() + } + + var searchDomains []string + if dnsServer != nil { + searchDomains = dnsServer.SearchDomains() + } + + return routes, searchDomains +} diff --git a/client/internal/getent/cgo_unix.go b/client/internal/getent/cgo_unix.go new file mode 100644 index 000000000..2853aafff --- /dev/null +++ b/client/internal/getent/cgo_unix.go @@ -0,0 +1,36 @@ +//go:build cgo && !osusergo && !windows + +package getent + +import "os/user" + +// Built with cgo, os/user resolves through libc (getpwnam_r and friends), +// which goes through the host's NSS stack natively. Whatever it fails to +// find, the getent command would not find either, so there is nothing to +// fall back to. + +// LookupUser looks up a user by name. +func LookupUser(username string) (*user.User, error) { + return user.Lookup(username) +} + +// LookupUserID looks up a user by UID. +func LookupUserID(uid string) (*user.User, error) { + return user.LookupId(uid) +} + +// CurrentUser returns the user this process runs as. +func CurrentUser() (*user.User, error) { + return user.Current() +} + +// LookupGroupID looks up a group by GID. +func LookupGroupID(gid string) (*user.Group, error) { + return user.LookupGroupId(gid) +} + +// GroupIDs returns the IDs of the groups the user is a member of; libc's +// getgrouplist handles NSS groups natively. +func GroupIDs(u *user.User) ([]string, error) { + return u.GroupIds() +} diff --git a/client/internal/getent/getent.go b/client/internal/getent/getent.go new file mode 100644 index 000000000..9cfebe64b --- /dev/null +++ b/client/internal/getent/getent.go @@ -0,0 +1,6 @@ +// Package getent resolves users and groups through the host's NSS stack. +// Built without cgo, os/user reads /etc/passwd and /etc/group alone and misses +// anything LDAP, SSSD or winbind provide; the getent and id commands resolve +// through NSS whatever the build. The lookups here try the standard library +// first, which needs no subprocess, and fall back to those commands. +package getent diff --git a/client/ssh/server/getent_test.go b/client/internal/getent/getent_test.go similarity index 53% rename from client/ssh/server/getent_test.go rename to client/internal/getent/getent_test.go index 5eac2fdbe..8176eba36 100644 --- a/client/ssh/server/getent_test.go +++ b/client/internal/getent/getent_test.go @@ -1,4 +1,4 @@ -package server +package getent import ( "os/user" @@ -10,38 +10,48 @@ import ( "github.com/stretchr/testify/require" ) -func TestLookupWithGetent_CurrentUser(t *testing.T) { +func TestLookupUser_CurrentUser(t *testing.T) { // The current user should always be resolvable on any platform current, err := user.Current() require.NoError(t, err) - u, err := lookupWithGetent(current.Username) + u, err := LookupUser(current.Username) require.NoError(t, err) assert.Equal(t, current.Username, u.Username) assert.Equal(t, current.Uid, u.Uid) assert.Equal(t, current.Gid, u.Gid) } -func TestLookupWithGetent_NonexistentUser(t *testing.T) { - _, err := lookupWithGetent("nonexistent_user_xyzzy_12345") +func TestLookupUser_NonexistentUser(t *testing.T) { + _, err := LookupUser("nonexistent_user_xyzzy_12345") require.Error(t, err, "should fail for nonexistent user") } -func TestCurrentUserWithGetent(t *testing.T) { +func TestLookupUserID_CurrentUser(t *testing.T) { + current, err := user.Current() + require.NoError(t, err) + + u, err := LookupUserID(current.Uid) + require.NoError(t, err) + assert.Equal(t, current.Username, u.Username) + assert.Equal(t, current.Uid, u.Uid) +} + +func TestCurrentUser(t *testing.T) { stdUser, err := user.Current() require.NoError(t, err) - u, err := currentUserWithGetent() + u, err := CurrentUser() require.NoError(t, err) assert.Equal(t, stdUser.Uid, u.Uid) assert.Equal(t, stdUser.Username, u.Username) } -func TestGroupIdsWithFallback_CurrentUser(t *testing.T) { +func TestGroupIDs_CurrentUser(t *testing.T) { current, err := user.Current() require.NoError(t, err) - groups, err := groupIdsWithFallback(current) + groups, err := GroupIDs(current) require.NoError(t, err) require.NotEmpty(t, groups, "current user should have at least one group") @@ -53,32 +63,30 @@ func TestGroupIdsWithFallback_CurrentUser(t *testing.T) { } } -func TestGetShellFromGetent_CurrentUser(t *testing.T) { - if runtime.GOOS == "windows" { - // Windows stub always returns empty, which is correct - shell := getShellFromGetent("1000") - assert.Empty(t, shell, "Windows stub should return empty") - return - } - +func TestUserShell_CurrentUser(t *testing.T) { current, err := user.Current() require.NoError(t, err) - // getent may not be available on all systems (e.g., macOS without Homebrew getent) - shell := getShellFromGetent(current.Uid) + // getent may not be available on all systems (e.g., macOS without + // Homebrew getent), and Windows has no login shells at all. + shell, err := UserShell(current.Uid) + if err != nil { + t.Logf("UserShell failed, getent may not be available: %v", err) + return + } if shell == "" { - t.Log("getShellFromGetent returned empty, getent may not be available") + t.Log("UserShell returned empty, the user has no shell set") return } assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell) } -func TestLookupWithGetent_RootUser(t *testing.T) { +func TestLookupUser_RootUser(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("no root user on Windows") } - u, err := lookupWithGetent("root") + u, err := LookupUser("root") if err != nil { t.Skip("root user not available on this system") } @@ -86,25 +94,25 @@ func TestLookupWithGetent_RootUser(t *testing.T) { } // TestIntegration_FullLookupChain exercises the complete user lookup chain -// against the real system, testing that all wrappers (lookupWithGetent, -// currentUserWithGetent, groupIdsWithFallback, getShellFromGetent) produce -// consistent and correct results when composed together. +// against the real system, testing that all wrappers (LookupUser, +// CurrentUser, GroupIDs, UserShell) produce consistent and correct results +// when composed together. func TestIntegration_FullLookupChain(t *testing.T) { - // Step 1: currentUserWithGetent must resolve the running user. - current, err := currentUserWithGetent() - require.NoError(t, err, "currentUserWithGetent must resolve the running user") + // Step 1: CurrentUser must resolve the running user. + current, err := CurrentUser() + require.NoError(t, err, "CurrentUser must resolve the running user") require.NotEmpty(t, current.Uid) require.NotEmpty(t, current.Username) - // Step 2: lookupWithGetent by the same username must return matching identity. - byName, err := lookupWithGetent(current.Username) + // Step 2: LookupUser by the same username must return matching identity. + byName, err := LookupUser(current.Username) require.NoError(t, err) assert.Equal(t, current.Uid, byName.Uid, "lookup by name should return same UID") assert.Equal(t, current.Gid, byName.Gid, "lookup by name should return same GID") assert.Equal(t, current.HomeDir, byName.HomeDir, "lookup by name should return same home") - // Step 3: groupIdsWithFallback must return at least the primary GID. - groups, err := groupIdsWithFallback(current) + // Step 3: GroupIDs must return at least the primary GID. + groups, err := GroupIDs(current) require.NoError(t, err) require.NotEmpty(t, groups, "user must have at least one group") @@ -119,29 +127,20 @@ func TestIntegration_FullLookupChain(t *testing.T) { } } assert.True(t, foundPrimary, "primary GID %s should appear in supplementary groups", current.Gid) - - // Step 4: getShellFromGetent should either return a valid shell path or empty - // (empty is OK when getent is not available, e.g. macOS without Homebrew getent). - if runtime.GOOS != "windows" { - shell := getShellFromGetent(current.Uid) - if shell != "" { - assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell) - } - } } // TestIntegration_LookupAndGroupsConsistency verifies that a user resolved via -// lookupWithGetent can have their groups resolved via groupIdsWithFallback, -// testing the handoff between the two functions as used by the SSH server. +// LookupUser can have their groups resolved via GroupIDs, testing the handoff +// between the two functions as used by the SSH server. func TestIntegration_LookupAndGroupsConsistency(t *testing.T) { current, err := user.Current() require.NoError(t, err) // Simulate the SSH server flow: lookup user, then get their groups. - resolved, err := lookupWithGetent(current.Username) + resolved, err := LookupUser(current.Username) require.NoError(t, err) - groups, err := groupIdsWithFallback(resolved) + groups, err := GroupIDs(resolved) require.NoError(t, err) require.NotEmpty(t, groups, "resolved user must have groups") @@ -154,19 +153,3 @@ func TestIntegration_LookupAndGroupsConsistency(t *testing.T) { } } } - -// TestIntegration_ShellLookupChain tests the full shell resolution chain -// (getShellFromPasswd -> getShellFromGetent -> $SHELL -> default) on Unix. -func TestIntegration_ShellLookupChain(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("Unix shell lookup not applicable on Windows") - } - - current, err := user.Current() - require.NoError(t, err) - - // getUserShell is the top-level function used by the SSH server. - shell := getUserShell(current.Uid) - require.NotEmpty(t, shell, "getUserShell must always return a shell") - assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell) -} diff --git a/client/internal/getent/nocgo_unix.go b/client/internal/getent/nocgo_unix.go new file mode 100644 index 000000000..94d8ea6a9 --- /dev/null +++ b/client/internal/getent/nocgo_unix.go @@ -0,0 +1,110 @@ +//go:build (!cgo || osusergo) && !windows + +package getent + +import ( + "os" + "os/user" + "strconv" + + log "github.com/sirupsen/logrus" +) + +// Without cgo, os/user only reads /etc/passwd and /etc/group and misses +// NSS-provided users and groups; the getent and id commands go through the +// host's NSS stack. + +// LookupUser looks up a user by name, falling back to getent if os/user fails. +func LookupUser(username string) (*user.User, error) { + u, err := user.Lookup(username) + if err == nil { + return u, nil + } + + stdErr := err + log.Debugf("os/user.Lookup(%q) failed, trying getent: %v", username, err) + + u, _, getentErr := passwdLookup(username) + if getentErr != nil { + log.Debugf("getent fallback for %q also failed: %v", username, getentErr) + return nil, stdErr + } + return u, nil +} + +// LookupUserID looks up a user by UID, falling back to getent if os/user fails. +func LookupUserID(uid string) (*user.User, error) { + u, err := user.LookupId(uid) + if err == nil { + return u, nil + } + + stdErr := err + log.Debugf("os/user.LookupId(%q) failed, trying getent: %v", uid, err) + + u, _, getentErr := passwdLookup(uid) + if getentErr != nil { + log.Debugf("getent fallback for uid %s also failed: %v", uid, getentErr) + return nil, stdErr + } + return u, nil +} + +// CurrentUser returns the user this process runs as, falling back to getent +// if os/user fails. +func CurrentUser() (*user.User, error) { + u, err := user.Current() + if err == nil { + return u, nil + } + + stdErr := err + uid := strconv.Itoa(os.Getuid()) + log.Debugf("os/user.Current() failed, trying getent with UID %s: %v", uid, err) + + u, _, getentErr := passwdLookup(uid) + if getentErr != nil { + return nil, stdErr + } + return u, nil +} + +// LookupGroupID looks up a group by GID, falling back to getent if os/user +// fails. +func LookupGroupID(gid string) (*user.Group, error) { + g, err := user.LookupGroupId(gid) + if err == nil { + return g, nil + } + + stdErr := err + log.Debugf("os/user.LookupGroupId(%q) failed, trying getent: %v", gid, err) + + g, _, getentErr := groupLookup(gid) + if getentErr != nil { + log.Debugf("getent fallback for gid %s also failed: %v", gid, getentErr) + return nil, stdErr + } + return g, nil +} + +// GroupIDs returns the IDs of the groups the user is a member of. +// NOTE: unlike the lookups above, which try the standard library first, this +// intentionally tries `id -G` first because without cgo, user.GroupIds only +// reads /etc/group and silently returns incomplete results for NSS users +// (no error, just missing groups). The id command goes through NSS and +// returns the full set. +func GroupIDs(u *user.User) ([]string, error) { + ids, err := idGroups(u.Username) + if err == nil { + return ids, nil + } + + log.Debugf("id -G %q failed, falling back to user.GroupIds(): %v", u.Username, err) + + ids, stdErr := u.GroupIds() + if stdErr != nil { + return nil, stdErr + } + return ids, nil +} diff --git a/client/internal/getent/unix.go b/client/internal/getent/unix.go new file mode 100644 index 000000000..7d29810f5 --- /dev/null +++ b/client/internal/getent/unix.go @@ -0,0 +1,224 @@ +//go:build !windows + +package getent + +import ( + "bufio" + "context" + "fmt" + "os" + "os/exec" + "os/user" + "runtime" + "strings" + "time" + + log "github.com/sirupsen/logrus" +) + +const commandTimeout = 5 * time.Second + +// groupFile lists which accounts are in which group, for hosts where the +// getent command is not available (macOS ships without it). +const groupFile = "/etc/group" + +// UserShell returns the login shell getent reports for the user with this UID. +// It reaches shells that /etc/passwd does not list, because getent resolves +// through the host's NSS stack. +func UserShell(uid string) (string, error) { + _, shell, err := passwdLookup(uid) + if err != nil { + return "", err + } + return shell, nil +} + +// GroupMembers returns the names of the group's members: from getent, which +// resolves through NSS, or from /etc/group where getent is not available. A +// group neither source describes is an error; an empty member list is not, +// since accounts with the group as their primary one are not listed in it. +func GroupMembers(name string) ([]string, error) { + _, members, err := groupLookup(name) + if err == nil { + return members, nil + } + log.Debugf("getent cannot list group %q, reading %s: %v", name, groupFile, err) + return groupMembersFromFile(groupFile, name) +} + +// passwdLookup executes `getent passwd `, where query is a username or +// UID, and returns the user and login shell. +func passwdLookup(query string) (*user.User, string, error) { + out, err := run("passwd", query) + if err != nil { + return nil, "", err + } + return parsePasswd(string(out)) +} + +// groupLookup executes `getent group `, where query is a group name or +// GID, and returns the group and its member names. +func groupLookup(query string) (*user.Group, []string, error) { + out, err := run("group", query) + if err != nil { + return nil, nil, err + } + return parseGroup(string(out)) +} + +// run executes `getent ` with a timeout. +func run(database, key string) ([]byte, error) { + if !validateInput(key) { + return nil, fmt.Errorf("invalid getent input: %q", key) + } + + ctx, cancel := context.WithTimeout(context.Background(), commandTimeout) + defer cancel() + + out, err := exec.CommandContext(ctx, "getent", database, key).Output() + if err != nil { + return nil, fmt.Errorf("getent %s %s: %w", database, key, err) + } + return out, nil +} + +// parsePasswd parses getent passwd output: "name:x:uid:gid:gecos:home:shell" +func parsePasswd(output string) (*user.User, string, error) { + fields := strings.SplitN(strings.TrimSpace(output), ":", 8) + if len(fields) < 6 { + return nil, "", fmt.Errorf("unexpected getent output (need 6+ fields): %q", output) + } + + if fields[0] == "" || fields[2] == "" || fields[3] == "" { + return nil, "", fmt.Errorf("missing required fields in getent output: %q", output) + } + + var shell string + if len(fields) >= 7 { + shell = fields[6] + } + + return &user.User{ + Username: fields[0], + Uid: fields[2], + Gid: fields[3], + Name: fields[4], + HomeDir: fields[5], + }, shell, nil +} + +// parseGroup parses getent group output: "name:x:gid:member,member" +func parseGroup(output string) (*user.Group, []string, error) { + fields := strings.SplitN(strings.TrimSpace(output), ":", 4) + if len(fields) < 3 { + return nil, nil, fmt.Errorf("unexpected getent output (need 3+ fields): %q", output) + } + + if fields[0] == "" || fields[2] == "" { + return nil, nil, fmt.Errorf("missing required fields in getent output: %q", output) + } + + var members []string + if len(fields) >= 4 { + members = splitMembers(fields[3]) + } + return &user.Group{Name: fields[0], Gid: fields[2]}, members, nil +} + +func splitMembers(list string) []string { + var members []string + for member := range strings.SplitSeq(list, ",") { + if member != "" { + members = append(members, member) + } + } + return members +} + +// groupMembersFromFile finds the group's member list in a file of /etc/group's +// format. A group the file does not describe, because it comes from LDAP or +// another NSS source, is an error rather than an empty list. +func groupMembersFromFile(path, name string) ([]string, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + defer func() { + if err := file.Close(); err != nil { + log.Debugf("close %s: %v", path, err) + } + }() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + // name:password:gid:member,member + fields := strings.Split(scanner.Text(), ":") + if len(fields) < 4 || fields[0] != name { + continue + } + return splitMembers(fields[3]), nil + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + return nil, fmt.Errorf("%s does not describe group %q", path, name) +} + +// validateInput checks that the input is safe to pass to getent or id. +// Allows POSIX usernames, numeric IDs, and common NSS extensions +// (@ for Kerberos, $ for Samba, + for NIS compat). A leading hyphen is +// rejected so the input can never be parsed as a command-line flag. +func validateInput(input string) bool { + maxLen := 32 + if runtime.GOOS == "linux" { + maxLen = 256 + } + + if len(input) == 0 || len(input) > maxLen { + return false + } + + if input[0] == '-' { + return false + } + + for _, r := range input { + if isAllowedChar(r) { + continue + } + return false + } + return true +} + +func isAllowedChar(r rune) bool { + if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' { + return true + } + switch r { + case '.', '_', '-', '@', '+', '$': + return true + } + return false +} + +// idGroups runs `id -G ` and returns the space-separated group IDs. +func idGroups(username string) ([]string, error) { + if !validateInput(username) { + return nil, fmt.Errorf("invalid username for id command: %q", username) + } + + ctx, cancel := context.WithTimeout(context.Background(), commandTimeout) + defer cancel() + + out, err := exec.CommandContext(ctx, "id", "-G", username).Output() + if err != nil { + return nil, fmt.Errorf("id -G %s: %w", username, err) + } + + trimmed := strings.TrimSpace(string(out)) + if trimmed == "" { + return nil, fmt.Errorf("id -G %s: empty output", username) + } + return strings.Fields(trimmed), nil +} diff --git a/client/ssh/server/getent_unix_test.go b/client/internal/getent/unix_test.go similarity index 62% rename from client/ssh/server/getent_unix_test.go rename to client/internal/getent/unix_test.go index e44563b79..5ab100ce5 100644 --- a/client/ssh/server/getent_unix_test.go +++ b/client/internal/getent/unix_test.go @@ -1,10 +1,12 @@ //go:build !windows -package server +package getent import ( + "os" "os/exec" "os/user" + "path/filepath" "runtime" "strconv" "testing" @@ -13,7 +15,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestParseGetentPasswd(t *testing.T) { +func TestParsePasswd(t *testing.T) { tests := []struct { name string input string @@ -128,7 +130,7 @@ func TestParseGetentPasswd(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - u, shell, err := parseGetentPasswd(tt.input) + u, shell, err := parsePasswd(tt.input) if tt.wantErr { require.Error(t, err) if tt.errContains != "" { @@ -147,7 +149,120 @@ func TestParseGetentPasswd(t *testing.T) { } } -func TestValidateGetentInput(t *testing.T) { +func TestParseGroup(t *testing.T) { + tests := []struct { + name string + input string + wantGroup *user.Group + wantMembers []string + wantErr bool + }{ + { + name: "no members", + input: "vma:x:1000:\n", + wantGroup: &user.Group{Name: "vma", Gid: "1000"}, + }, + { + name: "one member", + input: "sudo:x:27:alice", + wantGroup: &user.Group{Name: "sudo", Gid: "27"}, + wantMembers: []string{"alice"}, + }, + { + name: "several members", + input: "docker:x:998:alice,bob\n", + wantGroup: &user.Group{Name: "docker", Gid: "998"}, + wantMembers: []string{"alice", "bob"}, + }, + { + name: "too few fields", + input: "bad:x", + wantErr: true, + }, + { + name: "empty group name", + input: ":x:1000:alice", + wantErr: true, + }, + { + name: "empty GID", + input: "vma:x::alice", + wantErr: true, + }, + { + name: "empty input", + input: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g, members, err := parseGroup(tt.input) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantGroup.Name, g.Name, "group name") + assert.Equal(t, tt.wantGroup.Gid, g.Gid, "GID") + assert.Equal(t, tt.wantMembers, members, "members") + }) + } +} + +func TestGroupMembersFromFile(t *testing.T) { + tests := []struct { + name string + entry string + want []string + }{ + {name: "no members", entry: "vma:x:1000:"}, + {name: "only the owner", entry: "vma:x:1000:vma", want: []string{"vma"}}, + {name: "two members", entry: "vma:x:1000:vma,bob", want: []string{"vma", "bob"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "group") + body := "root:x:0:\n" + tt.entry + "\nsudo:x:27:vma\n" + require.NoError(t, os.WriteFile(path, []byte(body), 0o644), "write the group file") + + members, err := groupMembersFromFile(path, "vma") + require.NoError(t, err, "entry %q", tt.entry) + assert.Equal(t, tt.want, members, "entry %q", tt.entry) + }) + } +} + +// A group the file does not describe, because it comes from LDAP or another +// NSS source, is an error rather than an empty member list: the caller must +// be able to tell "no members" from "no answer". +func TestGroupMembersFromFileUnknownGroup(t *testing.T) { + path := filepath.Join(t.TempDir(), "group") + require.NoError(t, os.WriteFile(path, []byte("root:x:0:\n"), 0o644), "write the group file") + + _, err := groupMembersFromFile(path, "vma") + assert.Error(t, err, "a group the file does not describe") + + _, err = groupMembersFromFile(filepath.Join(t.TempDir(), "absent"), "vma") + assert.Error(t, err, "no group file at all") +} + +// GroupMembers on the root group, which every Unix has, whichever source +// answers for it. +func TestGroupMembers_RootGroup(t *testing.T) { + rootGroup := "root" + switch runtime.GOOS { + case "darwin", "dragonfly", "freebsd", "netbsd", "openbsd": + rootGroup = "wheel" + } + + _, err := GroupMembers(rootGroup) + assert.NoError(t, err, "the %s group must be describable", rootGroup) +} + +func TestValidateInput(t *testing.T) { tests := []struct { name string input string @@ -157,6 +272,9 @@ func TestValidateGetentInput(t *testing.T) { {"numeric UID", "1001", true}, {"dots and underscores", "alice.bob_test", true}, {"hyphen", "alice-bob", true}, + {"leading hyphen rejected", "-i", false}, + {"leading double hyphen rejected", "--no-idn", false}, + {"lone hyphen rejected", "-", false}, {"kerberos principal", "user@REALM", true}, {"samba machine account", "MACHINE$", true}, {"NIS compat", "+user", true}, @@ -177,7 +295,7 @@ func TestValidateGetentInput(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, validateGetentInput(tt.input)) + assert.Equal(t, tt.want, validateInput(tt.input)) }) } } @@ -190,12 +308,12 @@ func makeLongString(n int) string { return string(b) } -func TestRunGetent_RootUser(t *testing.T) { +func TestPasswdLookup_RootUser(t *testing.T) { if _, err := exec.LookPath("getent"); err != nil { t.Skip("getent not available on this system") } - u, shell, err := runGetent("root") + u, shell, err := passwdLookup("root") require.NoError(t, err) assert.Equal(t, "root", u.Username) assert.Equal(t, "0", u.Uid) @@ -203,44 +321,55 @@ func TestRunGetent_RootUser(t *testing.T) { assert.NotEmpty(t, shell, "root should have a shell") } -func TestRunGetent_ByUID(t *testing.T) { +func TestPasswdLookup_ByUID(t *testing.T) { if _, err := exec.LookPath("getent"); err != nil { t.Skip("getent not available on this system") } - u, _, err := runGetent("0") + u, _, err := passwdLookup("0") require.NoError(t, err) assert.Equal(t, "root", u.Username) assert.Equal(t, "0", u.Uid) } -func TestRunGetent_NonexistentUser(t *testing.T) { +func TestPasswdLookup_NonexistentUser(t *testing.T) { if _, err := exec.LookPath("getent"); err != nil { t.Skip("getent not available on this system") } - _, _, err := runGetent("nonexistent_user_xyzzy_12345") + _, _, err := passwdLookup("nonexistent_user_xyzzy_12345") assert.Error(t, err) } -func TestRunGetent_InvalidInput(t *testing.T) { - _, _, err := runGetent("") +func TestPasswdLookup_InvalidInput(t *testing.T) { + _, _, err := passwdLookup("") assert.Error(t, err) - _, _, err = runGetent("user\x00name") + _, _, err = passwdLookup("user\x00name") assert.Error(t, err) } -func TestRunGetent_NotAvailable(t *testing.T) { +func TestPasswdLookup_NotAvailable(t *testing.T) { if _, err := exec.LookPath("getent"); err == nil { t.Skip("getent is available, can't test missing case") } - _, _, err := runGetent("root") + _, _, err := passwdLookup("root") assert.Error(t, err, "should fail when getent is not installed") } -func TestRunIdGroups_CurrentUser(t *testing.T) { +func TestGroupLookup_RootGroup(t *testing.T) { + if _, err := exec.LookPath("getent"); err != nil { + t.Skip("getent not available on this system") + } + + g, _, err := groupLookup("0") + require.NoError(t, err) + assert.Equal(t, "0", g.Gid, "GID 0 resolves to the root group") + assert.NotEmpty(t, g.Name, "the root group has a name") +} + +func TestIdGroups_CurrentUser(t *testing.T) { if _, err := exec.LookPath("id"); err != nil { t.Skip("id not available on this system") } @@ -248,7 +377,7 @@ func TestRunIdGroups_CurrentUser(t *testing.T) { current, err := user.Current() require.NoError(t, err) - groups, err := runIdGroups(current.Username) + groups, err := idGroups(current.Username) require.NoError(t, err) require.NotEmpty(t, groups, "current user should have at least one group") @@ -258,20 +387,20 @@ func TestRunIdGroups_CurrentUser(t *testing.T) { } } -func TestRunIdGroups_NonexistentUser(t *testing.T) { +func TestIdGroups_NonexistentUser(t *testing.T) { if _, err := exec.LookPath("id"); err != nil { t.Skip("id not available on this system") } - _, err := runIdGroups("nonexistent_user_xyzzy_12345") + _, err := idGroups("nonexistent_user_xyzzy_12345") assert.Error(t, err) } -func TestRunIdGroups_InvalidInput(t *testing.T) { - _, err := runIdGroups("") +func TestIdGroups_InvalidInput(t *testing.T) { + _, err := idGroups("") assert.Error(t, err) - _, err = runIdGroups("user\x00name") + _, err = idGroups("user\x00name") assert.Error(t, err) } @@ -283,7 +412,7 @@ func TestGetentResultsMatchStdlib(t *testing.T) { current, err := user.Current() require.NoError(t, err) - getentUser, _, err := runGetent(current.Username) + getentUser, _, err := passwdLookup(current.Username) require.NoError(t, err) assert.Equal(t, current.Username, getentUser.Username, "username should match") @@ -300,7 +429,7 @@ func TestGetentResultsMatchStdlib_ByUID(t *testing.T) { current, err := user.Current() require.NoError(t, err) - getentUser, _, err := runGetent(current.Uid) + getentUser, _, err := passwdLookup(current.Uid) require.NoError(t, err) assert.Equal(t, current.Username, getentUser.Username, "username should match when looked up by UID") @@ -320,12 +449,12 @@ func TestIdGroupsMatchStdlib(t *testing.T) { t.Skip("os/user.GroupIds() not working, likely CGO_ENABLED=0") } - idGroups, err := runIdGroups(current.Username) + idGroupIDs, err := idGroups(current.Username) require.NoError(t, err) // Deduplicate both lists: id -G can return duplicates (e.g., root in Docker) // and ElementsMatch treats duplicates as distinct. - assert.ElementsMatch(t, uniqueStrings(stdGroups), uniqueStrings(idGroups), "id -G should return same groups as os/user") + assert.ElementsMatch(t, uniqueStrings(stdGroups), uniqueStrings(idGroupIDs), "id -G should return same groups as os/user") } func uniqueStrings(ss []string) []string { @@ -340,71 +469,3 @@ func uniqueStrings(ss []string) []string { } return out } - -// TestGetShellFromPasswd_CurrentUser verifies that getShellFromPasswd correctly -// reads the current user's shell from /etc/passwd by comparing it against what -// getent reports (which goes through NSS). -func TestGetShellFromPasswd_CurrentUser(t *testing.T) { - current, err := user.Current() - require.NoError(t, err) - - shell := getShellFromPasswd(current.Uid) - if shell == "" { - t.Skip("current user not found in /etc/passwd (may be an NSS-only user)") - } - - assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell) - - if _, err := exec.LookPath("getent"); err == nil { - _, getentShell, getentErr := runGetent(current.Uid) - if getentErr == nil && getentShell != "" { - assert.Equal(t, getentShell, shell, "shell from /etc/passwd should match getent") - } - } -} - -// TestGetShellFromPasswd_RootUser verifies that getShellFromPasswd can read -// root's shell from /etc/passwd. Root is guaranteed to be in /etc/passwd on -// any standard Unix system. -func TestGetShellFromPasswd_RootUser(t *testing.T) { - shell := getShellFromPasswd("0") - require.NotEmpty(t, shell, "root (UID 0) must be in /etc/passwd") - assert.True(t, shell[0] == '/', "root shell should be an absolute path, got %q", shell) -} - -// TestGetShellFromPasswd_NonexistentUID verifies that getShellFromPasswd -// returns empty for a UID that doesn't exist in /etc/passwd. -func TestGetShellFromPasswd_NonexistentUID(t *testing.T) { - shell := getShellFromPasswd("4294967294") - assert.Empty(t, shell, "nonexistent UID should return empty shell") -} - -// TestGetShellFromPasswd_MatchesGetentForKnownUsers reads /etc/passwd directly -// and cross-validates every entry against getent to ensure parseGetentPasswd -// and getShellFromPasswd agree on shell values. -func TestGetShellFromPasswd_MatchesGetentForKnownUsers(t *testing.T) { - if _, err := exec.LookPath("getent"); err != nil { - t.Skip("getent not available") - } - - // Pick a few well-known system UIDs that are virtually always in /etc/passwd. - uids := []string{"0"} // root - - current, err := user.Current() - require.NoError(t, err) - uids = append(uids, current.Uid) - - for _, uid := range uids { - passwdShell := getShellFromPasswd(uid) - if passwdShell == "" { - continue - } - - _, getentShell, err := runGetent(uid) - if err != nil { - continue - } - - assert.Equal(t, getentShell, passwdShell, "shell mismatch for UID %s", uid) - } -} diff --git a/client/internal/getent/windows.go b/client/internal/getent/windows.go new file mode 100644 index 000000000..61881d162 --- /dev/null +++ b/client/internal/getent/windows.go @@ -0,0 +1,36 @@ +//go:build windows + +package getent + +import ( + "errors" + "os/user" +) + +// Windows does not use NSS or getent; os/user resolves accounts there +// without cgo, so everything delegates to it. + +// LookupUser looks up a user by name. +func LookupUser(username string) (*user.User, error) { + return user.Lookup(username) +} + +// LookupUserID looks up a user by UID. +func LookupUserID(uid string) (*user.User, error) { + return user.LookupId(uid) +} + +// CurrentUser returns the user this process runs as. +func CurrentUser() (*user.User, error) { + return user.Current() +} + +// GroupIDs returns the IDs of the groups the user is a member of. +func GroupIDs(u *user.User) ([]string, error) { + return u.GroupIds() +} + +// UserShell is unanswerable on Windows, which has no login-shell database. +func UserShell(string) (string, error) { + return "", errors.ErrUnsupported +} diff --git a/client/internal/iface_common.go b/client/internal/iface_common.go index 39e9bacfa..8ffa0b102 100644 --- a/client/internal/iface_common.go +++ b/client/internal/iface_common.go @@ -26,7 +26,7 @@ type wgIfaceBase interface { Address() wgaddr.Address ToInterface() *net.Interface Up() (*udpmux.UniversalUDPMuxDefault, error) - UpdateAddr(newAddr string) error + UpdateAddr(newAddr wgaddr.Address) error GetProxy() wgproxy.Proxy GetProxyPort() uint16 UpdatePeer(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error @@ -44,4 +44,5 @@ type wgIfaceBase interface { FullStats() (*configurer.Stats, error) LastActivities() map[string]monotime.Time SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error + MTU() uint16 } diff --git a/client/internal/ingressgw/manager.go b/client/internal/ingressgw/manager.go index b8952e5c0..605543d1c 100644 --- a/client/internal/ingressgw/manager.go +++ b/client/internal/ingressgw/manager.go @@ -24,14 +24,14 @@ type RulePair struct { type Manager struct { dnatFirewall DNATFirewall - rules map[string]RulePair // keys is the ID of the ForwardRule + rules map[firewall.RuleID]RulePair rulesMu sync.Mutex } func NewManager(dnatFirewall DNATFirewall) *Manager { return &Manager{ dnatFirewall: dnatFirewall, - rules: make(map[string]RulePair), + rules: make(map[firewall.RuleID]RulePair), } } @@ -41,7 +41,7 @@ func (h *Manager) Update(forwardRules []firewall.ForwardRule) error { var mErr *multierror.Error - toDelete := make(map[string]RulePair, len(h.rules)) + toDelete := make(map[firewall.RuleID]RulePair, len(h.rules)) for id, r := range h.rules { toDelete[id] = r } @@ -59,6 +59,10 @@ func (h *Manager) Update(forwardRules []firewall.ForwardRule) error { mErr = multierror.Append(mErr, fmt.Errorf("add forward rule '%s': %v", fwdRule.String(), err)) continue } + if rule == nil { + mErr = multierror.Append(mErr, fmt.Errorf("add forward rule '%s': backend returned no rule", fwdRule.String())) + continue + } log.Infof("forward rule has been added '%s'", fwdRule) h.rules[id] = RulePair{ ForwardRule: fwdRule, @@ -90,7 +94,7 @@ func (h *Manager) Close() error { } } - h.rules = make(map[string]RulePair) + h.rules = make(map[firewall.RuleID]RulePair) return nberrors.FormatErrorOrNil(mErr) } diff --git a/client/internal/ingressgw/manager_test.go b/client/internal/ingressgw/manager_test.go index 591ea0dd8..0cd40fcc4 100644 --- a/client/internal/ingressgw/manager_test.go +++ b/client/internal/ingressgw/manager_test.go @@ -14,11 +14,11 @@ var ( ) type MocFwRule struct { - id string + id firewall.RuleID } -func (m *MocFwRule) ID() string { - return string(m.id) +func (m *MocFwRule) ID() firewall.RuleID { + return m.id } type MockDNATFirewall struct { diff --git a/client/internal/ipcauth/creds_stub.go b/client/internal/ipcauth/creds_stub.go new file mode 100644 index 000000000..154948716 --- /dev/null +++ b/client/internal/ipcauth/creds_stub.go @@ -0,0 +1,31 @@ +//go:build !linux && !darwin && !freebsd && !windows + +package ipcauth + +import ( + "errors" + "net" + + "google.golang.org/grpc/credentials" +) + +// errUnsupported is returned on platforms with no local peer-identity +// primitive, so consumers fail closed instead of guessing an identity. +var errUnsupported = errors.New("peer identity is not available on this platform") + +// NewTransportCredentials returns nil: without a peer-identity primitive the +// daemon cannot authenticate local callers, and the caller must treat that as +// "authorization cannot be enforced". +func NewTransportCredentials() credentials.TransportCredentials { + return nil +} + +// PeerIdentity always fails on this platform. +func PeerIdentity(net.Conn) (Identity, error) { + return Identity{}, errUnsupported +} + +// ConnIdentity always fails on this platform. +func ConnIdentity(net.Conn) (Identity, error) { + return Identity{}, errUnsupported +} diff --git a/client/internal/ipcauth/creds_unix.go b/client/internal/ipcauth/creds_unix.go new file mode 100644 index 000000000..688fe4623 --- /dev/null +++ b/client/internal/ipcauth/creds_unix.go @@ -0,0 +1,56 @@ +//go:build linux || darwin || freebsd + +package ipcauth + +import ( + "context" + "net" + + "google.golang.org/grpc/credentials" +) + +// NewTransportCredentials returns gRPC transport credentials that expose the +// caller's kernel-authenticated identity via IdentityFromContext. It returns +// nil on platforms that have no peer-identity primitive, which the caller must +// treat as "authorization cannot be enforced". +// +// The handshake exchanges no bytes on the wire, so a client dialing with +// insecure credentials interoperates with a server using these. That keeps +// older CLI and UI binaries working against an upgraded daemon. +func NewTransportCredentials() credentials.TransportCredentials { + return unixCreds{} +} + +// ConnIdentity extracts the caller's identity from an accepted local IPC +// connection. It is shared by the gRPC transport credentials and by the JSON +// gateway, which reads the identity of its own HTTP clients. +func ConnIdentity(conn net.Conn) (Identity, error) { + return PeerIdentity(conn) +} + +type unixCreds struct{} + +func (unixCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) { + return conn, AuthInfo{}, nil +} + +// ServerHandshake extracts the peer identity and fails closed when it cannot +// be read, so a connection whose caller is unknown never reaches a handler. +func (unixCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) { + id, err := ConnIdentity(conn) + if err != nil { + return nil, nil, err + } + return conn, AuthInfo{ + CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity}, + Identity: id, + }, nil +} + +func (unixCreds) Info() credentials.ProtocolInfo { + return credentials.ProtocolInfo{SecurityProtocol: AuthInfo{}.AuthType()} +} + +func (unixCreds) Clone() credentials.TransportCredentials { return unixCreds{} } + +func (unixCreds) OverrideServerName(string) error { return nil } diff --git a/client/internal/ipcauth/creds_windows.go b/client/internal/ipcauth/creds_windows.go new file mode 100644 index 000000000..37f902c52 --- /dev/null +++ b/client/internal/ipcauth/creds_windows.go @@ -0,0 +1,194 @@ +//go:build windows + +package ipcauth + +import ( + "context" + "fmt" + "net" + "runtime" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" + "google.golang.org/grpc/credentials" +) + +var ( + modadvapi32 = windows.NewLazySystemDLL("advapi32.dll") + procImpersonateNamedPipeClient = modadvapi32.NewProc("ImpersonateNamedPipeClient") +) + +// DefaultPipeSDDL is the security descriptor for the daemon control pipe. +// +// D:P protected DACL, no inheritance +// (A;;GA;;;SY) allow GENERIC_ALL to LocalSystem (the daemon's service account) +// (A;;GA;;;WD) allow GENERIC_ALL to Everyone +// +// Any local caller may connect, as with a Unix socket at 0666; what a caller may +// actually do is decided from its token, not from the DACL. Remote callers are not +// a concern here: winio.ListenPipe creates the pipe with +// FILE_PIPE_REJECT_REMOTE_CLIENTS, so NPFS rejects connections from other machines +// before the descriptor is consulted. +// +// A deny ACE on the NETWORK SID would not add anything and would break callers: +// that SID is present in any network-logon token, which includes OpenSSH and WinRM +// sessions, so it denies administrators driving the CLI over SSH and denies the +// daemon itself when started from such a session. +func DefaultPipeSDDL() string { + return "D:P(A;;GA;;;SY)(A;;GA;;;WD)" +} + +// NewTransportCredentials returns gRPC transport credentials that derive the +// caller's identity from the named-pipe client token. +// +// The client must connect at SECURITY_IDENTIFICATION for the daemon to be able +// to read its token, which is what DialNamedPipe does. +func NewTransportCredentials() credentials.TransportCredentials { + return winpipeCreds{} +} + +// ConnIdentity extracts the caller's identity from an accepted named-pipe +// connection by impersonating the pipe client and reading its token. It is +// shared by the gRPC transport credentials and by the JSON gateway, which +// reads the identity of its own HTTP clients. +func ConnIdentity(conn net.Conn) (Identity, error) { + // go-winio's pipe connection embeds *win32File, which exposes Fd(). + fdConn, ok := conn.(interface{ Fd() uintptr }) + if !ok { + return Identity{}, fmt.Errorf("connection %T does not expose a pipe handle", conn) + } + return pipeClientIdentity(windows.Handle(fdConn.Fd())) +} + +type winpipeCreds struct{} + +func (winpipeCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) { + return conn, AuthInfo{}, nil +} + +// ServerHandshake extracts the connecting client's identity and fails closed +// when the handle or token cannot be read, so a connection whose caller is +// unknown never reaches a handler. +func (winpipeCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) { + id, err := ConnIdentity(conn) + if err != nil { + return nil, nil, err + } + return conn, AuthInfo{ + CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity}, + Identity: id, + }, nil +} + +func (winpipeCreds) Info() credentials.ProtocolInfo { + return credentials.ProtocolInfo{SecurityProtocol: AuthInfo{}.AuthType()} +} + +func (winpipeCreds) Clone() credentials.TransportCredentials { return winpipeCreds{} } + +func (winpipeCreds) OverrideServerName(string) error { return nil } + +// pipeClientIdentity reads the connecting client's user SID, usable group +// SIDs, and elevation state by impersonating the pipe client on this thread +// and reading the resulting impersonation token. +func pipeClientIdentity(handle windows.Handle) (id Identity, err error) { + // Impersonation is per-thread, so the goroutine must stay on this thread + // until RevertToSelf, otherwise an unrelated goroutine could inherit the + // impersonated context. + runtime.LockOSThread() + + // The thread only goes back to the runtime's pool once it is provably no + // longer impersonating the client. If the revert fails, leaving it locked + // makes Go terminate it when this goroutine exits, which costs one thread + // and keeps a thread running as the client from ever being reused. + clean := false + defer func() { + if clean { + runtime.UnlockOSThread() + } + }() + + if err = impersonateNamedPipeClient(handle); err != nil { + clean = true + return Identity{}, fmt.Errorf("impersonate named pipe client: %w", err) + } + defer func() { + // Surface the revert failure only when nothing else failed: leaving + // the thread impersonated is worse than the original error. + revErr := windows.RevertToSelf() + if revErr != nil { + if err == nil { + err = fmt.Errorf("revert impersonation: %w", revErr) + } + return + } + clean = true + }() + + // openAsSelf=true opens the token with the daemon's own process context + // rather than the impersonated client's, so the open cannot fail because + // the client lacks access to its own token. + var token windows.Token + if err = windows.OpenThreadToken(windows.CurrentThread(), windows.TOKEN_QUERY, true, &token); err != nil { + return Identity{}, fmt.Errorf("open thread token: %w", err) + } + defer func() { + if cerr := token.Close(); cerr != nil { + log.Debugf("close client token: %v", cerr) + } + }() + + return identityFromToken(token) +} + +// identityFromToken reads the user SID, usable group SIDs and elevation state +// out of a Windows token. +func identityFromToken(token windows.Token) (Identity, error) { + user, err := token.GetTokenUser() + if err != nil { + return Identity{}, fmt.Errorf("read token user: %w", err) + } + + groups, err := tokenGroupSIDs(token) + if err != nil { + return Identity{}, err + } + + return Identity{ + SID: user.User.Sid.String(), + Groups: groups, + Elevated: token.IsElevated(), + }, nil +} + +// tokenGroupSIDs returns the SIDs of the groups the token can actually +// exercise. Groups that are disabled or marked deny-only are skipped: a +// UAC-filtered administrator carries BUILTIN\Administrators as deny-only, and +// treating that as membership would hand every admin account privilege it +// cannot currently use. +func tokenGroupSIDs(token windows.Token) ([]string, error) { + tg, err := token.GetTokenGroups() + if err != nil { + return nil, fmt.Errorf("read token groups: %w", err) + } + + var sids []string + for _, g := range tg.AllGroups() { + if g.Attributes&windows.SE_GROUP_ENABLED == 0 { + continue + } + if g.Attributes&windows.SE_GROUP_USE_FOR_DENY_ONLY != 0 { + continue + } + sids = append(sids, g.Sid.String()) + } + return sids, nil +} + +func impersonateNamedPipeClient(h windows.Handle) error { + r, _, e := procImpersonateNamedPipeClient.Call(uintptr(h)) + if r == 0 { + return e + } + return nil +} diff --git a/client/internal/ipcauth/forward.go b/client/internal/ipcauth/forward.go new file mode 100644 index 000000000..57749528c --- /dev/null +++ b/client/internal/ipcauth/forward.go @@ -0,0 +1,272 @@ +package ipcauth + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "fmt" + "slices" + "strconv" + "strings" + + "google.golang.org/grpc/metadata" +) + +// Metadata keys the local JSON gateway uses to forward the identity of its own +// HTTP client to the daemon. The gateway runs inside the daemon process and +// re-dials the daemon over the control socket, so without forwarding every +// JSON request would appear to come from the daemon itself. +const ( + // mdFwd marks a request as forwarded by the JSON gateway. It is always + // set, even when the gateway could not read its client's identity, so the + // daemon can tell "no identity available" apart from "not forwarded". + mdFwd = "x-netbird-fwd" + mdFwdUID = "x-netbird-fwd-uid" // Unix user ID + mdFwdGID = "x-netbird-fwd-gid" // Unix primary group ID + mdFwdSID = "x-netbird-fwd-sid" // Windows user SID + mdFwdGroup = "x-netbird-fwd-group" // Windows group SID, repeated + mdFwdElevated = "x-netbird-fwd-elevated" // Windows, "1" when elevated + + // mdFwdProof proves the forwarded identity was stamped by this process. The + // gateway runs inside the daemon, so a secret held in memory is available to + // the only legitimate producer and to nothing else. + mdFwdProof = "x-netbird-fwd-proof" +) + +// forwardKeys is every metadata key the gateway sets. An HTTP client must never +// be able to supply one itself: see IsReservedForwardKey. +var forwardKeys = []string{mdFwd, mdFwdUID, mdFwdGID, mdFwdSID, mdFwdGroup, mdFwdElevated, mdFwdProof} + +// forwardProof authenticates the gateway's forwarding metadata. It is generated +// once per daemon process and never leaves it: it is not written to disk, not +// logged, and not sent anywhere except over the daemon's own control socket to +// itself. +// +// Without it, trusting a forwarded identity rests on every layer in front of it +// stripping incoming forwarding keys, and on each key's value shape being +// distinguishable from an injected one. A single injected group SID or an +// injected "elevated" flag has the same shape as a legitimate one, so no +// cardinality rule can catch it. Requiring the proof means metadata that did not +// come from this process is refused whatever it contains. +var forwardProof = mustForwardProof() + +func mustForwardProof() string { + var buf [32]byte + if _, err := rand.Read(buf[:]); err != nil { + // Continuing would leave the forwarded path authenticated by a + // predictable value, which is worse than not starting. + panic(fmt.Sprintf("generate identity forwarding proof: %v", err)) + } + return hex.EncodeToString(buf[:]) +} + +// IsReservedForwardKey reports whether a gRPC metadata key belongs to the +// gateway's identity forwarding, and therefore must be dropped when it arrives +// from outside. +// +// grpc-gateway maps "Grpc-Metadata-" request headers into gRPC metadata and +// joins them ahead of the values its own annotators add. Without dropping these, +// an HTTP client could hand the daemon "x-netbird-fwd-uid: 0" and be believed, +// because the daemon trusts forwarded metadata when the transport peer is the +// (privileged) gateway. +func IsReservedForwardKey(key string) bool { + key = strings.ToLower(key) + return slices.Contains(forwardKeys, key) +} + +// ForwardIdentityMetadata encodes an HTTP client's identity for the JSON +// gateway to forward to the daemon. When known is false only the marker is +// set, which makes the daemon treat the caller as unidentified rather than as +// the daemon itself. +func ForwardIdentityMetadata(id Identity, known bool) metadata.MD { + md := metadata.MD{} + md.Set(mdFwd, "1") + md.Set(mdFwdProof, forwardProof) + if !known { + return md + } + + if id.IsWindows() { + md.Set(mdFwdSID, id.SID) + if len(id.Groups) > 0 { + md.Set(mdFwdGroup, id.Groups...) + } + if id.Elevated { + md.Set(mdFwdElevated, "1") + } + return md + } + + md.Set(mdFwdUID, strconv.FormatUint(uint64(id.UID), 10)) + md.Set(mdFwdGID, strconv.FormatUint(uint64(id.GID), 10)) + return md +} + +// CallerIdentity returns the identity to authorize a request against. For a +// direct connection that is the transport peer's kernel identity. For a +// request relayed by the local JSON gateway it is the identity the gateway +// forwarded, since the transport peer is then the daemon itself. +// +// A forwarded identity is only honoured when the transport peer is the daemon's +// own identity and the metadata carries this process's forwarding proof, so +// forged forwarding metadata gains a caller nothing. A forwarded request that +// carries no identity is reported as unidentified, never as the daemon. +// +// The second return value is false when no identity could be established, and +// callers MUST fail closed in that case. +func CallerIdentity(ctx context.Context) (Identity, bool) { + id, ok := IdentityFromContext(ctx) + if !ok { + return Identity{}, false + } + + // A forwarding key that arrives more than once did not come from the gateway + // alone, so nothing about the request can be trusted to describe its caller. + // Refusing outright matters because the alternative reading, "not forwarded", + // would authorize the request as the transport peer, which on the gateway's + // connection is the daemon itself. + if duplicatedForwardKey(ctx) { + return Identity{}, false + } + + forwarded := isForwarded(ctx) + + // Our own process on the other end of the socket is the JSON gateway, the only + // thing that dials the daemon from inside it. Such a call must carry a + // forwarded identity; without one there is no caller to authorize, and + // treating it as the daemon would authorize whatever reached the JSON socket. + // Only Linux reports the peer PID, so this is a belt on top of the gateway's + // interceptor rather than the sole guarantee. + if id.PID != 0 && int(id.PID) == selfPID && !forwarded { + return Identity{}, false + } + + // Only the gateway's own connection may speak for someone else. Being + // privileged is not enough and not the point: the gateway runs inside the + // daemon, so it dials as the daemon's identity whatever user that is, which + // also covers a rootless container. + if !forwarded || !IsDaemonSelf(id) { + return id, true + } + + // Speaking for someone else additionally requires the proof only this process + // holds. Refusing is the only safe reading: the transport peer here is the + // daemon itself, so falling back to it would authorize the request as the + // daemon. This is also what makes the forwarded values trustworthy once + // accepted, so they need no shape checks of their own. + if !authenticForward(ctx) { + return Identity{}, false + } + + return forwardedIdentity(ctx) +} + +// duplicatedForwardKey reports whether any forwarding key carries more than one +// value. The gateway's interceptor sets each key exactly once and replaces what +// was already there, so a repeat means a second source supplied it. +func duplicatedForwardKey(ctx context.Context) bool { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return false + } + for _, key := range forwardKeys { + // Group SIDs are legitimately repeated; the rest identify the caller. + if key == mdFwdGroup { + continue + } + if len(md.Get(key)) > 1 { + return true + } + } + return false +} + +// authenticForward reports whether the request carries this process's forwarding +// proof, which only the in-process JSON gateway can supply. +func authenticForward(ctx context.Context) bool { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return false + } + got := mdSingle(md, mdFwdProof) + return subtle.ConstantTimeCompare([]byte(got), []byte(forwardProof)) == 1 +} + +// isForwarded reports whether the request carries the JSON gateway marker. +func isForwarded(ctx context.Context) bool { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return false + } + return mdSingle(md, mdFwd) != "" +} + +// forwardedIdentity decodes the identity the JSON gateway attached. +func forwardedIdentity(ctx context.Context) (Identity, bool) { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return Identity{}, false + } + + if sid := mdSingle(md, mdFwdSID); sid != "" { + return Identity{ + SID: sid, + // Repeated by design, one value per group, and only reachable once + // the forwarding proof has been verified. + Groups: md.Get(mdFwdGroup), + Elevated: mdSingle(md, mdFwdElevated) == "1", + }, true + } + + uid, err := strconv.ParseUint(mdSingle(md, mdFwdUID), 10, 32) + if err != nil { + return Identity{}, false + } + + id := Identity{UID: uint32(uid)} + if gid, err := strconv.ParseUint(mdSingle(md, mdFwdGID), 10, 32); err == nil { + id.GID = uint32(gid) + } + return id, true +} + +// mdSingle returns the value of a forwarded key only when exactly one was +// supplied. The gateway's interceptor sets each key exactly once, so more than one +// value means something else also supplied it, and the whole identity is treated as +// unknown rather than picking a winner. Defence in depth behind the gateway's +// header filter. +func mdSingle(md metadata.MD, key string) string { + if v := md.Get(key); len(v) == 1 { + return v[0] + } + return "" +} + +// WithForwardedIdentity stamps id onto a context's outgoing metadata for the JSON +// gateway's call to the daemon, replacing any forwarding keys already present so +// values supplied from outside cannot survive alongside it. +// +// This is deliberately not done with runtime.WithMetadata: grpc-gateway skips its +// annotators entirely when no request header maps to metadata ("if len(pairs) == 0 +// { return ctx, nil, nil }", runtime/context.go), which an HTTP/1.0 request with no +// Host header over a unix socket achieves. The daemon would then see an unmarked +// call whose transport peer is the daemon's own identity, and authorize it as the +// daemon. A client interceptor runs for every RPC regardless of headers. +func WithForwardedIdentity(ctx context.Context, id Identity, known bool) context.Context { + md, ok := metadata.FromOutgoingContext(ctx) + if !ok { + md = metadata.MD{} + } else { + md = md.Copy() + } + + for _, key := range forwardKeys { + delete(md, key) + } + for key, values := range ForwardIdentityMetadata(id, known) { + md[key] = values + } + + return metadata.NewOutgoingContext(ctx, md) +} diff --git a/client/internal/ipcauth/forward_test.go b/client/internal/ipcauth/forward_test.go new file mode 100644 index 000000000..d9adf05da --- /dev/null +++ b/client/internal/ipcauth/forward_test.go @@ -0,0 +1,214 @@ +package ipcauth + +import ( + "context" + "testing" + + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/peer" +) + +// transportCtx builds a request context as the daemon's transport credentials +// would: the identity of whoever opened the socket, plus whatever metadata the +// request carried. +func transportCtx(id Identity, md metadata.MD) context.Context { + ctx := peer.NewContext(context.Background(), &peer.Peer{ + AuthInfo: AuthInfo{ + CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity}, + Identity: id, + }, + }) + if md != nil { + ctx = metadata.NewIncomingContext(ctx, md) + } + return ctx +} + +var ( + root = Identity{UID: 0} + unprivUser = Identity{UID: 1000, GID: 1000} +) + +// asDaemon pins which identity counts as this process for the duration of a test. +// Without it the test binary's own uid decides, which silently changes what +// "the gateway" means. +func asDaemon(t *testing.T, id Identity) { + t.Helper() + prevID, prevKnown, prevDelegate := selfIdentity, selfKnown, selfMayDelegate + t.Cleanup(func() { selfIdentity, selfKnown, selfMayDelegate = prevID, prevKnown, prevDelegate }) + selfIdentity, selfKnown = id, true + selfMayDelegate = !id.IsPrivileged() +} + +func TestCallerIdentity_DirectConnections(t *testing.T) { + t.Run("no transport credentials is not an identity", func(t *testing.T) { + if _, ok := CallerIdentity(context.Background()); ok { + t.Fatal("a caller with no credentials must not be identified") + } + }) + + t.Run("a direct caller is its transport identity", func(t *testing.T) { + id, ok := CallerIdentity(transportCtx(unprivUser, nil)) + if !ok || id.UID != 1000 { + t.Fatalf("got %v ok=%t, want uid 1000", id, ok) + } + }) + + // The whole point of honouring forwarded metadata only from a privileged + // transport peer: an unprivileged caller can set any metadata it likes on its + // own connection to the daemon socket. + t.Run("an unprivileged caller cannot forge an identity", func(t *testing.T) { + asDaemon(t, root) + forged := metadata.Pairs(mdFwd, "1", mdFwdUID, "0", mdFwdGID, "0") + id, ok := CallerIdentity(transportCtx(unprivUser, forged)) + if !ok { + t.Fatal("caller should still be identified, as itself") + } + if id.IsPrivileged() || id.UID != 1000 { + t.Fatalf("forged metadata was believed: got %v", id) + } + }) +} + +func TestCallerIdentity_GatewayForwarding(t *testing.T) { + t.Run("the gateway's client identity is used, not the gateway's own", func(t *testing.T) { + asDaemon(t, root) + md := ForwardIdentityMetadata(unprivUser, true) + id, ok := CallerIdentity(transportCtx(root, md)) + if !ok { + t.Fatal("forwarded identity should be usable") + } + if id.IsPrivileged() || id.UID != 1000 { + t.Fatalf("got %v, want the forwarded uid 1000 and not privileged", id) + } + }) + + t.Run("a privileged gateway client stays privileged", func(t *testing.T) { + asDaemon(t, root) + md := ForwardIdentityMetadata(root, true) + id, ok := CallerIdentity(transportCtx(root, md)) + if !ok || !id.IsPrivileged() { + t.Fatalf("got %v ok=%t, want a privileged identity", id, ok) + } + }) + + // A JSON socket the gateway cannot read peer credentials from (a TCP socket, + // say) must not make every request look like the daemon itself. + t.Run("an unreadable client identity is unknown, not the daemon", func(t *testing.T) { + asDaemon(t, root) + md := ForwardIdentityMetadata(Identity{}, false) + if _, ok := CallerIdentity(transportCtx(root, md)); ok { + t.Fatal("a forwarded request with no identity must not be identified") + } + }) + + // grpc-gateway turns Grpc-Metadata- headers into gRPC metadata and joins + // them ahead of its annotators' values. If an HTTP client's header survived + // that, this is the shape the daemon would see: the attacker's uid 0 first, + // the real uid second. The gateway filters those headers out, and reading a + // duplicated key as unknown makes the daemon safe even if it did not. + t.Run("a duplicated key from an injected header is not believed", func(t *testing.T) { + asDaemon(t, root) + md := metadata.MD{} + md.Append(mdFwd, "1") + md.Append(mdFwdUID, "0") // injected by the HTTP client + md.Append(mdFwdUID, "1000") // appended by the gateway's annotator + if id, ok := CallerIdentity(transportCtx(root, md)); ok { + t.Fatalf("injected uid was accepted: got %v", id) + } + }) + + t.Run("a duplicated marker is not believed either", func(t *testing.T) { + asDaemon(t, root) + md := metadata.MD{} + md.Append(mdFwd, "1") + md.Append(mdFwd, "1") + md.Append(mdFwdUID, "1000") + // A repeated marker must not be read as "not forwarded": that would + // authorize the request as the transport peer, which on the gateway's + // connection is the daemon itself. + if id, ok := CallerIdentity(transportCtx(root, md)); ok { + t.Fatalf("a duplicated marker was believed: got %v", id) + } + }) + + // The layers in front of this (the gateway's header matcher, and its + // interceptor replacing every forwarding key) are what keep outside metadata + // from arriving at all. The proof is what the daemon can check for itself, and + // it is the only defence that works for a value whose legitimate shape is + // indistinguishable from an injected one: a lone group SID, or "elevated". + t.Run("forwarding metadata without this process's proof is refused", func(t *testing.T) { + asDaemon(t, root) + for name, md := range map[string]metadata.MD{ + "no proof": metadata.Pairs(mdFwd, "1", mdFwdUID, "0"), + "wrong proof": metadata.Pairs(mdFwd, "1", mdFwdUID, "0", mdFwdProof, "deadbeef"), + "windows identity without a proof": metadata.Pairs(mdFwd, "1", + mdFwdSID, "S-1-5-21-1-2-3-1001", mdFwdGroup, sidAdministrators, mdFwdElevated, "1"), + } { + t.Run(name, func(t *testing.T) { + if id, ok := CallerIdentity(transportCtx(root, md)); ok { + t.Fatalf("unstamped forwarding metadata was believed: got %v", id) + } + }) + } + }) + + // A caller that reaches the gateway cannot see the proof, so it cannot append + // a group of its own to a genuine forwarded identity: doing so would have to + // go through the interceptor, which replaces the whole set. + t.Run("a group appended to a stamped identity does not survive the interceptor", func(t *testing.T) { + asDaemon(t, root) + injected := metadata.MD{} + injected.Append(mdFwdGroup, sidAdministrators) + + ctx := WithForwardedIdentity(metadata.NewOutgoingContext(context.Background(), injected), + Identity{SID: "S-1-5-21-1-2-3-1001"}, true) + out, ok := metadata.FromOutgoingContext(ctx) + if !ok { + t.Fatal("no outgoing metadata") + } + if groups := out.Get(mdFwdGroup); len(groups) != 0 { + t.Fatalf("injected group survived: %v", groups) + } + }) +} + +func TestIsReservedForwardKey(t *testing.T) { + for _, key := range forwardKeys { + if !IsReservedForwardKey(key) { + t.Errorf("%q must be reserved", key) + } + } + + // grpc-gateway canonicalises header names, so the check has to be + // case-insensitive. + if !IsReservedForwardKey("X-Netbird-Fwd-Uid") { + t.Error("the check must be case-insensitive") + } + + for _, key := range []string{"authorization", "x-netbird", "x-netbird-fwd-uid-extra", ""} { + if IsReservedForwardKey(key) { + t.Errorf("%q must not be reserved", key) + } + } +} + +func TestForwardIdentityMetadata_AlwaysMarksForwarded(t *testing.T) { + for _, tc := range []struct { + name string + id Identity + known bool + }{ + {"known unix identity", unprivUser, true}, + {"unknown identity", Identity{}, false}, + {"windows identity", Identity{SID: "S-1-5-21-1-2-3-1001", Elevated: true}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + md := ForwardIdentityMetadata(tc.id, tc.known) + if got := md.Get(mdFwd); len(got) != 1 || got[0] != "1" { + t.Fatalf("marker = %v, want exactly one \"1\"", got) + } + }) + } +} diff --git a/client/internal/ipcauth/identity.go b/client/internal/ipcauth/identity.go new file mode 100644 index 000000000..d7d10f57d --- /dev/null +++ b/client/internal/ipcauth/identity.go @@ -0,0 +1,140 @@ +// Package ipcauth provides the kernel-authenticated identity of a local IPC +// (gRPC) caller and the transport credentials that surface it into the gRPC +// context, so the daemon can authorize individual RPCs by caller identity. +// +// On Unix the identity is read from the kernel via SO_PEERCRED (Linux) or +// LOCAL_PEERCRED (Darwin/FreeBSD). On Windows it is derived from the +// named-pipe client token. Platforms without a peer-identity primitive get no +// credentials, and every consumer must fail closed when no identity is +// available. +package ipcauth + +import ( + "context" + "fmt" + "slices" + + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/peer" +) + +// Well-known Windows SIDs that identify a fully privileged principal. +const ( + sidLocalSystem = "S-1-5-18" // NT AUTHORITY\SYSTEM + sidLocalService = "S-1-5-19" // NT AUTHORITY\LOCAL SERVICE + sidNetworkService = "S-1-5-20" // NT AUTHORITY\NETWORK SERVICE + sidAdministrators = "S-1-5-32-544" // BUILTIN\Administrators +) + +// Identity is the kernel-authenticated identity of a local IPC caller. The +// zero value is not a valid identity: consumers must only use one obtained +// with a true ok/nil error return. +type Identity struct { + // UID and GID are the caller's Unix user ID and primary group ID. Both are + // zero on Windows, where SID is authoritative instead. + UID uint32 + GID uint32 + + // SID is the caller's Windows security identifier, empty on Unix. + SID string + + // Groups holds the caller's Windows group SIDs, captured from the client + // token at handshake time. Only groups that are enabled and not + // deny-only are captured, so a group listed here is one the caller can + // actually exercise. Empty on Unix. + Groups []string + + // Elevated reports whether the Windows client token is elevated (running + // as administrator, or an administrator with UAC turned off). Always false + // on Unix, where privilege is uid 0. + Elevated bool + + // PID is the caller's process ID where the platform reports it (Linux's + // SO_PEERCRED), and 0 where it does not. It identifies the daemon's own + // process dialling itself, which is what the JSON gateway does, and is never + // used to grant anything. + PID int32 +} + +// IsWindows reports whether this identity is a Windows principal (SID-based) +// rather than a Unix uid/gid principal. +func (i Identity) IsWindows() bool { + return i.SID != "" +} + +// IsPrivileged reports whether the caller is the platform's administrative +// principal, which is what the daemon requires for changes that cross the +// user-to-root boundary. +// +// On Windows the decision comes from the caller's token rather than from +// account names or group RIDs: an elevated token, one of the service accounts +// the daemon itself may run as, or a token with BUILTIN\Administrators +// enabled. A UAC-filtered administrator has that group marked deny-only, and +// deny-only groups are dropped when the identity is captured, so such a +// caller is correctly reported as unprivileged. Domain group memberships +// (Domain Admins and friends) are deliberately not consulted: they say +// nothing about what this token may do on this machine. +func (i Identity) IsPrivileged() bool { + if !i.IsWindows() { + return i.UID == 0 + } + + if i.Elevated { + return true + } + + switch i.SID { + case sidLocalSystem, sidLocalService, sidNetworkService: + return true + } + + return slices.Contains(i.Groups, sidAdministrators) +} + +// SameUser reports whether two identities are the same local principal. Only +// the account is compared: the group set and the elevation flag describe what a +// token may do, not who it belongs to. A SID on either side decides the +// comparison, so a Windows principal never matches a Unix one on the UID both +// happen to leave at zero. The zero Identity carries uid 0, so callers must +// establish that both identities are real before the answer means anything. +func (i Identity) SameUser(other Identity) bool { + if i.SID != "" || other.SID != "" { + return i.SID == other.SID + } + return i.UID == other.UID +} + +// String renders the identity for audit logs and denial messages. +func (i Identity) String() string { + if i.IsWindows() { + return fmt.Sprintf("sid=%s elevated=%t", i.SID, i.Elevated) + } + return fmt.Sprintf("uid=%d gid=%d", i.UID, i.GID) +} + +// AuthInfo carries the peer Identity as a gRPC credentials.AuthInfo so +// handlers can retrieve it from the request context via IdentityFromContext. +type AuthInfo struct { + credentials.CommonAuthInfo + Identity Identity +} + +// AuthType identifies the authentication scheme. +func (AuthInfo) AuthType() string { return "netbird-ipc-peercred" } + +// IdentityFromContext extracts the caller's kernel-authenticated identity from +// the gRPC peer context. The second return value is false when no IPC +// transport credentials were negotiated, which happens on a TCP daemon socket +// and on platforms without a peer-identity primitive. Callers MUST fail closed +// in that case. +func IdentityFromContext(ctx context.Context) (Identity, bool) { + p, ok := peer.FromContext(ctx) + if !ok { + return Identity{}, false + } + info, ok := p.AuthInfo.(AuthInfo) + if !ok { + return Identity{}, false + } + return info.Identity, true +} diff --git a/client/internal/ipcauth/identity_sameuser_test.go b/client/internal/ipcauth/identity_sameuser_test.go new file mode 100644 index 000000000..c98f583db --- /dev/null +++ b/client/internal/ipcauth/identity_sameuser_test.go @@ -0,0 +1,66 @@ +package ipcauth + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIdentitySameUser(t *testing.T) { + tests := []struct { + name string + a Identity + b Identity + want bool + }{ + { + name: "same uid", + a: Identity{UID: 1000, GID: 1000}, + b: Identity{UID: 1000, GID: 1000}, + want: true, + }, + { + name: "same uid, different gid and pid still the same user", + a: Identity{UID: 1000, GID: 1000, PID: 11}, + b: Identity{UID: 1000, GID: 27, PID: 22}, + want: true, + }, + { + name: "different uid", + a: Identity{UID: 1000}, + b: Identity{UID: 1001}, + want: false, + }, + { + name: "same sid", + a: Identity{SID: "S-1-5-21-1-2-3-1001"}, + b: Identity{SID: "S-1-5-21-1-2-3-1001"}, + want: true, + }, + { + name: "same sid, elevation and groups differ", + a: Identity{SID: "S-1-5-21-1-2-3-1001", Elevated: true, Groups: []string{sidAdministrators}}, + b: Identity{SID: "S-1-5-21-1-2-3-1001"}, + want: true, + }, + { + name: "different sid", + a: Identity{SID: "S-1-5-21-1-2-3-1001"}, + b: Identity{SID: "S-1-5-21-1-2-3-1002"}, + want: false, + }, + { + name: "a windows principal is never a unix one", + a: Identity{SID: "S-1-5-18"}, + b: Identity{UID: 0}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.a.SameUser(tt.b)) + assert.Equal(t, tt.want, tt.b.SameUser(tt.a), "SameUser must be symmetric") + }) + } +} diff --git a/client/internal/ipcauth/ownedfile.go b/client/internal/ipcauth/ownedfile.go new file mode 100644 index 000000000..be7bf4864 --- /dev/null +++ b/client/internal/ipcauth/ownedfile.go @@ -0,0 +1,63 @@ +package ipcauth + +import ( + "fmt" + "os" +) + +// OpenOwnedFile opens path for reading on behalf of the IPC caller identified by +// id, and fails unless the opened file is a regular file that id owns. +// +// It exists for the paths a local caller hands to the daemon over the IPC. The +// daemon runs as root, so opening such a path unchecked lets any local user read +// any file through it. Ownership is the invariant that keeps the daemon from +// reading, with its own privileges, a file the caller could not read itself: a +// symlink or hard link planted at the path resolves to a file someone else owns +// and is refused. +// +// The check is made against the open descriptor rather than the path, so +// swapping the path between the check and the read cannot change the answer. +// +// A privileged caller is exempt: it can read the file directly, so refusing it +// here would protect nothing. The regular-file requirement still applies to +// everyone, since a fifo or device planted at the path is never a log file. +func OpenOwnedFile(id Identity, path string) (*os.File, error) { + f, err := openForRead(path) + if err != nil { + return nil, err + } + + if err := checkOwnership(id, f); err != nil { + if cerr := f.Close(); cerr != nil { + return nil, fmt.Errorf("%w (close: %v)", err, cerr) + } + return nil, err + } + + return f, nil +} + +func checkOwnership(id Identity, f *os.File) error { + info, err := f.Stat() + if err != nil { + return fmt.Errorf("stat %s: %w", f.Name(), err) + } + + if !info.Mode().IsRegular() { + return fmt.Errorf("%s is not a regular file", f.Name()) + } + + if IsPrivilegedCaller(id) { + return nil + } + + owned, err := fileOwnedBy(id, f) + if err != nil { + return fmt.Errorf("read owner of %s: %w", f.Name(), err) + } + if !owned { + return fmt.Errorf("%s is not owned by the caller (%s)", f.Name(), id) + } + + return nil +} diff --git a/client/internal/ipcauth/ownedfile_test.go b/client/internal/ipcauth/ownedfile_test.go new file mode 100644 index 000000000..b8178ad45 --- /dev/null +++ b/client/internal/ipcauth/ownedfile_test.go @@ -0,0 +1,64 @@ +package ipcauth + +import ( + "io" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/require" +) + +// otherIdentity is an unprivileged caller that owns nothing the test creates. +func otherIdentity(t *testing.T) Identity { + t.Helper() + if runtime.GOOS == "windows" { + return Identity{SID: "S-1-5-21-1-2-3-1001"} + } + return Identity{UID: uint32(os.Geteuid() + 1), GID: uint32(os.Getegid() + 1)} +} + +func TestOpenOwnedFileReadsFileOwnedByCaller(t *testing.T) { + path := filepath.Join(t.TempDir(), "gui-client.log") + require.NoError(t, os.WriteFile(path, []byte("hello"), 0600)) + + id, err := CurrentProcessIdentity() + require.NoError(t, err) + + f, err := OpenOwnedFile(id, path) + require.NoError(t, err) + t.Cleanup(func() { _ = f.Close() }) + + content, err := io.ReadAll(f) + require.NoError(t, err) + require.Equal(t, "hello", string(content)) +} + +func TestOpenOwnedFileRefusesFileOwnedByAnother(t *testing.T) { + path := filepath.Join(t.TempDir(), "gui-client.log") + require.NoError(t, os.WriteFile(path, []byte("secret"), 0600)) + + _, err := OpenOwnedFile(otherIdentity(t), path) + require.ErrorContains(t, err, "not owned by the caller") +} + +func TestOpenOwnedFileRefusesNonRegularFile(t *testing.T) { + dir := t.TempDir() + + // The caller owns the directory, so this is the regular-file requirement + // talking, not the ownership check. + id, err := CurrentProcessIdentity() + require.NoError(t, err) + + _, err = OpenOwnedFile(id, dir) + require.ErrorContains(t, err, "not a regular file") +} + +func TestOpenOwnedFileRefusesMissingFile(t *testing.T) { + id, err := CurrentProcessIdentity() + require.NoError(t, err) + + _, err = OpenOwnedFile(id, filepath.Join(t.TempDir(), "absent.log")) + require.Error(t, err) +} diff --git a/client/internal/ipcauth/ownedfile_unix.go b/client/internal/ipcauth/ownedfile_unix.go new file mode 100644 index 000000000..4a6afcea7 --- /dev/null +++ b/client/internal/ipcauth/ownedfile_unix.go @@ -0,0 +1,35 @@ +//go:build !windows + +package ipcauth + +import ( + "fmt" + "os" + "syscall" +) + +// openForRead opens a caller-supplied path without following a symlink at its +// final component and without blocking: a fifo planted at the path would +// otherwise stall the open until a writer appears, and the daemon holds a lock +// while it collects the file. +func openForRead(path string) (*os.File, error) { + f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + return f, nil +} + +func fileOwnedBy(id Identity, f *os.File) (bool, error) { + info, err := f.Stat() + if err != nil { + return false, err + } + + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return false, fmt.Errorf("no owner information in %T", info.Sys()) + } + + return stat.Uid == id.UID, nil +} diff --git a/client/internal/ipcauth/ownedfile_unix_test.go b/client/internal/ipcauth/ownedfile_unix_test.go new file mode 100644 index 000000000..9e7831991 --- /dev/null +++ b/client/internal/ipcauth/ownedfile_unix_test.go @@ -0,0 +1,57 @@ +//go:build !windows + +package ipcauth + +import ( + "errors" + "os" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// A symlink is the shape the arbitrary-read attempt takes: the caller owns the +// link, the file it points at belongs to someone else. +func TestOpenOwnedFileRefusesSymlink(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target.log") + require.NoError(t, os.WriteFile(target, []byte("secret"), 0600)) + + link := filepath.Join(dir, "gui-client.log") + require.NoError(t, os.Symlink(target, link)) + + id, err := CurrentProcessIdentity() + require.NoError(t, err) + + _, err = OpenOwnedFile(id, link) + // O_NOFOLLOW on a symlink reports ELOOP on Linux/Darwin and EMLINK on FreeBSD. + if !errors.Is(err, syscall.ELOOP) && !errors.Is(err, syscall.EMLINK) { + t.Fatalf("symlink open: got %v, want ELOOP or EMLINK", err) + } +} + +// A fifo would block the open until a writer showed up, stalling the daemon +// while it holds its lock. +func TestOpenOwnedFileRefusesFifoWithoutBlocking(t *testing.T) { + path := filepath.Join(t.TempDir(), "gui-client.log") + require.NoError(t, syscall.Mkfifo(path, 0600)) + + id, err := CurrentProcessIdentity() + require.NoError(t, err) + + done := make(chan error, 1) + go func() { + _, err := OpenOwnedFile(id, path) + done <- err + }() + + select { + case err := <-done: + require.ErrorContains(t, err, "not a regular file") + case <-time.After(5 * time.Second): + t.Fatal("opening a fifo blocked") + } +} diff --git a/client/internal/ipcauth/ownedfile_windows.go b/client/internal/ipcauth/ownedfile_windows.go new file mode 100644 index 000000000..19acaec4d --- /dev/null +++ b/client/internal/ipcauth/ownedfile_windows.go @@ -0,0 +1,59 @@ +//go:build windows + +package ipcauth + +import ( + "fmt" + "os" + + "golang.org/x/sys/windows" +) + +// openForRead opens a caller-supplied path without following a reparse point at +// it. FILE_FLAG_OPEN_REPARSE_POINT is the Windows analogue of O_NOFOLLOW: it +// opens a symlink/junction itself rather than its target, so the regular-file +// check in checkOwnership refuses a link the caller planted to redirect the +// read. FILE_FLAG_BACKUP_SEMANTICS lets a directory open too (as os.Open does), +// so a directory planted at the path is refused as non-regular rather than +// erroring here. The share mode matches os.Open so a log being written stays +// openable. +func openForRead(path string) (*os.File, error) { + p, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, fmt.Errorf("convert path %s: %w", path, err) + } + + handle, err := windows.CreateFile( + p, + windows.GENERIC_READ, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_OPEN_REPARSE_POINT|windows.FILE_FLAG_BACKUP_SEMANTICS, + 0, + ) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + + return os.NewFile(uintptr(handle), path), nil +} + +// fileOwnedBy compares the file's owner SID with the caller's. Files an elevated +// process creates are owned by BUILTIN\Administrators rather than by the user, +// but such a caller is privileged and never reaches this check. +func fileOwnedBy(id Identity, f *os.File) (bool, error) { + // x/sys/windows GetSecurityInfo frees the OS buffer itself and returns a + // Go-heap copy, so there is nothing to LocalFree here. + sd, err := windows.GetSecurityInfo(windows.Handle(f.Fd()), windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION) + if err != nil { + return false, fmt.Errorf("read security info: %w", err) + } + + owner, _, err := sd.Owner() + if err != nil { + return false, fmt.Errorf("read owner: %w", err) + } + + return id.SID != "" && owner.String() == id.SID, nil +} diff --git a/client/internal/ipcauth/ownedfile_windows_test.go b/client/internal/ipcauth/ownedfile_windows_test.go new file mode 100644 index 000000000..ab68fdf39 --- /dev/null +++ b/client/internal/ipcauth/ownedfile_windows_test.go @@ -0,0 +1,78 @@ +//go:build windows + +package ipcauth + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +// fileOwnerSID reads the owner SID of path the same way OpenOwnedFile does, so +// the test can construct an Identity that matches (or deliberately does not). +func fileOwnerSID(t *testing.T, path string) string { + t.Helper() + f, err := os.Open(path) + require.NoError(t, err) + t.Cleanup(func() { _ = f.Close() }) + + sd, err := windows.GetSecurityInfo(windows.Handle(f.Fd()), windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION) + require.NoError(t, err) + owner, _, err := sd.Owner() + require.NoError(t, err) + return owner.String() +} + +// The allow branch of fileOwnedBy is the SID-equality path the legitimate GUI +// flow depends on. Running elevated, a created file is owned by +// BUILTIN\Administrators; an Identity carrying that SID with Elevated=false and +// no groups is unprivileged by IsPrivileged (which reads the token, not the +// SID's RID), so this exercises the real GetSecurityInfo equality rather than +// the privileged-caller shortcut. +func TestOpenOwnedFileWindowsOwnerMatchAllows(t *testing.T) { + path := filepath.Join(t.TempDir(), "gui-client.log") + require.NoError(t, os.WriteFile(path, []byte("hello"), 0600)) + + ownerSID := fileOwnerSID(t, path) + id := Identity{SID: ownerSID} + require.False(t, id.IsPrivileged(), "identity built from the owner SID must be unprivileged for this to test the match path") + + f, err := OpenOwnedFile(id, path) + require.NoError(t, err) + _ = f.Close() +} + +func TestOpenOwnedFileWindowsOwnerMismatchRefuses(t *testing.T) { + path := filepath.Join(t.TempDir(), "gui-client.log") + require.NoError(t, os.WriteFile(path, []byte("secret"), 0600)) + + other := Identity{SID: "S-1-5-21-9-9-9-9999"} + require.False(t, other.IsPrivileged()) + + _, err := OpenOwnedFile(other, path) + require.ErrorContains(t, err, "not owned by the caller") +} + +// FILE_FLAG_OPEN_REPARSE_POINT must make OpenOwnedFile refuse a symlink the same +// way O_NOFOLLOW does on Unix, so a planted link can't redirect the read to +// another file. Creating a symlink needs a privilege the runner may lack, so the +// test skips rather than fails when it can't. +func TestOpenOwnedFileWindowsRefusesSymlink(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target.log") + require.NoError(t, os.WriteFile(target, []byte("secret"), 0600)) + + link := filepath.Join(dir, "gui-client.log") + if err := os.Symlink(target, link); err != nil { + t.Skipf("cannot create symlink (privilege not held?): %v", err) + } + + id, err := CurrentProcessIdentity() + require.NoError(t, err) + + _, err = OpenOwnedFile(id, link) + require.Error(t, err, "a symlink must be refused") +} diff --git a/client/internal/ipcauth/peercred_bsd.go b/client/internal/ipcauth/peercred_bsd.go new file mode 100644 index 000000000..6d9c5247f --- /dev/null +++ b/client/internal/ipcauth/peercred_bsd.go @@ -0,0 +1,43 @@ +//go:build darwin || freebsd + +package ipcauth + +import ( + "fmt" + "net" + + "golang.org/x/sys/unix" +) + +// PeerIdentity reads the kernel-authenticated identity of the process on the +// other end of a Unix socket via LOCAL_PEERCRED. The xucred is recorded by the +// kernel at connect() time and carries the peer's uid and its group list, of +// which the first entry is the primary group. +func PeerIdentity(conn net.Conn) (Identity, error) { + uc, ok := conn.(*net.UnixConn) + if !ok { + return Identity{}, fmt.Errorf("connection is not a unix socket: %T", conn) + } + + raw, err := uc.SyscallConn() + if err != nil { + return Identity{}, fmt.Errorf("raw conn: %w", err) + } + + var cred *unix.Xucred + var credErr error + if err := raw.Control(func(fd uintptr) { + cred, credErr = unix.GetsockoptXucred(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERCRED) + }); err != nil { + return Identity{}, fmt.Errorf("control raw conn: %w", err) + } + if credErr != nil { + return Identity{}, fmt.Errorf("read LOCAL_PEERCRED: %w", credErr) + } + + id := Identity{UID: cred.Uid} + if cred.Ngroups > 0 { + id.GID = cred.Groups[0] + } + return id, nil +} diff --git a/client/internal/ipcauth/peercred_linux.go b/client/internal/ipcauth/peercred_linux.go new file mode 100644 index 000000000..417cc1e00 --- /dev/null +++ b/client/internal/ipcauth/peercred_linux.go @@ -0,0 +1,39 @@ +//go:build linux + +package ipcauth + +import ( + "fmt" + "net" + + "golang.org/x/sys/unix" +) + +// PeerIdentity reads the kernel-authenticated identity of the process on the +// other end of a Unix socket via SO_PEERCRED. The credentials are recorded by +// the kernel at connect() time and cannot be changed for the life of the +// connection, so they are not spoofable by the caller. +func PeerIdentity(conn net.Conn) (Identity, error) { + uc, ok := conn.(*net.UnixConn) + if !ok { + return Identity{}, fmt.Errorf("connection is not a unix socket: %T", conn) + } + + raw, err := uc.SyscallConn() + if err != nil { + return Identity{}, fmt.Errorf("raw conn: %w", err) + } + + var cred *unix.Ucred + var credErr error + if err := raw.Control(func(fd uintptr) { + cred, credErr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED) + }); err != nil { + return Identity{}, fmt.Errorf("control raw conn: %w", err) + } + if credErr != nil { + return Identity{}, fmt.Errorf("read SO_PEERCRED: %w", credErr) + } + + return Identity{UID: cred.Uid, GID: cred.Gid, PID: cred.Pid}, nil +} diff --git a/client/internal/ipcauth/pipeserver_windows.go b/client/internal/ipcauth/pipeserver_windows.go new file mode 100644 index 000000000..7ba59d574 --- /dev/null +++ b/client/internal/ipcauth/pipeserver_windows.go @@ -0,0 +1,87 @@ +//go:build windows + +package ipcauth + +import ( + "fmt" + "net" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" +) + +// PipeServerTrusted reports an error unless the pipe behind conn was created by a +// principal this client may hand secrets to. Clients call it for a pipe whose name +// carries no guarantee of its own, which is any name outside the +// ProtectedPrefix\Administrators namespace: that namespace already restricts +// creation to administrators and LocalSystem, while a plain name can be created by +// any local user before the daemon gets there. +// +// The decision is made from the pipe object's owner, not from the serving process, +// because a client cannot open a process running as another user at all, and the +// legitimate case is precisely an unprivileged client talking to a privileged +// daemon. Trusted owners are the service accounts, BUILTIN\Administrators, and +// this client's own user, the last of which is the daemon a user runs themselves +// as in netstack mode. A pipe owned by anyone else gets no setup key, pre-shared +// key or SSO prompt out of this client. +func PipeServerTrusted(conn net.Conn) error { + // go-winio's pipe connection embeds *win32File, which exposes Fd(). + fdConn, ok := conn.(interface{ Fd() uintptr }) + if !ok { + return fmt.Errorf("connection %T does not expose a pipe handle", conn) + } + + owner, err := pipeOwnerSID(windows.Handle(fdConn.Fd())) + if err != nil { + return err + } + + if !trustedPipeOwner(owner) { + return fmt.Errorf("pipe owned by %s, which is neither an administrator nor this user", owner) + } + return nil +} + +// PipeOwnedBySelf reports whether the pipe behind conn was created by this very +// user, which is how a client recognises a daemon running as itself. Ownership it +// cannot read is reported as false. +func PipeOwnedBySelf(conn net.Conn) bool { + fdConn, ok := conn.(interface{ Fd() uintptr }) + if !ok { + return false + } + + owner, err := pipeOwnerSID(windows.Handle(fdConn.Fd())) + if err != nil { + log.Debugf("read daemon pipe owner: %v", err) + return false + } + return selfKnown && selfIdentity.SID != "" && owner == selfIdentity.SID +} + +// pipeOwnerSID reads the owner of the pipe object a client is connected to. The +// handle was opened with GENERIC_READ, which includes READ_CONTROL, so no extra +// access is needed. +func pipeOwnerSID(handle windows.Handle) (string, error) { + sd, err := windows.GetSecurityInfo(handle, windows.SE_KERNEL_OBJECT, windows.OWNER_SECURITY_INFORMATION) + if err != nil { + return "", fmt.Errorf("read pipe security info: %w", err) + } + + owner, _, err := sd.Owner() + if err != nil { + return "", fmt.Errorf("read pipe owner: %w", err) + } + return owner.String(), nil +} + +// trustedPipeOwner reports whether a pipe's owner is a principal a client may +// speak to. An elevated process's objects are owned by BUILTIN\Administrators by +// default, an unelevated one's by the user, which is why both forms appear here. +func trustedPipeOwner(owner string) bool { + switch owner { + case sidLocalSystem, sidLocalService, sidNetworkService, sidAdministrators: + return true + } + return selfKnown && selfIdentity.SID != "" && owner == selfIdentity.SID +} diff --git a/client/internal/ipcauth/privileged.go b/client/internal/ipcauth/privileged.go new file mode 100644 index 000000000..3c2e68432 --- /dev/null +++ b/client/internal/ipcauth/privileged.go @@ -0,0 +1,141 @@ +package ipcauth + +import ( + "os" + "runtime" +) + +// Fields of the ErrorInfo detail the daemon attaches to a PermissionDenied it +// raises for an operation that requires root/administrator. Clients match on +// Reason and Domain rather than on the message text, and render the summary and +// command themselves so the user gets guidance instead of a gRPC error dump. +const ( + // ErrorReasonPrivilegeRequired identifies the detail. + ErrorReasonPrivilegeRequired = "PRIVILEGE_REQUIRED" + // ErrorDomain scopes the reason to the NetBird daemon. + ErrorDomain = "daemon.netbird.io" + // ErrorMetaSummary is the one-sentence explanation of what was refused. + ErrorMetaSummary = "summary" + // ErrorMetaCommand is the command that performs the same operation with the + // privileges it needs, ready to copy and run. + ErrorMetaCommand = "command" +) + +// The identity of the process evaluating callers, captured once because it cannot +// change. selfKnown is false when it could not be read, in which case nothing is +// ever treated as this process. selfMayDelegate additionally requires this +// process to be unprivileged: see IsPrivilegedCaller. +var ( + selfIdentity Identity + selfKnown bool + selfMayDelegate bool + // selfPID is this process's PID, used to recognise the daemon dialling itself. + selfPID = os.Getpid() +) + +func init() { + id, err := CurrentProcessIdentity() + if err != nil { + return + } + selfIdentity, selfKnown = id, true + // Only an unprivileged daemon delegates its authority to its own identity. + // When it is root or LocalSystem, sharing its identity does not mean sharing + // its power: on Windows a filtered and a full token carry the same SID, so + // matching there would let a non-elevated shell of an administrator account + // act as an administrator, which is the boundary the token check exists to + // keep. + selfMayDelegate = !id.IsPrivileged() +} + +// IsDaemonSelf reports whether an identity is this very process. The JSON gateway +// runs inside the daemon and re-dials it locally, so this is what distinguishes +// the gateway from any other caller, whatever user the daemon runs as. +func IsDaemonSelf(id Identity) bool { + if !selfKnown || id.IsWindows() != selfIdentity.IsWindows() { + return false + } + if id.IsWindows() { + return id.SID != "" && id.SID == selfIdentity.SID + } + return id.UID == selfIdentity.UID +} + +// IsPrivilegedCaller reports whether an identity may make the changes the daemon +// restricts to the platform administrator. This is the daemon's own rule and +// cannot be evaluated by a client, which does not know what the daemon runs as. +// +// Beyond root/administrator it accepts a caller running as the daemon's own +// identity when the daemon is itself unprivileged. That keeps a rootless container +// working, where there is no uid 0 at all, and a Windows daemon in netstack mode, +// which needs no administrator rights. In those setups a caller sharing the +// daemon's identity can already rewrite the config files it reads and replace the +// binary it runs, so refusing it a config change would protect nothing; and an +// unprivileged daemon cannot hand out a root shell in the first place. +func IsPrivilegedCaller(id Identity) bool { + if id.IsPrivileged() { + return true + } + return selfMayDelegate && IsDaemonSelf(id) +} + +// SelfDelegatesTo returns the identity this process delegates its authority to, +// and whether it delegates at all. Only an unprivileged daemon does: see +// IsPrivilegedCaller. It exists so a refusal can name who may actually perform the +// operation, because on such a host root is neither required nor necessarily +// available. +func SelfDelegatesTo() (Identity, bool) { + if !selfKnown || !selfMayDelegate { + return Identity{}, false + } + return selfIdentity, true +} + +// The values PrivilegedActorKey returns. +const ( + ActorKeyAdministrator = "administrator" + ActorKeyRoot = "root" +) + +// PrivilegedActor names the principal a privileged operation requires, for use +// in messages shown to the user. +func PrivilegedActor() string { + if runtime.GOOS == "windows" { + return "administrator privileges" + } + return "root" +} + +// PrivilegedActorKey identifies that principal without wording it, for a client +// that writes its own message in the user's language. The words PrivilegedActor +// returns are English, and a translated sentence cannot borrow them. +func PrivilegedActorKey() string { + if runtime.GOOS == "windows" { + return ActorKeyAdministrator + } + return ActorKeyRoot +} + +// ElevatedCommand renders a command so that running it grants the privileges the +// operation needs. Windows has no in-line equivalent of sudo, so the command is +// returned unchanged and the user is expected to run it from an elevated +// terminal. +func ElevatedCommand(command string) string { + if runtime.GOOS == "windows" { + return command + } + return "sudo " + command +} + +// UpCommand renders an elevated `netbird up` with the given flags, preceded by a +// `down`. The down is what makes the command work on a connected client: `netbird +// up` prints "Already connected" and returns without applying any config flag, so +// on its own the command would appear to do nothing. It is a no-op, exit 0, when +// the client is not connected. +// +// ";" rather than "&&" so the line can be pasted into any of the shells a user +// might have: PowerShell 5.1, still the default on Windows Server, rejects "&&" +// as a syntax error. +func UpCommand(flags string) string { + return ElevatedCommand("netbird down") + "; " + ElevatedCommand("netbird up "+flags) +} diff --git a/client/internal/ipcauth/privileged_test.go b/client/internal/ipcauth/privileged_test.go new file mode 100644 index 000000000..c1c7c1543 --- /dev/null +++ b/client/internal/ipcauth/privileged_test.go @@ -0,0 +1,134 @@ +package ipcauth + +import "testing" + +// The self rule is the one place privilege is granted to something other than the +// platform administrator, so its two guards matter: it must apply only when the +// daemon is itself unprivileged, and only to a caller with the daemon's identity. +func TestIsPrivilegedCaller_SelfRule(t *testing.T) { + tests := []struct { + name string + // self stands in for the process the daemon runs as. + self Identity + selfKnown bool + caller Identity + want bool + }{ + { + name: "root is privileged whatever the daemon runs as", + self: Identity{UID: 1000}, + selfKnown: true, + caller: Identity{UID: 0}, + want: true, + }, + { + name: "an unprivileged daemon delegates to its own user (rootless container)", + self: Identity{UID: 1000}, + selfKnown: true, + caller: Identity{UID: 1000}, + want: true, + }, + { + name: "an unprivileged daemon delegates to nobody else", + self: Identity{UID: 1000}, + selfKnown: true, + caller: Identity{UID: 1001}, + want: false, + }, + { + // The daemon is root on a normal install, so sharing its identity is + // already covered by being root; nothing else may match. + name: "a root daemon delegates to nobody", + self: Identity{UID: 0}, + selfKnown: true, + caller: Identity{UID: 1000}, + want: false, + }, + { + // Windows netstack mode: the daemon needs no administrator rights. + name: "an unprivileged windows daemon delegates to its own SID", + self: Identity{SID: "S-1-5-21-1-2-3-1001"}, + selfKnown: true, + caller: Identity{SID: "S-1-5-21-1-2-3-1001"}, + want: true, + }, + { + name: "an unprivileged windows daemon delegates to no other SID", + self: Identity{SID: "S-1-5-21-1-2-3-1001"}, + selfKnown: true, + caller: Identity{SID: "S-1-5-21-1-2-3-1002"}, + want: false, + }, + { + // The UAC boundary: a filtered and a full token of the same account + // carry the same SID but not the same power, so an elevated daemon must + // never delegate to its own SID. + name: "an elevated windows daemon does not delegate to its own SID", + self: Identity{SID: "S-1-5-21-1-2-3-500", Elevated: true}, + selfKnown: true, + caller: Identity{SID: "S-1-5-21-1-2-3-500"}, + want: false, + }, + { + name: "LocalSystem is privileged on its own merits, not by delegation", + self: Identity{SID: sidLocalSystem}, + selfKnown: true, + caller: Identity{SID: sidLocalSystem}, + want: true, // LocalSystem is privileged on its own merits + }, + { + name: "identities of different kinds never match", + self: Identity{UID: 1000}, + selfKnown: true, + caller: Identity{SID: "S-1-5-21-1-2-3-1001"}, + want: false, + }, + { + name: "an unknown self identity delegates to nobody", + self: Identity{}, + selfKnown: false, + caller: Identity{UID: 1000}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prevID, prevKnown, prevDelegate := selfIdentity, selfKnown, selfMayDelegate + t.Cleanup(func() { selfIdentity, selfKnown, selfMayDelegate = prevID, prevKnown, prevDelegate }) + + selfIdentity, selfKnown = tt.self, tt.selfKnown + selfMayDelegate = tt.selfKnown && !tt.self.IsPrivileged() + + if got := IsPrivilegedCaller(tt.caller); got != tt.want { + t.Fatalf("IsPrivilegedCaller(%v) with daemon %v = %t, want %t", + tt.caller, tt.self, got, tt.want) + } + }) + } +} + +// The real process must never accidentally delegate: a test binary running as a +// normal user is unprivileged, so it may match itself, but nothing else. +func TestIsPrivilegedCaller_ThisProcess(t *testing.T) { + id, err := CurrentProcessIdentity() + if err != nil { + t.Skipf("cannot read this process's identity: %v", err) + } + + // This process is always allowed to act as itself: either it is privileged, or + // it is unprivileged and therefore delegates to its own identity. + if !IsPrivilegedCaller(id) { + t.Errorf("this process %v was refused its own identity", id) + } + + // A caller that is neither root nor this process must be refused, whatever + // this process happens to be. + other := Identity{UID: id.UID + 1} + if id.IsWindows() { + other = Identity{SID: id.SID + "9"} + } + if IsPrivilegedCaller(other) { + t.Errorf("an unrelated identity %v was treated as privileged", other) + } +} diff --git a/client/internal/ipcauth/self_unix.go b/client/internal/ipcauth/self_unix.go new file mode 100644 index 000000000..1b86c4fc0 --- /dev/null +++ b/client/internal/ipcauth/self_unix.go @@ -0,0 +1,17 @@ +//go:build !windows + +package ipcauth + +import "os" + +// CurrentProcessIdentity returns this process's identity as the daemon would +// see it if this process connected to the local IPC. It lets a client (the UI) +// decide up front whether a privileged operation can succeed, without a +// round-trip and without duplicating the rules: the answer comes from the same +// Identity.IsPrivileged the daemon applies. +func CurrentProcessIdentity() (Identity, error) { + return Identity{ + UID: uint32(os.Geteuid()), + GID: uint32(os.Getegid()), + }, nil +} diff --git a/client/internal/ipcauth/self_windows.go b/client/internal/ipcauth/self_windows.go new file mode 100644 index 000000000..5474cc101 --- /dev/null +++ b/client/internal/ipcauth/self_windows.go @@ -0,0 +1,35 @@ +//go:build windows + +package ipcauth + +import ( + "fmt" + + "golang.org/x/sys/windows" +) + +// CurrentProcessIdentity returns this process's identity as the daemon would see +// it if this process connected to the local IPC. It lets a client (the UI) +// decide up front whether a privileged operation can succeed, without a +// round-trip and without duplicating the rules: the answer comes from the same +// Identity.IsPrivileged the daemon applies to the token it reads off the pipe. +func CurrentProcessIdentity() (Identity, error) { + // A pseudo-token, so it must not be closed. + token := windows.GetCurrentProcessToken() + + user, err := token.GetTokenUser() + if err != nil { + return Identity{}, fmt.Errorf("read token user: %w", err) + } + + groups, err := tokenGroupSIDs(token) + if err != nil { + return Identity{}, err + } + + return Identity{ + SID: user.User.Sid.String(), + Groups: groups, + Elevated: token.IsElevated(), + }, nil +} diff --git a/client/internal/lazyconn/activity/listener_bind.go b/client/internal/lazyconn/activity/listener_bind.go index 792d04215..72a0cfc76 100644 --- a/client/internal/lazyconn/activity/listener_bind.go +++ b/client/internal/lazyconn/activity/listener_bind.go @@ -57,6 +57,7 @@ func NewBindListener(wgIface WgInterface, bind device.EndpointManager, cfg lazyc // deriveFakeIP creates a deterministic fake IP for bind mode based on peer's NetBird IP. // Maps peer IP 100.64.x.y to fake IP 127.2.x.y (similar to relay proxy using 127.1.x.y). // It finds the peer's actual NetBird IP by checking which allowedIP is in the same subnet as our WG interface. +// For IPv6-only peers, the last two bytes of the v6 address are used. func deriveFakeIP(wgIface WgInterface, allowedIPs []netip.Prefix) (netip.Addr, error) { if len(allowedIPs) == 0 { return netip.Addr{}, fmt.Errorf("no allowed IPs for peer") @@ -64,6 +65,7 @@ func deriveFakeIP(wgIface WgInterface, allowedIPs []netip.Prefix) (netip.Addr, e ourNetwork := wgIface.Address().Network + // Try v4 first (preferred: deterministic from overlay IP) var peerIP netip.Addr for _, allowedIP := range allowedIPs { ip := allowedIP.Addr() @@ -76,13 +78,24 @@ func deriveFakeIP(wgIface WgInterface, allowedIPs []netip.Prefix) (netip.Addr, e } } - if !peerIP.IsValid() { - return netip.Addr{}, fmt.Errorf("no peer NetBird IP found in allowed IPs") + if peerIP.IsValid() { + octets := peerIP.As4() + return netip.AddrFrom4([4]byte{127, 2, octets[2], octets[3]}), nil } - octets := peerIP.As4() - fakeIP := netip.AddrFrom4([4]byte{127, 2, octets[2], octets[3]}) - return fakeIP, nil + // Fallback: use last two bytes of first v6 overlay IP + addr := wgIface.Address() + if addr.IPv6Net.IsValid() { + for _, allowedIP := range allowedIPs { + ip := allowedIP.Addr() + if ip.Is6() && addr.IPv6Net.Contains(ip) { + raw := ip.As16() + return netip.AddrFrom4([4]byte{127, 2, raw[14], raw[15]}), nil + } + } + } + + return netip.Addr{}, fmt.Errorf("no peer NetBird IP found in allowed IPs") } func (d *BindListener) setupLazyConn() error { @@ -106,15 +119,16 @@ func (d *BindListener) ReadPackets() { } d.peerCfg.Log.Debugf("removing lazy endpoint for peer %s", d.peerCfg.PublicKey) - if err := d.wgIface.RemovePeer(d.peerCfg.PublicKey); err != nil { - d.peerCfg.Log.Errorf("failed to remove endpoint: %s", err) - } - _ = d.lazyConn.Close() d.bind.RemoveEndpoint(d.fakeIP) d.done.Done() } +// CapturedPacket is unused in userspace bind mode: first-packet reinjection is kernel-only. +func (d *BindListener) CapturedPacket() []byte { + return nil +} + // Close stops the listener and cleans up resources. func (d *BindListener) Close() { d.peerCfg.Log.Infof("closing activity listener (LazyConn)") diff --git a/client/internal/lazyconn/activity/listener_bind_test.go b/client/internal/lazyconn/activity/listener_bind_test.go index f86dd3877..7026a9c97 100644 --- a/client/internal/lazyconn/activity/listener_bind_test.go +++ b/client/internal/lazyconn/activity/listener_bind_test.go @@ -3,7 +3,6 @@ package activity import ( "net" "net/netip" - "runtime" "testing" "time" @@ -18,10 +17,6 @@ import ( peerid "github.com/netbirdio/netbird/client/internal/peer/id" ) -func isBindListenerPlatform() bool { - return runtime.GOOS == "windows" || runtime.GOOS == "js" -} - // mockEndpointManager implements device.EndpointManager for testing type mockEndpointManager struct { endpoints map[netip.Addr]net.Conn @@ -50,10 +45,6 @@ type MockWGIfaceBind struct { endpointMgr *mockEndpointManager } -func (m *MockWGIfaceBind) RemovePeer(string) error { - return nil -} - func (m *MockWGIfaceBind) UpdatePeer(string, []netip.Prefix, time.Duration, *net.UDPAddr, *wgtypes.Key) error { return nil } @@ -73,6 +64,10 @@ func (m *MockWGIfaceBind) GetBind() device.EndpointManager { return m.endpointMgr } +func (m *MockWGIfaceBind) MTU() uint16 { + return 1280 +} + func TestBindListener_Creation(t *testing.T) { mockEndpointMgr := newMockEndpointManager() mockIface := &MockWGIfaceBind{endpointMgr: mockEndpointMgr} @@ -181,10 +176,6 @@ func TestBindListener_Close(t *testing.T) { } func TestManager_BindMode(t *testing.T) { - if !isBindListenerPlatform() { - t.Skip("BindListener only used on Windows/JS platforms") - } - mockEndpointMgr := newMockEndpointManager() mockIface := &MockWGIfaceBind{endpointMgr: mockEndpointMgr} @@ -216,8 +207,9 @@ func TestManager_BindMode(t *testing.T) { require.NoError(t, err) select { - case peerConnID := <-mgr.OnActivityChan: - assert.Equal(t, cfg.PeerConnID, peerConnID, "Received peer connection ID should match") + case ev := <-mgr.OnActivityChan: + assert.Equal(t, cfg.PeerConnID, ev.PeerConnID, "Received peer connection ID should match") + assert.Nil(t, ev.FirstPacket, "Bind mode does not capture packets: reinjection is kernel-only") case <-time.After(2 * time.Second): t.Fatal("timeout waiting for activity notification") } @@ -226,10 +218,6 @@ func TestManager_BindMode(t *testing.T) { } func TestManager_BindMode_MultiplePeers(t *testing.T) { - if !isBindListenerPlatform() { - t.Skip("BindListener only used on Windows/JS platforms") - } - mockEndpointMgr := newMockEndpointManager() mockIface := &MockWGIfaceBind{endpointMgr: mockEndpointMgr} @@ -279,8 +267,8 @@ func TestManager_BindMode_MultiplePeers(t *testing.T) { receivedPeers := make(map[peerid.ConnID]bool) for i := 0; i < 2; i++ { select { - case peerConnID := <-mgr.OnActivityChan: - receivedPeers[peerConnID] = true + case ev := <-mgr.OnActivityChan: + receivedPeers[ev.PeerConnID] = true case <-time.After(2 * time.Second): t.Fatal("timeout waiting for activity notifications") } diff --git a/client/internal/lazyconn/activity/listener_udp.go b/client/internal/lazyconn/activity/listener_udp.go index e0b09be6c..4b7e0ddf7 100644 --- a/client/internal/lazyconn/activity/listener_udp.go +++ b/client/internal/lazyconn/activity/listener_udp.go @@ -3,11 +3,13 @@ package activity import ( "fmt" "net" + "slices" "sync" "sync/atomic" log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/client/iface/bufsize" "github.com/netbirdio/netbird/client/internal/lazyconn" ) @@ -20,6 +22,8 @@ type UDPListener struct { done sync.Mutex isClosed atomic.Bool + + capturedPacket []byte } // NewUDPListener creates a listener that detects activity via UDP socket reads. @@ -46,9 +50,13 @@ func NewUDPListener(wgIface WgInterface, cfg lazyconn.PeerConfig) (*UDPListener, } // ReadPackets blocks reading from the UDP socket until activity is detected or the listener is closed. +// The first packet that triggers activity is captured so it can be reinjected through the real +// transport once it is established. Without this, kernel WireGuard's handshake initiation would be +// dropped and WG would only retry after REKEY_TIMEOUT. func (d *UDPListener) ReadPackets() { for { - n, remoteAddr, err := d.conn.ReadFromUDP(make([]byte, 1)) + buf := make([]byte, int(d.wgIface.MTU())+bufsize.WGBufferOverhead) + n, remoteAddr, err := d.conn.ReadFromUDP(buf) if err != nil { if d.isClosed.Load() { d.peerCfg.Log.Infof("exit from activity listener") @@ -62,20 +70,24 @@ func (d *UDPListener) ReadPackets() { d.peerCfg.Log.Warnf("received %d bytes from %s, too short", n, remoteAddr) continue } - d.peerCfg.Log.Infof("activity detected") + d.capturedPacket = slices.Clone(buf[:n]) + d.peerCfg.Log.Infof("activity detected, captured %d bytes for reinjection", n) break } - d.peerCfg.Log.Debugf("removing lazy endpoint: %s", d.endpoint.String()) - if err := d.wgIface.RemovePeer(d.peerCfg.PublicKey); err != nil { - d.peerCfg.Log.Errorf("failed to remove endpoint: %s", err) - } - - // Ignore close error as it may return "use of closed network connection" if already closed. + // Leave the peer in place. ConfigureWGEndpoint will UpdatePeer with the real endpoint; + // removing the peer here wipes kernel WG's staged queue and drops the user packet that + // triggered activation. _ = d.conn.Close() d.done.Unlock() } +// CapturedPacket returns the first packet that triggered activity, or nil if none was captured. +// Safe to call after ReadPackets returns. +func (d *UDPListener) CapturedPacket() []byte { + return d.capturedPacket +} + // Close stops the listener and cleans up resources. func (d *UDPListener) Close() { d.peerCfg.Log.Infof("closing activity listener: %s", d.conn.LocalAddr().String()) diff --git a/client/internal/lazyconn/activity/manager.go b/client/internal/lazyconn/activity/manager.go index 1c11378c8..9de8c0fa7 100644 --- a/client/internal/lazyconn/activity/manager.go +++ b/client/internal/lazyconn/activity/manager.go @@ -4,14 +4,12 @@ import ( "errors" "net" "net/netip" - "runtime" "sync" "time" 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" @@ -21,17 +19,25 @@ import ( type listener interface { ReadPackets() Close() + CapturedPacket() []byte +} + +// Event reports activity on a managed peer. FirstPacket is the bytes that triggered activation, +// captured for reinjection through the real transport. +type Event struct { + PeerConnID peerid.ConnID + FirstPacket []byte } type WgInterface interface { - RemovePeer(peerKey string) error UpdatePeer(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error IsUserspaceBind() bool Address() wgaddr.Address + MTU() uint16 } type Manager struct { - OnActivityChan chan peerid.ConnID + OnActivityChan chan Event wgIface WgInterface @@ -43,7 +49,7 @@ type Manager struct { func NewManager(wgIface WgInterface) *Manager { m := &Manager{ - OnActivityChan: make(chan peerid.ConnID, 1), + OnActivityChan: make(chan Event, 1), wgIface: wgIface, peers: make(map[peerid.ConnID]listener), done: make(chan struct{}), @@ -75,16 +81,6 @@ func (m *Manager) createListener(peerCfg lazyconn.PeerConfig) (listener, error) return NewUDPListener(m.wgIface, peerCfg) } - // 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. - // - 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) - } - provider, ok := m.wgIface.(bindProvider) if !ok { return nil, errors.New("interface claims userspace bind but doesn't implement bindProvider") @@ -128,12 +124,12 @@ func (m *Manager) waitForTraffic(l listener, peerConnID peerid.ConnID) { delete(m.peers, peerConnID) m.mu.Unlock() - m.notify(peerConnID) + m.notify(Event{PeerConnID: peerConnID, FirstPacket: l.CapturedPacket()}) } -func (m *Manager) notify(peerConnID peerid.ConnID) { +func (m *Manager) notify(ev Event) { select { case <-m.done: - case m.OnActivityChan <- peerConnID: + case m.OnActivityChan <- ev: } } diff --git a/client/internal/lazyconn/activity/manager_test.go b/client/internal/lazyconn/activity/manager_test.go index 0768d9219..07dd8d84c 100644 --- a/client/internal/lazyconn/activity/manager_test.go +++ b/client/internal/lazyconn/activity/manager_test.go @@ -1,6 +1,7 @@ package activity import ( + "bytes" "net" "net/netip" "testing" @@ -25,10 +26,6 @@ func (m *MocPeer) ConnID() peerid.ConnID { type MocWGIface struct { } -func (m MocWGIface) RemovePeer(string) error { - return nil -} - func (m MocWGIface) UpdatePeer(string, []netip.Prefix, time.Duration, *net.UDPAddr, *wgtypes.Key) error { return nil } @@ -44,6 +41,10 @@ func (m MocWGIface) Address() wgaddr.Address { } } +func (m MocWGIface) MTU() uint16 { + return 1280 +} + // GetPeerListener is a test helper to access listeners func (m *Manager) GetPeerListener(peerConnID peerid.ConnID) (listener, bool) { m.mu.Lock() @@ -86,11 +87,15 @@ func TestManager_MonitorPeerActivity(t *testing.T) { } select { - case peerConnID := <-mgr.OnActivityChan: - if peerConnID != peerCfg1.PeerConnID { - t.Fatalf("unexpected peerConnID: %v", peerConnID) + case ev := <-mgr.OnActivityChan: + if ev.PeerConnID != peerCfg1.PeerConnID { + t.Fatalf("unexpected peerConnID: %v", ev.PeerConnID) + } + if !bytes.Equal(ev.FirstPacket, []byte{0x01, 0x02, 0x03, 0x04, 0x05}) { + t.Fatalf("unexpected first packet: %v", ev.FirstPacket) } case <-time.After(1 * time.Second): + t.Fatal("timed out waiting for activity") } } diff --git a/client/internal/lazyconn/env.go b/client/internal/lazyconn/env.go index 649d1cd65..d408083e7 100644 --- a/client/internal/lazyconn/env.go +++ b/client/internal/lazyconn/env.go @@ -3,24 +3,57 @@ package lazyconn import ( "os" "strconv" + "strings" log "github.com/sirupsen/logrus" ) const ( - EnvEnableLazyConn = "NB_ENABLE_EXPERIMENTAL_LAZY_CONN" + EnvLazyConn = "NB_LAZY_CONN" EnvInactivityThreshold = "NB_LAZY_CONN_INACTIVITY_THRESHOLD" ) -func IsLazyConnEnabledByEnv() bool { - val := os.Getenv(EnvEnableLazyConn) - if val == "" { - return false +// State is the tri-state local override for lazy connections read from the environment. +type State int + +const ( + // StateUnset means no local override; defer to the management feature flag. + StateUnset State = iota + // StateOn forces lazy connections on, overriding management. + StateOn + // StateOff forces lazy connections off, overriding management. + StateOff +) + +// EnvState reads NB_LAZY_CONN and returns the local override state. +func EnvState() State { + return ParseState(os.Getenv(EnvLazyConn)) +} + +// ParseState interprets a lazy-connection override value (from the environment or an MDM +// policy). It accepts the on/off aliases plus any value strconv.ParseBool understands +// (true/false/1/0). An empty or unrecognized value returns StateUnset so that the +// management feature flag remains in control. +func ParseState(raw string) State { + if raw == "" { + return StateUnset } - enabled, err := strconv.ParseBool(val) + + normalized := strings.ToLower(strings.TrimSpace(raw)) + switch normalized { + case "on": + return StateOn + case "off": + return StateOff + } + + enabled, err := strconv.ParseBool(normalized) if err != nil { - log.Warnf("failed to parse %s: %v", EnvEnableLazyConn, err) - return false + log.Warnf("failed to parse lazy connection value %q (from %s env or MDM policy): %v", raw, EnvLazyConn, err) + return StateUnset + } + if enabled { + return StateOn } - return enabled + return StateOff } diff --git a/client/internal/lazyconn/env_test.go b/client/internal/lazyconn/env_test.go new file mode 100644 index 000000000..59ee40c4b --- /dev/null +++ b/client/internal/lazyconn/env_test.go @@ -0,0 +1,45 @@ +package lazyconn + +import ( + "os" + "testing" +) + +func TestEnvState(t *testing.T) { + tests := []struct { + value string + set bool + want State + }{ + {set: false, want: StateUnset}, + {value: "", set: true, want: StateUnset}, + {value: "on", set: true, want: StateOn}, + {value: "ON", set: true, want: StateOn}, + {value: "true", set: true, want: StateOn}, + {value: "1", set: true, want: StateOn}, + {value: " on ", set: true, want: StateOn}, + {value: "off", set: true, want: StateOff}, + {value: "OFF", set: true, want: StateOff}, + {value: "false", set: true, want: StateOff}, + {value: "0", set: true, want: StateOff}, + {value: "auto", set: true, want: StateUnset}, + {value: "garbage", set: true, want: StateUnset}, + } + + for _, tt := range tests { + name := tt.value + if !tt.set { + name = "unset" + } + t.Run(name, func(t *testing.T) { + t.Setenv(EnvLazyConn, tt.value) + if !tt.set { + os.Unsetenv(EnvLazyConn) + } + + if got := EnvState(); got != tt.want { + t.Fatalf("EnvState() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/client/internal/lazyconn/manager/manager.go b/client/internal/lazyconn/manager/manager.go index b6b3c6091..b7424bb2f 100644 --- a/client/internal/lazyconn/manager/manager.go +++ b/client/internal/lazyconn/manager/manager.go @@ -6,7 +6,6 @@ import ( "time" log "github.com/sirupsen/logrus" - "golang.org/x/exp/maps" "github.com/netbirdio/netbird/client/internal/lazyconn" "github.com/netbirdio/netbird/client/internal/lazyconn/activity" @@ -30,6 +29,11 @@ type managedPeer struct { type Config struct { InactivityThreshold *time.Duration + // ReconcileAllowedIPs re-applies a peer's routed allowed IPs after its wake endpoint is + // armed. The activity listener creates the wake peer with the overlay /32 only; without the + // routed prefixes WireGuard would not steer subnet-bound traffic to the wake endpoint, so an + // idle routing peer could never be woken by that traffic. Optional; nil disables the reconcile. + ReconcileAllowedIPs func(peerKey string) error } // Manager manages lazy connections @@ -57,6 +61,9 @@ type Manager struct { peerToHAGroups map[string][]route.HAUniqueID // peer ID -> HA groups they belong to haGroupToPeers map[route.HAUniqueID][]string // HA group -> peer IDs in the group routesMu sync.RWMutex + + // reconcileAllowedIPs re-applies a peer's routed allowed IPs after its wake endpoint is armed. + reconcileAllowedIPs func(peerKey string) error } // NewManager creates a new lazy connection manager @@ -74,6 +81,7 @@ func NewManager(config Config, engineCtx context.Context, peerStore *peerstore.S activityManager: activity.NewManager(wgIface), peerToHAGroups: make(map[string][]route.HAUniqueID), haGroupToPeers: make(map[route.HAUniqueID][]string), + reconcileAllowedIPs: config.ReconcileAllowedIPs, } if wgIface.IsUserspaceBind() { @@ -91,8 +99,8 @@ func (m *Manager) UpdateRouteHAMap(haMap route.HAMap) { m.routesMu.Lock() defer m.routesMu.Unlock() - maps.Clear(m.peerToHAGroups) - maps.Clear(m.haGroupToPeers) + clear(m.peerToHAGroups) + clear(m.haGroupToPeers) for haUniqueID, routes := range haMap { var peers []string @@ -131,8 +139,8 @@ func (m *Manager) Start(ctx context.Context) { select { case <-ctx.Done(): return - case peerConnID := <-m.activityManager.OnActivityChan: - m.onPeerActivity(peerConnID) + case ev := <-m.activityManager.OnActivityChan: + m.onPeerActivity(ev) case peerIDs := <-m.inactivityManager.InactivePeersChan(): m.onPeerInactivityTimedOut(peerIDs) } @@ -202,7 +210,7 @@ func (m *Manager) AddPeer(peerCfg lazyconn.PeerConfig) (bool, error) { return false, nil } - if err := m.activityManager.MonitorPeerActivity(peerCfg); err != nil { + if err := m.armActivityListener(peerCfg); err != nil { return false, err } @@ -289,7 +297,7 @@ func (m *Manager) DeactivatePeer(peerID peerid.ConnID) { m.inactivityManager.RemovePeer(mp.peerCfg.PublicKey) - if err := m.activityManager.MonitorPeerActivity(*mp.peerCfg); err != nil { + if err := m.armActivityListener(*mp.peerCfg); err != nil { mp.peerCfg.Log.Errorf("failed to create activity monitor: %v", err) return } @@ -466,6 +474,31 @@ func (m *Manager) close() { } // shouldDeferIdleForHA checks if peer should stay connected due to HA group requirements +// armRoutedAllowedIPs re-applies the peer's routed allowed IPs onto its freshly armed wake +// endpoint. The activity listener creates the wake peer with the overlay /32 only, so without +// this the routed prefixes would be missing and traffic to a routed subnet could not wake the +// idle routing peer. It is a no-op when no reconciler is configured. +// armActivityListener (re)arms the peer's wake endpoint via the activity manager and then +// re-applies its routed allowed IPs, so traffic to a routed subnet can wake an idle routing +// peer. The routed prefixes must be re-applied after the wake endpoint exists because the +// listener creates it with the overlay /32 only. +func (m *Manager) armActivityListener(peerCfg lazyconn.PeerConfig) error { + if err := m.activityManager.MonitorPeerActivity(peerCfg); err != nil { + return err + } + m.armRoutedAllowedIPs(&peerCfg) + return nil +} + +func (m *Manager) armRoutedAllowedIPs(peerCfg *lazyconn.PeerConfig) { + if m.reconcileAllowedIPs == nil { + return + } + if err := m.reconcileAllowedIPs(peerCfg.PublicKey); err != nil { + peerCfg.Log.Errorf("failed to reconcile routed allowed IPs on wake endpoint: %v", err) + } +} + func (m *Manager) shouldDeferIdleForHA(inactivePeers map[string]struct{}, peerID string) bool { m.routesMu.RLock() defer m.routesMu.RUnlock() @@ -514,13 +547,13 @@ func (m *Manager) checkHaGroupActivity(haGroup route.HAUniqueID, peerID string, return false } -func (m *Manager) onPeerActivity(peerConnID peerid.ConnID) { +func (m *Manager) onPeerActivity(ev activity.Event) { m.managedPeersMu.Lock() defer m.managedPeersMu.Unlock() - mp, ok := m.managedPeersByConnID[peerConnID] + mp, ok := m.managedPeersByConnID[ev.PeerConnID] if !ok { - log.Errorf("peer not found by conn id: %v", peerConnID) + log.Errorf("peer not found by conn id: %v", ev.PeerConnID) return } @@ -537,7 +570,7 @@ func (m *Manager) onPeerActivity(peerConnID peerid.ConnID) { m.activateHAGroupPeers(mp.peerCfg) - m.peerStore.PeerConnOpen(m.engineCtx, mp.peerCfg.PublicKey) + m.peerStore.PeerConnOpenWithFirstPacket(m.engineCtx, mp.peerCfg.PublicKey, ev.FirstPacket) } func (m *Manager) onPeerInactivityTimedOut(peerIDs map[string]struct{}) { @@ -578,7 +611,7 @@ func (m *Manager) onPeerInactivityTimedOut(peerIDs map[string]struct{}) { mp.peerCfg.Log.Infof("start activity monitor") - if err := m.activityManager.MonitorPeerActivity(*mp.peerCfg); err != nil { + if err := m.armActivityListener(*mp.peerCfg); err != nil { mp.peerCfg.Log.Errorf("failed to create activity monitor: %v", err) continue } diff --git a/client/internal/lazyconn/support.go b/client/internal/lazyconn/support.go index 5e765c2d6..cc0e95e53 100644 --- a/client/internal/lazyconn/support.go +++ b/client/internal/lazyconn/support.go @@ -4,6 +4,8 @@ import ( "strings" "github.com/hashicorp/go-version" + + nbversion "github.com/netbirdio/netbird/version" ) var ( @@ -11,7 +13,7 @@ var ( ) func IsSupported(agentVersion string) bool { - if agentVersion == "development" { + if nbversion.IsDevelopmentVersion(agentVersion) { return true } diff --git a/client/internal/lazyconn/wgiface.go b/client/internal/lazyconn/wgiface.go index 0626c1815..f003ab3cf 100644 --- a/client/internal/lazyconn/wgiface.go +++ b/client/internal/lazyconn/wgiface.go @@ -17,4 +17,5 @@ type WGIface interface { IsUserspaceBind() bool Address() wgaddr.Address LastActivities() map[string]monotime.Time + MTU() uint16 } diff --git a/client/internal/listener/network_change.go b/client/internal/listener/network_change.go index 08bf5fd52..e0aa43abe 100644 --- a/client/internal/listener/network_change.go +++ b/client/internal/listener/network_change.go @@ -5,4 +5,5 @@ type NetworkChangeListener interface { // OnNetworkChanged invoke when network settings has been changed OnNetworkChanged(string) SetInterfaceIP(string) + SetInterfaceIPv6(string) } diff --git a/client/internal/localmetrics/localmetrics.go b/client/internal/localmetrics/localmetrics.go new file mode 100644 index 000000000..f829fa132 --- /dev/null +++ b/client/internal/localmetrics/localmetrics.go @@ -0,0 +1,274 @@ +// Package localmetrics exposes client connection state as a local +// Prometheus /metrics endpoint. +package localmetrics + +import ( + "context" + "errors" + "net" + "net/http" + "net/netip" + "sync" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + dto "github.com/prometheus/client_model/go" + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/peer" +) + +// DefaultListenAddress is used when local metrics are enabled without an explicit address. +const DefaultListenAddress = "127.0.0.1:9191" + +const ( + shutdownTimeout = 3 * time.Second + readHeaderTimeout = 5 * time.Second + readTimeout = 10 * time.Second + writeTimeout = 30 * time.Second + idleTimeout = time.Minute +) + +// statusSource provides the connection state snapshots the collector reads on scrape. +type statusSource interface { + GetPeerStates() []peer.State + GetManagementState() peer.ManagementState + GetSignalState() peer.SignalState +} + +// GathererProvider returns the current client metrics gatherer, or nil when +// no engine is running. It is called on every scrape. +type GathererProvider func() prometheus.Gatherer + +// Manager runs the local /metrics HTTP endpoint according to the active +// client configuration. Reconcile is safe to call on every config change. +type Manager struct { + status statusSource + clientMetrics GathererProvider + + mu sync.Mutex + srv *http.Server + addr string +} + +// NewManager creates a manager that serves metrics from status and +// clientMetrics and shuts down when ctx is canceled. +func NewManager(ctx context.Context, status statusSource, clientMetrics GathererProvider) *Manager { + m := &Manager{status: status, clientMetrics: clientMetrics} + go func() { + <-ctx.Done() + m.Stop() + }() + return m +} + +// Reconcile starts, stops, or restarts the metrics endpoint to match the +// desired state. An empty addr falls back to DefaultListenAddress. +func (m *Manager) Reconcile(enabled bool, addr string) { + if addr == "" { + addr = DefaultListenAddress + } + warnIfNotLoopback(addr) + + m.mu.Lock() + defer m.mu.Unlock() + + if !enabled { + m.stop() + return + } + if m.srv != nil && m.addr == addr { + return + } + m.stop() + + registry := prometheus.NewRegistry() + registry.MustRegister(newCollector(m.status)) + + gatherers := prometheus.Gatherers{registry, prometheus.GathererFunc(func() ([]*dto.MetricFamily, error) { + if m.clientMetrics == nil { + return nil, nil + } + g := m.clientMetrics() + if g == nil { + return nil, nil + } + return g.Gather() + })} + + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.HandlerFor(gatherers, promhttp.HandlerOpts{})) + + srv := &http.Server{ + Addr: addr, + Handler: mux, + ReadHeaderTimeout: readHeaderTimeout, + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + IdleTimeout: idleTimeout, + } + m.srv = srv + m.addr = addr + + log.Infof("serving local metrics on http://%s/metrics", addr) + go func() { + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Errorf("failed to serve local metrics on %s: %v", addr, err) + m.clear(srv) + } + }() +} + +// clear drops the reference to srv so a later Reconcile with the same +// address restarts it. A newer server may already have replaced it, in +// which case the reference must stay. +func (m *Manager) clear(srv *http.Server) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.srv != srv { + return + } + m.srv = nil + m.addr = "" +} + +// Stop shuts down the metrics endpoint if it is running. +func (m *Manager) Stop() { + m.mu.Lock() + defer m.mu.Unlock() + m.stop() +} + +// stop shuts down the running server. Callers must hold m.mu. +func (m *Manager) stop() { + if m.srv == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + if err := m.srv.Shutdown(ctx); err != nil { + log.Debugf("failed to shut down local metrics server: %v", err) + } + m.srv = nil + m.addr = "" +} + +// collector converts status recorder snapshots into Prometheus metrics at scrape time. +type collector struct { + status statusSource + + managementConnected *prometheus.Desc + signalConnected *prometheus.Desc + peersTotal *prometheus.Desc + peersConnected *prometheus.Desc + peerLatency *prometheus.Desc +} + +func newCollector(status statusSource) *collector { + return &collector{ + status: status, + managementConnected: prometheus.NewDesc( + "netbird_management_connected", + "Whether the client is connected to the management service (1 connected, 0 disconnected).", + nil, nil, + ), + signalConnected: prometheus.NewDesc( + "netbird_signal_connected", + "Whether the client is connected to the signal service (1 connected, 0 disconnected).", + nil, nil, + ), + peersTotal: prometheus.NewDesc( + "netbird_peers", + "Number of peers known to this client.", + nil, nil, + ), + peersConnected: prometheus.NewDesc( + "netbird_peers_connected", + "Number of connected peers by connection type.", + []string{"connection_type"}, nil, + ), + peerLatency: prometheus.NewDesc( + "netbird_peer_latency_seconds", + "Round-trip latency per directly connected peer; relayed connections have no latency measurement.", + []string{"peer"}, nil, + ), + } +} + +// Describe implements prometheus.Collector. +func (c *collector) Describe(ch chan<- *prometheus.Desc) { + ch <- c.managementConnected + ch <- c.signalConnected + ch <- c.peersTotal + ch <- c.peersConnected + ch <- c.peerLatency +} + +// Collect implements prometheus.Collector. +func (c *collector) Collect(ch chan<- prometheus.Metric) { + ch <- prometheus.MustNewConstMetric(c.managementConnected, prometheus.GaugeValue, boolToFloat(c.status.GetManagementState().Connected)) + ch <- prometheus.MustNewConstMetric(c.signalConnected, prometheus.GaugeValue, boolToFloat(c.status.GetSignalState().Connected)) + + peers := c.status.GetPeerStates() + ch <- prometheus.MustNewConstMetric(c.peersTotal, prometheus.GaugeValue, float64(len(peers))) + + var p2p, relayed float64 + for _, p := range peers { + if p.ConnStatus != peer.StatusConnected { + continue + } + if p.Relayed { + relayed++ + continue + } + p2p++ + + if latency := p.Latency.Seconds(); latency > 0 { + ch <- prometheus.MustNewConstMetric(c.peerLatency, prometheus.GaugeValue, latency, p.FQDN) + } + } + ch <- prometheus.MustNewConstMetric(c.peersConnected, prometheus.GaugeValue, p2p, "p2p") + ch <- prometheus.MustNewConstMetric(c.peersConnected, prometheus.GaugeValue, relayed, "relay") +} + +func boolToFloat(b bool) float64 { + if b { + return 1 + } + return 0 +} + +// IsLoopback reports whether addr binds the endpoint to the local host only. +// An empty address means DefaultListenAddress. It fails closed: an address +// that cannot be confirmed loopback, including an unparseable one, is not. +func IsLoopback(addr string) bool { + if addr == "" { + addr = DefaultListenAddress + } + + host, _, err := net.SplitHostPort(addr) + if err != nil { + return false + } + if host == "localhost" { + return true + } + + ip, err := netip.ParseAddr(host) + if err != nil { + return false + } + return ip.Unmap().IsLoopback() +} + +// warnIfNotLoopback logs a warning when the listen address cannot be +// confirmed to be local-only, since the endpoint exposes peer and +// connectivity details without authentication. +func warnIfNotLoopback(addr string) { + if IsLoopback(addr) { + return + } + log.Warnf("local metrics endpoint listens on non-loopback address %s and is reachable from the network without authentication", addr) +} diff --git a/client/internal/localmetrics/localmetrics_test.go b/client/internal/localmetrics/localmetrics_test.go new file mode 100644 index 000000000..727137077 --- /dev/null +++ b/client/internal/localmetrics/localmetrics_test.go @@ -0,0 +1,151 @@ +package localmetrics + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "strings" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/peer" +) + +type stubStatus struct { + peers []peer.State + management peer.ManagementState + signal peer.SignalState +} + +func (s *stubStatus) GetPeerStates() []peer.State { return s.peers } +func (s *stubStatus) GetManagementState() peer.ManagementState { return s.management } +func (s *stubStatus) GetSignalState() peer.SignalState { return s.signal } + +func testStatus() *stubStatus { + return &stubStatus{ + management: peer.ManagementState{Connected: true}, + signal: peer.SignalState{Connected: true}, + peers: []peer.State{ + {FQDN: "peer-a.netbird.cloud", IP: "100.90.0.1", ConnStatus: peer.StatusConnected, Relayed: false, Latency: 12 * time.Millisecond}, + {FQDN: "peer-b.netbird.cloud", IP: "100.90.0.2", ConnStatus: peer.StatusConnected, Relayed: false, Latency: 36 * time.Millisecond}, + {FQDN: "peer-c.netbird.cloud", IP: "100.90.0.3", ConnStatus: peer.StatusConnected, Relayed: true}, + {FQDN: "peer-d.netbird.cloud", IP: "100.90.0.4", ConnStatus: peer.StatusIdle}, + }, + } +} + +func TestCollector(t *testing.T) { + c := newCollector(testStatus()) + + expected := ` +# HELP netbird_management_connected Whether the client is connected to the management service (1 connected, 0 disconnected). +# TYPE netbird_management_connected gauge +netbird_management_connected 1 +# HELP netbird_peer_latency_seconds Round-trip latency per directly connected peer; relayed connections have no latency measurement. +# TYPE netbird_peer_latency_seconds gauge +netbird_peer_latency_seconds{peer="peer-a.netbird.cloud"} 0.012 +netbird_peer_latency_seconds{peer="peer-b.netbird.cloud"} 0.036 +# HELP netbird_peers Number of peers known to this client. +# TYPE netbird_peers gauge +netbird_peers 4 +# HELP netbird_peers_connected Number of connected peers by connection type. +# TYPE netbird_peers_connected gauge +netbird_peers_connected{connection_type="p2p"} 2 +netbird_peers_connected{connection_type="relay"} 1 +# HELP netbird_signal_connected Whether the client is connected to the signal service (1 connected, 0 disconnected). +# TYPE netbird_signal_connected gauge +netbird_signal_connected 1 +` + require.NoError(t, testutil.CollectAndCompare(c, strings.NewReader(expected))) +} + +func TestServe(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err, "must find a free port") + addr := ln.Addr().String() + require.NoError(t, ln.Close()) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + m := NewManager(ctx, testStatus(), nil) + m.Reconcile(true, addr) + + var body string + require.Eventually(t, func() bool { + resp, err := http.Get(fmt.Sprintf("http://%s/metrics", addr)) + if err != nil { + return false + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil || resp.StatusCode != http.StatusOK { + return false + } + body = string(data) + return true + }, 2*time.Second, 50*time.Millisecond, "metrics endpoint should come up") + + assert.Contains(t, body, "netbird_peers 4") + assert.Contains(t, body, `netbird_peers_connected{connection_type="relay"} 1`) + assert.Contains(t, body, `netbird_peer_latency_seconds{peer="peer-a.netbird.cloud"} 0.012`) +} + +// A server that never came up must not be remembered, otherwise reconciling the +// same address again is a no-op and the endpoint never recovers. +func TestReconcileForgetsAFailedServer(t *testing.T) { + blocker, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err, "must find a free port") + t.Cleanup(func() { _ = blocker.Close() }) + addr := blocker.Addr().String() + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + m := NewManager(ctx, testStatus(), nil) + m.Reconcile(true, addr) + + require.Eventually(t, func() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.srv == nil && m.addr == "" + }, 2*time.Second, 20*time.Millisecond, "the failed server should be dropped") + + require.NoError(t, blocker.Close()) + m.Reconcile(true, addr) + + require.Eventually(t, func() bool { + resp, err := http.Get(fmt.Sprintf("http://%s/metrics", addr)) + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK + }, 2*time.Second, 50*time.Millisecond, "reconciling the same address should retry the bind") +} + +func TestIsLoopback(t *testing.T) { + tests := map[string]bool{ + "": true, + "127.0.0.1:9191": true, + "127.9.9.9:9191": true, + "[::1]:9191": true, + "[::ffff:127.0.0.1]:9191": true, + "localhost:9191": true, + "0.0.0.0:9191": false, + "[::]:9191": false, + "192.168.1.10:9191": false, + "not-an-address": false, + "example.com:9191": false, + } + + for addr, want := range tests { + t.Run(addr, func(t *testing.T) { + assert.Equal(t, want, IsLoopback(addr), "loopback verdict for %q", addr) + }) + } +} diff --git a/client/internal/message_convert.go b/client/internal/message_convert.go index 97da32c06..60f19e228 100644 --- a/client/internal/message_convert.go +++ b/client/internal/message_convert.go @@ -10,21 +10,6 @@ import ( mgmProto "github.com/netbirdio/netbird/shared/management/proto" ) -func convertToFirewallProtocol(protocol mgmProto.RuleProtocol) (firewallManager.Protocol, error) { - switch protocol { - case mgmProto.RuleProtocol_TCP: - return firewallManager.ProtocolTCP, nil - case mgmProto.RuleProtocol_UDP: - return firewallManager.ProtocolUDP, nil - case mgmProto.RuleProtocol_ICMP: - return firewallManager.ProtocolICMP, nil - case mgmProto.RuleProtocol_ALL: - return firewallManager.ProtocolALL, nil - default: - return "", fmt.Errorf("invalid protocol type: %s", protocol.String()) - } -} - func convertPortInfo(portInfo *mgmProto.PortInfo) (*firewallManager.Port, error) { if portInfo == nil { return nil, errors.New("portInfo cannot be nil") diff --git a/client/internal/metrics/connection_type.go b/client/internal/metrics/connection_type.go index a3406a6b8..d393e5112 100644 --- a/client/internal/metrics/connection_type.go +++ b/client/internal/metrics/connection_type.go @@ -4,11 +4,17 @@ package metrics type ConnectionType string const ( - // ConnectionTypeICE represents a direct peer-to-peer connection using ICE - ConnectionTypeICE ConnectionType = "ice" + // ConnectionTypeICEP2P represents a direct peer-to-peer connection using ICE + ConnectionTypeICEP2P ConnectionType = "ice_p2p" + + // ConnectionTypeICETurn represents an ICE connection through a TURN server + ConnectionTypeICETurn ConnectionType = "ice_turn" // ConnectionTypeRelay represents a relayed connection ConnectionTypeRelay ConnectionType = "relay" + + // ConnectionTypeUnknown represents a connection with no active transport. It is not pushed. + ConnectionTypeUnknown ConnectionType = "unknown" ) // String returns the string representation of the connection type diff --git a/client/internal/metrics/env.go b/client/internal/metrics/env.go index 1f06ce484..c19dcc7f1 100644 --- a/client/internal/metrics/env.go +++ b/client/internal/metrics/env.go @@ -60,6 +60,13 @@ func getMetricsInterval() time.Duration { return interval } +// isMetricsPushEnvSet returns true if NB_METRICS_PUSH_ENABLED is explicitly set (to any value). +// When set, the env var takes full precedence over management server configuration. +func isMetricsPushEnvSet() bool { + _, set := os.LookupEnv(EnvMetricsPushEnabled) + return set +} + func isForceSending() bool { force, _ := strconv.ParseBool(os.Getenv(EnvMetricsForceSending)) return force diff --git a/client/internal/metrics/influxdb.go b/client/internal/metrics/influxdb.go index 531f6a986..717544f6a 100644 --- a/client/internal/metrics/influxdb.go +++ b/client/internal/metrics/influxdb.go @@ -45,30 +45,13 @@ func (m *influxDBMetrics) RecordConnectionStages( isReconnection bool, timestamps ConnectionStageTimestamps, ) { - var signalingReceivedToConnection, connectionToWgHandshake, totalDuration float64 - - if !timestamps.SignalingReceived.IsZero() && !timestamps.ConnectionReady.IsZero() { - signalingReceivedToConnection = timestamps.ConnectionReady.Sub(timestamps.SignalingReceived).Seconds() - } - - if !timestamps.ConnectionReady.IsZero() && !timestamps.WgHandshakeSuccess.IsZero() { - connectionToWgHandshake = timestamps.WgHandshakeSuccess.Sub(timestamps.ConnectionReady).Seconds() - } - - if !timestamps.SignalingReceived.IsZero() && !timestamps.WgHandshakeSuccess.IsZero() { - totalDuration = timestamps.WgHandshakeSuccess.Sub(timestamps.SignalingReceived).Seconds() - } - - attemptType := "initial" - if isReconnection { - attemptType = "reconnection" - } + signalingReceivedToConnection, connectionToWgHandshake, totalDuration := timestamps.Durations() connTypeStr := connectionType.String() tags := fmt.Sprintf("deployment_type=%s,connection_type=%s,attempt_type=%s,version=%s,os=%s,arch=%s,peer_id=%s,connection_pair_id=%s", agentInfo.DeploymentType.String(), connTypeStr, - attemptType, + attemptType(isReconnection), agentInfo.Version, agentInfo.OS, agentInfo.Arch, @@ -94,7 +77,7 @@ func (m *influxDBMetrics) RecordConnectionStages( m.trimLocked() log.Tracef("peer connection metrics [%s, %s, %s]: signalingReceived→connection: %.3fs, connection→wg_handshake: %.3fs, total: %.3fs", - agentInfo.DeploymentType.String(), connTypeStr, attemptType, signalingReceivedToConnection, connectionToWgHandshake, totalDuration) + agentInfo.DeploymentType.String(), connTypeStr, attemptType(isReconnection), signalingReceivedToConnection, connectionToWgHandshake, totalDuration) } func (m *influxDBMetrics) RecordSyncDuration(_ context.Context, agentInfo AgentInfo, duration time.Duration) { @@ -120,6 +103,30 @@ func (m *influxDBMetrics) RecordSyncDuration(_ context.Context, agentInfo AgentI m.trimLocked() } +func (m *influxDBMetrics) RecordSyncPhase(_ context.Context, agentInfo AgentInfo, phase string, duration time.Duration) { + tags := fmt.Sprintf("deployment_type=%s,version=%s,os=%s,arch=%s,peer_id=%s,phase=%s", + agentInfo.DeploymentType.String(), + agentInfo.Version, + agentInfo.OS, + agentInfo.Arch, + agentInfo.peerID, + phase, + ) + + m.mu.Lock() + defer m.mu.Unlock() + + m.samples = append(m.samples, influxSample{ + measurement: "netbird_sync_phase", + tags: tags, + fields: map[string]float64{ + "duration_seconds": duration.Seconds(), + }, + timestamp: time.Now(), + }) + m.trimLocked() +} + func (m *influxDBMetrics) RecordLoginDuration(_ context.Context, agentInfo AgentInfo, duration time.Duration, success bool) { result := "success" if !success { diff --git a/client/internal/metrics/influxdb_test.go b/client/internal/metrics/influxdb_test.go index b964e31a3..6a226fe2f 100644 --- a/client/internal/metrics/influxdb_test.go +++ b/client/internal/metrics/influxdb_test.go @@ -28,7 +28,7 @@ func TestInfluxDBMetrics_RecordAndExport(t *testing.T) { WgHandshakeSuccess: time.Now().Add(-1 * time.Second), } - m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICE, false, ts) + m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICEP2P, false, ts) var buf bytes.Buffer err := m.Export(&buf) @@ -60,7 +60,7 @@ func TestInfluxDBMetrics_ExportDeterministicFieldOrder(t *testing.T) { // Record multiple times and verify consistent field order for i := 0; i < 10; i++ { - m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICE, false, ts) + m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICEP2P, false, ts) } var buf bytes.Buffer diff --git a/client/internal/metrics/infra/README.md b/client/internal/metrics/infra/README.md index 5a93dbd87..7c23e42bd 100644 --- a/client/internal/metrics/infra/README.md +++ b/client/internal/metrics/infra/README.md @@ -32,13 +32,24 @@ Clients do not talk to InfluxDB directly. An ingest server sits between clients ```text Client ──POST──▶ Ingest Server (:8087) ──▶ InfluxDB (internal) │ + ├─ Checks the X-Peer-ID header format ├─ Validates line protocol ├─ Allowlists measurements, fields, and tags ├─ Rejects out-of-bound values └─ Serves remote config at /config ``` -- **No secret/token-based client auth** — the ingest server holds the InfluxDB token server-side. Clients must send a hashed peer ID via `X-Peer-ID` header. +- **Intentionally unauthenticated** — the endpoint receives obfuscated telemetry from + the peers of both cloud and self-hosted deployments. For a self-hosted peer there is + no shared trust anchor with this server, so there is nothing to authenticate against. +- **`X-Peer-ID` is a correlation tag, not a credential** — it carries the obfuscated + peer identifier so samples from one peer can be grouped. The server only checks that + the header is well-formed (16 hex chars); a malformed value is rejected with + `400 Bad Request`, not `401`. Any well-formed value is accepted by design, and the + header must not be relied on for access control. The header itself is not forwarded + to InfluxDB — the stored `peer_id` tag comes from the request body and is constrained + only by the tag allowlist and the maximum tag value length. +- **The InfluxDB token stays server-side** — clients never hold a write credential. - **InfluxDB is not exposed** — only accessible within the docker network - Source: `ingest/main.go` @@ -56,14 +67,33 @@ Measurement: `netbird_peer_connection` Tags: - `deployment_type`: "cloud" | "selfhosted" | "unknown" -- `connection_type`: "ice" | "relay" +- `connection_type`: "ice_p2p" | "ice_turn" | "relay" (see below) - `attempt_type`: "initial" | "reconnection" - `version`: NetBird version string - `os`: Operating system (linux, darwin, windows, android, ios, etc.) - `arch`: CPU architecture (amd64, arm64, etc.) +- `peer_id`: obfuscated peer identifier (truncated SHA-256 of the WireGuard public key) +- `connection_pair_id`: deterministic identifier for the peer pair, identical on both sides **Note:** `SignalingReceived` is set when the first offer or answer arrives from the remote peer (in both initial and reconnection paths). It excludes the potentially unbounded wait for the remote peer to come online. +#### `connection_type` values + +Derived from the connection priority (`conntype.ConnPriority`) by `metricsConnType` in `client/internal/peer/conn.go`: + +| Value | Priority | Traffic is | +|-------|----------|------------| +| `ice_p2p` | `ICEP2P` | direct peer-to-peer | +| `ice_turn` | `ICETurn` | relayed, through a TURN server | +| `relay` | `Relay` | relayed, through a NetBird relay | +| `unknown` | `None` or unrecognised | no active transport — **the sample is not pushed** | + +**Direct traffic is `ice_p2p` only.** `ice_turn` is relayed despite being negotiated by ICE, matching `Conn.isRelayed`. + +`None` means no transport is active: not established yet, or reset after a relay drop or a peer-state reset. Such a sample cannot be attributed to a transport, so `recordConnectionMetrics` drops it instead of pushing it — `unknown` therefore never appears in the bucket. Connection counts are counts of connections whose transport was known at sampling time. + +**Samples recorded before 0.77 used a single `ice` value** which covered `ICEP2P`, `ICETurn` *and* `None`, so historical `ice` samples overstate direct connections by an unknown amount and must not be compared with `ice_p2p`. + ### Sync Duration Measurement: `netbird_sync` @@ -78,6 +108,25 @@ Tags: - `os`: Operating system (linux, darwin, windows, android, ios, etc.) - `arch`: CPU architecture (amd64, arm64, etc.) +### Sync Phase Timing + +Measurement: `netbird_sync_phase` + +Breaks down where time goes inside a single sync, so the total `netbird_sync` duration can be attributed to the sub-step that dominates. + +| Field | Description | +|-------|-------------| +| `duration_seconds` | Time spent in one sub-phase of sync processing | + +Tags: +- `phase`: the sub-phase — `netbird_config`, `checks`, `persist`, `dns_server`, `routes_classify`, `routes_apply`, `filtering`, `dns_forwarder`, `forward_rules`, `offline_peers`, `removed_peers`, `modified_peers`, `added_peers`, `lazy_exclude` +- `deployment_type`: "cloud" | "selfhosted" | "unknown" +- `version`: NetBird version string +- `os`: Operating system (linux, darwin, windows, android, ios, etc.) +- `arch`: CPU architecture (amd64, arm64, etc.) + +**Note:** this is wall-time per phase — it includes both CPU work and time spent waiting on locks. A slow phase points to *where* the time goes, not *why*; pair it with lock-wait metrics to tell contention apart from real work. + ### Login Duration Measurement: `netbird_login` @@ -157,7 +206,7 @@ docker compose up -d ``` This starts: -- **Ingest server** on http://localhost:8087 — accepts client metrics (requires `X-Peer-ID` header, no secret/token auth) +- **Ingest server** on http://localhost:8087 — accepts client metrics (unauthenticated by design; expects a well-formed `X-Peer-ID` correlation tag) - **InfluxDB** — internal only, not exposed to host - **Grafana** on http://localhost:3001 @@ -191,4 +240,52 @@ docker compose exec influxdb influx query \ # Check ingest server health curl http://localhost:8087/health -``` \ No newline at end of file +``` + +## Analyzing a Debug Bundle + +Metrics collection is always on, so every debug bundle ships a `metrics.txt` in InfluxDB line protocol — a timestamped time series of all recorded events (sync durations, sync phases, connection stages, login). You can replay it into the local stack and graph it, without a running client. + +The bundle's `metrics.txt` is a rolling window (capped at 5 days / ~20k samples, see [Buffer Limits](#buffer-limits)). For a connection incident the relevant window is short (connection setup is seconds), so a bundle captured during the issue is enough. + +### 1. Start the stack + +```bash +# From this directory (client/internal/metrics/infra) +INFLUXDB_ADMIN_TOKEN=admin123 INFLUXDB_ADMIN_PASSWORD=admin123 GRAFANA_ADMIN_PASSWORD=admin123 \ + docker compose up -d +``` + +(`admin123` are throwaway local credentials — fine for offline analysis.) + +### 2. Clear any previous data + +So you only see this bundle: + +```bash +docker exec influxdb influx delete --org netbird --bucket metrics --token admin123 \ + --start 1970-01-01T00:00:00Z --stop 2100-01-01T00:00:00Z +``` + +### 3. Import the bundle's metrics.txt + +InfluxDB is not exposed on the host, so import inside the container: + +```bash +docker cp /path/to/bundle/metrics.txt influxdb:/tmp/m.txt +docker exec influxdb influx write --org netbird --bucket metrics --precision ns \ + --token admin123 --file /tmp/m.txt +``` + +Re-importing the same file is idempotent (same measurement+tags+timestamp overwrites). + +### 4. View the dashboards + +Grafana on http://localhost:3001 (login `admin` / `admin123`), datasource pre-provisioned: + +- **Where sync time goes:** http://localhost:3001/d/netbird-sync-phases/netbird-sync-phases-where-time-goes +- **General client metrics:** http://localhost:3001/d/netbird-influxdb-metrics + +**Set the time range** to cover the bundle's timestamps (e.g. "Last 7 days" or an absolute range matching when the bundle was taken) — with the default short range the panels look empty. + +Bundles are distinguishable by the `version` tag; add a tag at import time (e.g. `sed 's/^netbird_\([a-z_]*\),/netbird_\1,bundle=mycase,/' metrics.txt`) if you want to compare several side by side. \ No newline at end of file diff --git a/client/internal/metrics/infra/grafana/provisioning/dashboards/json/netbird-sync-phases.json b/client/internal/metrics/infra/grafana/provisioning/dashboards/json/netbird-sync-phases.json new file mode 100644 index 000000000..69dbac0ae --- /dev/null +++ b/client/internal/metrics/infra/grafana/provisioning/dashboards/json/netbird-sync-phases.json @@ -0,0 +1,259 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "links": [], + "refresh": "", + "schemaVersion": 39, + "tags": [ + "netbird", + "sync" + ], + "templating": { + "list": [ + { + "current": { + "text": "All", + "value": "$__all" + }, + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "definition": "import \"influxdata/influxdb/schema\"\nschema.tagValues(bucket: \"metrics\", tag: \"version\")", + "includeAll": true, + "label": "version", + "multi": true, + "name": "version", + "query": "import \"influxdata/influxdb/schema\"\nschema.tagValues(bucket: \"metrics\", tag: \"version\")", + "refresh": 2, + "type": "query", + "allValue": ".*" + } + ] + }, + "time": { + "from": "now-2d", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "NetBird Sync Phases (where time goes)", + "uid": "netbird-sync-phases", + "version": 1, + "panels": [ + { + "id": 1, + "title": "Time per phase over time (stacked, ms)", + "type": "timeseries", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 0 + }, + "fieldConfig": { + "defaults": { + "unit": "ms", + "custom": { + "drawStyle": "bars", + "stacking": { + "mode": "normal", + "group": "A" + }, + "fillOpacity": 80, + "lineWidth": 0 + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "calcs": [ + "max", + "mean" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> keep(columns: [\"_time\", \"_value\", \"phase\"])\n |> group(columns: [\"phase\"])" + } + ] + }, + { + "id": 2, + "title": "p95 per phase (ms)", + "type": "bargauge", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "gridPos": { + "h": 11, + "w": 12, + "x": 0, + "y": 10 + }, + "fieldConfig": { + "defaults": { + "unit": "ms", + "color": { + "mode": "continuous-GrYlRd" + } + }, + "overrides": [] + }, + "options": { + "displayMode": "gradient", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> group(columns: [\"phase\"])\n |> quantile(q: 0.95)\n |> group()\n |> sort(columns: [\"_value\"], desc: true)" + } + ] + }, + { + "id": 3, + "title": "Per-phase stats (ms): mean / p95 / max", + "type": "table", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "gridPos": { + "h": 11, + "w": 12, + "x": 12, + "y": 10 + }, + "fieldConfig": { + "defaults": { + "unit": "ms" + }, + "overrides": [] + }, + "options": { + "showHeader": true, + "sortBy": [ + { + "displayName": "max", + "desc": true + } + ] + }, + "transformations": [ + { + "id": "merge", + "options": {} + } + ], + "targets": [ + { + "refId": "mean", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> group(columns: [\"phase\"])\n |> mean()\n |> group()\n |> keep(columns: [\"phase\", \"_value\"])\n |> rename(columns: {_value: \"mean\"})" + }, + { + "refId": "p95", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> group(columns: [\"phase\"])\n |> quantile(q: 0.95)\n |> group()\n |> keep(columns: [\"phase\", \"_value\"])\n |> rename(columns: {_value: \"p95\"})" + }, + { + "refId": "max", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> group(columns: [\"phase\"])\n |> max()\n |> group()\n |> keep(columns: [\"phase\", \"_value\"])\n |> rename(columns: {_value: \"max\"})" + } + ] + }, + { + "id": 4, + "title": "Total sync duration (netbird_sync, ms) \u2014 reference", + "type": "timeseries", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 21 + }, + "fieldConfig": { + "defaults": { + "unit": "ms", + "custom": { + "drawStyle": "points", + "pointSize": 5 + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "calcs": [ + "max", + "mean" + ] + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> keep(columns: [\"_time\", \"_value\", \"version\"])\n |> group(columns: [\"version\"])" + } + ] + } + ] +} \ No newline at end of file diff --git a/client/internal/metrics/infra/ingest/main.go b/client/internal/metrics/infra/ingest/main.go index a5031a873..a9fb25178 100644 --- a/client/internal/metrics/infra/ingest/main.go +++ b/client/internal/metrics/infra/ingest/main.go @@ -19,9 +19,14 @@ const ( defaultListenAddr = ":8087" defaultInfluxDBURL = "http://influxdb:8086/api/v2/write?org=netbird&bucket=metrics&precision=ns" maxBodySize = 50 * 1024 * 1024 // 50 MB max request body - maxDurationSeconds = 300.0 // reject any duration field > 5 minutes + maxDurationSeconds = 86400.0 // reject any duration field > 24 hours peerIDLength = 16 // truncated SHA-256: 8 bytes = 16 hex chars maxTagValueLength = 64 // reject tag values longer than this + readTimeout = 30 * time.Second // must fit reading a compressed body up to maxBodySize + writeTimeout = 60 * time.Second // must exceed the upstream client timeout below + idleTimeout = 120 * time.Second + readHeaderTimeout = 10 * time.Second + maxHeaderBytes = 1 << 20 // 1 MB ) type measurementSpec struct { @@ -59,6 +64,19 @@ var allowedMeasurements = map[string]measurementSpec{ "peer_id": true, }, }, + "netbird_sync_phase": { + allowedFields: map[string]bool{ + "duration_seconds": true, + }, + allowedTags: map[string]bool{ + "deployment_type": true, + "version": true, + "os": true, + "arch": true, + "peer_id": true, + "phase": true, + }, + }, "netbird_login": { allowedFields: map[string]bool{ "duration_seconds": true, @@ -111,8 +129,17 @@ func main() { fmt.Fprint(w, "ok") //nolint:errcheck }) + srv := &http.Server{ + Addr: listenAddr, + ReadTimeout: readTimeout, + ReadHeaderTimeout: readHeaderTimeout, + WriteTimeout: writeTimeout, + IdleTimeout: idleTimeout, + MaxHeaderBytes: maxHeaderBytes, + } + log.Printf("ingest server listening on %s, forwarding to %s", listenAddr, influxURL) - if err := http.ListenAndServe(listenAddr, nil); err != nil { //nolint:gosec + if err := srv.ListenAndServe(); err != nil { log.Fatal(err) } } @@ -124,8 +151,8 @@ func handleIngest(client *http.Client, influxURL, influxToken string) http.Handl return } - if err := validateAuth(r); err != nil { - http.Error(w, err.Error(), http.StatusUnauthorized) + if err := validatePeerIDFormat(r); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -174,8 +201,13 @@ func forwardToInflux(w http.ResponseWriter, r *http.Request, client *http.Client io.Copy(w, resp.Body) //nolint:errcheck } -// validateAuth checks that the X-Peer-ID header contains a valid hashed peer ID. -func validateAuth(r *http.Request) error { +// validatePeerIDFormat checks the shape of the X-Peer-ID header. The header is a +// correlation tag, not a credential: this endpoint is intentionally +// unauthenticated so that peers of self-hosted deployments, for which no shared +// trust anchor exists, can report obfuscated telemetry. The header is not forwarded +// to InfluxDB, so this check does not bound the stored peer_id tag; it only rejects +// a malformed header as a bad request rather than an auth failure. +func validatePeerIDFormat(r *http.Request) error { peerID := r.Header.Get("X-Peer-ID") if peerID == "" { return fmt.Errorf("missing X-Peer-ID header") diff --git a/client/internal/metrics/infra/ingest/main_test.go b/client/internal/metrics/infra/ingest/main_test.go index bacaa4588..526f127cb 100644 --- a/client/internal/metrics/infra/ingest/main_test.go +++ b/client/internal/metrics/infra/ingest/main_test.go @@ -53,14 +53,14 @@ func TestValidateLine_NegativeValue(t *testing.T) { } func TestValidateLine_DurationTooLarge(t *testing.T) { - line := `netbird_sync,deployment_type=cloud,version=1.0.0,os=linux,arch=amd64,peer_id=abc duration_seconds=999 1234567890` + line := `netbird_sync,deployment_type=cloud,version=1.0.0,os=linux,arch=amd64,peer_id=abc duration_seconds=100000 1234567890` err := validateLine(line) require.Error(t, err) assert.Contains(t, err.Error(), "too large") } func TestValidateLine_TotalSecondsTooLarge(t *testing.T) { - line := `netbird_peer_connection,deployment_type=cloud,connection_type=ice,attempt_type=initial,version=1.0.0,os=linux,arch=amd64,peer_id=abc,connection_pair_id=pair total_seconds=500 1234567890` + line := `netbird_peer_connection,deployment_type=cloud,connection_type=ice,attempt_type=initial,version=1.0.0,os=linux,arch=amd64,peer_id=abc,connection_pair_id=pair total_seconds=100000 1234567890` err := validateLine(line) require.Error(t, err) assert.Contains(t, err.Error(), "too large") @@ -94,7 +94,7 @@ func TestValidateLineProtocol_RejectsOnBadLine(t *testing.T) { require.Error(t, err) } -func TestValidateAuth(t *testing.T) { +func TestValidatePeerIDFormat(t *testing.T) { tests := []struct { name string peerID string @@ -113,7 +113,7 @@ func TestValidateAuth(t *testing.T) { if tt.peerID != "" { r.Header.Set("X-Peer-ID", tt.peerID) } - err := validateAuth(r) + err := validatePeerIDFormat(r) if tt.wantErr { require.Error(t, err) } else { diff --git a/client/internal/metrics/metrics.go b/client/internal/metrics/metrics.go index 4ebb43496..5edf1d9c7 100644 --- a/client/internal/metrics/metrics.go +++ b/client/internal/metrics/metrics.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "sync" + "sync/atomic" "time" log "github.com/sirupsen/logrus" @@ -56,6 +57,9 @@ type metricsImplementation interface { // RecordSyncDuration records how long it took to process a sync message RecordSyncDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration) + // RecordSyncPhase records how long a single sub-phase of sync processing took + RecordSyncPhase(ctx context.Context, agentInfo AgentInfo, phase string, duration time.Duration) + // RecordLoginDuration records how long the login to management took RecordLoginDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration, success bool) @@ -72,7 +76,7 @@ type ClientMetrics struct { agentInfo AgentInfo mu sync.RWMutex - push *Push + push atomic.Pointer[Push] pushMu sync.Mutex wg sync.WaitGroup pushCancel context.CancelFunc @@ -85,6 +89,21 @@ type ConnectionStageTimestamps struct { WgHandshakeSuccess time.Time } +// Durations returns the stage durations in seconds. A duration is zero when +// either of its timestamps is missing. +func (c ConnectionStageTimestamps) Durations() (signalingToConnection, connectionToWgHandshake, total float64) { + if !c.SignalingReceived.IsZero() && !c.ConnectionReady.IsZero() { + signalingToConnection = c.ConnectionReady.Sub(c.SignalingReceived).Seconds() + } + if !c.ConnectionReady.IsZero() && !c.WgHandshakeSuccess.IsZero() { + connectionToWgHandshake = c.WgHandshakeSuccess.Sub(c.ConnectionReady).Seconds() + } + if !c.SignalingReceived.IsZero() && !c.WgHandshakeSuccess.IsZero() { + total = c.WgHandshakeSuccess.Sub(c.SignalingReceived).Seconds() + } + return signalingToConnection, connectionToWgHandshake, total +} + // String returns a human-readable representation of the connection stage timestamps func (c ConnectionStageTimestamps) String() string { return fmt.Sprintf("ConnectionStageTimestamps{SignalingReceived=%v, ConnectionReady=%v, WgHandshakeSuccess=%v}", @@ -127,6 +146,18 @@ func (c *ClientMetrics) RecordSyncDuration(ctx context.Context, duration time.Du c.impl.RecordSyncDuration(ctx, agentInfo, duration) } +// RecordSyncPhase records the duration of a single sub-phase of sync processing +func (c *ClientMetrics) RecordSyncPhase(ctx context.Context, phase string, duration time.Duration) { + if c == nil { + return + } + c.mu.RLock() + agentInfo := c.agentInfo + c.mu.RUnlock() + + c.impl.RecordSyncPhase(ctx, agentInfo, phase, duration) +} + // RecordLoginDuration records how long the login to management server took func (c *ClientMetrics) RecordLoginDuration(ctx context.Context, duration time.Duration, success bool) { if c == nil { @@ -152,10 +183,7 @@ func (c *ClientMetrics) UpdateAgentInfo(agentInfo AgentInfo, publicKey string) { c.agentInfo = agentInfo c.mu.Unlock() - c.pushMu.Lock() - push := c.push - c.pushMu.Unlock() - if push != nil { + if push := c.push.Load(); push != nil { push.SetPeerID(agentInfo.peerID) } } @@ -169,7 +197,7 @@ func (c *ClientMetrics) Export(w io.Writer) error { return c.impl.Export(w) } -// StartPush starts periodic pushing of metrics with the given configuration +// StartPush starts periodic pushing of metrics with the given configuration. // Precedence: PushConfig.ServerAddress > remote config server_url func (c *ClientMetrics) StartPush(ctx context.Context, config PushConfig) { if c == nil { @@ -179,11 +207,58 @@ func (c *ClientMetrics) StartPush(ctx context.Context, config PushConfig) { c.pushMu.Lock() defer c.pushMu.Unlock() - if c.push != nil { + if c.push.Load() != nil { log.Warnf("metrics push already running") return } + c.startPushLocked(ctx, config) +} + +// StopPush stops the periodic metrics push. +func (c *ClientMetrics) StopPush() { + if c == nil { + return + } + c.pushMu.Lock() + defer c.pushMu.Unlock() + + c.stopPushLocked() +} + +// UpdatePushFromMgm updates metrics push based on management server configuration. +// If NB_METRICS_PUSH_ENABLED is explicitly set (true or false), management config is ignored. +// When unset, management controls whether push is enabled. +func (c *ClientMetrics) UpdatePushFromMgm(ctx context.Context, enabled bool) { + if c == nil { + return + } + + if isMetricsPushEnvSet() { + log.Debugf("ignoring management config, env var is explicitly set: %s", EnvMetricsPushEnabled) + return + } + + c.pushMu.Lock() + defer c.pushMu.Unlock() + + if enabled { + if c.push.Load() != nil { + return + } + log.Infof("enabled metrics push by management") + c.startPushLocked(ctx, PushConfigFromEnv()) + } else { + if c.push.Load() == nil { + return + } + log.Infof("disabled metrics push by management") + c.stopPushLocked() + } +} + +// startPushLocked starts push. Caller must hold pushMu. +func (c *ClientMetrics) startPushLocked(ctx context.Context, config PushConfig) { c.mu.RLock() agentVersion := c.agentInfo.Version peerID := c.agentInfo.peerID @@ -199,26 +274,31 @@ func (c *ClientMetrics) StartPush(ctx context.Context, config PushConfig) { ctx, cancel := context.WithCancel(ctx) c.pushCancel = cancel + c.push.Store(push) c.wg.Add(1) go func() { defer c.wg.Done() push.Start(ctx) + c.push.CompareAndSwap(push, nil) }() - c.push = push } -func (c *ClientMetrics) StopPush() { - if c == nil { - return - } - c.pushMu.Lock() - defer c.pushMu.Unlock() - if c.push == nil { +// stopPushLocked stops push. Caller must hold pushMu. +func (c *ClientMetrics) stopPushLocked() { + if c.push.Load() == nil { return } c.pushCancel() c.wg.Wait() - c.push = nil + c.push.Store(nil) +} + +// attemptType returns the metric label for an initial vs reconnection attempt. +func attemptType(isReconnection bool) string { + if isReconnection { + return "reconnection" + } + return "initial" } diff --git a/client/internal/metrics/metrics_default.go b/client/internal/metrics/metrics_default.go index 927ab51d1..3798adab6 100644 --- a/client/internal/metrics/metrics_default.go +++ b/client/internal/metrics/metrics_default.go @@ -2,10 +2,24 @@ package metrics +import "github.com/prometheus/client_golang/prometheus" + // NewClientMetrics creates a new ClientMetrics instance func NewClientMetrics(agentInfo AgentInfo) *ClientMetrics { return &ClientMetrics{ - impl: newInfluxDBMetrics(), + impl: newPrometheusMetrics(newInfluxDBMetrics()), agentInfo: agentInfo, } } + +// PrometheusGatherer returns the registry with the mirrored Prometheus +// metrics, or nil when unavailable. +func (c *ClientMetrics) PrometheusGatherer() prometheus.Gatherer { + if c == nil { + return nil + } + if pm, ok := c.impl.(*prometheusMetrics); ok { + return pm.Gatherer() + } + return nil +} diff --git a/client/internal/metrics/prometheus.go b/client/internal/metrics/prometheus.go new file mode 100644 index 000000000..7f5020ea9 --- /dev/null +++ b/client/internal/metrics/prometheus.go @@ -0,0 +1,119 @@ +//go:build !js + +package metrics + +import ( + "context" + "io" + "strconv" + "time" + + "github.com/prometheus/client_golang/prometheus" +) + +// prometheusMetrics mirrors recorded client metrics into a Prometheus +// registry for the local /metrics endpoint, then delegates to the wrapped +// implementation. Export and Reset pass through untouched: Prometheus +// metrics are cumulative and pull-based. +type prometheusMetrics struct { + next metricsImplementation + registry *prometheus.Registry + + connectionStages *prometheus.HistogramVec + syncDuration prometheus.Histogram + syncPhaseDuration *prometheus.HistogramVec + loginDuration *prometheus.HistogramVec +} + +func newPrometheusMetrics(next metricsImplementation) *prometheusMetrics { + connectionBuckets := []float64{.05, .1, .25, .5, 1, 2.5, 5, 10, 30, 60} + + m := &prometheusMetrics{ + next: next, + registry: prometheus.NewRegistry(), + connectionStages: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "netbird_peer_connection_stage_duration_seconds", + Help: "Duration of peer connection establishment stages.", + Buckets: connectionBuckets, + }, []string{"stage", "connection_type", "attempt_type"}), + syncDuration: prometheus.NewHistogram(prometheus.HistogramOpts{ + Name: "netbird_sync_duration_seconds", + Help: "Duration of management sync message processing.", + Buckets: prometheus.DefBuckets, + }), + syncPhaseDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "netbird_sync_phase_duration_seconds", + Help: "Duration of individual sync processing phases.", + Buckets: prometheus.DefBuckets, + }, []string{"phase"}), + loginDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "netbird_login_duration_seconds", + Help: "Duration of logins to the management service.", + Buckets: prometheus.DefBuckets, + }, []string{"success"}), + } + + m.registry.MustRegister(m.connectionStages, m.syncDuration, m.syncPhaseDuration, m.loginDuration) + return m +} + +// Gatherer returns the registry holding the mirrored metrics. +func (m *prometheusMetrics) Gatherer() prometheus.Gatherer { + return m.registry +} + +// RecordConnectionStages implements metricsImplementation. +func (m *prometheusMetrics) RecordConnectionStages( + ctx context.Context, + agentInfo AgentInfo, + connectionPairID string, + connectionType ConnectionType, + isReconnection bool, + timestamps ConnectionStageTimestamps, +) { + attempt := attemptType(isReconnection) + connType := connectionType.String() + + signalingToConnection, connectionToWgHandshake, total := timestamps.Durations() + if signalingToConnection > 0 { + m.connectionStages.WithLabelValues("signaling_to_connection", connType, attempt).Observe(signalingToConnection) + } + if connectionToWgHandshake > 0 { + m.connectionStages.WithLabelValues("connection_to_wg_handshake", connType, attempt).Observe(connectionToWgHandshake) + } + if total > 0 { + m.connectionStages.WithLabelValues("total", connType, attempt).Observe(total) + } + + m.next.RecordConnectionStages(ctx, agentInfo, connectionPairID, connectionType, isReconnection, timestamps) +} + +// RecordSyncDuration implements metricsImplementation. +func (m *prometheusMetrics) RecordSyncDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration) { + m.syncDuration.Observe(duration.Seconds()) + m.next.RecordSyncDuration(ctx, agentInfo, duration) +} + +// RecordSyncPhase implements metricsImplementation. +func (m *prometheusMetrics) RecordSyncPhase(ctx context.Context, agentInfo AgentInfo, phase string, duration time.Duration) { + m.syncPhaseDuration.WithLabelValues(phase).Observe(duration.Seconds()) + m.next.RecordSyncPhase(ctx, agentInfo, phase, duration) +} + +// RecordLoginDuration implements metricsImplementation. +func (m *prometheusMetrics) RecordLoginDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration, success bool) { + m.loginDuration.WithLabelValues(strconv.FormatBool(success)).Observe(duration.Seconds()) + m.next.RecordLoginDuration(ctx, agentInfo, duration, success) +} + +// Export implements metricsImplementation by delegating to the wrapped +// implementation; Prometheus metrics are pulled via the registry instead. +func (m *prometheusMetrics) Export(w io.Writer) error { + return m.next.Export(w) +} + +// Reset implements metricsImplementation by delegating to the wrapped +// implementation; Prometheus metrics must not be cleared on push. +func (m *prometheusMetrics) Reset() { + m.next.Reset() +} diff --git a/client/internal/metrics/push_test.go b/client/internal/metrics/push_test.go index 20a509da1..43c1b2c06 100644 --- a/client/internal/metrics/push_test.go +++ b/client/internal/metrics/push_test.go @@ -70,6 +70,9 @@ func (m *mockMetrics) RecordConnectionStages(_ context.Context, _ AgentInfo, _ s func (m *mockMetrics) RecordSyncDuration(_ context.Context, _ AgentInfo, _ time.Duration) { } +func (m *mockMetrics) RecordSyncPhase(_ context.Context, _ AgentInfo, _ string, _ time.Duration) { +} + func (m *mockMetrics) RecordLoginDuration(_ context.Context, _ AgentInfo, _ time.Duration, _ bool) { } diff --git a/client/internal/mobile_dependency.go b/client/internal/mobile_dependency.go index 310d61a25..0234432b1 100644 --- a/client/internal/mobile_dependency.go +++ b/client/internal/mobile_dependency.go @@ -11,12 +11,14 @@ import ( // MobileDependency collect all dependencies for mobile platform type MobileDependency struct { - // Android only - TunAdapter device.TunAdapter - IFaceDiscover stdnet.ExternalIFaceDiscover + // Android and iOS NetworkChangeListener listener.NetworkChangeListener - HostDNSAddresses []netip.AddrPort - DnsReadyListener dns.ReadyListener + + // Android only + TunAdapter device.TunAdapter + IFaceDiscover stdnet.ExternalIFaceDiscover + HostDNSAddresses []netip.AddrPort + DnsReadyListener dns.ReadyListener // iOS only DnsManager dns.IosDnsManager diff --git a/client/internal/netflow/conntrack/conntrack.go b/client/internal/netflow/conntrack/conntrack.go index 2420b1fdf..6f1da5138 100644 --- a/client/internal/netflow/conntrack/conntrack.go +++ b/client/internal/netflow/conntrack/conntrack.go @@ -316,7 +316,7 @@ func (c *ConnTrack) handleEvent(event nfct.Event) { case nftypes.TCP, nftypes.UDP, nftypes.SCTP: srcPort = flow.TupleOrig.Proto.SourcePort dstPort = flow.TupleOrig.Proto.DestinationPort - case nftypes.ICMP: + case nftypes.ICMP, nftypes.ICMPv6: icmpType = flow.TupleOrig.Proto.ICMPType icmpCode = flow.TupleOrig.Proto.ICMPCode } @@ -359,8 +359,14 @@ func (c *ConnTrack) relevantFlow(mark uint32, srcIP, dstIP netip.Addr) bool { } // fallback if mark rules are not in place - wgnet := c.iface.Address().Network - return wgnet.Contains(srcIP) || wgnet.Contains(dstIP) + addr := c.iface.Address() + if addr.Network.Contains(srcIP) || addr.Network.Contains(dstIP) { + return true + } + if addr.IPv6Net.IsValid() { + return addr.IPv6Net.Contains(srcIP) || addr.IPv6Net.Contains(dstIP) + } + return false } // mapRxPackets maps packet counts to RX based on flow direction @@ -419,17 +425,16 @@ func (c *ConnTrack) inferDirection(mark uint32, srcIP, dstIP netip.Addr) nftypes } // fallback if marks are not set - wgaddr := c.iface.Address().IP - wgnetwork := c.iface.Address().Network + addr := c.iface.Address() switch { - case wgaddr == srcIP: + case addr.IP == srcIP || (addr.IPv6.IsValid() && addr.IPv6 == srcIP): return nftypes.Egress - case wgaddr == dstIP: + case addr.IP == dstIP || (addr.IPv6.IsValid() && addr.IPv6 == dstIP): return nftypes.Ingress - case wgnetwork.Contains(srcIP): + case addr.Network.Contains(srcIP) || (addr.IPv6Net.IsValid() && addr.IPv6Net.Contains(srcIP)): // netbird network -> resource network return nftypes.Ingress - case wgnetwork.Contains(dstIP): + case addr.Network.Contains(dstIP) || (addr.IPv6Net.IsValid() && addr.IPv6Net.Contains(dstIP)): // resource network -> netbird network return nftypes.Egress } diff --git a/client/internal/netflow/logger/logger.go b/client/internal/netflow/logger/logger.go index a033a2a7c..deb38bc4d 100644 --- a/client/internal/netflow/logger/logger.go +++ b/client/internal/netflow/logger/logger.go @@ -24,16 +24,18 @@ type Logger struct { cancel context.CancelFunc statusRecorder *peer.Status wgIfaceNet netip.Prefix + wgIfaceNetV6 netip.Prefix dnsCollection atomic.Bool exitNodeCollection atomic.Bool - Store types.Store + Store types.AggregatingStore } -func New(statusRecorder *peer.Status, wgIfaceIPNet netip.Prefix) *Logger { +func New(statusRecorder *peer.Status, wgIfaceIPNet, wgIfaceIPNetV6 netip.Prefix) *Logger { return &Logger{ statusRecorder: statusRecorder, wgIfaceNet: wgIfaceIPNet, - Store: store.NewMemoryStore(), + wgIfaceNetV6: wgIfaceIPNetV6, + Store: store.NewAggregatingMemoryStore(), } } @@ -88,11 +90,11 @@ func (l *Logger) startReceiver() { var isSrcExitNode bool var isDestExitNode bool - if !l.wgIfaceNet.Contains(event.SourceIP) { + if !l.isOverlayIP(event.SourceIP) { event.SourceResourceID, isSrcExitNode = l.statusRecorder.CheckRoutes(event.SourceIP) } - if !l.wgIfaceNet.Contains(event.DestIP) { + if !l.isOverlayIP(event.DestIP) { event.DestResourceID, isDestExitNode = l.statusRecorder.CheckRoutes(event.DestIP) } @@ -123,6 +125,10 @@ func (l *Logger) stop() { l.mux.Unlock() } +func (l *Logger) ResetAggregationWindow() types.FlowEventAggregator { + return l.Store.ResetAggregationWindow() +} + func (l *Logger) GetEvents() []*types.Event { return l.Store.GetEvents() } @@ -136,6 +142,10 @@ func (l *Logger) UpdateConfig(dnsCollection, exitNodeCollection bool) { l.exitNodeCollection.Store(exitNodeCollection) } +func (l *Logger) isOverlayIP(ip netip.Addr) bool { + return l.wgIfaceNet.Contains(ip) || (l.wgIfaceNetV6.IsValid() && l.wgIfaceNetV6.Contains(ip)) +} + func (l *Logger) shouldStore(event *types.EventFields, isExitNode bool) bool { // check dns collection if !l.dnsCollection.Load() && event.Protocol == types.UDP && diff --git a/client/internal/netflow/logger/logger_test.go b/client/internal/netflow/logger/logger_test.go index 1144544d8..ad2eedef2 100644 --- a/client/internal/netflow/logger/logger_test.go +++ b/client/internal/netflow/logger/logger_test.go @@ -12,7 +12,7 @@ import ( ) func TestStore(t *testing.T) { - logger := logger.New(nil, netip.Prefix{}) + logger := logger.New(nil, netip.Prefix{}, netip.Prefix{}) logger.Enable() event := types.EventFields{ diff --git a/client/internal/netflow/manager.go b/client/internal/netflow/manager.go index 7752c97b0..43d61b771 100644 --- a/client/internal/netflow/manager.go +++ b/client/internal/netflow/manager.go @@ -9,12 +9,14 @@ import ( "sync" "time" + "github.com/cenkalti/backoff/v4" "github.com/google/uuid" log "github.com/sirupsen/logrus" "google.golang.org/protobuf/types/known/timestamppb" "github.com/netbirdio/netbird/client/internal/netflow/conntrack" "github.com/netbirdio/netbird/client/internal/netflow/logger" + "github.com/netbirdio/netbird/client/internal/netflow/store" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/flow/client" @@ -23,23 +25,26 @@ import ( // Manager handles netflow tracking and logging type Manager struct { - mux sync.Mutex - shutdownWg sync.WaitGroup - logger nftypes.FlowLogger - flowConfig *nftypes.FlowConfig - conntrack nftypes.ConnTracker - receiverClient *client.GRPCClient - publicKey []byte - cancel context.CancelFunc + mux sync.Mutex + shutdownWg sync.WaitGroup + logger nftypes.FlowLogger + flowConfig *nftypes.FlowConfig + conntrack nftypes.ConnTracker + receiverClient *client.GRPCClient + eventsWithoutAcks nftypes.Store + publicKey []byte + cancel context.CancelFunc + retryInterval time.Duration } // NewManager creates a new netflow manager func NewManager(iface nftypes.IFaceMapper, publicKey []byte, statusRecorder *peer.Status) *Manager { - var prefix netip.Prefix + var prefix, prefixV6 netip.Prefix if iface != nil { prefix = iface.Address().Network + prefixV6 = iface.Address().IPv6Net } - flowLogger := logger.New(statusRecorder, prefix) + flowLogger := logger.New(statusRecorder, prefix, prefixV6) var ct nftypes.ConnTracker if runtime.GOOS == "linux" && iface != nil && !iface.IsUserspaceBind() { @@ -47,9 +52,11 @@ func NewManager(iface nftypes.IFaceMapper, publicKey []byte, statusRecorder *pee } return &Manager{ - logger: flowLogger, - conntrack: ct, - publicKey: publicKey, + logger: flowLogger, + conntrack: ct, + publicKey: publicKey, + retryInterval: time.Second, + eventsWithoutAcks: store.NewMemoryStore(), } } @@ -65,6 +72,7 @@ func (m *Manager) needsNewClient(previous *nftypes.FlowConfig) bool { } // enableFlow starts components for flow tracking +// must be called under m.mux lock func (m *Manager) enableFlow(previous *nftypes.FlowConfig) error { // first make sender ready so events don't pile up if m.needsNewClient(previous) { @@ -84,6 +92,7 @@ func (m *Manager) enableFlow(previous *nftypes.FlowConfig) error { return nil } +// must be called under m.mux lock func (m *Manager) resetClient() error { if m.receiverClient != nil { if err := m.receiverClient.Close(); err != nil { @@ -106,14 +115,19 @@ func (m *Manager) resetClient() error { ctx, cancel := context.WithCancel(context.Background()) m.cancel = cancel - m.shutdownWg.Add(2) + m.shutdownWg.Add(3) + flowConfigInterval := m.flowConfig.Interval go func() { defer m.shutdownWg.Done() - m.receiveACKs(ctx, flowClient) + m.receiveACKs(ctx, flowClient, flowConfigInterval) }() go func() { defer m.shutdownWg.Done() - m.startSender(ctx) + m.startSender(ctx, flowConfigInterval) + }() + go func() { + defer m.shutdownWg.Done() + m.startRetries(ctx, flowConfigInterval) }() return nil @@ -197,8 +211,8 @@ func (m *Manager) GetLogger() nftypes.FlowLogger { return m.logger } -func (m *Manager) startSender(ctx context.Context) { - ticker := time.NewTicker(m.flowConfig.Interval) +func (m *Manager) startSender(ctx context.Context, flowConfigInterval time.Duration) { + ticker := time.NewTicker(flowConfigInterval) defer ticker.Stop() for { @@ -206,27 +220,29 @@ func (m *Manager) startSender(ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: - events := m.logger.GetEvents() + collectedEvents := m.logger.ResetAggregationWindow() + events := collectedEvents.GetAggregatedEvents() for _, event := range events { + m.eventsWithoutAcks.StoreEvent(event) if err := m.send(event); err != nil { log.Errorf("failed to send flow event to server: %v", err) - continue + } else { + log.Tracef("sent flow event: %s", event.ID) } - log.Tracef("sent flow event: %s", event.ID) } } } } -func (m *Manager) receiveACKs(ctx context.Context, client *client.GRPCClient) { - err := client.Receive(ctx, m.flowConfig.Interval, func(ack *proto.FlowEventAck) error { +func (m *Manager) receiveACKs(ctx context.Context, client *client.GRPCClient, flowConfigInterval time.Duration) { + err := client.Receive(ctx, flowConfigInterval, func(ack *proto.FlowEventAck) error { id, err := uuid.FromBytes(ack.EventId) if err != nil { log.Warnf("failed to convert ack event id to uuid: %v", err) return nil } log.Tracef("received flow event ack: %s", id) - m.logger.DeleteEvents([]uuid.UUID{id}) + m.eventsWithoutAcks.DeleteEvents([]uuid.UUID{id}) return nil }) @@ -235,6 +251,51 @@ func (m *Manager) receiveACKs(ctx context.Context, client *client.GRPCClient) { } } +// We effectively never drop events (see MaxInterval), which makes eventsWithoutAcks unbounded. +// We may want to limit the max size of the store, and start dropping oldest events when the threshold is reached. +func (m *Manager) startRetries(ctx context.Context, flowConfigInterval time.Duration) { + timer := time.NewTimer(m.retryInterval) + retryBackoff := backoff.WithContext(&backoff.ExponentialBackOff{ + InitialInterval: 1 * time.Second, + RandomizationFactor: 0.5, + Multiplier: 1.7, + MaxInterval: flowConfigInterval / 2, + MaxElapsedTime: 3 * 30 * 24 * time.Hour, // 3 months + Stop: backoff.Stop, + Clock: backoff.SystemClock, + }, ctx) + defer timer.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + resetBackoff := true + for _, e := range m.eventsWithoutAcks.GetEvents() { + if e.Timestamp.Add(time.Second).After(time.Now()) { + // grace period on retries to avoid early retries + // do not retry if the event is less than 1 sec old + continue + } + if err := m.send(e); err != nil { + if nextBackoff := retryBackoff.NextBackOff(); nextBackoff != backoff.Stop { + timer = time.NewTimer(nextBackoff) + resetBackoff = false + } else { + resetBackoff = true // we exhausted retries, reset retry loop + } + break + } + } + if resetBackoff { // use regular retry interval in absence of network errors + retryBackoff.Reset() + timer = time.NewTimer(m.retryInterval) + } + } + } +} + func (m *Manager) send(event *nftypes.Event) error { m.mux.Lock() client := m.receiverClient @@ -249,9 +310,11 @@ func (m *Manager) send(event *nftypes.Event) error { func toProtoEvent(publicKey []byte, event *nftypes.Event) *proto.FlowEvent { protoEvent := &proto.FlowEvent{ - EventId: event.ID[:], - Timestamp: timestamppb.New(event.Timestamp), - PublicKey: publicKey, + EventId: event.ID[:], + Timestamp: timestamppb.New(event.Timestamp), + PublicKey: publicKey, + WindowStart: timestamppb.New(event.WindowStart), + WindowEnd: timestamppb.New(event.WindowEnd), FlowFields: &proto.FlowFields{ FlowId: event.FlowID[:], RuleId: event.RuleID, @@ -266,10 +329,13 @@ func toProtoEvent(publicKey []byte, event *nftypes.Event) *proto.FlowEvent { TxBytes: event.TxBytes, SourceResourceId: event.SourceResourceID, DestResourceId: event.DestResourceID, + NumOfStarts: event.NumOfStarts, + NumOfEnds: event.NumOfEnds, + NumOfDrops: event.NumOfDrops, }, } - if event.Protocol == nftypes.ICMP { + if event.Protocol == nftypes.ICMP || event.Protocol == nftypes.ICMPv6 { protoEvent.FlowFields.ConnectionInfo = &proto.FlowFields_IcmpInfo{ IcmpInfo: &proto.ICMPInfo{ IcmpType: uint32(event.ICMPType), diff --git a/client/internal/netflow/manager_integration_test.go b/client/internal/netflow/manager_integration_test.go new file mode 100644 index 000000000..9029bdda2 --- /dev/null +++ b/client/internal/netflow/manager_integration_test.go @@ -0,0 +1,291 @@ +package netflow + +import ( + "context" + "errors" + "fmt" + "net" + "net/netip" + "slices" + "testing" + "time" + + "github.com/google/uuid" + "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/internal/netflow/types" + "github.com/netbirdio/netbird/flow/proto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "google.golang.org/grpc" +) + +type testServer struct { + proto.UnimplementedFlowServiceServer + events chan *proto.FlowEvent + acks chan *proto.FlowEventAck + grpcSrv *grpc.Server + addr string + handlerDone chan struct{} // signaled each time Events() exits + handlerStarted chan struct{} // signaled each time Events() begins +} + +func newTestServer(t *testing.T) *testServer { + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + s := &testServer{ + events: make(chan *proto.FlowEvent, 100), + acks: make(chan *proto.FlowEventAck, 100), + grpcSrv: grpc.NewServer(), + addr: listener.Addr().String(), + handlerDone: make(chan struct{}, 10), + handlerStarted: make(chan struct{}, 10), + } + + proto.RegisterFlowServiceServer(s.grpcSrv, s) + + go func() { + if err := s.grpcSrv.Serve(listener); err != nil && !errors.Is(err, grpc.ErrServerStopped) { + t.Logf("server error: %v", err) + } + }() + + t.Cleanup(func() { + s.grpcSrv.Stop() + }) + + return s +} + +func (s *testServer) Events(stream proto.FlowService_EventsServer) error { + defer func() { + select { + case s.handlerDone <- struct{}{}: + default: + } + }() + + err := stream.Send(&proto.FlowEventAck{IsInitiator: true}) + if err != nil { + return err + } + + select { + case s.handlerStarted <- struct{}{}: + default: + } + + ctx, cancel := context.WithCancel(stream.Context()) + defer cancel() + + go func() { + defer cancel() + for { + event, err := stream.Recv() + if err != nil { + return + } + + if !event.IsInitiator { + select { + case s.events <- event: + case <-ctx.Done(): + return + } + } + } + }() + + for { + select { + case ack := <-s.acks: + if err := stream.Send(ack); err != nil { + return err + } + case <-ctx.Done(): + return ctx.Err() + } + } +} + +func TestSendEventReceiveAck(t *testing.T) { + _, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.Cleanup(cancel) + + server := newTestServer(t) + manager := createManager(t, server.addr, 60*time.Second) // set high to prevent retries in this test + defer manager.Close() + + assert.Eventually(t, func() bool { + select { + case <-server.handlerStarted: + return true + default: + return false + } + }, 3*time.Second, 100*time.Millisecond) + + event1 := types.EventFields{ + FlowID: uuid.New(), + Type: types.TypeStart, + Direction: types.Ingress, + DestIP: ipAddr("172.16.1.2"), + DestPort: 2345, + Protocol: 6, + } + manager.logger.StoreEvent(event1) + event2 := types.EventFields{ + FlowID: uuid.New(), + Type: types.TypeStart, + Direction: types.Ingress, + DestIP: ipAddr("172.16.1.1"), + DestPort: 1234, + Protocol: 6, + } + manager.logger.StoreEvent(event2) + + // verify the server received logged events + serverSideEvents := make([]*proto.FlowEvent, 0) + assert.Eventually(t, func() bool { + select { + case event := <-server.events: + serverSideEvents = append(serverSideEvents, event) + if len(serverSideEvents) == 2 { + return true + } + default: + if len(serverSideEvents) == 2 { + return true + } + } + return false + }, 5*time.Second, 100*time.Millisecond) + + serverSideFlowIds := make([]uuid.UUID, 0, 2) + slices.Values(serverSideEvents)(func(e *proto.FlowEvent) bool { + id, err := uuid.FromBytes(e.FlowFields.FlowId) + assert.NoError(t, err) + serverSideFlowIds = append(serverSideFlowIds, id) + return true + }) + assert.ElementsMatch(t, []uuid.UUID{event1.FlowID, event2.FlowID}, serverSideFlowIds) + + // verify the manager tracks un-acked events + unackedEvents := manager.eventsWithoutAcks.GetEvents() + assert.Len(t, unackedEvents, 2) + flowIds := make([]uuid.UUID, 0) + slices.Values(unackedEvents)(func(e *types.Event) bool { + flowIds = append(flowIds, e.FlowID) + return true + }) + assert.ElementsMatch(t, flowIds, []uuid.UUID{event1.FlowID, event2.FlowID}) +} + +// verify handling of retries: +// - unacked events are retried +// - when acks arrive, events are removed from the un-acked event tracker +func TestRetryEvents(t *testing.T) { + _, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.Cleanup(cancel) + + server := newTestServer(t) + manager := createManager(t, server.addr, time.Second) // set low to start retries sooner + defer manager.Close() + + assert.Eventually(t, func() bool { + select { + case <-server.handlerStarted: + return true + default: + return false + } + }, 3*time.Second, 100*time.Millisecond) + + event1 := types.EventFields{ + FlowID: uuid.New(), + Type: types.TypeStart, + Direction: types.Ingress, + DestIP: ipAddr("172.16.1.2"), + DestPort: 2345, + Protocol: 6, + } + manager.logger.StoreEvent(event1) + event2 := types.EventFields{ + FlowID: uuid.New(), + Type: types.TypeStart, + Direction: types.Ingress, + DestIP: ipAddr("172.16.1.1"), + DestPort: 1234, + Protocol: 6, + } + manager.logger.StoreEvent(event2) + + // verify the server received retries of logged events + serverSideEvents := make([]*proto.FlowEvent, 0) + func() { + c := time.After(2500 * time.Millisecond) + for { + select { + case event := <-server.events: + serverSideEvents = append(serverSideEvents, event) + case <-c: + return + } + } + }() + assert.True(t, len(serverSideEvents) > 2) // must see retries + + uniqueServerSideEvents := make(map[uuid.UUID]*proto.FlowEvent) + slices.Values(serverSideEvents)(func(e *proto.FlowEvent) bool { + id, err := uuid.FromBytes(e.FlowFields.FlowId) + assert.NoError(t, err) + uniqueServerSideEvents[id] = e + return true + }) + assert.Contains(t, uniqueServerSideEvents, event1.FlowID) + assert.Contains(t, uniqueServerSideEvents, event2.FlowID) + + // ack events + server.acks <- &proto.FlowEventAck{EventId: uniqueServerSideEvents[event1.FlowID].EventId} + server.acks <- &proto.FlowEventAck{EventId: uniqueServerSideEvents[event2.FlowID].EventId} + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + unackedEvents := manager.eventsWithoutAcks.GetEvents() + assert.Empty(c, unackedEvents) + + }, 3*time.Second, 100*time.Millisecond) +} + +func createManager(t *testing.T, serverAddr string, retryInterval time.Duration) *Manager { + t.Helper() + + mockIFace := &mockIFaceMapper{ + address: wgaddr.Address{ + Network: netip.MustParsePrefix("192.168.1.1/32"), + }, + isUserspaceBind: true, + } + + publicKey := []byte("test-public-key") + manager := NewManager(mockIFace, publicKey, nil) + manager.retryInterval = retryInterval + + initialConfig := &types.FlowConfig{ + Enabled: true, + URL: fmt.Sprintf("http://%s", serverAddr), + TokenPayload: "initial-payload", + TokenSignature: "initial-signature", + Interval: 500 * time.Millisecond, + } + + err := manager.Update(initialConfig) + require.NoError(t, err) + + return manager +} + +func ipAddr(a string) netip.Addr { + addr, _ := netip.ParseAddr(a) + return addr +} diff --git a/client/internal/netflow/store/event_aggregation_test.go b/client/internal/netflow/store/event_aggregation_test.go new file mode 100644 index 000000000..8abe0d162 --- /dev/null +++ b/client/internal/netflow/store/event_aggregation_test.go @@ -0,0 +1,365 @@ +package store + +import ( + "math/rand" + "net/netip" + "testing" + "time" + + "github.com/google/uuid" + "github.com/netbirdio/netbird/client/internal/netflow/types" + "github.com/stretchr/testify/assert" +) + +var random = rand.New(rand.NewSource(time.Now().UnixNano())) + +func TestFlowAggregation(t *testing.T) { + var protocols = []types.Protocol{types.ICMP, types.ICMPv6, types.TCP, types.UDP} + var tests = []struct { + description string + addresses [][]netip.Addr + dstPort uint16 + eventTypes []types.Type + }{ + { + description: "start and stop", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}}, + dstPort: uint16(random.Uint32() >> 16), + eventTypes: []types.Type{types.TypeStart, types.TypeEnd}, + }, + { + description: "start and drop", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}}, + dstPort: uint16(random.Uint32() >> 16), + eventTypes: []types.Type{types.TypeStart, types.TypeDrop}, + }, + { + description: "start only", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}}, + dstPort: uint16(random.Uint32() >> 16), + eventTypes: []types.Type{types.TypeStart}, + }, + { + description: "drop only", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}}, + dstPort: uint16(random.Uint32() >> 16), + eventTypes: []types.Type{types.TypeDrop}, + }} + + for _, protocol := range protocols { + for _, tt := range tests { + t.Run(tt.description+" "+protocol.String(), func(t *testing.T) { + store := NewAggregatingMemoryStore() + store.WindowEnd = time.Now().Add(5 * time.Second) + + allExpected := make([]*types.Event, 0) + + for _, srcAndDst := range tt.addresses { + inEvents, expected := generateEvents(srcAndDst[0], srcAndDst[1], tt.dstPort, tt.eventTypes, protocol, types.Ingress, 0, store.WindowStart, store.WindowEnd) + for _, e := range inEvents { + store.StoreEvent(e) + } + allExpected = append(allExpected, expected) + } + + events := store.GetAggregatedEvents() + assert.ElementsMatch(t, events, allExpected) + }) + } + } +} + +func TestIcmpEventAggregation(t *testing.T) { + var protocols = []types.Protocol{types.ICMP, types.ICMPv6} + var icmpTypes = []uint8{1, 2, 3} + + var tests = []struct { + description string + addresses [][]netip.Addr + eventTypes []types.Type + }{ + { + description: "start and stop", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}}, + eventTypes: []types.Type{types.TypeStart, types.TypeEnd}, + }, + { + description: "start and drop", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}}, + eventTypes: []types.Type{types.TypeStart, types.TypeDrop}, + }, + { + description: "start only", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}}, + eventTypes: []types.Type{types.TypeStart}, + }, + { + description: "drop only", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}}, + eventTypes: []types.Type{types.TypeDrop}, + }} + + for _, protocol := range protocols { + for _, tt := range tests { + t.Run(tt.description+" "+protocol.String(), func(t *testing.T) { + store := NewAggregatingMemoryStore() + store.WindowEnd = time.Now().Add(5 * time.Second) + + allExpected := make([]*types.Event, 0) + for _, icmpType := range icmpTypes { + events, expected := generateEvents(tt.addresses[0][0], tt.addresses[0][1], 0, tt.eventTypes, protocol, types.Ingress, icmpType, store.WindowStart, store.WindowEnd) + for _, e := range events { + store.StoreEvent(e) + } + allExpected = append(allExpected, expected) + } + aggregatedEvents := store.GetAggregatedEvents() + assert.Len(t, aggregatedEvents, len(allExpected)) + assert.ElementsMatch(t, aggregatedEvents, allExpected) + }) + } + } +} + +func TestFlowAggregationOfUnknownProtocols(t *testing.T) { + var tests = []struct { + description string + addresses [][]netip.Addr + dstPort uint16 + eventTypes []types.Type + }{ + { + description: "start and stop", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}}, + dstPort: uint16(random.Uint32() >> 16), + eventTypes: []types.Type{types.TypeStart, types.TypeEnd}, + }, + { + description: "start and drop", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}}, + dstPort: uint16(random.Uint32() >> 16), + eventTypes: []types.Type{types.TypeStart, types.TypeDrop}, + }, + { + description: "start only", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}}, + dstPort: uint16(random.Uint32() >> 16), + eventTypes: []types.Type{types.TypeStart}, + }, + { + description: "drop only", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}}, + dstPort: uint16(random.Uint32() >> 16), + eventTypes: []types.Type{types.TypeDrop}, + }} + + for _, tt := range tests { + t.Run(tt.description+" "+types.ProtocolUnknown.String(), func(t *testing.T) { + store := NewAggregatingMemoryStore() + store.WindowEnd = time.Now().Add(5 * time.Second) + + allExpected := make([]*types.Event, 0) + + for _, srcAndDst := range tt.addresses { + inEvents, expected := generateEventsForUnknownProtocol(srcAndDst[0], srcAndDst[1], tt.dstPort, tt.eventTypes, types.ProtocolUnknown, types.Ingress, store.WindowStart, store.WindowEnd) + for _, e := range inEvents { + store.StoreEvent(e) + } + allExpected = append(allExpected, expected...) + } + + events := store.GetAggregatedEvents() + assert.ElementsMatch(t, events, allExpected) + }) + } +} + +func TestResetAggregationWindow(t *testing.T) { + now := time.Now() + nowFunc := func() time.Time { return now } + store := NewAggregatingMemoryStoreWithTimeFunc(nowFunc) + store.StoreEvent(&types.Event{ + ID: uuid.New(), + Timestamp: time.Now(), + EventFields: types.EventFields{ + FlowID: uuid.New(), + Type: types.TypeStart, + Protocol: types.TCP, + RuleID: []byte("rule-id-1"), + Direction: types.Ingress, + SourceIP: netip.MustParseAddr("1.1.1.1"), + SourcePort: 1234, + DestIP: netip.MustParseAddr("2.2.2.2"), + DestPort: 5678, + SourceResourceID: []byte("source-resource-id"), + DestResourceID: []byte("dest-resource-id"), + RxPackets: random.Uint64(), + TxPackets: random.Uint64(), + RxBytes: random.Uint64(), + TxBytes: random.Uint64(), + }, + }) + + now = now.Add(1 * time.Second) + reset := store.ResetAggregationWindow() + previousEvents, ok := reset.(*AggregatingMemory) + assert.True(t, ok) + assert.NotEqual(t, previousEvents.WindowStart, store.WindowStart) + assert.Equal(t, previousEvents.WindowEnd, store.WindowStart) + assert.NotEmpty(t, previousEvents.events) + assert.Empty(t, store.events) +} + +func generateEvents(srcIp, dstIp netip.Addr, dstPort uint16, eventTypes []types.Type, protocol types.Protocol, + direction types.Direction, icmpType uint8, windowStart, windowEnd time.Time) ([]*types.Event, *types.Event) { + var rxPackets, txPackets, rxBytes, txBytes uint64 + inEvents := make([]*types.Event, 0) + ts := time.Now() + flowId := uuid.New() + srcPort := uint16(random.Uint32() >> 16) + + for idx, eventType := range eventTypes { + e := &types.Event{ + ID: uuid.New(), + Timestamp: ts.Add(time.Duration(idx) * time.Second), + EventFields: types.EventFields{ + FlowID: flowId, + Type: eventType, + Protocol: protocol, + RuleID: []byte("rule-id-1"), + Direction: direction, + SourceIP: srcIp, + SourcePort: srcPort, + DestIP: dstIp, + DestPort: dstPort, + SourceResourceID: []byte("source-resource-id"), + DestResourceID: []byte("dest-resource-id"), + RxPackets: random.Uint64(), + TxPackets: random.Uint64(), + RxBytes: random.Uint64(), + TxBytes: random.Uint64(), + }} + rxBytes += e.RxBytes + txBytes += e.TxBytes + rxPackets += e.RxPackets + txPackets += e.TxPackets + inEvents = append(inEvents, e) + if protocol == types.ICMP || protocol == types.ICMPv6 { + e.ICMPType = icmpType + } + } + + var start, end, drop uint64 + for _, eventType := range eventTypes { + switch eventType { + case types.TypeStart: + start += 1 + case types.TypeDrop: + drop += 1 + case types.TypeEnd: + end += 1 + } + } + aggregatedEvent := &types.Event{ + ID: inEvents[0].ID, + Timestamp: inEvents[0].Timestamp, + WindowStart: windowStart, + WindowEnd: windowEnd, + EventFields: types.EventFields{ + FlowID: flowId, + Type: types.TypeUnknown, + Protocol: inEvents[0].Protocol, + RuleID: []byte("rule-id-1"), + Direction: inEvents[0].Direction, + SourceIP: srcIp, + SourcePort: srcPort, + DestIP: dstIp, + DestPort: dstPort, + SourceResourceID: []byte("source-resource-id"), + DestResourceID: []byte("dest-resource-id"), + RxPackets: rxPackets, + TxPackets: txPackets, + RxBytes: rxBytes, + TxBytes: txBytes, + NumOfStarts: start, + NumOfEnds: end, + NumOfDrops: drop, + }} + if protocol == types.ICMP || protocol == types.ICMPv6 { + aggregatedEvent.ICMPType = icmpType + } + + return inEvents, aggregatedEvent +} + +func generateEventsForUnknownProtocol(srcIp, dstIp netip.Addr, dstPort uint16, eventTypes []types.Type, protocol types.Protocol, + direction types.Direction, windowStart, windowEnd time.Time) ([]*types.Event, []*types.Event) { + inEvents := make([]*types.Event, 0) + expectedEvents := make([]*types.Event, 0) + + ts := time.Now() + flowId := uuid.New() + srcPort := uint16(random.Uint32() >> 16) + + for idx, eventType := range eventTypes { + e := &types.Event{ + ID: uuid.New(), + Timestamp: ts.Add(time.Duration(idx) * time.Second), + EventFields: types.EventFields{ + FlowID: flowId, + Type: eventType, + Protocol: protocol, + RuleID: []byte("rule-id-1"), + Direction: direction, + SourceIP: srcIp, + SourcePort: srcPort, + DestIP: dstIp, + DestPort: dstPort, + SourceResourceID: []byte("source-resource-id"), + DestResourceID: []byte("dest-resource-id"), + RxPackets: random.Uint64(), + TxPackets: random.Uint64(), + RxBytes: random.Uint64(), + TxBytes: random.Uint64(), + }} + inEvents = append(inEvents, e) + + var start, end, drop uint64 + switch eventType { + case types.TypeStart: + start = 1 + case types.TypeDrop: + drop = 1 + case types.TypeEnd: + end = 1 + } + + expectedEvents = append(expectedEvents, &types.Event{ + ID: e.ID, + Timestamp: e.Timestamp, + WindowStart: windowStart, + WindowEnd: windowEnd, + EventFields: types.EventFields{ + FlowID: flowId, + Type: types.TypeUnknown, + Protocol: e.Protocol, + RuleID: []byte("rule-id-1"), + Direction: e.Direction, + SourceIP: srcIp, + SourcePort: srcPort, + DestIP: dstIp, + DestPort: dstPort, + SourceResourceID: []byte("source-resource-id"), + DestResourceID: []byte("dest-resource-id"), + RxPackets: e.RxPackets, + TxPackets: e.TxPackets, + RxBytes: e.RxBytes, + TxBytes: e.TxBytes, + NumOfStarts: start, + NumOfEnds: end, + NumOfDrops: drop, + }}) + } + + return inEvents, expectedEvents +} diff --git a/client/internal/netflow/store/memory.go b/client/internal/netflow/store/memory.go index b695a0a12..dfe764032 100644 --- a/client/internal/netflow/store/memory.go +++ b/client/internal/netflow/store/memory.go @@ -1,12 +1,15 @@ package store import ( + "maps" + "math/rand" + v2 "math/rand/v2" + "net/netip" + "slices" "sync" - - "golang.org/x/exp/maps" + "time" "github.com/google/uuid" - "github.com/netbirdio/netbird/client/internal/netflow/types" ) @@ -21,6 +24,14 @@ type Memory struct { events map[uuid.UUID]*types.Event } +type AggregatingMemory struct { + Memory + WindowStart time.Time + WindowEnd time.Time + rnd *v2.PCG + nowFunc func() time.Time +} + func (m *Memory) StoreEvent(event *types.Event) { m.mux.Lock() defer m.mux.Unlock() @@ -30,7 +41,7 @@ func (m *Memory) StoreEvent(event *types.Event) { func (m *Memory) Close() { m.mux.Lock() defer m.mux.Unlock() - maps.Clear(m.events) + clear(m.events) } func (m *Memory) GetEvents() []*types.Event { @@ -50,3 +61,104 @@ func (m *Memory) DeleteEvents(ids []uuid.UUID) { delete(m.events, id) } } + +func NewAggregatingMemoryStore() *AggregatingMemory { + return NewAggregatingMemoryStoreWithTimeFunc(defaultNowFunc) +} + +// used in tests when deterministic (less random) time intervals are required +func NewAggregatingMemoryStoreWithTimeFunc(nowFunc func() time.Time) *AggregatingMemory { + return &AggregatingMemory{WindowStart: nowFunc(), Memory: Memory{events: make(map[uuid.UUID]*types.Event)}, nowFunc: nowFunc, rnd: v2.NewPCG(rand.Uint64(), rand.Uint64())} +} + +func (am *AggregatingMemory) ResetAggregationWindow() types.FlowEventAggregator { + am.mux.Lock() + defer am.mux.Unlock() + + now := am.nowFunc() + toret := AggregatingMemory{WindowStart: am.WindowStart, WindowEnd: now, Memory: Memory{events: am.events}, rnd: v2.NewPCG(rand.Uint64(), rand.Uint64())} + + am.events = make(map[uuid.UUID]*types.Event) + am.WindowStart = now + + return &toret +} + +type aggregationKey struct { + srcAddr netip.Addr + destAddr netip.Addr + destPort uint16 + direction int + protocol uint8 + icmpType uint8 + unique uint64 // used to prevent aggregation on non icmp/udp/tcp events +} + +func (am *AggregatingMemory) GetAggregatedEvents() []*types.Event { + am.mux.Lock() + defer am.mux.Unlock() + + aggregated := make(map[aggregationKey]*types.Event) + for _, v := range am.events { + lookupKey := aggregationKey{srcAddr: v.SourceIP, destAddr: v.DestIP, destPort: v.DestPort, direction: int(v.Direction), protocol: uint8(v.Protocol), icmpType: v.ICMPType} + if _, ok := aggregated[lookupKey]; !ok { + event := v.Clone() + + switch event.Type { + case types.TypeStart: + event.NumOfStarts += 1 + case types.TypeDrop: + event.NumOfDrops += 1 + case types.TypeEnd: + event.NumOfEnds += 1 + } + event.Type = types.TypeUnknown + + // Please note that ICMPCode field isn't propagated by the manager (see flow/proto/flow.pb.go, FlowFields struct) + // so the field value in an icmp event in the "aggregated" doesn't matter + + event.WindowStart = am.WindowStart + event.WindowEnd = am.WindowEnd + + if event.Protocol != types.ICMP && event.Protocol != types.ICMPv6 && event.Protocol != types.UDP && event.Protocol != types.TCP { + lookupKey.unique = am.rnd.Uint64() // to make the lookup key unique so we don't aggregate on it + } + + aggregated[lookupKey] = event + continue + } + + aggregatedEvent := aggregated[lookupKey] + if aggregatedEvent.Protocol != types.ICMP && aggregatedEvent.Protocol != types.ICMPv6 && aggregatedEvent.Protocol != types.UDP && aggregatedEvent.Protocol != types.TCP { + continue // we don't aggregate this type of events; shouldn't ever get here + } + + // track the number of connections, duration?, open and close events? + aggregatedEvent.RxBytes += v.RxBytes + aggregatedEvent.RxPackets += v.RxPackets + aggregatedEvent.TxBytes += v.TxBytes + aggregatedEvent.TxPackets += v.TxPackets + switch v.Type { + case types.TypeStart: + aggregatedEvent.NumOfStarts += 1 + case types.TypeDrop: + aggregatedEvent.NumOfDrops += 1 + case types.TypeEnd: + aggregatedEvent.NumOfEnds += 1 + } + if aggregatedEvent.Timestamp.Compare(v.Timestamp) > 0 { + aggregatedEvent.Timestamp = v.Timestamp + aggregatedEvent.ID = v.ID + aggregatedEvent.SourcePort = v.SourcePort + } + if len(aggregatedEvent.RuleID) == 0 && len(v.RuleID) != 0 { + aggregatedEvent.RuleID = slices.Clone(v.RuleID) + } + } + + return slices.Collect(maps.Values(aggregated)) // could return an iterator instead here +} + +func defaultNowFunc() time.Time { + return time.Now() +} diff --git a/client/internal/netflow/types/types.go b/client/internal/netflow/types/types.go index f76146ba3..ccb2da66b 100644 --- a/client/internal/netflow/types/types.go +++ b/client/internal/netflow/types/types.go @@ -2,6 +2,7 @@ package types import ( "net/netip" + "slices" "strconv" "time" @@ -19,6 +20,7 @@ const ( ICMP = Protocol(1) TCP = Protocol(6) UDP = Protocol(17) + ICMPv6 = Protocol(58) SCTP = Protocol(132) ) @@ -30,6 +32,8 @@ func (p Protocol) String() string { return "TCP" case 17: return "UDP" + case 58: + return "ICMPv6" case 132: return "SCTP" default: @@ -66,8 +70,10 @@ const ( ) type Event struct { - ID uuid.UUID - Timestamp time.Time + ID uuid.UUID + Timestamp time.Time + WindowStart time.Time + WindowEnd time.Time EventFields } @@ -89,6 +95,17 @@ type EventFields struct { TxPackets uint64 RxBytes uint64 TxBytes uint64 + NumOfStarts uint64 + NumOfEnds uint64 + NumOfDrops uint64 +} + +func (e *Event) Clone() *Event { + toret := *e + toret.RuleID = slices.Clone(e.RuleID) + toret.SourceResourceID = slices.Clone(e.SourceResourceID) + toret.DestResourceID = slices.Clone(e.DestResourceID) + return &toret } type FlowConfig struct { @@ -111,13 +128,15 @@ type FlowManager interface { GetLogger() FlowLogger } +type FlowEventAggregator interface { + ResetAggregationWindow() FlowEventAggregator + GetAggregatedEvents() []*Event +} + type FlowLogger interface { + ResetAggregationWindow() FlowEventAggregator // StoreEvent stores a flow event StoreEvent(flowEvent EventFields) - // GetEvents returns all stored events - GetEvents() []*Event - // DeleteEvents deletes events from the store - DeleteEvents([]uuid.UUID) // Close closes the logger Close() // Enable enables the flow logger receiver @@ -137,6 +156,11 @@ type Store interface { Close() } +type AggregatingStore interface { + FlowEventAggregator + Store +} + // ConnTracker defines the interface for connection tracking functionality type ConnTracker interface { // Start begins tracking connections by listening for conntrack events. diff --git a/client/internal/networkmonitor/check_change_common.go b/client/internal/networkmonitor/check_change_common.go index a4a4f76ac..f693081a6 100644 --- a/client/internal/networkmonitor/check_change_common.go +++ b/client/internal/networkmonitor/check_change_common.go @@ -50,7 +50,7 @@ func routeCheck(ctx context.Context, fd int, nexthopv4, nexthopv6 systemops.Next switch msg.Type { // handle route changes case unix.RTM_ADD, syscall.RTM_DELETE: - route, err := parseRouteMessage(buf[:n]) + route, flags, err := parseRouteMessage(buf[:n]) if err != nil { log.Debugf("Network monitor: error parsing routing message: %v", err) continue @@ -66,6 +66,10 @@ func routeCheck(ctx context.Context, fd int, nexthopv4, nexthopv6 systemops.Next } switch msg.Type { case unix.RTM_ADD: + if systemops.IgnoreAddedDefaultRoute(flags) { + log.Debugf("Network monitor: ignoring added default route via %s, interface %s, flags %#x", route.Gw, intf, flags) + continue + } log.Infof("Network monitor: default route changed: via %s, interface %s", route.Gw, intf) return nil case unix.RTM_DELETE: @@ -78,22 +82,26 @@ func routeCheck(ctx context.Context, fd int, nexthopv4, nexthopv6 systemops.Next } } -func parseRouteMessage(buf []byte) (*systemops.Route, error) { +func parseRouteMessage(buf []byte) (*systemops.Route, int, error) { msgs, err := route.ParseRIB(route.RIBTypeRoute, buf) if err != nil { - return nil, fmt.Errorf("parse RIB: %v", err) + return nil, 0, fmt.Errorf("parse RIB: %v", err) } if len(msgs) != 1 { - return nil, fmt.Errorf("unexpected RIB message msgs: %v", msgs) + return nil, 0, fmt.Errorf("unexpected RIB message msgs: %v", msgs) } msg, ok := msgs[0].(*route.RouteMessage) if !ok { - return nil, fmt.Errorf("unexpected RIB message type: %T", msgs[0]) + return nil, 0, fmt.Errorf("unexpected RIB message type: %T", msgs[0]) } - return systemops.MsgToRoute(msg) + r, err := systemops.MsgToRoute(msg) + if err != nil { + return nil, 0, err + } + return r, msg.Flags, nil } // waitReadable blocks until fd has data to read, or ctx is cancelled. diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go index 1e416bfe7..83089606f 100644 --- a/client/internal/peer/conn.go +++ b/client/internal/peer/conn.go @@ -6,6 +6,7 @@ import ( "net" "net/netip" "runtime" + "slices" "sync" "time" @@ -23,11 +24,18 @@ import ( "github.com/netbirdio/netbird/client/internal/peer/id" "github.com/netbirdio/netbird/client/internal/peer/worker" "github.com/netbirdio/netbird/client/internal/portforward" + "github.com/netbirdio/netbird/client/internal/rosenpass" "github.com/netbirdio/netbird/client/internal/stdnet" + "github.com/netbirdio/netbird/client/netevents" "github.com/netbirdio/netbird/route" relayClient "github.com/netbirdio/netbird/shared/relay/client" ) +// wgTimeoutEscalationThreshold is the number of consecutive WireGuard +// handshake timeouts after which the rosenpass state for the peer is +// considered desynced and gets reset. +const wgTimeoutEscalationThreshold = 3 + // MetricsRecorder is an interface for recording peer connection metrics type MetricsRecorder interface { RecordConnectionStages( @@ -86,6 +94,10 @@ type ConnConfig struct { // ICEConfig ICE protocol configuration ICEConfig icemaker.Config + + // NetMgr gates the reconnection guard on OS-reported network + // availability; nil disables gating. + NetMgr *netevents.Manager } type Conn struct { @@ -116,6 +128,9 @@ type Conn struct { wgWatcher *WGWatcher wgWatcherWg sync.WaitGroup wgWatcherCancel context.CancelFunc + // wgTimeouts counts consecutive WireGuard handshake timeouts without a + // successful handshake in between. Guarded by mu. + wgTimeouts int // used to store the remote Rosenpass key for Relayed connection in case of connection update from ice rosenpassRemoteKey []byte @@ -135,6 +150,39 @@ type Conn struct { // Connection stage timestamps for metrics metricsRecorder MetricsRecorder metricsStages *MetricsStages + + // pendingFirstPacket is the lazyconn-captured handshake init, replayed once the real + // transport is up. + pendingFirstPacket []byte +} + +// injectPendingFirstPacket replays the captured handshake through the proxy if present, else +// directly through the ICE conn. The packet is cleared only after a successful write, so a failed +// or transport-less attempt leaves it available for a later reinjection. Caller must hold conn.mu. +func (conn *Conn) injectPendingFirstPacket(proxy wgproxy.Proxy, directConn net.Conn) { + pkt := conn.pendingFirstPacket + if len(pkt) == 0 { + return + } + + switch { + case proxy != nil: + if err := proxy.InjectPacket(pkt); err != nil { + conn.Log.Debugf("failed to reinject captured first packet via proxy: %v", err) + return + } + case directConn != nil: + if _, err := directConn.Write(pkt); err != nil { + conn.Log.Debugf("failed to reinject captured first packet via direct conn: %v", err) + return + } + default: + conn.Log.Debugf("no transport available to reinject captured first packet") + return + } + + conn.pendingFirstPacket = nil + conn.Log.Debugf("reinjected captured first packet (%d bytes)", len(pkt)) } // NewConn creates a new not opened Conn to the remote peer. @@ -160,7 +208,6 @@ func NewConn(config ConnConfig, services ServiceDependencies) (*Conn, error) { statusICE: worker.NewAtomicStatus(), dumpState: dumpState, endpointUpdater: NewEndpointUpdater(connLog, config.WgConfig, isController(config)), - wgWatcher: NewWGWatcher(connLog, config.WgConfig.WgInterface, config.Key, dumpState), metricsRecorder: services.MetricsRecorder, } @@ -171,6 +218,16 @@ 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 { + return conn.open(engineCtx, nil) +} + +// OpenWithFirstPacket opens the connection like Open and stashes firstPacket to be replayed once +// the real transport is established. The packet is retained only on a successful open. +func (conn *Conn) OpenWithFirstPacket(engineCtx context.Context, firstPacket []byte) error { + return conn.open(engineCtx, firstPacket) +} + +func (conn *Conn) open(engineCtx context.Context, firstPacket []byte) error { conn.mu.Lock() defer conn.mu.Unlock() @@ -202,7 +259,7 @@ func (conn *Conn) Open(engineCtx context.Context) error { conn.handshaker.AddICEListener(conn.workerICE.OnNewOffer) } - conn.guard = guard.NewGuard(conn.Log, conn.isConnectedOnAllWay, conn.config.Timeout, conn.srWatcher) + conn.guard = guard.NewGuard(conn.Log, conn.isConnectedOnAllWay, conn.config.Timeout, conn.srWatcher, conn.config.NetMgr) conn.wg.Add(1) go func() { @@ -226,6 +283,9 @@ func (conn *Conn) Open(engineCtx context.Context) error { defer conn.wg.Done() conn.guard.Start(conn.ctx, conn.onGuardEvent) }() + if len(firstPacket) > 0 { + conn.pendingFirstPacket = slices.Clone(firstPacket) + } conn.opened = true return nil } @@ -252,6 +312,8 @@ func (conn *Conn) Close(signalToRemote bool) { if conn.wgWatcherCancel != nil { conn.wgWatcherCancel() + conn.wgWatcher = nil + conn.wgWatcherCancel = nil } conn.workerRelay.CloseConn() if conn.workerICE != nil { @@ -383,7 +445,7 @@ func (conn *Conn) onICEConnectionIsReady(priority conntype.ConnPriority, iceConn conn.dumpState.NewLocalProxy() wgProxy, err = conn.newProxy(iceConnInfo.RemoteConn) if err != nil { - conn.Log.Errorf("failed to add turn net.Conn to local proxy: %v", err) + conn.Log.Errorf("failed to add relayed net.Conn to local proxy: %v", err) return } ep = wgProxy.EndpointAddr() @@ -422,6 +484,8 @@ func (conn *Conn) onICEConnectionIsReady(priority conntype.ConnPriority, iceConn conn.wgProxyRelay.RedirectAs(ep) } + conn.injectPendingFirstPacket(wgProxy, iceConnInfo.RemoteConn) + conn.currentConnPriority = priority conn.statusICE.SetConnected() conn.updateIceState(iceConnInfo, updateTime) @@ -545,6 +609,8 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) { wgConfigWorkaround() + conn.injectPendingFirstPacket(wgProxy, nil) + conn.rosenpassRemoteKey = rci.rosenpassPubKey conn.currentConnPriority = conntype.Relay conn.statusRelay.SetConnected() @@ -611,11 +677,12 @@ func (conn *Conn) onGuardEvent() { } } -func (conn *Conn) onWGDisconnected() { +func (conn *Conn) onWGDisconnected(watcherCtx context.Context) { conn.mu.Lock() defer conn.mu.Unlock() - if conn.ctx.Err() != nil { + // watcherCtx guards against a stale watcher tearing down a connection that already superseded it. + if conn.ctx.Err() != nil || watcherCtx.Err() != nil { return } @@ -631,6 +698,29 @@ func (conn *Conn) onWGDisconnected() { default: conn.Log.Debugf("No active connection to close on WG timeout") } + + conn.escalateWGTimeoutLocked() +} + +// escalateWGTimeoutLocked resets the peer's rosenpass state after repeated +// handshake timeouts. With rosenpass enabled, persistent timeouts mean the +// preshared keys have desynced; the renewal exchange runs over the dead +// tunnel and cannot resync them. Reporting the peer disconnected drops its +// rosenpass state, so the next connection configuration programs the +// rendezvous key and the tunnel can bootstrap again. Callers must hold mu. +func (conn *Conn) escalateWGTimeoutLocked() { + if conn.config.RosenpassConfig.PubKey == nil { + return + } + + conn.wgTimeouts++ + if conn.wgTimeouts < wgTimeoutEscalationThreshold || conn.onDisconnected == nil { + return + } + conn.wgTimeouts = 0 + + conn.Log.Warnf("%d consecutive WireGuard handshake timeouts, resetting rosenpass state for peer", wgTimeoutEscalationThreshold) + conn.onDisconnected(conn.config.WgConfig.RemoteKey) } func (conn *Conn) updateRelayStatus(relayServerAddr string, rosenpassPubKey []byte, updateTime time.Time) { @@ -750,23 +840,39 @@ func (conn *Conn) isConnectedOnAllWay() (status guard.ConnStatus) { }) } +// enableWgWatcherIfNeeded starts a fresh watcher instance per connection attempt, so its +// lifecycle stays bound to conn.mu and enable/disable can't race an old goroutine's shutdown. +// Caller must hold conn.mu. func (conn *Conn) enableWgWatcherIfNeeded(enabledTime time.Time) { - if !conn.wgWatcher.IsEnabled() { - wgWatcherCtx, wgWatcherCancel := context.WithCancel(conn.ctx) - conn.wgWatcherCancel = wgWatcherCancel - conn.wgWatcherWg.Add(1) - go func() { - defer conn.wgWatcherWg.Done() - conn.wgWatcher.EnableWgWatcher(wgWatcherCtx, enabledTime, conn.onWGDisconnected, conn.onWGHandshakeSuccess) - }() + if conn.wgWatcher != nil { + return } + + watcher := NewWGWatcher(conn.Log, conn.config.WgConfig.WgInterface, conn.config.Key, conn.dumpState) + watcher.PrepareInitialHandshake() + + wgWatcherCtx, wgWatcherCancel := context.WithCancel(conn.ctx) + conn.wgWatcher = watcher + conn.wgWatcherCancel = wgWatcherCancel + + conn.wgWatcherWg.Add(1) + go func() { + defer conn.wgWatcherWg.Done() + onDisconnected := func() { conn.onWGDisconnected(wgWatcherCtx) } + watcher.EnableWgWatcher(wgWatcherCtx, enabledTime, onDisconnected, conn.onWGHandshakeSuccess, conn.onWGCheckSuccess) + }() } +// disableWgWatcherIfNeeded cancels and drops the watcher once no transport is active. It never +// waits for the goroutine: the timeout path reentrantly calls back here under conn.mu, so +// blocking would deadlock. Caller must hold conn.mu. func (conn *Conn) disableWgWatcherIfNeeded() { - if conn.currentConnPriority == conntype.None && conn.wgWatcherCancel != nil { - conn.wgWatcherCancel() - conn.wgWatcherCancel = nil + if conn.currentConnPriority != conntype.None || conn.wgWatcher == nil { + return } + conn.wgWatcherCancel() + conn.wgWatcher = nil + conn.wgWatcherCancel = nil } func (conn *Conn) newProxy(remoteConn net.Conn) (wgproxy.Proxy, error) { @@ -777,9 +883,8 @@ func (conn *Conn) newProxy(remoteConn net.Conn) (wgproxy.Proxy, error) { } wgProxy := conn.config.WgConfig.WgInterface.GetProxy() - if err := wgProxy.AddTurnConn(conn.ctx, udpAddr, remoteConn); err != nil { - conn.Log.Errorf("failed to add turn net.Conn to local proxy: %v", err) - return nil, err + if err := wgProxy.AddRelayedConn(conn.ctx, udpAddr, remoteConn); err != nil { + return nil, fmt.Errorf("add relayed conn to proxy: %w", err) } return wgProxy, nil } @@ -789,7 +894,9 @@ func (conn *Conn) resetEndpoint() { return } conn.Log.Infof("reset wg endpoint") - conn.wgWatcher.Reset() + if conn.wgWatcher != nil { + conn.wgWatcher.Reset() + } if err := conn.endpointUpdater.RemoveEndpointAddress(); err != nil { conn.Log.Warnf("failed to remove endpoint address before update: %v", err) } @@ -838,6 +945,15 @@ func (conn *Conn) onWGHandshakeSuccess(when time.Time) { conn.recordConnectionMetrics() } +// onWGCheckSuccess is called for every watcher check that observed a fresh +// handshake, including handshakes of connections that were already up when +// the watcher started. +func (conn *Conn) onWGCheckSuccess() { + conn.mu.Lock() + conn.wgTimeouts = 0 + conn.mu.Unlock() +} + // recordConnectionMetrics records connection stage timestamps as metrics func (conn *Conn) recordConnectionMetrics() { if conn.metricsRecorder == nil { @@ -849,12 +965,9 @@ func (conn *Conn) recordConnectionMetrics() { priority := conn.currentConnPriority conn.mu.Unlock() - var connType metrics.ConnectionType - switch priority { - case conntype.Relay: - connType = metrics.ConnectionTypeRelay - default: - connType = metrics.ConnectionTypeICE + connType := metricsConnType(priority) + if connType == metrics.ConnectionTypeUnknown { + return } // Record metrics with timestamps - duration calculation happens in metrics package @@ -899,7 +1012,7 @@ func (conn *Conn) presharedKey(remoteRosenpassKey []byte) *wgtypes.Key { } // Fallback to deterministic key if no NetBird PSK is configured - determKey, err := conn.rosenpassDetermKey() + determKey, err := rosenpass.DeterministicSeedKey(conn.config.LocalKey, conn.config.Key) if err != nil { conn.Log.Errorf("failed to generate Rosenpass initial key: %v", err) return nil @@ -908,26 +1021,6 @@ func (conn *Conn) presharedKey(remoteRosenpassKey []byte) *wgtypes.Key { return determKey } -// todo: move this logic into Rosenpass package -func (conn *Conn) rosenpassDetermKey() (*wgtypes.Key, error) { - lk := []byte(conn.config.LocalKey) - rk := []byte(conn.config.Key) // remote key - var keyInput []byte - if string(lk) > string(rk) { - //nolint:gocritic - keyInput = append(lk[:16], rk[:16]...) - } else { - //nolint:gocritic - keyInput = append(rk[:16], lk[:16]...) - } - - key, err := wgtypes.NewKey(keyInput) - if err != nil { - return nil, err - } - return &key, nil -} - func isController(config ConnConfig) bool { return config.LocalKey > config.Key } @@ -975,3 +1068,16 @@ func boolToConnStatus(connected bool) guard.ConnStatus { } return guard.ConnStatusDisconnected } + +func metricsConnType(priority conntype.ConnPriority) metrics.ConnectionType { + switch priority { + case conntype.Relay: + return metrics.ConnectionTypeRelay + case conntype.ICETurn: + return metrics.ConnectionTypeICETurn + case conntype.ICEP2P: + return metrics.ConnectionTypeICEP2P + default: + return metrics.ConnectionTypeUnknown + } +} diff --git a/client/internal/peer/conn_status.go b/client/internal/peer/conn_status.go index b43e245f3..d6ad37b70 100644 --- a/client/internal/peer/conn_status.go +++ b/client/internal/peer/conn_status.go @@ -26,7 +26,6 @@ type connStatusInputs struct { iceInProgress bool // a negotiation is currently in flight } - // ConnStatus describe the status of a peer's connection type ConnStatus int32 diff --git a/client/internal/peer/conn_test.go b/client/internal/peer/conn_test.go index 59216b647..b709d5e40 100644 --- a/client/internal/peer/conn_test.go +++ b/client/internal/peer/conn_test.go @@ -7,9 +7,12 @@ import ( "testing" "time" + log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/internal/metrics" + "github.com/netbirdio/netbird/client/internal/peer/conntype" "github.com/netbirdio/netbird/client/internal/peer/dispatcher" "github.com/netbirdio/netbird/client/internal/peer/guard" "github.com/netbirdio/netbird/client/internal/peer/ice" @@ -304,3 +307,114 @@ func TestConn_presharedKey_RosenpassManaged(t *testing.T) { t.Fatalf("expected non-nil presharedKey before Rosenpass manages PSK") } } + +func newWGTimeoutTestConn(rosenpassEnabled bool, disconnected *[]string) *Conn { + cfg := ConnConfig{ + Key: "LLHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=", + LocalKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=", + WgConfig: WgConfig{RemoteKey: "LLHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU="}, + } + if rosenpassEnabled { + cfg.RosenpassConfig = RosenpassConfig{PubKey: []byte("dummykey")} + } + + conn := &Conn{ + ctx: context.Background(), + config: cfg, + Log: log.WithField("peer", cfg.Key), + metricsStages: &MetricsStages{}, + } + conn.SetOnDisconnected(func(remotePeer string) { + *disconnected = append(*disconnected, remotePeer) + }) + return conn +} + +// TestConn_onWGDisconnected_EscalatesToRosenpassReset: repeated handshake +// timeouts with rosenpass enabled mean the preshared keys have desynced. The +// renewal exchange runs over the dead tunnel and cannot resync them, so after +// wgTimeoutEscalationThreshold consecutive timeouts the conn must report the +// peer disconnected, dropping its rosenpass state so the next configuration +// programs the rendezvous key. +func TestConn_onWGDisconnected_EscalatesToRosenpassReset(t *testing.T) { + var disconnected []string + conn := newWGTimeoutTestConn(true, &disconnected) + + for i := 0; i < wgTimeoutEscalationThreshold-1; i++ { + conn.onWGDisconnected(conn.ctx) + } + assert.Empty(t, disconnected, "escalation must not fire below the threshold") + + conn.onWGDisconnected(conn.ctx) + assert.Equal(t, []string{conn.config.WgConfig.RemoteKey}, disconnected, + "reaching the threshold must report the peer disconnected once") + + for i := 0; i < wgTimeoutEscalationThreshold-1; i++ { + conn.onWGDisconnected(conn.ctx) + } + assert.Len(t, disconnected, 1, "escalation must restart counting after firing") + + conn.onWGDisconnected(conn.ctx) + assert.Len(t, disconnected, 2, "continued timeouts must escalate again") +} + +// TestConn_onWGDisconnected_CheckSuccessResetsEscalation: a successful +// handshake between timeouts means the tunnel recovered; the counter must +// start over. +func TestConn_onWGDisconnected_CheckSuccessResetsEscalation(t *testing.T) { + var disconnected []string + conn := newWGTimeoutTestConn(true, &disconnected) + + for i := 0; i < wgTimeoutEscalationThreshold-1; i++ { + conn.onWGDisconnected(conn.ctx) + } + conn.onWGCheckSuccess() + + for i := 0; i < wgTimeoutEscalationThreshold-1; i++ { + conn.onWGDisconnected(conn.ctx) + } + assert.Empty(t, disconnected, "handshake success must reset the timeout count") +} + +// TestConn_onWGDisconnected_NoEscalationWithoutRosenpass: without rosenpass +// there is no per-peer key state to reset; repeated timeouts must not report +// disconnects. +func TestConn_onWGDisconnected_NoEscalationWithoutRosenpass(t *testing.T) { + var disconnected []string + conn := newWGTimeoutTestConn(false, &disconnected) + + for i := 0; i < wgTimeoutEscalationThreshold*3; i++ { + conn.onWGDisconnected(conn.ctx) + } + assert.Empty(t, disconnected, "escalation must be limited to rosenpass connections") +} + +func TestMetricsConnType(t *testing.T) { + tests := []struct { + name string + priority conntype.ConnPriority + expected metrics.ConnectionType + }{ + {"relay", conntype.Relay, metrics.ConnectionTypeRelay}, + {"ice over turn is relayed, not p2p", conntype.ICETurn, metrics.ConnectionTypeICETurn}, + {"direct p2p", conntype.ICEP2P, metrics.ConnectionTypeICEP2P}, + {"unset priority is unknown, not p2p", conntype.None, metrics.ConnectionTypeUnknown}, + {"unrecognised priority is unknown", conntype.ConnPriority(99), metrics.ConnectionTypeUnknown}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, metricsConnType(tc.priority)) + }) + } +} + +func TestMetricsConnType_RelayedMatchesIsRelayed(t *testing.T) { + for _, priority := range []conntype.ConnPriority{conntype.None, conntype.Relay, conntype.ICETurn, conntype.ICEP2P} { + conn := &Conn{currentConnPriority: priority} + tag := metricsConnType(priority) + relayedTag := tag == metrics.ConnectionTypeRelay || tag == metrics.ConnectionTypeICETurn + assert.Equal(t, conn.isRelayed(), relayedTag, + "priority %s: isRelayed and the %q metric tag must agree", priority, tag) + } +} diff --git a/client/internal/peer/env.go b/client/internal/peer/env.go index b4ba9ad7b..ed6a3af53 100644 --- a/client/internal/peer/env.go +++ b/client/internal/peer/env.go @@ -7,7 +7,8 @@ import ( ) const ( - EnvKeyNBForceRelay = "NB_FORCE_RELAY" + EnvKeyNBForceRelay = "NB_FORCE_RELAY" + EnvKeyNBHomeRelayServers = "NB_HOME_RELAY_SERVERS" ) func IsForceRelayed() bool { @@ -16,3 +17,28 @@ func IsForceRelayed() bool { } return strings.EqualFold(os.Getenv(EnvKeyNBForceRelay), "true") } + +// OverrideRelayURLs returns the relay server URL list set in +// NB_HOME_RELAY_SERVERS (comma-separated) and a boolean indicating whether +// the override is active. When the env var is unset, the boolean is false +// and the caller should keep the list received from the management server. +// Intended for lab/debug scenarios where a peer must pin to a specific home +// relay regardless of what management offers. +func OverrideRelayURLs() ([]string, bool) { + raw := os.Getenv(EnvKeyNBHomeRelayServers) + if raw == "" { + return nil, false + } + parts := strings.Split(raw, ",") + urls := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + urls = append(urls, p) + } + } + if len(urls) == 0 { + return nil, false + } + return urls, true +} diff --git a/client/internal/peer/guard/guard.go b/client/internal/peer/guard/guard.go index 2e5efbcc5..73bab2a89 100644 --- a/client/internal/peer/guard/guard.go +++ b/client/internal/peer/guard/guard.go @@ -22,6 +22,12 @@ const ( type connStatusFunc func() ConnStatus +// NetworkWatcher is the availability view the guard gates reconnects on. +type NetworkWatcher interface { + IsOnline() bool + Changed() <-chan struct{} +} + // Guard is responsible for the reconnection logic. // It will trigger to send an offer to the peer then has connection issues. // Watch these events: @@ -31,20 +37,26 @@ type connStatusFunc func() ConnStatus // - Relayed connection disconnected // - ICE candidate changes type Guard struct { - log *log.Entry - isConnectedOnAllWay connStatusFunc - timeout time.Duration - srWatcher *SRWatcher + log *log.Entry + isConnectedOnAllWay connStatusFunc + timeout time.Duration + srWatcher *SRWatcher + // netWatcher gates reconnect attempts on OS-reported network availability; + // nil disables gating. + netWatcher NetworkWatcher relayedConnDisconnected chan struct{} iCEConnDisconnected chan struct{} } -func NewGuard(log *log.Entry, isConnectedFn connStatusFunc, timeout time.Duration, srWatcher *SRWatcher) *Guard { +// NewGuard creates a reconnection guard for a peer connection. A nil netWatcher +// disables network availability gating. +func NewGuard(log *log.Entry, isConnectedFn connStatusFunc, timeout time.Duration, srWatcher *SRWatcher, netWatcher NetworkWatcher) *Guard { return &Guard{ log: log, isConnectedOnAllWay: isConnectedFn, timeout: timeout, srWatcher: srWatcher, + netWatcher: netWatcher, relayedConnDisconnected: make(chan struct{}, 1), iCEConnDisconnected: make(chan struct{}, 1), } @@ -85,16 +97,30 @@ func (g *Guard) reconnectLoopWithRetry(ctx context.Context, callback func()) { defer g.srWatcher.RemoveListener(srReconnectedChan) ticker := g.initialTicker(ctx) - defer ticker.Stop() + defer func() { + // If backoff.Ticker.send is blocked, context.Done will not close the Ticker goroutine. + // We have to explicitly call Stop, even if we use backoff.WithContext. + ticker.Stop() + }() tickerChannel := ticker.C iceState := &iceRetryState{log: g.log} defer iceState.reset() + var netChanged <-chan struct{} + if g.netWatcher != nil { + netChanged = g.netWatcher.Changed() + } + for { select { case <-tickerChannel: + // skip attempts while the OS reports no usable network; the + // netChanged case below resumes the loop once it returns + if g.netWatcher != nil && !g.netWatcher.IsOnline() { + continue + } switch g.isConnectedOnAllWay() { case ConnStatusConnected: // all good, nothing to do @@ -131,6 +157,23 @@ func (g *Guard) reconnectLoopWithRetry(ctx context.Context, callback func()) { tickerChannel = ticker.C iceState.reset() + case <-netChanged: + // Re-arm for the next transition before acting on this one. + netChanged = g.netWatcher.Changed() + if !g.netWatcher.IsOnline() { + continue + } + // Ticks skipped while offline drove the backoff towards its + // maximum without ever attempting, and left the ICE budget + // frozen — possibly in hourly mode. Recover on our own so the + // peer does not depend on a signal or relay event that never + // comes when both stayed up across the outage. + g.log.Debugf("network is back, reset reconnection ticker") + ticker.Stop() + ticker = g.newReconnectTicker(ctx) + tickerChannel = ticker.C + iceState.reset() + case <-ctx.Done(): g.log.Debugf("context is done, stop reconnect loop") return diff --git a/client/internal/peer/guard/guard_leak_test.go b/client/internal/peer/guard/guard_leak_test.go new file mode 100644 index 000000000..3d82ec591 --- /dev/null +++ b/client/internal/peer/guard/guard_leak_test.go @@ -0,0 +1,92 @@ +package guard + +import ( + "context" + "runtime" + "strings" + "sync" + "testing" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/peer/ice" +) + +func newTestGuard(status connStatusFunc) *Guard { + srw := NewSRWatcher(nil, nil, nil, ice.Config{}) + return NewGuard(log.WithField("test", "guard"), status, 50*time.Millisecond, srw, nil) +} + +// countBackoffTickerGoroutines returns how many goroutines are currently sitting +// in backoff/v4.(*Ticker).run (a ticker goroutine that has not exited). +func countBackoffTickerGoroutines() int { + buf := make([]byte, 1<<25) // 32MB + n := runtime.Stack(buf, true) + return strings.Count(string(buf[:n]), "backoff/v4.(*Ticker).run") +} + +// TestGuard_ReconnectTicker_NoGoroutineLeakOnShutdown reproduces a observed +// leak: after a shutdown burst, ticker run/send goroutines stay parked +// forever even though every reconnect loop has exited. +func TestGuard_ReconnectTicker_NoGoroutineLeakOnShutdown(t *testing.T) { + before := countBackoffTickerGoroutines() + + const peers = 6000 + cancels := make([]context.CancelFunc, 0, peers) + var wg sync.WaitGroup + + // A status check slower than the tick cadence. This models the real + // isConnectedOnAllWay/callback doing work: while the loop is busy in the + // handler, the ticker fires the next tick and parks in send(), because + // send() never selects on ctx. + slowStatus := func() ConnStatus { + time.Sleep(70 * time.Millisecond) + return ConnStatusConnected + } + + for range peers { + g := newTestGuard(slowStatus) + ctx, cancel := context.WithCancel(context.Background()) + cancels = append(cancels, cancel) + wg.Add(1) + go func() { + defer wg.Done() + g.Start(ctx, func() {}) + }() + // Force the live ticker to be a newReconnectTicker. + g.SetRelayedConnDisconnected() + } + + // Let the replacement tickers get past their 800ms initial interval, so + // many are parked in send() waiting on the (slow) consumer when we tear + // everything down. + time.Sleep(1500 * time.Millisecond) + + // Shutdown burst: cancel every peer at once, like engine teardown. + for _, c := range cancels { + c() + } + + // Every reconnect loop must return + waitCh := make(chan struct{}) + go func() { wg.Wait(); close(waitCh) }() + select { + case <-waitCh: + case <-time.After(30 * time.Second): + t.Fatal("not all reconnect loops returned after ctx cancel") + } + + // Give any correctly-stopped ticker goroutines time to unwind. + for range 50 { + runtime.Gosched() + time.Sleep(10 * time.Millisecond) + } + + leaked := countBackoffTickerGoroutines() - before + t.Logf("backoff Ticker.run goroutines still parked after teardown of %d peers: %d", peers, leaked) + if leaked > 0 { + t.Errorf("LEAK: %d backoff ticker goroutines parked after all reconnect loops exited "+ + "(defer ticker.Stop() stops the initial ticker, not the live replacement)", leaked) + } +} diff --git a/client/internal/peer/guard/guard_netstate_test.go b/client/internal/peer/guard/guard_netstate_test.go new file mode 100644 index 000000000..44999cae1 --- /dev/null +++ b/client/internal/peer/guard/guard_netstate_test.go @@ -0,0 +1,107 @@ +package guard + +import ( + "context" + "sync/atomic" + "testing" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/peer/ice" + "github.com/netbirdio/netbird/client/netevents/netstate" +) + +// newTestGuardWithNetState builds a guard with a realistic MaxInterval: the +// backoff must be able to grow well past the outage, as it does in production +// where the timeout is seconds to minutes. +func newTestGuardWithNetState(status connStatusFunc, netState *netstate.State) *Guard { + srw := NewSRWatcher(nil, nil, nil, ice.Config{}) + return NewGuard(log.WithField("test", "guard"), status, 30*time.Second, srw, netState) +} + +// TestGuard_RecoversAfterOfflineToOnline covers a peer that stays disconnected +// across a network outage while neither signal nor relay reports an event — +// both stayed up, as on a short airplane mode toggle over Wi-Fi. +// +// Every tick taken while offline is skipped, but it still advances the +// exponential backoff, so by the time the network returns the next tick can be +// tens of seconds away. Without an explicit reaction to the transition the +// peer waits out that interval for a recovery that could start immediately. +func TestGuard_RecoversAfterOfflineToOnline(t *testing.T) { + netState := netstate.New() + + var attempts atomic.Int32 + g := newTestGuardWithNetState(func() ConnStatus { return ConnStatusDisconnected }, netState) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + // Start from the reconnect ticker (800ms initial interval), the state a + // peer is in after it loses its connection. + go g.Start(ctx, func() { attempts.Add(1) }) + g.SetRelayedConnDisconnected() + + // Let the backoff climb: 0.8s, 1.6s, 3.2s, 6.4s ... every tick is skipped + // while offline, but each one doubles the wait for the next. + netState.Set(false) + time.Sleep(8 * time.Second) + + offlineAttempts := attempts.Load() + if offlineAttempts != 0 { + t.Fatalf("callback ran %d times while offline, want 0", offlineAttempts) + } + + netState.Set(true) + + // The next organic tick is now several seconds out, so anything within + // this window can only come from reacting to the transition itself. + pollCtx, stopPolling := context.WithTimeout(ctx, 2*time.Second) + defer stopPolling() + + select { + case <-pollCtx.Done(): + t.Fatal("peer was not retried within 2s of the network coming back, " + + "with neither a signal nor a relay event to fall back on") + case <-pollUntil(pollCtx, func() bool { return attempts.Load() > 0 }): + } +} + +// TestGuard_OfflineTransitionDoesNotRetry checks the other direction: going +// offline must not itself trigger an attempt. +func TestGuard_OfflineTransitionDoesNotRetry(t *testing.T) { + netState := netstate.New() + + var attempts atomic.Int32 + g := newTestGuardWithNetState(func() ConnStatus { return ConnStatusDisconnected }, netState) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go g.Start(ctx, func() { attempts.Add(1) }) + + netState.Set(false) + time.Sleep(5 * time.Second) + + if got := attempts.Load(); got != 0 { + t.Fatalf("callback ran %d times after going offline, want 0", got) + } +} + +// pollUntil closes the returned channel once cond holds. It gives up when ctx +// is done, so the polling goroutine never outlives the test that started it. +func pollUntil(ctx context.Context, cond func() bool) <-chan struct{} { + done := make(chan struct{}) + go func() { + for { + if cond() { + close(done) + return + } + select { + case <-ctx.Done(): + return + case <-time.After(10 * time.Millisecond): + } + } + }() + return done +} diff --git a/client/internal/peer/handshaker.go b/client/internal/peer/handshaker.go index 741dfce60..6ecb2a947 100644 --- a/client/internal/peer/handshaker.go +++ b/client/internal/peer/handshaker.go @@ -3,6 +3,7 @@ package peer import ( "context" "errors" + "net/netip" "sync" "sync/atomic" @@ -40,6 +41,10 @@ type OfferAnswer struct { // relay server address RelaySrvAddress string + // RelaySrvIP is the IP the remote peer is connected to on its + // relay server. Used as a dial target if DNS for RelaySrvAddress + // fails. Zero value if the peer did not advertise an IP. + RelaySrvIP netip.Addr // SessionID is the unique identifier of the session, used to discard old messages SessionID *ICESessionID } @@ -76,14 +81,19 @@ type Handshaker struct { func NewHandshaker(log *log.Entry, config ConnConfig, signaler *Signaler, ice *WorkerICE, relay *WorkerRelay, metricsStages *MetricsStages) *Handshaker { h := &Handshaker{ - log: log, - config: config, - signaler: signaler, - ice: ice, - relay: relay, - metricsStages: metricsStages, - remoteOffersCh: make(chan OfferAnswer), - remoteAnswerCh: make(chan OfferAnswer), + log: log, + config: config, + signaler: signaler, + ice: ice, + relay: relay, + metricsStages: metricsStages, + // Buffered by one so an offer or answer that arrives between Open launching + // the Listen goroutine and it reaching its receive is held rather than + // dropped. A peer activated by an incoming signal receives the remote's + // message in that window; an unbuffered channel skips it as "receiver not + // ready", and the connection cannot proceed until the remote re-sends. + remoteOffersCh: make(chan OfferAnswer, 1), + remoteAnswerCh: make(chan OfferAnswer, 1), } // assume remote supports ICE until we learn otherwise from received offers h.remoteICESupported.Store(ice != nil) @@ -157,29 +167,38 @@ func (h *Handshaker) SendOffer() error { return h.sendOffer() } -// OnRemoteOffer handles an offer from the remote peer and returns true if the message was accepted, false otherwise -// doesn't block, discards the message if connection wasn't ready +// OnRemoteOffer hands an offer to Listen without blocking, keeping only the most +// recent one if several arrive before Listen reads them. func (h *Handshaker) OnRemoteOffer(offer OfferAnswer) { - select { - case h.remoteOffersCh <- offer: - return - default: - h.log.Warnf("skipping remote offer message because receiver not ready") - // connection might not be ready yet to receive so we ignore the message - return - } + enqueueLatest(h.remoteOffersCh, offer) } -// OnRemoteAnswer handles an offer from the remote peer and returns true if the message was accepted, false otherwise -// doesn't block, discards the message if connection wasn't ready +// OnRemoteAnswer hands an answer to Listen without blocking, keeping only the most +// recent one if several arrive before Listen reads them. func (h *Handshaker) OnRemoteAnswer(answer OfferAnswer) { + enqueueLatest(h.remoteAnswerCh, answer) +} + +// enqueueLatest delivers msg on a one-slot channel without blocking. When the slot +// already holds an unread message the older one is discarded in favor of msg, so a +// message arriving before Listen starts reading is held rather than dropped, and +// the newest wins if several arrive first. Safe because there is a single producer +// (the engine loop): after draining the stale value the send always has room. +func enqueueLatest(ch chan OfferAnswer, msg OfferAnswer) { select { - case h.remoteAnswerCh <- answer: + case ch <- msg: return default: - // connection might not be ready yet to receive so we ignore the message - h.log.Warnf("skipping remote answer message because receiver not ready") - return + } + + select { + case <-ch: + default: + } + + select { + case ch <- msg: + default: } } @@ -190,14 +209,14 @@ func (h *Handshaker) sendOffer() error { } offer := h.buildOfferAnswer() - h.log.Infof("sending offer with serial: %s", offer.SessionIDString()) + h.log.Debugf("sending offer with serial: %s", offer.SessionIDString()) return h.signaler.SignalOffer(offer, h.config.Key) } func (h *Handshaker) sendAnswer() error { answer := h.buildOfferAnswer() - h.log.Infof("sending answer with serial: %s", answer.SessionIDString()) + h.log.Debugf("sending answer with serial: %s", answer.SessionIDString()) return h.signaler.SignalAnswer(answer, h.config.Key) } @@ -217,8 +236,9 @@ func (h *Handshaker) buildOfferAnswer() OfferAnswer { answer.SessionID = &sid } - if addr, err := h.relay.RelayInstanceAddress(); err == nil { + if addr, ip, err := h.relay.RelayInstanceAddress(); err == nil { answer.RelaySrvAddress = addr + answer.RelaySrvIP = ip } return answer diff --git a/client/internal/peer/handshaker_test.go b/client/internal/peer/handshaker_test.go new file mode 100644 index 000000000..5e203d78b --- /dev/null +++ b/client/internal/peer/handshaker_test.go @@ -0,0 +1,63 @@ +package peer + +import ( + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +func newTestHandshaker(t *testing.T) *Handshaker { + t.Helper() + // The tests exercise the answer path, whose Listen branch dispatches to the + // relay listener without sending an answer, so no signaler/ICE/relay is needed. + return NewHandshaker(log.WithField("test", t.Name()), ConnConfig{}, nil, nil, nil, nil) +} + +// TestHandshakerHoldsSignalArrivingBeforeListen covers the case where a peer is +// activated by an incoming signal: the remote's offer/answer arrives in the same +// step that opens the connection, before the Listen loop starts reading. The +// message must be held rather than dropped, or the connection cannot proceed until +// the remote re-sends. This is the path taken when an eager peer connects to a +// lazily-managed one. +func TestHandshakerHoldsSignalArrivingBeforeListen(t *testing.T) { + h := newTestHandshaker(t) + + processed := make(chan *OfferAnswer, 4) + h.AddRelayListener(func(o *OfferAnswer) { processed <- o }) + + // Delivered before Listen is reading, as when the peer is woken by the remote's + // signal and the message is delivered right after Open. + h.OnRemoteAnswer(OfferAnswer{WgListenPort: 51820}) + + go h.Listen(t.Context()) + + select { + case <-processed: + case <-time.After(2 * time.Second): + assert.Fail(t, "remote-answer dispatch: signal delivered before Listen was ready was dropped") + } +} + +// TestHandshakerKeepsLatestSignalBeforeListen covers several signals arriving +// before Listen reads: the newest must win (matching the latest-offer contract), +// rather than the first being kept and later ones discarded. +func TestHandshakerKeepsLatestSignalBeforeListen(t *testing.T) { + h := newTestHandshaker(t) + + processed := make(chan *OfferAnswer, 4) + h.AddRelayListener(func(o *OfferAnswer) { processed <- o }) + + h.OnRemoteAnswer(OfferAnswer{WgListenPort: 1111}) + h.OnRemoteAnswer(OfferAnswer{WgListenPort: 2222}) + + go h.Listen(t.Context()) + + select { + case got := <-processed: + assert.Equal(t, 2222, got.WgListenPort, "remote-answer dispatch: the latest queued signal should be processed") + case <-time.After(2 * time.Second): + assert.Fail(t, "remote-answer dispatch: queued signal was dropped") + } +} diff --git a/client/internal/peer/listener.go b/client/internal/peer/listener.go index c601fe534..2bb7fcf32 100644 --- a/client/internal/peer/listener.go +++ b/client/internal/peer/listener.go @@ -1,11 +1,40 @@ package peer +// ClientState identifies the client connection state delivered via +// Listener.OnStateChanged. +type ClientState int + +// Client states. The numeric values cross the gomobile boundary (the mobile +// bindings re-export them as integer constants), so they are a wire format: +// append new states at the end, never reorder or insert. +const ( + ClientStateDisconnected ClientState = iota + ClientStateConnected + ClientStateConnecting + ClientStateDisconnecting + // ClientStateNoNetwork is an overlay state: it is never stored as the + // last notification, only derived from ClientStateConnecting while the + // OS reports no usable network (see notifier.effectiveState). + ClientStateNoNetwork +) + // Listener is a callback type about the NetBird network connection state type Listener interface { + // OnStateChanged reports every client state transition. New states are + // delivered only through this callback; the per-state callbacks below + // are kept for compatibility and will be removed once all consumers + // have migrated. + OnStateChanged(state ClientState) + + // Deprecated: consume OnStateChanged instead. OnConnected() + // Deprecated: consume OnStateChanged instead. OnDisconnected() + // Deprecated: consume OnStateChanged instead. OnConnecting() + // Deprecated: consume OnStateChanged instead. OnDisconnecting() + OnAddressChanged(string, string) OnPeersListChanged(int) } diff --git a/client/internal/peer/notifier.go b/client/internal/peer/notifier.go index 8d1954fe5..1ee1d32ea 100644 --- a/client/internal/peer/notifier.go +++ b/client/internal/peer/notifier.go @@ -4,31 +4,64 @@ import ( "sync" ) -const ( - stateDisconnected = iota - stateConnected - stateConnecting - stateDisconnecting -) - type notifier struct { + // publishLock orders state publication: it is held across computing the + // effective state and handing it to the listener, so a transition cannot + // overtake a newer one and leave the listener on a stale state. + publishLock sync.Mutex serverStateLock sync.Mutex listenersLock sync.Mutex listener Listener currentClientState bool - lastNotification int + lastNotification ClientState lastNumberOfPeers int lastFqdnAddress string lastIPAddress string + networkAvailable bool } func newNotifier() *notifier { - return ¬ifier{} + return ¬ifier{ + networkAvailable: true, + } +} + +// effectiveState maps the computed state to what listeners should see: +// while the OS reports no usable network, "Connecting" would be a lie — +// connection attempts are suspended — so it is reported as NoNetwork. +// Caller must hold serverStateLock. +func (n *notifier) effectiveState(state ClientState) ClientState { + if !n.networkAvailable && state == ClientStateConnecting { + return ClientStateNoNetwork + } + return state +} + +// setNetworkAvailable records the OS network availability and re-notifies +// the listener when the flag flips the effective state (Connecting <-> +// NoNetwork). +func (n *notifier) setNetworkAvailable(available bool) { + n.publishLock.Lock() + defer n.publishLock.Unlock() + + n.serverStateLock.Lock() + if n.networkAvailable == available { + n.serverStateLock.Unlock() + return + } + previous := n.effectiveState(n.lastNotification) + n.networkAvailable = available + current := n.effectiveState(n.lastNotification) + n.serverStateLock.Unlock() + + if previous != current { + n.notify(current) + } } func (n *notifier) setListener(listener Listener) { n.serverStateLock.Lock() - lastNotification := n.lastNotification + lastNotification := n.effectiveState(n.lastNotification) numOfPeers := n.lastNumberOfPeers fqdnAddress := n.lastFqdnAddress address := n.lastIPAddress @@ -52,6 +85,9 @@ func (n *notifier) removeListener() { } func (n *notifier) updateServerStates(mgmState bool, signalState bool) { + n.publishLock.Lock() + defer n.publishLock.Unlock() + n.serverStateLock.Lock() calculatedState := n.calculateState(mgmState, signalState) @@ -61,43 +97,54 @@ func (n *notifier) updateServerStates(mgmState bool, signalState bool) { } n.lastNotification = calculatedState + effective := n.effectiveState(calculatedState) n.serverStateLock.Unlock() - n.notify(calculatedState) + n.notify(effective) } func (n *notifier) clientStart() { + n.publishLock.Lock() + defer n.publishLock.Unlock() + n.serverStateLock.Lock() n.currentClientState = true - n.lastNotification = stateConnecting + n.lastNotification = ClientStateConnecting + effective := n.effectiveState(ClientStateConnecting) n.serverStateLock.Unlock() - n.notify(stateConnecting) + n.notify(effective) } func (n *notifier) clientStop() { + n.publishLock.Lock() + defer n.publishLock.Unlock() + n.serverStateLock.Lock() n.currentClientState = false - n.lastNotification = stateDisconnected + n.lastNotification = ClientStateDisconnected n.serverStateLock.Unlock() - n.notify(stateDisconnected) + n.notify(ClientStateDisconnected) } func (n *notifier) clientTearDown() { + n.publishLock.Lock() + defer n.publishLock.Unlock() + n.serverStateLock.Lock() n.currentClientState = false - n.lastNotification = stateDisconnecting + n.lastNotification = ClientStateDisconnecting n.serverStateLock.Unlock() - n.notify(stateDisconnecting) + n.notify(ClientStateDisconnecting) } -func (n *notifier) isServerStateChanged(newState int) bool { +func (n *notifier) isServerStateChanged(newState ClientState) bool { return n.lastNotification != newState } -func (n *notifier) notify(state int) { +func (n *notifier) notify(state ClientState) { n.listenersLock.Lock() listener := n.listener n.listenersLock.Unlock() @@ -109,20 +156,20 @@ func (n *notifier) notify(state int) { notifyListener(listener, state) } -func (n *notifier) calculateState(managementConn, signalConn bool) int { +func (n *notifier) calculateState(managementConn, signalConn bool) ClientState { if managementConn && signalConn { - return stateConnected + return ClientStateConnected } if !managementConn && !signalConn && !n.currentClientState { - return stateDisconnected + return ClientStateDisconnected } - if n.lastNotification == stateDisconnecting { - return stateDisconnecting + if n.lastNotification == ClientStateDisconnecting { + return ClientStateDisconnecting } - return stateConnecting + return ClientStateConnecting } func (n *notifier) peerListChanged(numOfPeers int) { @@ -159,15 +206,19 @@ func (n *notifier) localAddressChanged(fqdn, address string) { listener.OnAddressChanged(fqdn, address) } -func notifyListener(l Listener, state int) { +func notifyListener(l Listener, state ClientState) { + // legacy per-state callbacks; NoNetwork is delivered only via + // OnStateChanged below switch state { - case stateDisconnected: + case ClientStateDisconnected: l.OnDisconnected() - case stateConnected: + case ClientStateConnected: l.OnConnected() - case stateConnecting: + case ClientStateConnecting: l.OnConnecting() - case stateDisconnecting: + case ClientStateDisconnecting: l.OnDisconnecting() } + + l.OnStateChanged(state) } diff --git a/client/internal/peer/notifier_concurrent_test.go b/client/internal/peer/notifier_concurrent_test.go new file mode 100644 index 000000000..fcaaaad3b --- /dev/null +++ b/client/internal/peer/notifier_concurrent_test.go @@ -0,0 +1,108 @@ +package peer + +import ( + "sync" + "testing" + "time" +) + +type recordingListener struct { + mu sync.Mutex + states []ClientState + onState func(ClientState) +} + +func (l *recordingListener) OnStateChanged(state ClientState) { + l.mu.Lock() + l.states = append(l.states, state) + hook := l.onState + l.mu.Unlock() + + if hook != nil { + hook(state) + } +} + +func (l *recordingListener) last() (ClientState, bool) { + l.mu.Lock() + defer l.mu.Unlock() + if len(l.states) == 0 { + return 0, false + } + return l.states[len(l.states)-1], true +} + +func (l *recordingListener) snapshot() []ClientState { + l.mu.Lock() + defer l.mu.Unlock() + return append([]ClientState(nil), l.states...) +} + +func (l *recordingListener) OnConnected() {} +func (l *recordingListener) OnDisconnected() {} +func (l *recordingListener) OnConnecting() {} +func (l *recordingListener) OnDisconnecting() {} +func (l *recordingListener) OnAddressChanged(string, string) {} +func (l *recordingListener) OnPeersListChanged(int) {} + +// TestNotifier_ConcurrentAvailabilityFlipOrdersPublication holds the first +// transition inside the listener callback and flips availability again from +// another goroutine while it is parked. The second flip must not publish +// ahead of the one in flight, otherwise the listener ends up on a state the +// notifier already superseded. +func TestNotifier_ConcurrentAvailabilityFlipOrdersPublication(t *testing.T) { + n := newNotifier() + n.currentClientState = true + n.lastNotification = ClientStateConnecting + + entered := make(chan struct{}) + release := make(chan struct{}) + + l := &recordingListener{} + l.onState = func(state ClientState) { + if state != ClientStateNoNetwork { + return + } + l.mu.Lock() + l.onState = nil + l.mu.Unlock() + close(entered) + <-release + } + n.listener = l + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + n.setNetworkAvailable(false) + }() + + <-entered + + flipped := make(chan struct{}) + go func() { + defer close(flipped) + n.setNetworkAvailable(true) + }() + + select { + case <-flipped: + t.Fatal("the online transition published while the offline one was " + + "still in flight; publication is not serialized") + case <-time.After(200 * time.Millisecond): + } + + close(release) + <-flipped + wg.Wait() + + got, ok := l.last() + if !ok { + t.Fatal("listener never observed a state") + } + if got != ClientStateConnecting { + t.Fatalf("listener holds %v after the network came back, want Connecting; sequence: %v", + got, l.snapshot()) + } +} diff --git a/client/internal/peer/notifier_test.go b/client/internal/peer/notifier_test.go index bbdc00e13..a73016b05 100644 --- a/client/internal/peer/notifier_test.go +++ b/client/internal/peer/notifier_test.go @@ -6,33 +6,38 @@ import ( ) type mocListener struct { - lastState int + lastState ClientState wg sync.WaitGroup + peersWg sync.WaitGroup peers int } func (l *mocListener) OnConnected() { - l.lastState = stateConnected + l.lastState = ClientStateConnected l.wg.Done() } func (l *mocListener) OnDisconnected() { - l.lastState = stateDisconnected + l.lastState = ClientStateDisconnected l.wg.Done() } func (l *mocListener) OnConnecting() { - l.lastState = stateConnecting + l.lastState = ClientStateConnecting l.wg.Done() } func (l *mocListener) OnDisconnecting() { - l.lastState = stateDisconnecting + l.lastState = ClientStateDisconnecting l.wg.Done() } +func (l *mocListener) OnStateChanged(state ClientState) { + +} func (l *mocListener) OnAddressChanged(host, addr string) { } func (l *mocListener) OnPeersListChanged(size int) { l.peers = size + l.peersWg.Done() } func (l *mocListener) setWaiter() { @@ -43,19 +48,27 @@ func (l *mocListener) wait() { l.wg.Wait() } +func (l *mocListener) setPeersWaiter() { + l.peersWg.Add(1) +} + +func (l *mocListener) waitPeers() { + l.peersWg.Wait() +} + func Test_notifier_serverState(t *testing.T) { type scenario struct { name string - expected int + expected ClientState mgmState bool signalState bool } scenarios := []scenario{ - {"connected", stateConnected, true, true}, - {"mgm down", stateConnecting, false, true}, - {"signal down", stateConnecting, true, false}, - {"disconnected", stateDisconnected, false, false}, + {"connected", ClientStateConnected, true, true}, + {"mgm down", ClientStateConnecting, false, true}, + {"signal down", ClientStateConnecting, true, false}, + {"disconnected", ClientStateDisconnected, false, false}, } for _, tt := range scenarios { @@ -72,11 +85,13 @@ func Test_notifier_serverState(t *testing.T) { func Test_notifier_SetListener(t *testing.T) { listener := &mocListener{} listener.setWaiter() + listener.setPeersWaiter() n := newNotifier() - n.lastNotification = stateConnecting + n.lastNotification = ClientStateConnecting n.setListener(listener) listener.wait() + listener.waitPeers() if listener.lastState != n.lastNotification { t.Errorf("invalid state: %d, expected: %d", listener.lastState, n.lastNotification) } @@ -85,9 +100,14 @@ func Test_notifier_SetListener(t *testing.T) { func Test_notifier_RemoveListener(t *testing.T) { listener := &mocListener{} listener.setWaiter() + listener.setPeersWaiter() n := newNotifier() - n.lastNotification = stateConnecting + n.lastNotification = ClientStateConnecting n.setListener(listener) + // setListener replays cached state on a goroutine; wait for both the state + // and peers callbacks to finish so we don't race on listener.peers. + listener.wait() + listener.waitPeers() n.removeListener() n.peerListChanged(1) diff --git a/client/internal/peer/signaler.go b/client/internal/peer/signaler.go index f6eb87cca..5e437d96b 100644 --- a/client/internal/peer/signaler.go +++ b/client/internal/peer/signaler.go @@ -54,19 +54,19 @@ func (s *Signaler) signalOfferAnswer(offerAnswer OfferAnswer, remoteKey string, log.Warnf("failed to get session ID bytes: %v", err) } } - msg, err := signal.MarshalCredential( - s.wgPrivateKey, - offerAnswer.WgListenPort, - remoteKey, - &signal.Credential{ + msg, err := signal.MarshalCredential(s.wgPrivateKey, remoteKey, signal.CredentialPayload{ + Type: bodyType, + WgListenPort: offerAnswer.WgListenPort, + Credential: &signal.Credential{ UFrag: offerAnswer.IceCredentials.UFrag, Pwd: offerAnswer.IceCredentials.Pwd, }, - bodyType, - offerAnswer.RosenpassPubKey, - offerAnswer.RosenpassAddr, - offerAnswer.RelaySrvAddress, - sessionIDBytes) + RosenpassPubKey: offerAnswer.RosenpassPubKey, + RosenpassAddr: offerAnswer.RosenpassAddr, + RelaySrvAddress: offerAnswer.RelaySrvAddress, + RelaySrvIP: offerAnswer.RelaySrvIP, + SessionID: sessionIDBytes, + }) if err != nil { return err } diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go index abedc208e..bf36b944b 100644 --- a/client/internal/peer/status.go +++ b/client/internal/peer/status.go @@ -7,6 +7,7 @@ import ( "net/netip" "slices" "sync" + "sync/atomic" "time" "github.com/google/uuid" @@ -53,6 +54,7 @@ type RouterState struct { type State struct { Mux *sync.RWMutex IP string + IPv6 string PubKey string FQDN string ConnStatus ConnStatus @@ -106,9 +108,11 @@ func (s *State) GetRoutes() map[string]struct{} { // LocalPeerState contains the latest state of the local peer type LocalPeerState struct { IP string + IPv6 string PubKey string KernelInterface bool FQDN string + WgPort int Routes map[string]struct{} } @@ -183,23 +187,35 @@ func (s *StatusChangeSubscription) Events() chan map[string]RouterState { return s.eventsChan } -// Status holds a state of peers, signal, management connections and relays +// Status holds a state of peers, signal, management connections and relays. +// mux is an RWMutex so hot read paths (notably PeerStateByIP, called for +// every private-service request) don't contend against each other. +// Pure read methods take RLock; anything that mutates state takes Lock. type Status struct { - mux sync.Mutex - peers map[string]State - changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription - signalState bool - signalError error - managementState bool - managementError error - relayStates []relay.ProbeResult - localPeer LocalPeerState - offlinePeers []State - mgmAddress string - signalAddress string - notifier *notifier - rosenpassEnabled bool - rosenpassPermissive bool + mux sync.RWMutex + muxRelays sync.RWMutex + peers map[string]State + ipToKey map[string]string + changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription + signalState bool + signalError error + managementState bool + managementError error + relayStates []relay.ProbeResult + localPeer LocalPeerState + offlinePeers []State + mgmAddress string + signalAddress string + notifier *notifier + rosenpassEnabled bool + rosenpassPermissive bool + // sessionExpiresAt is the absolute UTC instant at which the peer's SSO + // session expires. Zero when the peer is not SSO-tracked or login + // expiration is disabled. Populated from management LoginResponse / + // SyncResponse and exposed via the daemon's Status / SubscribeStatus RPC + // so the UI can show remaining time without itself talking to mgm. + sessionExpiresAt time.Time + nsGroupStates []NSGroupState resolvedDomainsStates map[domain.Domain]ResolvedDomainInfo lazyConnectionEnabled bool @@ -215,6 +231,21 @@ type Status struct { eventStreams map[string]chan *proto.SystemEvent eventQueue *EventQueue + // stateChangeStreams fan-out connection-state changes (connected / + // disconnected / connecting / address change / peers list change) to + // every active SubscribeStatus gRPC stream. Each subscriber gets a + // buffered chan; the notifier non-blockingly pings them so a slow + // consumer can never stall the daemon. + stateChangeMux sync.Mutex + stateChangeStreams map[string]chan struct{} + + // networksRevision bumps whenever the routed-networks set or their + // selected state changes (driven by the route manager). Surfaced in the + // status snapshot so the UI can fingerprint on it and re-fetch + // ListNetworks only on a real change. Atomic so the snapshot builder can + // read it without taking mux. + networksRevision atomic.Uint64 + ingressGwMgr *ingressgw.Manager routeIDLookup routeIDLookup @@ -225,9 +256,11 @@ type Status struct { func NewRecorder(mgmAddress string) *Status { return &Status{ peers: make(map[string]State), + ipToKey: make(map[string]string), changeNotify: make(map[string]map[string]*StatusChangeSubscription), eventStreams: make(map[string]chan *proto.SystemEvent), eventQueue: NewEventQueue(eventQueueSize), + stateChangeStreams: make(map[string]chan struct{}), offlinePeers: make([]State, 0), notifier: newNotifier(), mgmAddress: mgmAddress, @@ -236,8 +269,8 @@ func NewRecorder(mgmAddress string) *Status { } func (d *Status) SetRelayMgr(manager *relayClient.Manager) { - d.mux.Lock() - defer d.mux.Unlock() + d.muxRelays.Lock() + defer d.muxRelays.Unlock() d.relayMgr = manager } @@ -259,7 +292,7 @@ func (d *Status) ReplaceOfflinePeers(replacement []State) { } // AddPeer adds peer to Daemon status map -func (d *Status) AddPeer(peerPubKey string, fqdn string, ip string) error { +func (d *Status) AddPeer(peerPubKey string, fqdn string, ip string, ipv6 string) error { d.mux.Lock() defer d.mux.Unlock() @@ -270,18 +303,25 @@ func (d *Status) AddPeer(peerPubKey string, fqdn string, ip string) error { d.peers[peerPubKey] = State{ PubKey: peerPubKey, IP: ip, + IPv6: ipv6, ConnStatus: StatusIdle, FQDN: fqdn, Mux: new(sync.RWMutex), } d.peerListChangedForNotification = true + if ipv6 != "" { + d.ipToKey[ipv6] = peerPubKey + } + if ip != "" { + d.ipToKey[ip] = peerPubKey + } return nil } // GetPeer adds peer to Daemon status map func (d *Status) GetPeer(peerPubKey string) (State, error) { - d.mux.Lock() - defer d.mux.Unlock() + d.mux.RLock() + defer d.mux.RUnlock() state, ok := d.peers[peerPubKey] if !ok { @@ -291,8 +331,8 @@ func (d *Status) GetPeer(peerPubKey string) (State, error) { } func (d *Status) PeerByIP(ip string) (string, bool) { - d.mux.Lock() - defer d.mux.Unlock() + d.mux.RLock() + defer d.mux.RUnlock() for _, state := range d.peers { if state.IP == ip { @@ -302,17 +342,45 @@ func (d *Status) PeerByIP(ip string) (string, bool) { return "", false } +// PeerStateByIP returns the full peer State for the given tunnel IP. +// Matches against either the IPv4 (State.IP) or IPv6 (State.IPv6) tunnel +// address so dual-stack peers are reachable on either family. Only +// active peers are matched; peers moved into the offline slice by +// ReplaceOfflinePeers are intentionally treated as unknown. +func (d *Status) PeerStateByIP(ip string) (State, bool) { + if ip == "" { + return State{}, false + } + d.mux.RLock() + defer d.mux.RUnlock() + key, ok := d.ipToKey[ip] + if !ok { + return State{}, false + } + state, ok := d.peers[key] + if ok { + return state, true + } + return State{}, false +} + // RemovePeer removes peer from Daemon status map func (d *Status) RemovePeer(peerPubKey string) error { d.mux.Lock() defer d.mux.Unlock() - _, ok := d.peers[peerPubKey] + p, ok := d.peers[peerPubKey] if !ok { return errors.New("no peer with to remove") } delete(d.peers, peerPubKey) + if mappedKey, exists := d.ipToKey[p.IP]; exists && mappedKey == peerPubKey { + delete(d.ipToKey, p.IP) + } + if mappedKey, exists := d.ipToKey[p.IPv6]; exists && mappedKey == peerPubKey { + delete(d.ipToKey, p.IPv6) + } d.peerListChangedForNotification = true return nil } @@ -320,10 +388,10 @@ func (d *Status) RemovePeer(peerPubKey string) error { // UpdatePeerState updates peer status func (d *Status) UpdatePeerState(receivedState State) error { d.mux.Lock() - defer d.mux.Unlock() peerState, ok := d.peers[receivedState.PubKey] if !ok { + d.mux.Unlock() return errors.New("peer doesn't exist") } @@ -343,23 +411,30 @@ func (d *Status) UpdatePeerState(receivedState State) error { d.peers[receivedState.PubKey] = peerState - if hasConnStatusChanged(oldState, receivedState.ConnStatus) { - d.notifyPeerListChanged() - } - + notifyList := hasConnStatusChanged(oldState, receivedState.ConnStatus) // when we close the connection we will not notify the router manager - if receivedState.ConnStatus == StatusIdle { - d.notifyPeerStateChangeListeners(receivedState.PubKey) + notifyRouter := receivedState.ConnStatus == StatusIdle + routerSnapshot := d.snapshotRouterPeersLocked(receivedState.PubKey, notifyRouter) + numPeers := d.numOfPeers() + + d.mux.Unlock() + + if notifyList { + d.notifier.peerListChanged(numPeers) } + if notifyRouter { + d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot) + } + d.notifyStateChange() return nil } func (d *Status) AddPeerStateRoute(peer string, route string, resourceId route.ResID) error { d.mux.Lock() - defer d.mux.Unlock() peerState, ok := d.peers[peer] if !ok { + d.mux.Unlock() return errors.New("peer doesn't exist") } @@ -371,17 +446,21 @@ func (d *Status) AddPeerStateRoute(peer string, route string, resourceId route.R d.routeIDLookup.AddRemoteRouteID(resourceId, pref) } + numPeers := d.numOfPeers() + d.mux.Unlock() + // todo: consider to make sense of this notification or not - d.notifyPeerListChanged() + d.notifier.peerListChanged(numPeers) + d.notifyStateChange() return nil } func (d *Status) RemovePeerStateRoute(peer string, route string) error { d.mux.Lock() - defer d.mux.Unlock() peerState, ok := d.peers[peer] if !ok { + d.mux.Unlock() return errors.New("peer doesn't exist") } @@ -393,8 +472,12 @@ func (d *Status) RemovePeerStateRoute(peer string, route string) error { d.routeIDLookup.RemoveRemoteRouteID(pref) } + numPeers := d.numOfPeers() + d.mux.Unlock() + // todo: consider to make sense of this notification or not - d.notifyPeerListChanged() + d.notifier.peerListChanged(numPeers) + d.notifyStateChange() return nil } @@ -410,10 +493,10 @@ func (d *Status) CheckRoutes(ip netip.Addr) ([]byte, bool) { func (d *Status) UpdatePeerICEState(receivedState State) error { d.mux.Lock() - defer d.mux.Unlock() peerState, ok := d.peers[receivedState.PubKey] if !ok { + d.mux.Unlock() return errors.New("peer doesn't exist") } @@ -431,22 +514,29 @@ func (d *Status) UpdatePeerICEState(receivedState State) error { d.peers[receivedState.PubKey] = peerState - if hasConnStatusChanged(oldState, receivedState.ConnStatus) { - d.notifyPeerListChanged() - } + notifyList := hasConnStatusChanged(oldState, receivedState.ConnStatus) + notifyRouter := hasStatusOrRelayedChange(oldState, receivedState.ConnStatus, oldIsRelayed, receivedState.Relayed) + routerSnapshot := d.snapshotRouterPeersLocked(receivedState.PubKey, notifyRouter) + numPeers := d.numOfPeers() - if hasStatusOrRelayedChange(oldState, receivedState.ConnStatus, oldIsRelayed, receivedState.Relayed) { - d.notifyPeerStateChangeListeners(receivedState.PubKey) + d.mux.Unlock() + + if notifyList { + d.notifier.peerListChanged(numPeers) } + if notifyRouter { + d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot) + } + d.notifyStateChange() return nil } func (d *Status) UpdatePeerRelayedState(receivedState State) error { d.mux.Lock() - defer d.mux.Unlock() peerState, ok := d.peers[receivedState.PubKey] if !ok { + d.mux.Unlock() return errors.New("peer doesn't exist") } @@ -461,22 +551,29 @@ func (d *Status) UpdatePeerRelayedState(receivedState State) error { d.peers[receivedState.PubKey] = peerState - if hasConnStatusChanged(oldState, receivedState.ConnStatus) { - d.notifyPeerListChanged() - } + notifyList := hasConnStatusChanged(oldState, receivedState.ConnStatus) + notifyRouter := hasStatusOrRelayedChange(oldState, receivedState.ConnStatus, oldIsRelayed, receivedState.Relayed) + routerSnapshot := d.snapshotRouterPeersLocked(receivedState.PubKey, notifyRouter) + numPeers := d.numOfPeers() - if hasStatusOrRelayedChange(oldState, receivedState.ConnStatus, oldIsRelayed, receivedState.Relayed) { - d.notifyPeerStateChangeListeners(receivedState.PubKey) + d.mux.Unlock() + + if notifyList { + d.notifier.peerListChanged(numPeers) } + if notifyRouter { + d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot) + } + d.notifyStateChange() return nil } func (d *Status) UpdatePeerRelayedStateToDisconnected(receivedState State) error { d.mux.Lock() - defer d.mux.Unlock() peerState, ok := d.peers[receivedState.PubKey] if !ok { + d.mux.Unlock() return errors.New("peer doesn't exist") } @@ -490,22 +587,29 @@ func (d *Status) UpdatePeerRelayedStateToDisconnected(receivedState State) error d.peers[receivedState.PubKey] = peerState - if hasConnStatusChanged(oldState, receivedState.ConnStatus) { - d.notifyPeerListChanged() - } + notifyList := hasConnStatusChanged(oldState, receivedState.ConnStatus) + notifyRouter := hasStatusOrRelayedChange(oldState, receivedState.ConnStatus, oldIsRelayed, receivedState.Relayed) + routerSnapshot := d.snapshotRouterPeersLocked(receivedState.PubKey, notifyRouter) + numPeers := d.numOfPeers() - if hasStatusOrRelayedChange(oldState, receivedState.ConnStatus, oldIsRelayed, receivedState.Relayed) { - d.notifyPeerStateChangeListeners(receivedState.PubKey) + d.mux.Unlock() + + if notifyList { + d.notifier.peerListChanged(numPeers) } + if notifyRouter { + d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot) + } + d.notifyStateChange() return nil } func (d *Status) UpdatePeerICEStateToDisconnected(receivedState State) error { d.mux.Lock() - defer d.mux.Unlock() peerState, ok := d.peers[receivedState.PubKey] if !ok { + d.mux.Unlock() return errors.New("peer doesn't exist") } @@ -522,13 +626,20 @@ func (d *Status) UpdatePeerICEStateToDisconnected(receivedState State) error { d.peers[receivedState.PubKey] = peerState - if hasConnStatusChanged(oldState, receivedState.ConnStatus) { - d.notifyPeerListChanged() - } + notifyList := hasConnStatusChanged(oldState, receivedState.ConnStatus) + notifyRouter := hasStatusOrRelayedChange(oldState, receivedState.ConnStatus, oldIsRelayed, receivedState.Relayed) + routerSnapshot := d.snapshotRouterPeersLocked(receivedState.PubKey, notifyRouter) + numPeers := d.numOfPeers() - if hasStatusOrRelayedChange(oldState, receivedState.ConnStatus, oldIsRelayed, receivedState.Relayed) { - d.notifyPeerStateChangeListeners(receivedState.PubKey) + d.mux.Unlock() + + if notifyList { + d.notifier.peerListChanged(numPeers) } + if notifyRouter { + d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot) + } + d.notifyStateChange() return nil } @@ -594,18 +705,35 @@ func (d *Status) UpdatePeerSSHHostKey(peerPubKey string, sshHostKey []byte) erro // FinishPeerListModifications this event invoke the notification func (d *Status) FinishPeerListModifications() { d.mux.Lock() - defer d.mux.Unlock() if !d.peerListChangedForNotification { + d.mux.Unlock() return } d.peerListChangedForNotification = false - d.notifyPeerListChanged() + numPeers := d.numOfPeers() - for key := range d.peers { - d.notifyPeerStateChangeListeners(key) + // snapshot per-peer router state to deliver after the lock is released + type routerDispatch struct { + peerID string + snapshot map[string]RouterState } + dispatches := make([]routerDispatch, 0, len(d.peers)) + for key := range d.peers { + snapshot := d.snapshotRouterPeersLocked(key, true) + if snapshot != nil { + dispatches = append(dispatches, routerDispatch{peerID: key, snapshot: snapshot}) + } + } + + d.mux.Unlock() + + d.notifier.peerListChanged(numPeers) + for _, rd := range dispatches { + d.dispatchRouterPeers(rd.peerID, rd.snapshot) + } + d.notifyStateChange() } func (d *Status) SubscribeToPeerStateChanges(ctx context.Context, peerID string) *StatusChangeSubscription { @@ -647,18 +775,53 @@ func (d *Status) UnsubscribePeerStateChanges(subscription *StatusChangeSubscript // GetLocalPeerState returns the local peer state func (d *Status) GetLocalPeerState() LocalPeerState { - d.mux.Lock() - defer d.mux.Unlock() + d.mux.RLock() + defer d.mux.RUnlock() return d.localPeer.Clone() } // UpdateLocalPeerState updates local peer status func (d *Status) UpdateLocalPeerState(localPeerState LocalPeerState) { d.mux.Lock() - defer d.mux.Unlock() - d.localPeer = localPeerState - d.notifyAddressChanged() + fqdn := d.localPeer.FQDN + ip := d.localPeer.IP + if d.localPeer.IPv6 != "" { + ip = ip + "\n" + d.localPeer.IPv6 + } + d.mux.Unlock() + + d.notifier.localAddressChanged(fqdn, ip) + d.notifyStateChange() +} + +// SetSessionExpiresAt records the absolute UTC instant at which the peer's +// SSO session is set to expire. Pass the zero value to clear (e.g. when the +// management server stops publishing a deadline because login expiration was +// disabled or the peer is not SSO-tracked). Same-value updates are no-ops; +// real changes fan out via notifyStateChange so SubscribeStatus consumers +// pick up the new deadline on their next read. +func (d *Status) SetSessionExpiresAt(deadline time.Time) { + d.mux.Lock() + if d.sessionExpiresAt.Equal(deadline) { + d.mux.Unlock() + return + } + d.sessionExpiresAt = deadline + d.mux.Unlock() + d.notifyStateChange() +} + +// GetSessionExpiresAt returns the most recently recorded SSO session deadline, +// or the zero value when no deadline is tracked. A deadline in the past is +// returned as-is: it means the session has expired, and consumers (tray row, +// CLI status) render it as "expired" rather than hiding it — masking it as +// "none" would blank the UI at the exact moment it should say the session +// ended. +func (d *Status) GetSessionExpiresAt() time.Time { + d.mux.Lock() + defer d.mux.Unlock() + return d.sessionExpiresAt } // AddLocalPeerStateRoute adds a route to the local peer state @@ -721,30 +884,50 @@ func (d *Status) CleanLocalPeerStateRoutes() { // CleanLocalPeerState cleans local peer status func (d *Status) CleanLocalPeerState() { d.mux.Lock() - defer d.mux.Unlock() - d.localPeer = LocalPeerState{} - d.notifyAddressChanged() + fqdn := d.localPeer.FQDN + ip := d.localPeer.IP + d.mux.Unlock() + + d.notifier.localAddressChanged(fqdn, ip) + d.notifyStateChange() } // MarkManagementDisconnected sets ManagementState to disconnected func (d *Status) MarkManagementDisconnected(err error) { d.mux.Lock() - defer d.mux.Unlock() - defer d.onConnectionChanged() - + // Health checks re-mark the same state on every probe; skip the fan-out + // when nothing actually changed so we don't flood SubscribeStatus + // consumers with identical snapshots. + if !d.managementState && errors.Is(d.managementError, err) { + d.mux.Unlock() + return + } d.managementState = false d.managementError = err + mgm := d.managementState + sig := d.signalState + d.mux.Unlock() + + d.notifier.updateServerStates(mgm, sig) + d.notifyStateChange() } // MarkManagementConnected sets ManagementState to connected func (d *Status) MarkManagementConnected() { d.mux.Lock() - defer d.mux.Unlock() - defer d.onConnectionChanged() - + if d.managementState && d.managementError == nil { + d.mux.Unlock() + return + } d.managementState = true d.managementError = nil + mgm := d.managementState + sig := d.signalState + d.mux.Unlock() + + d.notifier.updateServerStates(mgm, sig) + d.notifyStateChange() } // UpdateSignalAddress update the address of the signal server @@ -778,26 +961,40 @@ func (d *Status) UpdateLazyConnection(enabled bool) { // MarkSignalDisconnected sets SignalState to disconnected func (d *Status) MarkSignalDisconnected(err error) { d.mux.Lock() - defer d.mux.Unlock() - defer d.onConnectionChanged() - + if !d.signalState && errors.Is(d.signalError, err) { + d.mux.Unlock() + return + } d.signalState = false d.signalError = err + mgm := d.managementState + sig := d.signalState + d.mux.Unlock() + + d.notifier.updateServerStates(mgm, sig) + d.notifyStateChange() } // MarkSignalConnected sets SignalState to connected func (d *Status) MarkSignalConnected() { d.mux.Lock() - defer d.mux.Unlock() - defer d.onConnectionChanged() - + if d.signalState && d.signalError == nil { + d.mux.Unlock() + return + } d.signalState = true d.signalError = nil + mgm := d.managementState + sig := d.signalState + d.mux.Unlock() + + d.notifier.updateServerStates(mgm, sig) + d.notifyStateChange() } func (d *Status) UpdateRelayStates(relayResults []relay.ProbeResult) { - d.mux.Lock() - defer d.mux.Unlock() + d.muxRelays.Lock() + defer d.muxRelays.Unlock() d.relayStates = relayResults } @@ -839,8 +1036,8 @@ func (d *Status) DeleteResolvedDomainsStates(domain domain.Domain) { } func (d *Status) GetRosenpassState() RosenpassState { - d.mux.Lock() - defer d.mux.Unlock() + d.mux.RLock() + defer d.mux.RUnlock() return RosenpassState{ d.rosenpassEnabled, d.rosenpassPermissive, @@ -848,14 +1045,14 @@ func (d *Status) GetRosenpassState() RosenpassState { } func (d *Status) GetLazyConnection() bool { - d.mux.Lock() - defer d.mux.Unlock() + d.mux.RLock() + defer d.mux.RUnlock() return d.lazyConnectionEnabled } func (d *Status) GetManagementState() ManagementState { - d.mux.Lock() - defer d.mux.Unlock() + d.mux.RLock() + defer d.mux.RUnlock() return ManagementState{ d.mgmAddress, d.managementState, @@ -881,8 +1078,8 @@ func (d *Status) UpdateLatency(pubKey string, latency time.Duration) error { // IsLoginRequired determines if a peer's login has expired. func (d *Status) IsLoginRequired() bool { - d.mux.Lock() - defer d.mux.Unlock() + d.mux.RLock() + defer d.mux.RUnlock() // if peer is connected to the management then login is not expired if d.managementState { @@ -897,8 +1094,8 @@ func (d *Status) IsLoginRequired() bool { } func (d *Status) GetSignalState() SignalState { - d.mux.Lock() - defer d.mux.Unlock() + d.mux.RLock() + defer d.mux.RUnlock() return SignalState{ d.signalAddress, d.signalState, @@ -908,21 +1105,26 @@ func (d *Status) GetSignalState() SignalState { // GetRelayStates returns the stun/turn/permanent relay states func (d *Status) GetRelayStates() []relay.ProbeResult { - d.mux.Lock() - defer d.mux.Unlock() + d.muxRelays.RLock() if d.relayMgr == nil { - return d.relayStates + defer d.muxRelays.RUnlock() + return slices.Clone(d.relayStates) } - // extend the list of stun, turn servers with relay address + relayMgr := d.relayMgr + // extend the list of stun, turn servers with the relay server connections relayStates := slices.Clone(d.relayStates) + d.muxRelays.RUnlock() - // if the server connection is not established then we will use the general address - // in case of connection we will use the instance specific address - instanceAddr, err := d.relayMgr.RelayInstanceAddress() - if err != nil { - // TODO add their status - for _, r := range d.relayMgr.ServerURLs() { + states := relayMgr.RelayStates() + if len(states) == 0 { + // no relay connection tracked yet; surface configured servers as + // unavailable with the real reconnect error when known + err := relayClient.ErrRelayClientNotConnected + if connErr := relayMgr.RelayConnectError(); connErr != nil { + err = connErr + } + for _, r := range relayMgr.ServerURLs() { relayStates = append(relayStates, relay.ProbeResult{ URI: r, Err: err, @@ -931,15 +1133,19 @@ func (d *Status) GetRelayStates() []relay.ProbeResult { return relayStates } - relayState := relay.ProbeResult{ - URI: instanceAddr, + for _, rs := range states { + relayStates = append(relayStates, relay.ProbeResult{ + URI: rs.URL, + Err: rs.Err, + Transport: rs.Transport, + }) } - return append(relayStates, relayState) + return relayStates } func (d *Status) ForwardingRules() []firewall.ForwardRule { - d.mux.Lock() - defer d.mux.Unlock() + d.mux.RLock() + defer d.mux.RUnlock() if d.ingressGwMgr == nil { return nil } @@ -948,19 +1154,31 @@ func (d *Status) ForwardingRules() []firewall.ForwardRule { } func (d *Status) GetDNSStates() []NSGroupState { - d.mux.Lock() - defer d.mux.Unlock() + d.mux.RLock() + defer d.mux.RUnlock() // shallow copy is good enough, as slices fields are currently not updated return slices.Clone(d.nsGroupStates) } func (d *Status) GetResolvedDomainsStates() map[domain.Domain]ResolvedDomainInfo { - d.mux.Lock() - defer d.mux.Unlock() + d.mux.RLock() + defer d.mux.RUnlock() return maps.Clone(d.resolvedDomainsStates) } +// GetPeerStates returns a snapshot of all known peer states, including offline peers. +func (d *Status) GetPeerStates() []State { + d.mux.RLock() + defer d.mux.RUnlock() + + states := make([]State, 0, d.numOfPeers()) + for _, state := range d.peers { + states = append(states, state) + } + return append(states, d.offlinePeers...) +} + // GetFullStatus gets full status func (d *Status) GetFullStatus() FullStatus { fullStatus := FullStatus{ @@ -973,8 +1191,8 @@ func (d *Status) GetFullStatus() FullStatus { LazyConnectionEnabled: d.GetLazyConnection(), } - d.mux.Lock() - defer d.mux.Unlock() + d.mux.RLock() + defer d.mux.RUnlock() fullStatus.LocalPeerState = d.localPeer @@ -990,16 +1208,25 @@ func (d *Status) GetFullStatus() FullStatus { // ClientStart will notify all listeners about the new service state func (d *Status) ClientStart() { d.notifier.clientStart() + d.notifyStateChange() } // ClientStop will notify all listeners about the new service state func (d *Status) ClientStop() { d.notifier.clientStop() + d.notifyStateChange() } // ClientTeardown will notify all listeners about the service is under teardown func (d *Status) ClientTeardown() { d.notifier.clientTearDown() + d.notifyStateChange() +} + +// SetNetworkAvailable records the OS-reported network availability; while +// unavailable, listeners see NoNetwork instead of Connecting. +func (d *Status) SetNetworkAvailable(available bool) { + d.notifier.setNetworkAvailable(available) } // SetConnectionListener set a listener to the notifier @@ -1012,18 +1239,17 @@ func (d *Status) RemoveConnectionListener() { d.notifier.removeListener() } -func (d *Status) onConnectionChanged() { - d.notifier.updateServerStates(d.managementState, d.signalState) -} - -// notifyPeerStateChangeListeners notifies route manager about the change in peer state -func (d *Status) notifyPeerStateChangeListeners(peerID string) { - subs, ok := d.changeNotify[peerID] - if !ok { - return +// snapshotRouterPeersLocked builds the RouterState map for a peer's subscribers. +// Caller MUST hold d.mux. Returns nil when there are no subscribers for peerID +// or when notify is false. The snapshot is consumed later by dispatchRouterPeers +// outside the lock so the channel send cannot stall any d.mux holder. +func (d *Status) snapshotRouterPeersLocked(peerID string, notify bool) map[string]RouterState { + if !notify { + return nil + } + if _, ok := d.changeNotify[peerID]; !ok { + return nil } - - // collect the relevant data for router peers routerPeers := make(map[string]RouterState, len(d.changeNotify)) for pid := range d.changeNotify { s, ok := d.peers[pid] @@ -1031,13 +1257,35 @@ func (d *Status) notifyPeerStateChangeListeners(peerID string) { log.Warnf("router peer not found in peers list: %s", pid) continue } - routerPeers[pid] = RouterState{ Status: s.ConnStatus, Relayed: s.Relayed, Latency: s.Latency, } } + return routerPeers +} + +// dispatchRouterPeers delivers a previously snapshotted router-state map to +// the peer's subscribers. Caller MUST NOT hold d.mux. The method takes a +// fresh, short read of d.changeNotify under the lock to grab subscriber +// channels, then sends outside the lock so a slow consumer cannot block other +// d.mux holders. The send itself stays blocking (only short-circuited by the +// subscriber's context) so peer state transitions are not silently dropped. +func (d *Status) dispatchRouterPeers(peerID string, routerPeers map[string]RouterState) { + if routerPeers == nil { + return + } + + d.mux.Lock() + subsMap, ok := d.changeNotify[peerID] + subs := make([]*StatusChangeSubscription, 0, len(subsMap)) + if ok { + for _, sub := range subsMap { + subs = append(subs, sub) + } + } + d.mux.Unlock() for _, sub := range subs { select { @@ -1047,14 +1295,6 @@ func (d *Status) notifyPeerStateChangeListeners(peerID string) { } } -func (d *Status) notifyPeerListChanged() { - d.notifier.peerListChanged(d.numOfPeers()) -} - -func (d *Status) notifyAddressChanged() { - d.notifier.localAddressChanged(d.localPeer.FQDN, d.localPeer.IP) -} - func (d *Status) numOfPeers() int { return len(d.peers) + len(d.offlinePeers) } @@ -1128,6 +1368,79 @@ func (d *Status) GetEventHistory() []*proto.SystemEvent { return d.eventQueue.GetAll() } +// SubscribeToStateChanges hands back a channel that receives a tick on +// every connection-state change (connected / disconnected / connecting / +// address change / peers-list change). The channel is buffered to one +// pending tick so a coalesced burst still wakes the consumer exactly +// once. Pass the returned id to UnsubscribeFromStateChanges to detach. +func (d *Status) SubscribeToStateChanges() (string, <-chan struct{}) { + d.stateChangeMux.Lock() + defer d.stateChangeMux.Unlock() + + id := uuid.New().String() + ch := make(chan struct{}, 1) + d.stateChangeStreams[id] = ch + return id, ch +} + +// UnsubscribeFromStateChanges releases a SubscribeToStateChanges channel +// and closes it so any consumer goroutine selecting on the channel +// unblocks cleanly. +func (d *Status) UnsubscribeFromStateChanges(id string) { + d.stateChangeMux.Lock() + defer d.stateChangeMux.Unlock() + + if ch, ok := d.stateChangeStreams[id]; ok { + close(ch) + delete(d.stateChangeStreams, id) + } +} + +// notifyStateChange wakes every SubscribeToStateChanges subscriber. Drops +// the tick if a subscriber's buffer is full — by definition the consumer +// is already going to fetch the latest snapshot, so multiple pending ticks +// would be redundant. +func (d *Status) notifyStateChange() { + d.stateChangeMux.Lock() + defer d.stateChangeMux.Unlock() + + for _, ch := range d.stateChangeStreams { + select { + case ch <- struct{}{}: + default: + } + } +} + +// NotifyStateChange is the public wake-the-subscribers entry point used by +// callers that mutate state outside the peer recorder — most importantly +// the connect-state machine, which writes StatusNeedsLogin into the +// shared contextState (client/internal/state.go) without touching any +// recorder field. Without this push the SubscribeStatus stream stays on +// the previous snapshot until an unrelated peer/management/signal +// change happens to fire notifyStateChange, leaving the UI's status +// out of sync with the daemon. +func (d *Status) NotifyStateChange() { + d.notifyStateChange() +} + +// BumpNetworksRevision increments the routed-networks revision and wakes every +// SubscribeStatus subscriber. The route manager calls it when a network map +// changes the available routes or when a selection is applied — the peer +// status itself only records actively-routed (chosen) networks, so without +// this bump a candidate route appearing/disappearing would never reach the UI. +func (d *Status) BumpNetworksRevision() { + d.networksRevision.Add(1) + d.notifyStateChange() +} + +// GetNetworksRevision returns the current routed-networks revision, surfaced in +// the status snapshot so the UI can detect route/selection changes (see +// BumpNetworksRevision). +func (d *Status) GetNetworksRevision() uint64 { + return d.networksRevision.Load() +} + func (d *Status) SetWgIface(wgInterface WGIfaceStatus) { d.mux.Lock() defer d.mux.Unlock() @@ -1136,8 +1449,8 @@ func (d *Status) SetWgIface(wgInterface WGIfaceStatus) { } func (d *Status) PeersStatus() (*configurer.Stats, error) { - d.mux.Lock() - defer d.mux.Unlock() + d.mux.RLock() + defer d.mux.RUnlock() if d.wgIface == nil { return nil, fmt.Errorf("wgInterface is nil, cannot retrieve peers status") } @@ -1239,9 +1552,11 @@ func (fs FullStatus) ToProto() *proto.FullStatus { } pbFullStatus.LocalPeerState.IP = fs.LocalPeerState.IP + pbFullStatus.LocalPeerState.Ipv6 = fs.LocalPeerState.IPv6 pbFullStatus.LocalPeerState.PubKey = fs.LocalPeerState.PubKey pbFullStatus.LocalPeerState.KernelInterface = fs.LocalPeerState.KernelInterface pbFullStatus.LocalPeerState.Fqdn = fs.LocalPeerState.FQDN + pbFullStatus.LocalPeerState.WgPort = int32(fs.LocalPeerState.WgPort) pbFullStatus.LocalPeerState.RosenpassPermissive = fs.RosenpassState.Permissive pbFullStatus.LocalPeerState.RosenpassEnabled = fs.RosenpassState.Enabled pbFullStatus.NumberOfForwardingRules = int32(fs.NumOfForwardingRules) @@ -1254,6 +1569,7 @@ func (fs FullStatus) ToProto() *proto.FullStatus { pbPeerState := &proto.PeerState{ IP: peerState.IP, + Ipv6: peerState.IPv6, PubKey: peerState.PubKey, ConnStatus: peerState.ConnStatus.String(), ConnStatusUpdate: timestamppb.New(peerState.ConnStatusUpdate), @@ -1279,6 +1595,7 @@ func (fs FullStatus) ToProto() *proto.FullStatus { pbRelayState := &proto.RelayState{ URI: relayState.URI, Available: relayState.Err == nil, + Transport: relayState.Transport, } if err := relayState.Err; err != nil { pbRelayState.Error = err.Error() diff --git a/client/internal/peer/status_test.go b/client/internal/peer/status_test.go index 272638750..82dff0d6f 100644 --- a/client/internal/peer/status_test.go +++ b/client/internal/peer/status_test.go @@ -8,19 +8,20 @@ import ( "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestAddPeer(t *testing.T) { key := "abc" ip := "100.108.254.1" status := NewRecorder("https://mgm") - err := status.AddPeer(key, "abc.netbird", ip) + err := status.AddPeer(key, "abc.netbird", ip, "") assert.NoError(t, err, "shouldn't return error") _, exists := status.peers[key] assert.True(t, exists, "value was found") - err = status.AddPeer(key, "abc.netbird", ip) + err = status.AddPeer(key, "abc.netbird", ip, "") assert.Error(t, err, "should return error on duplicate") } @@ -29,7 +30,7 @@ func TestGetPeer(t *testing.T) { key := "abc" ip := "100.108.254.1" status := NewRecorder("https://mgm") - err := status.AddPeer(key, "abc.netbird", ip) + err := status.AddPeer(key, "abc.netbird", ip, "") assert.NoError(t, err, "shouldn't return error") peerStatus, err := status.GetPeer(key) @@ -46,7 +47,7 @@ func TestUpdatePeerState(t *testing.T) { ip := "10.10.10.10" fqdn := "peer-a.netbird.local" status := NewRecorder("https://mgm") - _ = status.AddPeer(key, fqdn, ip) + require.NoError(t, status.AddPeer(key, fqdn, ip, "")) peerState := State{ PubKey: key, @@ -62,6 +63,94 @@ func TestUpdatePeerState(t *testing.T) { assert.Equal(t, ip, state.IP, "ip should be equal") } +func TestStatus_PeerStateByIP(t *testing.T) { + status := NewRecorder("https://mgm") + req := require.New(t) + + req.NoError(status.AddPeer("pk-1", "peer-1.netbird", "100.64.0.10", "")) + req.NoError(status.AddPeer("pk-2", "peer-2.netbird", "100.64.0.11", "")) + + state, ok := status.PeerStateByIP("100.64.0.10") + req.True(ok, "known tunnel IP should resolve to a peer state") + req.Equal("pk-1", state.PubKey, "matching state must carry the right pub key") + req.Equal("peer-1.netbird", state.FQDN, "matching state must carry the right FQDN") + + _, ok = status.PeerStateByIP("100.64.0.99") + req.False(ok, "unknown IP must report ok=false") +} + +func TestStatus_PeerStateByIP_MatchesIPv6(t *testing.T) { + status := NewRecorder("https://mgm") + req := require.New(t) + + req.NoError(status.AddPeer("pk-1", "peer-1.netbird", "100.64.0.10", "fd00::1")) + + state, ok := status.PeerStateByIP("fd00::1") + req.True(ok, "IPv6-only match must resolve to the peer state") + req.Equal("pk-1", state.PubKey, "matching state must carry the right pub key") +} + +// TestStatus_PeerStateByIP_IgnoresOfflinePeers documents that peers +// moved into the offline slice via ReplaceOfflinePeers are intentionally +// not resolvable by IP: only active peers can carry traffic, so callers +// (DNS filter, embed.Client.IdentityForIP) treat them as unknown. +func TestStatus_PeerStateByIP_IgnoresOfflinePeers(t *testing.T) { + status := NewRecorder("https://mgm") + req := require.New(t) + + status.ReplaceOfflinePeers([]State{ + {PubKey: "pk-offline", FQDN: "offline.netbird", IP: "100.64.0.20", IPv6: "fd00::20"}, + }) + + _, ok := status.PeerStateByIP("100.64.0.20") + req.False(ok, "offline peer must not resolve by IPv4 tunnel address") + + _, ok = status.PeerStateByIP("fd00::20") + req.False(ok, "offline peer must not resolve by IPv6 tunnel address") +} + +// TestStatus_PeerStateByIP_RemovedPeer verifies RemovePeer drops the +// IP index entries for both address families. +func TestStatus_PeerStateByIP_RemovedPeer(t *testing.T) { + status := NewRecorder("https://mgm") + req := require.New(t) + + req.NoError(status.AddPeer("pk-1", "peer-1.netbird", "100.64.0.10", "fd00::1")) + + _, ok := status.PeerStateByIP("100.64.0.10") + req.True(ok, "active peer must resolve before removal") + + req.NoError(status.RemovePeer("pk-1")) + + _, ok = status.PeerStateByIP("100.64.0.10") + req.False(ok, "removed peer must not resolve by IPv4 tunnel address") + + _, ok = status.PeerStateByIP("fd00::1") + req.False(ok, "removed peer must not resolve by IPv6 tunnel address") +} + +// TestStatus_GetPeerStates_IncludesOfflinePeers keeps the snapshot in line with +// GetFullStatus: offline peers are known peers, so a consumer counting peers +// must see the same total the status command reports. +func TestStatus_GetPeerStates_IncludesOfflinePeers(t *testing.T) { + status := NewRecorder("https://mgm") + req := require.New(t) + + req.NoError(status.AddPeer("pk-online", "online.netbird", "100.64.0.10", "fd00::1")) + status.ReplaceOfflinePeers([]State{ + {PubKey: "pk-offline", FQDN: "offline.netbird", IP: "100.64.0.20", ConnStatus: StatusIdle}, + }) + + states := status.GetPeerStates() + req.Len(states, 2, "snapshot must carry both the online and the offline peer") + + keys := make([]string, 0, len(states)) + for _, s := range states { + keys = append(keys, s.PubKey) + } + req.ElementsMatch([]string{"pk-online", "pk-offline"}, keys, "snapshot must carry both peers") +} + func TestStatus_UpdatePeerFQDN(t *testing.T) { key := "abc" fqdn := "peer-a.netbird.local" @@ -85,7 +174,7 @@ func TestGetPeerStateChangeNotifierLogic(t *testing.T) { key := "abc" ip := "10.10.10.10" status := NewRecorder("https://mgm") - _ = status.AddPeer(key, "abc.netbird", ip) + _ = status.AddPeer(key, "abc.netbird", ip, "") sub := status.SubscribeToPeerStateChanges(context.Background(), key) assert.NotNil(t, sub, "channel shouldn't be nil") @@ -247,3 +336,39 @@ func TestGetFullStatus(t *testing.T) { assert.Equal(t, signalState, fullStatus.SignalState, "signal status should be equal") assert.ElementsMatch(t, []State{peerState1, peerState2}, fullStatus.Peers, "peers states should match") } + +// notified reports whether a state-change tick is pending on ch, draining it. +func notified(ch <-chan struct{}) bool { + select { + case <-ch: + return true + default: + return false + } +} + +func TestMarkServerStateDoesNotNotifyWhenUnchanged(t *testing.T) { + status := NewRecorder("https://mgm") + _, ch := status.SubscribeToStateChanges() + + // First transition is a real change and must notify. + status.MarkManagementConnected() + require.True(t, notified(ch), "first connect should notify") + + // Re-marking the same state must not notify again. + status.MarkManagementConnected() + assert.False(t, notified(ch), "redundant connect should not notify") + + // Same for signal. + status.MarkSignalConnected() + require.True(t, notified(ch), "first signal connect should notify") + status.MarkSignalConnected() + assert.False(t, notified(ch), "redundant signal connect should not notify") + + // A genuine change (disconnect with an error) notifies again. + err := errors.New("boom") + status.MarkManagementDisconnected(err) + require.True(t, notified(ch), "disconnect should notify") + status.MarkManagementDisconnected(err) + assert.False(t, notified(ch), "redundant disconnect should not notify") +} diff --git a/client/internal/peer/wg_watcher.go b/client/internal/peer/wg_watcher.go index 805a6f24a..39e3d3264 100644 --- a/client/internal/peer/wg_watcher.go +++ b/client/internal/peer/wg_watcher.go @@ -3,7 +3,6 @@ package peer import ( "context" "fmt" - "sync" "time" log "github.com/sirupsen/logrus" @@ -24,14 +23,16 @@ type WGInterfaceStater interface { GetStats() (map[string]configurer.WGStats, error) } +// WGWatcher is single-shot: one instance per connection attempt, run once, then discarded. +// Lifecycle is owned by Conn under conn.mu, so it keeps no "enabled" state to go stale. type WGWatcher struct { log *log.Entry wgIfaceStater WGInterfaceStater peerKey string stateDump *stateDump - enabled bool - muEnabled sync.RWMutex + // initialHandshake is not thread-safe; never call PrepareInitialHandshake and EnableWgWatcher concurrently. + initialHandshake time.Time resetCh chan struct{} } @@ -46,36 +47,23 @@ func NewWGWatcher(log *log.Entry, wgIfaceStater WGInterfaceStater, peerKey strin } } -// EnableWgWatcher starts the WireGuard watcher. If it is already enabled, it will return immediately and do nothing. -// The watcher runs until ctx is cancelled. Caller is responsible for context lifecycle management. -func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time)) { - w.muEnabled.Lock() - if w.enabled { - w.muEnabled.Unlock() - return - } - +// PrepareInitialHandshake reads the peer's current WireGuard handshake time. It must be +// called before the peer is (re)configured on the WireGuard interface, so the captured +// baseline reflects the state prior to this connection attempt instead of racing with +// that configuration. +func (w *WGWatcher) PrepareInitialHandshake() { w.log.Debugf("enable WireGuard watcher") - w.enabled = true - w.muEnabled.Unlock() - - initialHandshake, err := w.wgState() - if err != nil { - w.log.Warnf("failed to read initial wg stats: %v", err) - } - - w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, enabledTime, initialHandshake) - - w.muEnabled.Lock() - w.enabled = false - w.muEnabled.Unlock() + handshake, _ := w.wgState() + w.initialHandshake = handshake } -// IsEnabled returns true if the WireGuard watcher is currently enabled -func (w *WGWatcher) IsEnabled() bool { - w.muEnabled.RLock() - defer w.muEnabled.RUnlock() - return w.enabled +// EnableWgWatcher runs the WireGuard watcher loop using the handshake baseline captured by +// PrepareInitialHandshake. The watcher runs until ctx is cancelled. Caller is responsible +// for context lifecycle management. onHandshakeSuccessFn is called only for the first +// handshake observed by this run, onCheckSuccessFn for every check that observed a fresh +// handshake, including the first. +func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), onCheckSuccessFn func()) { + w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, onCheckSuccessFn, enabledTime, w.initialHandshake) } // Reset signals the watcher that the WireGuard peer has been reset and a new @@ -88,7 +76,7 @@ func (w *WGWatcher) Reset() { } // wgStateCheck help to check the state of the WireGuard handshake and relay connection -func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), enabledTime time.Time, initialHandshake time.Time) { +func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), onCheckSuccessFn func(), enabledTime time.Time, initialHandshake time.Time) { w.log.Infof("WireGuard watcher started") timer := time.NewTimer(wgHandshakeOvertime) @@ -101,17 +89,25 @@ func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn case <-timer.C: handshake, ok := w.handshakeCheck(lastHandshake) if !ok { + // early ctx cancel check return + if ctx.Err() != nil { + return + } onDisconnectedFn() return } if lastHandshake.IsZero() { elapsed := calcElapsed(enabledTime, *handshake) w.log.Infof("first wg handshake detected within: %.2fsec, (%s)", elapsed, handshake) - if onHandshakeSuccessFn != nil { + if onHandshakeSuccessFn != nil && ctx.Err() == nil { onHandshakeSuccessFn(*handshake) } } + if onCheckSuccessFn != nil && ctx.Err() == nil { + onCheckSuccessFn() + } + lastHandshake = *handshake resetTime := time.Until(handshake.Add(checkPeriod)) @@ -142,9 +138,9 @@ func (w *WGWatcher) handshakeCheck(lastHandshake time.Time) (*time.Time, bool) { w.log.Tracef("previous handshake, handshake: %v, %v", lastHandshake, handshake) - // the current know handshake did not change + // the current known handshake did not change if handshake.Equal(lastHandshake) { - w.log.Warnf("WireGuard handshake timed out: %v", handshake) + w.log.Warnf("WireGuard handshake not updated: %v", handshake) return nil, false } diff --git a/client/internal/peer/wg_watcher_test.go b/client/internal/peer/wg_watcher_test.go index 3ce91cd46..6a5a9acfe 100644 --- a/client/internal/peer/wg_watcher_test.go +++ b/client/internal/peer/wg_watcher_test.go @@ -23,6 +23,72 @@ func (m *MocWgIface) disconnect() { m.stop = true } +type mockHandshakeStats struct { + mu sync.Mutex + handshake time.Time +} + +func (m *mockHandshakeStats) GetStats() (map[string]configurer.WGStats, error) { + m.mu.Lock() + defer m.mu.Unlock() + return map[string]configurer.WGStats{"": {LastHandshake: m.handshake}}, nil +} + +func (m *mockHandshakeStats) advance() { + m.mu.Lock() + defer m.mu.Unlock() + m.handshake = time.Now() +} + +// TestWGWatcher_CheckSuccessCallback: onCheckSuccessFn must fire for a fresh +// handshake even when the watcher started with an existing handshake baseline, +// the case where onHandshakeSuccessFn stays silent. +func TestWGWatcher_CheckSuccessCallback(t *testing.T) { + // checkPeriod bounds how stale a handshake may be before the watcher treats it + // as a suspended-machine timeout. The first check fires after wgHandshakeOvertime, + // so keep checkPeriod well above any scheduling jitter to avoid a false timeout + // converting the expected success into a disconnect on a loaded runner. + checkPeriod = 1 * time.Minute + wgHandshakeOvertime = 1 * time.Second + + mlog := log.WithField("peer", "tet") + // Use an old baseline so advance() yields a strictly newer handshake even on + // platforms with coarse clock resolution (Windows), where two time.Now() calls + // microseconds apart can return the same instant and read as a timed-out handshake. + stats := &mockHandshakeStats{handshake: time.Now().Add(-time.Hour)} + watcher := NewWGWatcher(mlog, stats, "", newStateDump("peer", mlog, &Status{})) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + watcher.PrepareInitialHandshake() + + firstHandshake := make(chan struct{}, 1) + checkSuccess := make(chan struct{}, 1) + go watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) { + firstHandshake <- struct{}{} + }, func() { + select { + case checkSuccess <- struct{}{}: + default: + } + }) + + stats.advance() + + select { + case <-checkSuccess: + case <-time.After(10 * time.Second): + t.Errorf("timeout waiting for check success callback") + } + + select { + case <-firstHandshake: + t.Errorf("first-handshake callback must not fire for a non-zero baseline") + default: + } +} + func TestWGWatcher_EnableWgWatcher(t *testing.T) { checkPeriod = 5 * time.Second wgHandshakeOvertime = 1 * time.Second @@ -34,13 +100,15 @@ func TestWGWatcher_EnableWgWatcher(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + watcher.PrepareInitialHandshake() + onDisconnected := make(chan struct{}, 1) go watcher.EnableWgWatcher(ctx, time.Now(), func() { mlog.Infof("onDisconnectedFn") onDisconnected <- struct{}{} }, func(when time.Time) { mlog.Infof("onHandshakeSuccess: %v", when) - }) + }, nil) // wait for initial reading time.Sleep(2 * time.Second) @@ -62,11 +130,13 @@ func TestWGWatcher_ReEnable(t *testing.T) { watcher := NewWGWatcher(mlog, mocWgIface, "", newStateDump("peer", mlog, &Status{})) ctx, cancel := context.WithCancel(context.Background()) + watcher.PrepareInitialHandshake() + wg := &sync.WaitGroup{} wg.Add(1) go func() { defer wg.Done() - watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) {}) + watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) {}, nil) }() cancel() @@ -76,10 +146,12 @@ func TestWGWatcher_ReEnable(t *testing.T) { ctx, cancel = context.WithCancel(context.Background()) defer cancel() + watcher.PrepareInitialHandshake() + onDisconnected := make(chan struct{}, 1) go watcher.EnableWgWatcher(ctx, time.Now(), func() { onDisconnected <- struct{}{} - }, func(when time.Time) {}) + }, func(when time.Time) {}, nil) time.Sleep(2 * time.Second) mocWgIface.disconnect() diff --git a/client/internal/peer/worker_ice.go b/client/internal/peer/worker_ice.go index 29bf5aaaa..d17f6e693 100644 --- a/client/internal/peer/worker_ice.go +++ b/client/internal/peer/worker_ice.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "net" - "net/netip" "strconv" "sync" "time" @@ -65,6 +64,9 @@ type WorkerICE struct { // portForwardAttempted tracks if we've already tried port forwarding this session portForwardAttempted bool + + // dialFunc, when non-nil, replaces agentDial in connect(). Only for tests. + dialFunc func(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (net.Conn, error) } func NewWorkerICE(ctx context.Context, log *log.Entry, config ConnConfig, conn *Conn, signaler *Signaler, ifaceDiscover stdnet.ExternalIFaceDiscover, statusRecorder *Status, hasRelayOnLocally bool) (*WorkerICE, error) { @@ -124,7 +126,7 @@ func (w *WorkerICE) OnNewOffer(remoteOfferAnswer *OfferAnswer) { w.log.Errorf("failed to create new session ID: %s", err) } w.sessionID = sessionID - w.agent = nil + w.abandonNegotiation() } var preferredCandidateTypes []ice.CandidateType @@ -152,7 +154,9 @@ func (w *WorkerICE) OnNewOffer(remoteOfferAnswer *OfferAnswer) { w.remoteSessionID = "" } - go w.connect(dialerCtx, agent, remoteOfferAnswer) + // Capture the cancel func at spawn time: connect reads it from the argument + // instead of the field, which a newer OnNewOffer may already have replaced. + go w.connect(dialerCtx, dialerCancel, agent, remoteOfferAnswer) } // OnRemoteCandidate Handles ICE connection Candidate provided by the remote peer. @@ -165,10 +169,6 @@ func (w *WorkerICE) OnRemoteCandidate(candidate ice.Candidate, haRoutes route.HA return } - if candidateViaRoutes(candidate, haRoutes) { - return - } - if err := w.agent.AddRemoteCandidate(candidate); err != nil { w.log.Errorf("error while handling remote candidate") return @@ -205,16 +205,16 @@ func (w *WorkerICE) Close() { w.muxAgent.Lock() defer w.muxAgent.Unlock() - if w.agent == nil { - return + if w.agent != nil { + w.agentDialerCancel() + if err := w.agent.Close(); err != nil { + w.log.Warnf("failed to close ICE agent: %s", err) + } } - - w.agentDialerCancel() - if err := w.agent.Close(); err != nil { - w.log.Warnf("failed to close ICE agent: %s", err) - } - - w.agent = nil + // Unconditional: a dial goroutine racing this Close skips its own cleanup + // (closeAgent finds a nil agent), so the flags must be dropped here too or + // the reconnection guard reads the stale state as Connected forever. + w.abandonNegotiation() } func (w *WorkerICE) reCreateAgent(dialerCancel context.CancelFunc, candidates []ice.CandidateType) (*icemaker.ThreadSafeAgent, error) { @@ -252,31 +252,52 @@ func (w *WorkerICE) SessionID() ICESessionID { // will block until connection succeeded // but it won't release if ICE Agent went into Disconnected or Failed state, // so we have to cancel it with the provided context once agent detected a broken connection -func (w *WorkerICE) connect(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) { +func (w *WorkerICE) connect(ctx context.Context, dialerCancel context.CancelFunc, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) { w.log.Debugf("gather candidates") if err := agent.GatherCandidates(); err != nil { w.log.Warnf("failed to gather candidates: %s", err) - w.closeAgent(agent, w.agentDialerCancel) + w.closeAgent(agent, dialerCancel) return } - w.log.Debugf("turn agent dial") - remoteConn, err := w.turnAgentDial(ctx, agent, remoteOfferAnswer) + w.log.Debugf("agent dial") + dial := func(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (net.Conn, error) { + return w.agentDial(ctx, agent, remoteOfferAnswer) + } + if w.dialFunc != nil { + dial = w.dialFunc + } + remoteConn, err := dial(ctx, agent, remoteOfferAnswer) if err != nil { w.log.Debugf("failed to dial the remote peer: %s", err) - w.closeAgent(agent, w.agentDialerCancel) + w.closeAgent(agent, dialerCancel) return } w.log.Debugf("agent dial succeeded") + // A newer negotiation may have replaced our agent while agentDial was + // blocked. Drop the dead connection before running pair retrieval, port + // punching or candidate work against a closed agent. The commit-point + // check below still guards a replacement arriving after this point. + w.muxAgent.Lock() + stale := w.agent != agent + w.muxAgent.Unlock() + if stale { + if err := remoteConn.Close(); err != nil { + w.log.Warnf("failed to close stale ICE connection: %s", err) + } + w.log.Warnf("discarding connection from a stale ICE negotiation") + return + } + pair, err := agent.GetSelectedCandidatePair() if err != nil { - w.closeAgent(agent, w.agentDialerCancel) + w.closeAgent(agent, dialerCancel) return } if pair == nil { w.log.Warnf("selected candidate pair is nil, cannot proceed") - w.closeAgent(agent, w.agentDialerCancel) + w.closeAgent(agent, dialerCancel) return } @@ -306,11 +327,27 @@ func (w *WorkerICE) connect(ctx context.Context, agent *icemaker.ThreadSafeAgent w.log.Infof("connection succeeded with offer session: %s", remoteOfferAnswer.SessionIDString()) w.muxAgent.Lock() + // Authoritative ownership guard: a negotiation that lost w.agent to a newer + // one between the post-dial check and the commit must not clear agentConnecting, + // record lastSuccess or report the connection, so the state commit has to be + // atomic with the check. + if w.agent != agent { + w.muxAgent.Unlock() + if err := remoteConn.Close(); err != nil { + w.log.Warnf("failed to close stale ICE connection: %s", err) + } + w.log.Warnf("discarding connection from a stale ICE negotiation") + return + } w.agentConnecting = false w.lastSuccess = time.Now() w.muxAgent.Unlock() // todo: the potential problem is a race between the onConnectionStateChange + // and the delivery below: after this unlock, a newer offer can replace + // w.agent before onICEConnectionIsReady runs, delivering this (now stale) + // connection. The newer negotiation overwrites it with its own delivery, + // so the window only ever downgrades an endpoint transiently. w.conn.onICEConnectionIsReady(selectedPriority(pair), ci) } @@ -326,20 +363,32 @@ func (w *WorkerICE) closeAgent(agent *icemaker.ThreadSafeAgent, cancel context.C sessionChanged := w.remoteSessionChanged w.remoteSessionChanged = false + // Only the owner of the current session may reset its state: a stale dial + // goroutine waking after a newer attempt must not clobber it. if w.agent == agent { - // consider to remove from here and move to the OnNewOffer sessionID, err := NewICESessionID() if err != nil { w.log.Errorf("failed to create new session ID: %s", err) } w.sessionID = sessionID - w.agent = nil - w.agentConnecting = false - w.remoteSessionID = "" + w.abandonNegotiation() } return sessionChanged } +// abandonNegotiation drops all recorded ICE session state so the worker treats the +// next offer as a fresh start instead of a duplicate of a dead negotiation. The +// agent and agentConnecting flags must change together: leaving one stale wedges +// the reconnection guard into reporting Connected forever. It neither cancels an +// in-flight dial nor closes an agent — callers dispose of those themselves first, +// so a stale goroutine can never cancel another session's dial through this path. +// Caller must hold muxAgent. +func (w *WorkerICE) abandonNegotiation() { + w.agent = nil + w.agentConnecting = false + w.remoteSessionID = "" +} + func (w *WorkerICE) punchRemoteWGPort(pair *ice.CandidatePair, remoteWgPort int) { // wait local endpoint configuration time.Sleep(time.Second) @@ -394,6 +443,17 @@ func (w *WorkerICE) injectPortForwardedCandidate(srflxCandidate ice.Candidate) { return } + // A forwarded candidate only makes sense for an IPv4 mapping, which + // translates a port on the gateway's address. An IPv6 pinhole translates + // nothing: it unblocks the address ICE already gathers as a host candidate, + // so there is no second address to advertise. Injecting one here would also + // paste an IPv6 address onto whichever server-reflexive candidate arrived + // first, which is usually IPv4. + if mapping.ExternalIP != nil && mapping.ExternalIP.To4() == nil { + w.log.Debugf("skipping port-forwarded candidate: %s mapping is IPv6-only", mapping.NATType) + return + } + w.muxAgent.Lock() if w.portForwardAttempted { w.muxAgent.Unlock() @@ -522,8 +582,8 @@ func (w *WorkerICE) onConnectionStateChange(agent *icemaker.ThreadSafeAgent, dia w.logSuccessfulPaths(agent) return case ice.ConnectionStateFailed, ice.ConnectionStateDisconnected, ice.ConnectionStateClosed: - // ice.ConnectionStateClosed happens when we recreate the agent. For the P2P to TURN switch important to - // notify the conn.onICEStateDisconnected changes to update the current used priority + // ice.ConnectionStateClosed happens when we recreate the agent. The P2P to relay switch requires + // notifying conn.onICEStateDisconnected so it can update the currently used priority. sessionChanged := w.closeAgent(agent, dialerCancel) @@ -537,7 +597,7 @@ func (w *WorkerICE) onConnectionStateChange(agent *icemaker.ThreadSafeAgent, dia } } -func (w *WorkerICE) turnAgentDial(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (*ice.Conn, error) { +func (w *WorkerICE) agentDial(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (*ice.Conn, error) { if isController(w.config) { return agent.Dial(ctx, remoteOfferAnswer.IceCredentials.UFrag, remoteOfferAnswer.IceCredentials.Pwd) } else { @@ -589,34 +649,6 @@ func extraSrflxCandidate(candidate ice.Candidate) (*ice.CandidateServerReflexive return ec, nil } -func candidateViaRoutes(candidate ice.Candidate, clientRoutes route.HAMap) bool { - addr, err := netip.ParseAddr(candidate.Address()) - if err != nil { - log.Errorf("Failed to parse IP address %s: %v", candidate.Address(), err) - return false - } - - var routePrefixes []netip.Prefix - for _, routes := range clientRoutes { - if len(routes) > 0 && routes[0] != nil { - routePrefixes = append(routePrefixes, routes[0].Network) - } - } - - for _, prefix := range routePrefixes { - // default route is handled by route exclusion / ip rules - if prefix.Bits() == 0 { - continue - } - - if prefix.Contains(addr) { - log.Debugf("Ignoring candidate [%s], its address is part of routed network %s", candidate.String(), prefix) - return true - } - } - return false -} - func isRelayCandidate(candidate ice.Candidate) bool { return candidate.Type() == ice.CandidateTypeRelay } diff --git a/client/internal/peer/worker_ice_close_test.go b/client/internal/peer/worker_ice_close_test.go new file mode 100644 index 000000000..834a4dd6d --- /dev/null +++ b/client/internal/peer/worker_ice_close_test.go @@ -0,0 +1,257 @@ +package peer + +import ( + "context" + "net" + "sync/atomic" + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + icemaker "github.com/netbirdio/netbird/client/internal/peer/ice" + signal "github.com/netbirdio/netbird/shared/signal/client" + sProto "github.com/netbirdio/netbird/shared/signal/proto" +) + +// stubSignalClient satisfies signal.Client as a no-op so the candidate +// goroutine spawned by a real GatherCandidates never dereferences a nil +// signaler in tests. +type stubSignalClient struct{} + +func (stubSignalClient) Close() error { return nil } +func (stubSignalClient) StreamConnected() bool { return false } +func (stubSignalClient) GetStatus() signal.Status { return signal.StreamDisconnected } +func (stubSignalClient) Receive(context.Context, func(*sProto.Message) error) error { return nil } +func (stubSignalClient) Ready() bool { return false } +func (stubSignalClient) IsHealthy() bool { return false } +func (stubSignalClient) WaitStreamConnected(context.Context) {} +func (stubSignalClient) SendToStream(*sProto.EncryptedMessage) error { return nil } +func (stubSignalClient) Send(*sProto.Message) error { return nil } +func (stubSignalClient) SetOnReconnectedListener(func()) {} + +// newTestWorkerICE builds a worker with real pion plumbing and no-op signaling. +func newTestWorkerICE(t *testing.T) *WorkerICE { + t.Helper() + + config := connConf + stunTurn := &icemaker.StunTurn{} + stunTurn.Store(nil) + config.ICEConfig.StunTurn = stunTurn + + w, err := NewWorkerICE(context.Background(), log.WithField("test", t.Name()), config, nil, + NewSignaler(stubSignalClient{}, wgtypes.Key{}), nil, nil, false) + require.NoError(t, err, "worker setup must succeed") + return w +} + +// TestWorkerICE_CloseDuringDial_ClearsConnectingFlag drives the teardown race +// through the real dial goroutine instead of simulating its cleanup. +// +// The real-world sequence this models: +// 1. OnNewOffer starts a negotiation: agent set, agentConnecting = true, +// go connect() +// 2. The network dies and connect() stays blocked inside GatherCandidates/Dial +// 3. A WG handshake timeout calls Close(): the agent is released and the dial +// context cancelled, but agentConnecting is not reset +// 4. The real goroutine wakes with an error and runs its own cleanup +// (closeAgent), where `w.agent == agent` is now false, so the flag reset +// is skipped +// +// There is no remote responder, so Dial can never succeed: whatever point the +// goroutine is at, closing first forces it down the error path. Before the fix +// the flag stays true forever and the deadline below expires. +func TestWorkerICE_CloseDuringDial_ClearsConnectingFlag(t *testing.T) { + w := newTestWorkerICE(t) + + sid := ICESessionID("test-session-id") + w.OnNewOffer(&OfferAnswer{ + IceCredentials: IceCredentials{ + UFrag: "testufrag", + Pwd: "testpwdtestpwdtestpwd12", + }, + SessionID: &sid, + }) + require.True(t, w.InProgress(), "OnNewOffer must mark the negotiation as in progress") + + // Teardown wins the race while connect() is still running. + w.Close() + + // Close drops the flags synchronously, so the assertion below does not + // converge on the goroutine: the deadline only absorbs the dial goroutine + // waking up in the background, proving nothing re-wedges it afterwards. + require.Eventually(t, func() bool { + return !w.InProgress() + }, 10*time.Second, 50*time.Millisecond, + "Close must leave the negotiation idle even while the dial goroutine is still winding down") + + // abandonNegotiation owns these three fields together; the worker is idle + // only when all of them are dropped. + w.muxAgent.Lock() + defer w.muxAgent.Unlock() + assert.Nil(t, w.agent, "no agent may survive the teardown") + assert.False(t, w.agentConnecting, "the connecting flag must match the nil agent") + assert.Empty(t, w.remoteSessionID, "a dead session's remote ID must not linger") +} + +// TestWorkerICE_CloseClearsResidualConnectingState covers Close on a worker whose +// agent is already gone but whose flag is stuck on true, e.g. after an aborted +// recreate in OnNewOffer or after a first Close raced a dial goroutine. +func TestWorkerICE_CloseClearsResidualConnectingState(t *testing.T) { + w := newTestWorkerICE(t) + + w.muxAgent.Lock() + w.agentConnecting = true + w.muxAgent.Unlock() + + w.Close() + + assert.False(t, w.InProgress(), "Close must drop residual connecting state even without a live agent") + + w.muxAgent.Lock() + defer w.muxAgent.Unlock() + assert.Nil(t, w.agent) + assert.False(t, w.agentConnecting) + assert.Empty(t, w.remoteSessionID) +} + +// TestWorkerICE_StaleCloseAgentKeepsCurrentSession pins the ownership guard in +// closeAgent: a late-waking dial goroutine from an older session must not reset +// the state of a newer negotiation that reused the worker. The newer session +// must survive wholesale - agent, flag and remote session identity alike. +func TestWorkerICE_StaleCloseAgentKeepsCurrentSession(t *testing.T) { + w := newTestWorkerICE(t) + t.Cleanup(w.Close) + + sidA := ICESessionID("session-a") + w.OnNewOffer(&OfferAnswer{ + IceCredentials: IceCredentials{UFrag: "ufragaaaa", Pwd: "pwdpwdpwdpwdpwdpwdpwdp1"}, + SessionID: &sidA, + }) + w.muxAgent.Lock() + oldAgent := w.agent + oldCancel := w.agentDialerCancel + w.muxAgent.Unlock() + require.NotNil(t, oldAgent, "OnNewOffer must have created an ICE agent") + + w.Close() + + sidB := ICESessionID("session-b") + w.OnNewOffer(&OfferAnswer{ + IceCredentials: IceCredentials{UFrag: "ufragbbbb", Pwd: "pwdpwdpwdpwdpwdpwdpwdp2"}, + SessionID: &sidB, + }) + require.True(t, w.InProgress(), "the second negotiation must be in flight") + + w.muxAgent.Lock() + newAgent := w.agent + w.muxAgent.Unlock() + + // The old dial goroutine finally wakes and cleans up its captured agent. + w.closeAgent(oldAgent, oldCancel) + + w.muxAgent.Lock() + defer w.muxAgent.Unlock() + assert.Same(t, newAgent, w.agent, "the current agent must be untouched by the stale cleanup") + assert.True(t, w.agentConnecting, "the current negotiation must stay in flight") + // Read live under the lock: a snapshot captured before the stale cleanup + // would pass even if the cleanup wiped current state. + assert.Equal(t, sidB, w.remoteSessionID, "the remote session identity must be preserved") +} + +// closeTrackConn records Close calls so a test can assert that a discarded +// connection was actually released. +type closeTrackConn struct { + net.Conn + closed atomic.Bool +} + +func (c *closeTrackConn) Close() error { + c.closed.Store(true) + return c.Conn.Close() +} + +// TestWorkerICE_StaleDialSuccessKeepsNewerNegotiation pins the ownership guard +// in connect()'s success path: a dial that came back after a newer negotiation +// replaced the agent must discard its connection and leave the newer session's +// state - agent, agentConnecting, remoteSessionID, lastSuccess - intact. +// +// The dial hook holds session A's goroutine open until session B is installed, +// then returns a live connection, mimicking the vendored pion dial which hands +// out a live *ice.Conn when a pair is selected without checking afterwards +// whether the agent was replaced meanwhile. Releasing A's dial therefore +// exercises the stale-success commit path deterministically instead of racing +// real ICE. +func TestWorkerICE_StaleDialSuccessKeepsNewerNegotiation(t *testing.T) { + w := newTestWorkerICE(t) + t.Cleanup(w.Close) + + dialStarted := make(chan struct{}) + releaseDial := make(chan struct{}) + staleConn := &closeTrackConn{} + + var calls atomic.Int32 + w.dialFunc = func(ctx context.Context, _ *icemaker.ThreadSafeAgent, _ *OfferAnswer) (net.Conn, error) { + if calls.Add(1) == 1 { + // Session A: hold the goroutine open until session B is installed, + // then return a live connection, mimicking the vendored pion dial + // which hands out a live *ice.Conn once a pair is selected without + // re-checking whether the agent was replaced meanwhile. Releasing + // the dial therefore exercises the stale-success commit path + // deterministically instead of racing real ICE. + close(dialStarted) + <-releaseDial + client, _ := net.Pipe() + staleConn.Conn = client + return staleConn, nil + } + // A newer negotiation parks on its dialer context, cancelled by the + // t.Cleanup Close at test end. + <-ctx.Done() + return nil, ctx.Err() + } + + sidA := ICESessionID("session-a") + w.OnNewOffer(&OfferAnswer{ + IceCredentials: IceCredentials{UFrag: "ufragaaaa", Pwd: "pwdpwdpwdpwdpwdpwdpwdp1"}, + SessionID: &sidA, + }) + require.True(t, w.InProgress(), "session A must be in flight") + + // Session A's goroutine is now parked in the dial hook. + <-dialStarted + + sidB := ICESessionID("session-b") + w.OnNewOffer(&OfferAnswer{ + IceCredentials: IceCredentials{UFrag: "ufragbbbb", Pwd: "pwdpwdpwdpwdpwdpwdpwdp2"}, + SessionID: &sidB, + }) + + w.muxAgent.Lock() + agentB := w.agent + w.lastSuccess = time.Time{} + w.muxAgent.Unlock() + require.NotNil(t, agentB, "session B must have created an ICE agent") + require.True(t, w.InProgress(), "session B must be in flight") + + // Release session A's dial: it must be recognized as stale and discarded. + close(releaseDial) + require.Eventually(t, func() bool { + return staleConn.closed.Load() + }, 10*time.Second, 10*time.Millisecond, + "the stale connection must be closed by the ownership guard") + + w.muxAgent.Lock() + defer w.muxAgent.Unlock() + assert.Same(t, agentB, w.agent, "session A must not uninstall session B's agent") + assert.True(t, w.agentConnecting, "session A must not clear session B's connecting flag") + assert.Equal(t, sidB, w.remoteSessionID, "session A must not clear session B's remote session identity") + assert.True(t, w.lastSuccess.IsZero(), "session A must not record a success for session B") + // The commit block guards agentConnecting, lastSuccess and + // onICEConnectionIsReady together, so the state assertions above imply the + // callback never ran for session A; the nil conn would have panicked the + // stale goroutine on any invocation. +} diff --git a/client/internal/peer/worker_relay.go b/client/internal/peer/worker_relay.go index 06309fbaf..0402992c9 100644 --- a/client/internal/peer/worker_relay.go +++ b/client/internal/peer/worker_relay.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net" + "net/netip" "sync" "sync/atomic" @@ -53,15 +54,19 @@ func (w *WorkerRelay) OnNewOffer(remoteOfferAnswer *OfferAnswer) { w.relaySupportedOnRemotePeer.Store(true) // the relayManager will return with error in case if the connection has lost with relay server - currentRelayAddress, err := w.relayManager.RelayInstanceAddress() + currentRelayAddress, _, err := w.relayManager.RelayInstanceAddress() if err != nil { w.log.Errorf("failed to handle new offer: %s", err) return } srv := w.preferredRelayServer(currentRelayAddress, remoteOfferAnswer.RelaySrvAddress) + var serverIP netip.Addr + if srv == remoteOfferAnswer.RelaySrvAddress { + serverIP = remoteOfferAnswer.RelaySrvIP + } - relayedConn, err := w.relayManager.OpenConn(w.peerCtx, srv, w.config.Key) + relayedConn, err := w.relayManager.OpenConn(w.peerCtx, srv, w.config.Key, serverIP) if err != nil { if errors.Is(err, relayClient.ErrConnAlreadyExists) { w.log.Debugf("handled offer by reusing existing relay connection") @@ -90,7 +95,7 @@ func (w *WorkerRelay) OnNewOffer(remoteOfferAnswer *OfferAnswer) { }) } -func (w *WorkerRelay) RelayInstanceAddress() (string, error) { +func (w *WorkerRelay) RelayInstanceAddress() (string, netip.Addr, error) { return w.relayManager.RelayInstanceAddress() } diff --git a/client/internal/peerstore/store.go b/client/internal/peerstore/store.go index 099fe4528..112caa101 100644 --- a/client/internal/peerstore/store.go +++ b/client/internal/peerstore/store.go @@ -88,11 +88,24 @@ func (s *Store) PeerConnOpen(ctx context.Context, pubKey string) { if !ok { return } - // this can be blocked because of the connect open limiter semaphore if err := p.Open(ctx); err != nil { p.Log.Errorf("failed to open peer connection: %v", err) } +} +// PeerConnOpenWithFirstPacket opens the peer connection and stashes a first packet to be +// reinjected once the real transport is established. +func (s *Store) PeerConnOpenWithFirstPacket(ctx context.Context, pubKey string, firstPacket []byte) { + s.peerConnsMu.RLock() + defer s.peerConnsMu.RUnlock() + + p, ok := s.peerConns[pubKey] + if !ok { + return + } + if err := p.OpenWithFirstPacket(ctx, firstPacket); err != nil { + p.Log.Errorf("failed to open peer connection: %v", err) + } } func (s *Store) PeerConnIdle(pubKey string) { diff --git a/client/internal/portforward/manager.go b/client/internal/portforward/manager.go index b0680160c..7d5a4cb9e 100644 --- a/client/internal/portforward/manager.go +++ b/client/internal/portforward/manager.go @@ -10,10 +10,8 @@ import ( "sync" "time" - "github.com/libp2p/go-nat" + "github.com/netbirdio/go-nat" log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/internal/portforward/pcp" ) const ( @@ -168,6 +166,11 @@ func (m *Manager) setup(ctx context.Context) (nat.NAT, *Mapping, error) { if err != nil { return nil, nil, fmt.Errorf("create port mapping: %w", err) } + + // Only meaningful once a mapping has been attempted: that is what opens the + // pinhole and records its outcome. + logIPv6Pinhole(gateway) + return gateway, mapping, nil } @@ -265,7 +268,9 @@ func (m *Manager) checkHealthAndRecreate(ctx context.Context, gateway nat.NAT) b return false } - pcpNAT, ok := gateway.(*pcp.NAT) + // Assert on the interface, not on a concrete type: a dual-stack gateway is + // a wrapper around the IPv4 NAT, so a type assertion misses it. + checker, ok := gateway.(nat.HealthChecker) if !ok { return false } @@ -273,7 +278,7 @@ func (m *Manager) checkHealthAndRecreate(ctx context.Context, gateway nat.NAT) b ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - epoch, serverRestarted, err := pcpNAT.CheckServerHealth(ctx) + epoch, serverRestarted, err := checker.CheckServerHealth(ctx) if err != nil { log.Debugf("PCP health check failed: %v", err) return false @@ -340,3 +345,18 @@ func (m *Manager) startTearDown(ctx context.Context) { func isPermanentLeaseRequired(err error) bool { return err != nil && upnpErrPermanentLeaseOnly.MatchString(err.Error()) } + +// logIPv6Pinhole reports the outcome of the IPv6 pinhole. Pinholes are best +// effort and never fail a mapping on their own, so this is the only way to see +// whether one was actually opened. +func logIPv6Pinhole(gateway nat.NAT) { + reporter, ok := gateway.(nat.IPv6PinholeReporter) + if !ok { + return + } + if err := reporter.IPv6PinholeError(); err != nil { + log.Warnf("IPv6 pinhole: %v", err) + return + } + log.Infof("IPv6 pinhole open") +} diff --git a/client/internal/portforward/pcp/client.go b/client/internal/portforward/pcp/client.go deleted file mode 100644 index f6d243ef9..000000000 --- a/client/internal/portforward/pcp/client.go +++ /dev/null @@ -1,408 +0,0 @@ -package pcp - -import ( - "context" - "crypto/rand" - "errors" - "fmt" - "net" - "net/netip" - "sync" - "time" - - log "github.com/sirupsen/logrus" -) - -const ( - defaultTimeout = 3 * time.Second - responseBufferSize = 128 - - // RFC 6887 Section 8.1.1 retry timing - initialRetryDelay = 3 * time.Second - maxRetryDelay = 1024 * time.Second - maxRetries = 4 // 3s + 6s + 12s + 24s = 45s total worst case -) - -// Client is a PCP protocol client. -// All methods are safe for concurrent use. -type Client struct { - gateway netip.Addr - timeout time.Duration - - mu sync.Mutex - // localIP caches the resolved local IP address. - localIP netip.Addr - // lastEpoch is the last observed server epoch value. - lastEpoch uint32 - // epochTime tracks when lastEpoch was received for state loss detection. - epochTime time.Time - // externalIP caches the external IP from the last successful MAP response. - externalIP netip.Addr - // epochStateLost is set when epoch indicates server restart. - epochStateLost bool -} - -// NewClient creates a new PCP client for the gateway at the given IP. -func NewClient(gateway net.IP) *Client { - addr, ok := netip.AddrFromSlice(gateway) - if !ok { - log.Debugf("invalid gateway IP: %v", gateway) - } - return &Client{ - gateway: addr.Unmap(), - timeout: defaultTimeout, - } -} - -// NewClientWithTimeout creates a new PCP client with a custom timeout. -func NewClientWithTimeout(gateway net.IP, timeout time.Duration) *Client { - addr, ok := netip.AddrFromSlice(gateway) - if !ok { - log.Debugf("invalid gateway IP: %v", gateway) - } - return &Client{ - gateway: addr.Unmap(), - timeout: timeout, - } -} - -// SetLocalIP sets the local IP address to use in PCP requests. -func (c *Client) SetLocalIP(ip net.IP) { - addr, ok := netip.AddrFromSlice(ip) - if !ok { - log.Debugf("invalid local IP: %v", ip) - } - c.mu.Lock() - c.localIP = addr.Unmap() - c.mu.Unlock() -} - -// Gateway returns the gateway IP address. -func (c *Client) Gateway() net.IP { - return c.gateway.AsSlice() -} - -// Announce sends a PCP ANNOUNCE request to discover PCP support. -// Returns the server's epoch time on success. -func (c *Client) Announce(ctx context.Context) (epoch uint32, err error) { - localIP, err := c.getLocalIP() - if err != nil { - return 0, fmt.Errorf("get local IP: %w", err) - } - - req := buildAnnounceRequest(localIP) - resp, err := c.sendRequest(ctx, req) - if err != nil { - return 0, fmt.Errorf("send announce: %w", err) - } - - parsed, err := parseResponse(resp) - if err != nil { - return 0, fmt.Errorf("parse announce response: %w", err) - } - - if parsed.ResultCode != ResultSuccess { - return 0, fmt.Errorf("PCP ANNOUNCE failed: %s", ResultCodeString(parsed.ResultCode)) - } - - c.mu.Lock() - if c.updateEpochLocked(parsed.Epoch) { - log.Warnf("PCP server epoch indicates state loss - mappings may need refresh") - } - c.mu.Unlock() - return parsed.Epoch, nil -} - -// AddPortMapping requests a port mapping from the PCP server. -func (c *Client) AddPortMapping(ctx context.Context, protocol string, internalPort int, lifetime time.Duration) (*MapResponse, error) { - return c.addPortMappingWithHint(ctx, protocol, internalPort, internalPort, netip.Addr{}, lifetime) -} - -// AddPortMappingWithHint requests a port mapping with suggested external port and IP. -// Use lifetime <= 0 to delete a mapping. -func (c *Client) AddPortMappingWithHint(ctx context.Context, protocol string, internalPort, suggestedExtPort int, suggestedExtIP net.IP, lifetime time.Duration) (*MapResponse, error) { - var extIP netip.Addr - if suggestedExtIP != nil { - var ok bool - extIP, ok = netip.AddrFromSlice(suggestedExtIP) - if !ok { - log.Debugf("invalid suggested external IP: %v", suggestedExtIP) - } - extIP = extIP.Unmap() - } - return c.addPortMappingWithHint(ctx, protocol, internalPort, suggestedExtPort, extIP, lifetime) -} - -func (c *Client) addPortMappingWithHint(ctx context.Context, protocol string, internalPort, suggestedExtPort int, suggestedExtIP netip.Addr, lifetime time.Duration) (*MapResponse, error) { - localIP, err := c.getLocalIP() - if err != nil { - return nil, fmt.Errorf("get local IP: %w", err) - } - - proto, err := protocolNumber(protocol) - if err != nil { - return nil, fmt.Errorf("parse protocol: %w", err) - } - - var nonce [12]byte - if _, err := rand.Read(nonce[:]); err != nil { - return nil, fmt.Errorf("generate nonce: %w", err) - } - - // Convert lifetime to seconds. Lifetime 0 means delete, so only apply - // default for positive durations that round to 0 seconds. - var lifetimeSec uint32 - if lifetime > 0 { - lifetimeSec = uint32(lifetime.Seconds()) - if lifetimeSec == 0 { - lifetimeSec = DefaultLifetime - } - } - - req := buildMapRequest(localIP, nonce, proto, uint16(internalPort), uint16(suggestedExtPort), suggestedExtIP, lifetimeSec) - - resp, err := c.sendRequest(ctx, req) - if err != nil { - return nil, fmt.Errorf("send map request: %w", err) - } - - mapResp, err := parseMapResponse(resp) - if err != nil { - return nil, fmt.Errorf("parse map response: %w", err) - } - - if mapResp.Nonce != nonce { - return nil, fmt.Errorf("nonce mismatch in response") - } - - if mapResp.Protocol != proto { - return nil, fmt.Errorf("protocol mismatch: requested %d, got %d", proto, mapResp.Protocol) - } - if mapResp.InternalPort != uint16(internalPort) { - return nil, fmt.Errorf("internal port mismatch: requested %d, got %d", internalPort, mapResp.InternalPort) - } - - if mapResp.ResultCode != ResultSuccess { - return nil, &Error{ - Code: mapResp.ResultCode, - Message: ResultCodeString(mapResp.ResultCode), - } - } - - c.mu.Lock() - if c.updateEpochLocked(mapResp.Epoch) { - log.Warnf("PCP server epoch indicates state loss - mappings may need refresh") - } - c.cacheExternalIPLocked(mapResp.ExternalIP) - c.mu.Unlock() - return mapResp, nil -} - -// DeletePortMapping removes a port mapping by requesting zero lifetime. -func (c *Client) DeletePortMapping(ctx context.Context, protocol string, internalPort int) error { - if _, err := c.addPortMappingWithHint(ctx, protocol, internalPort, 0, netip.Addr{}, 0); err != nil { - var pcpErr *Error - if errors.As(err, &pcpErr) && pcpErr.Code == ResultNotAuthorized { - return nil - } - return fmt.Errorf("delete mapping: %w", err) - } - return nil -} - -// GetExternalAddress returns the external IP address. -// First checks for a cached value from previous MAP responses. -// If not cached, creates a short-lived mapping to discover the external IP. -func (c *Client) GetExternalAddress(ctx context.Context) (net.IP, error) { - c.mu.Lock() - if c.externalIP.IsValid() { - ip := c.externalIP.AsSlice() - c.mu.Unlock() - return ip, nil - } - c.mu.Unlock() - - // Use an ephemeral port in the dynamic range (49152-65535). - // Port 0 is not valid with UDP/TCP protocols per RFC 6887. - ephemeralPort := 49152 + int(uint16(time.Now().UnixNano()))%(65535-49152) - - // Use minimal lifetime (1 second) for discovery. - resp, err := c.AddPortMapping(ctx, "udp", ephemeralPort, time.Second) - if err != nil { - return nil, fmt.Errorf("create temporary mapping: %w", err) - } - - if err := c.DeletePortMapping(ctx, "udp", ephemeralPort); err != nil { - log.Debugf("cleanup temporary PCP mapping: %v", err) - } - - return resp.ExternalIP.AsSlice(), nil -} - -// LastEpoch returns the last observed server epoch value. -// A decrease in epoch indicates the server may have restarted and mappings may be lost. -func (c *Client) LastEpoch() uint32 { - c.mu.Lock() - defer c.mu.Unlock() - return c.lastEpoch -} - -// EpochStateLost returns true if epoch state loss was detected and clears the flag. -func (c *Client) EpochStateLost() bool { - c.mu.Lock() - defer c.mu.Unlock() - lost := c.epochStateLost - c.epochStateLost = false - return lost -} - -// updateEpoch updates the epoch tracking and detects potential state loss. -// Returns true if state loss was detected (server likely restarted). -// Caller must hold c.mu. -func (c *Client) updateEpochLocked(newEpoch uint32) bool { - now := time.Now() - stateLost := false - - // RFC 6887 Section 8.5: Detect invalid epoch indicating server state loss. - // client_delta = time since last response - // server_delta = epoch change since last response - // Invalid if: client_delta+2 < server_delta - server_delta/16 - // OR: server_delta+2 < client_delta - client_delta/16 - // The +2 handles quantization, /16 (6.25%) handles clock drift. - if !c.epochTime.IsZero() && c.lastEpoch > 0 { - clientDelta := uint32(now.Sub(c.epochTime).Seconds()) - serverDelta := newEpoch - c.lastEpoch - - // Check for epoch going backwards or jumping unexpectedly. - // Subtraction is safe: serverDelta/16 is always <= serverDelta. - if clientDelta+2 < serverDelta-(serverDelta/16) || - serverDelta+2 < clientDelta-(clientDelta/16) { - stateLost = true - c.epochStateLost = true - } - } - - c.lastEpoch = newEpoch - c.epochTime = now - return stateLost -} - -// cacheExternalIP stores the external IP from a successful MAP response. -// Caller must hold c.mu. -func (c *Client) cacheExternalIPLocked(ip netip.Addr) { - if ip.IsValid() && !ip.IsUnspecified() { - c.externalIP = ip - } -} - -// sendRequest sends a PCP request with retries per RFC 6887 Section 8.1.1. -func (c *Client) sendRequest(ctx context.Context, req []byte) ([]byte, error) { - addr := &net.UDPAddr{IP: c.gateway.AsSlice(), Port: Port} - - var lastErr error - delay := initialRetryDelay - - for range maxRetries { - resp, err := c.sendOnce(ctx, addr, req) - if err == nil { - return resp, nil - } - lastErr = err - - if ctx.Err() != nil { - return nil, ctx.Err() - } - - // RFC 6887 Section 8.1.1: RT = (1 + RAND) * MIN(2 * RTprev, MRT) - // RAND is random between -0.1 and +0.1 - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(retryDelayWithJitter(delay)): - } - delay = min(delay*2, maxRetryDelay) - } - - return nil, fmt.Errorf("PCP request failed after %d retries: %w", maxRetries, lastErr) -} - -// retryDelayWithJitter applies RFC 6887 jitter: multiply by (1 + RAND) where RAND is [-0.1, +0.1]. -func retryDelayWithJitter(d time.Duration) time.Duration { - var b [1]byte - _, _ = rand.Read(b[:]) - // Convert byte to range [-0.1, +0.1]: (b/255 * 0.2) - 0.1 - jitter := (float64(b[0])/255.0)*0.2 - 0.1 - return time.Duration(float64(d) * (1 + jitter)) -} - -func (c *Client) sendOnce(ctx context.Context, addr *net.UDPAddr, req []byte) ([]byte, error) { - // Use ListenUDP instead of DialUDP to validate response source address per RFC 6887 §8.3. - conn, err := net.ListenUDP("udp", nil) - if err != nil { - return nil, fmt.Errorf("listen: %w", err) - } - defer func() { - if err := conn.Close(); err != nil { - log.Debugf("close UDP connection: %v", err) - } - }() - - timeout := c.timeout - if deadline, ok := ctx.Deadline(); ok { - if remaining := time.Until(deadline); remaining < timeout { - timeout = remaining - } - } - - if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil { - return nil, fmt.Errorf("set deadline: %w", err) - } - - if _, err := conn.WriteToUDP(req, addr); err != nil { - return nil, fmt.Errorf("write: %w", err) - } - - resp := make([]byte, responseBufferSize) - n, from, err := conn.ReadFromUDP(resp) - if err != nil { - return nil, fmt.Errorf("read: %w", err) - } - - // RFC 6887 §8.3: Validate response came from expected PCP server. - if !from.IP.Equal(addr.IP) { - return nil, fmt.Errorf("response from unexpected source %s (expected %s)", from.IP, addr.IP) - } - - return resp[:n], nil -} - -func (c *Client) getLocalIP() (netip.Addr, error) { - c.mu.Lock() - defer c.mu.Unlock() - - if !c.localIP.IsValid() { - return netip.Addr{}, fmt.Errorf("local IP not set for gateway %s", c.gateway) - } - return c.localIP, nil -} - -func protocolNumber(protocol string) (uint8, error) { - switch protocol { - case "udp", "UDP": - return ProtoUDP, nil - case "tcp", "TCP": - return ProtoTCP, nil - default: - return 0, fmt.Errorf("unsupported protocol: %s", protocol) - } -} - -// Error represents a PCP error response. -type Error struct { - Code uint8 - Message string -} - -func (e *Error) Error() string { - return fmt.Sprintf("PCP error: %s (%d)", e.Message, e.Code) -} diff --git a/client/internal/portforward/pcp/client_test.go b/client/internal/portforward/pcp/client_test.go deleted file mode 100644 index 79f44a426..000000000 --- a/client/internal/portforward/pcp/client_test.go +++ /dev/null @@ -1,187 +0,0 @@ -package pcp - -import ( - "context" - "net" - "net/netip" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestAddrConversion(t *testing.T) { - tests := []struct { - name string - addr netip.Addr - }{ - {"IPv4", netip.MustParseAddr("192.168.1.100")}, - {"IPv4 loopback", netip.MustParseAddr("127.0.0.1")}, - {"IPv6", netip.MustParseAddr("2001:db8::1")}, - {"IPv6 loopback", netip.MustParseAddr("::1")}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - b16 := addrTo16(tt.addr) - - recovered := addrFrom16(b16) - assert.Equal(t, tt.addr, recovered, "address should round-trip") - }) - } -} - -func TestBuildAnnounceRequest(t *testing.T) { - clientIP := netip.MustParseAddr("192.168.1.100") - req := buildAnnounceRequest(clientIP) - - require.Len(t, req, headerSize) - assert.Equal(t, byte(Version), req[0], "version") - assert.Equal(t, byte(OpAnnounce), req[1], "opcode") - - // Check client IP is properly encoded as IPv4-mapped IPv6 - assert.Equal(t, byte(0xff), req[18], "IPv4-mapped prefix byte 10") - assert.Equal(t, byte(0xff), req[19], "IPv4-mapped prefix byte 11") - assert.Equal(t, byte(192), req[20], "IP octet 1") - assert.Equal(t, byte(168), req[21], "IP octet 2") - assert.Equal(t, byte(1), req[22], "IP octet 3") - assert.Equal(t, byte(100), req[23], "IP octet 4") -} - -func TestBuildMapRequest(t *testing.T) { - clientIP := netip.MustParseAddr("192.168.1.100") - nonce := [12]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12} - req := buildMapRequest(clientIP, nonce, ProtoUDP, 51820, 51820, netip.Addr{}, 3600) - - require.Len(t, req, mapRequestSize) - assert.Equal(t, byte(Version), req[0], "version") - assert.Equal(t, byte(OpMap), req[1], "opcode") - - // Lifetime at bytes 4-7 - assert.Equal(t, uint32(3600), (uint32(req[4])<<24)|(uint32(req[5])<<16)|(uint32(req[6])<<8)|uint32(req[7]), "lifetime") - - // Nonce at bytes 24-35 - assert.Equal(t, nonce[:], req[24:36], "nonce") - - // Protocol at byte 36 - assert.Equal(t, byte(ProtoUDP), req[36], "protocol") - - // Internal port at bytes 40-41 - assert.Equal(t, uint16(51820), (uint16(req[40])<<8)|uint16(req[41]), "internal port") - - // External port at bytes 42-43 - assert.Equal(t, uint16(51820), (uint16(req[42])<<8)|uint16(req[43]), "external port") -} - -func TestParseResponse(t *testing.T) { - // Construct a valid ANNOUNCE response - resp := make([]byte, headerSize) - resp[0] = Version - resp[1] = OpAnnounce | OpReply - // Result code = 0 (success) - // Lifetime = 0 - // Epoch = 12345 - resp[8] = 0 - resp[9] = 0 - resp[10] = 0x30 - resp[11] = 0x39 - - parsed, err := parseResponse(resp) - require.NoError(t, err) - assert.Equal(t, uint8(Version), parsed.Version) - assert.Equal(t, uint8(OpAnnounce|OpReply), parsed.Opcode) - assert.Equal(t, uint8(ResultSuccess), parsed.ResultCode) - assert.Equal(t, uint32(12345), parsed.Epoch) -} - -func TestParseResponseErrors(t *testing.T) { - t.Run("too short", func(t *testing.T) { - _, err := parseResponse([]byte{1, 2, 3}) - assert.Error(t, err) - }) - - t.Run("wrong version", func(t *testing.T) { - resp := make([]byte, headerSize) - resp[0] = 1 // Wrong version - resp[1] = OpReply - _, err := parseResponse(resp) - assert.Error(t, err) - }) - - t.Run("missing reply bit", func(t *testing.T) { - resp := make([]byte, headerSize) - resp[0] = Version - resp[1] = OpAnnounce // Missing OpReply bit - _, err := parseResponse(resp) - assert.Error(t, err) - }) -} - -func TestResultCodeString(t *testing.T) { - assert.Equal(t, "SUCCESS", ResultCodeString(ResultSuccess)) - assert.Equal(t, "NOT_AUTHORIZED", ResultCodeString(ResultNotAuthorized)) - assert.Equal(t, "ADDRESS_MISMATCH", ResultCodeString(ResultAddressMismatch)) - assert.Contains(t, ResultCodeString(255), "UNKNOWN") -} - -func TestProtocolNumber(t *testing.T) { - proto, err := protocolNumber("udp") - require.NoError(t, err) - assert.Equal(t, uint8(ProtoUDP), proto) - - proto, err = protocolNumber("tcp") - require.NoError(t, err) - assert.Equal(t, uint8(ProtoTCP), proto) - - proto, err = protocolNumber("UDP") - require.NoError(t, err) - assert.Equal(t, uint8(ProtoUDP), proto) - - _, err = protocolNumber("icmp") - assert.Error(t, err) -} - -func TestClientCreation(t *testing.T) { - gateway := netip.MustParseAddr("192.168.1.1").AsSlice() - - client := NewClient(gateway) - assert.Equal(t, net.IP(gateway), client.Gateway()) - assert.Equal(t, defaultTimeout, client.timeout) - - clientWithTimeout := NewClientWithTimeout(gateway, 5*time.Second) - assert.Equal(t, 5*time.Second, clientWithTimeout.timeout) -} - -func TestNATType(t *testing.T) { - n := NewNAT(netip.MustParseAddr("192.168.1.1").AsSlice(), netip.MustParseAddr("192.168.1.100").AsSlice()) - assert.Equal(t, "PCP", n.Type()) -} - -// Integration test - skipped unless PCP_TEST_GATEWAY env is set -func TestClientIntegration(t *testing.T) { - t.Skip("Integration test - run manually with PCP_TEST_GATEWAY=") - - gateway := netip.MustParseAddr("10.0.1.1").AsSlice() // Change to your test gateway - localIP := netip.MustParseAddr("10.0.1.100").AsSlice() // Change to your local IP - - client := NewClient(gateway) - client.SetLocalIP(localIP) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - // Test ANNOUNCE - epoch, err := client.Announce(ctx) - require.NoError(t, err) - t.Logf("Server epoch: %d", epoch) - - // Test MAP - resp, err := client.AddPortMapping(ctx, "udp", 51820, 1*time.Hour) - require.NoError(t, err) - t.Logf("Mapping: internal=%d external=%d externalIP=%s", - resp.InternalPort, resp.ExternalPort, resp.ExternalIP) - - // Cleanup - err = client.DeletePortMapping(ctx, "udp", 51820) - require.NoError(t, err) -} diff --git a/client/internal/portforward/pcp/nat.go b/client/internal/portforward/pcp/nat.go deleted file mode 100644 index 1dc24274b..000000000 --- a/client/internal/portforward/pcp/nat.go +++ /dev/null @@ -1,209 +0,0 @@ -package pcp - -import ( - "context" - "fmt" - "net" - "net/netip" - "sync" - "time" - - log "github.com/sirupsen/logrus" - - "github.com/libp2p/go-nat" - "github.com/libp2p/go-netroute" -) - -var _ nat.NAT = (*NAT)(nil) - -// NAT implements the go-nat NAT interface using PCP. -// Supports dual-stack (IPv4 and IPv6) when available. -// All methods are safe for concurrent use. -// -// TODO: IPv6 pinholes use the local IPv6 address. If the address changes -// (e.g., due to SLAAC rotation or network change), the pinhole becomes stale -// and needs to be recreated with the new address. -type NAT struct { - client *Client - - mu sync.RWMutex - // client6 is the IPv6 PCP client, nil if IPv6 is unavailable. - client6 *Client - // localIP6 caches the local IPv6 address used for PCP requests. - localIP6 netip.Addr -} - -// NewNAT creates a new NAT instance backed by PCP. -func NewNAT(gateway, localIP net.IP) *NAT { - client := NewClient(gateway) - client.SetLocalIP(localIP) - return &NAT{ - client: client, - } -} - -// Type returns "PCP" as the NAT type. -func (n *NAT) Type() string { - return "PCP" -} - -// GetDeviceAddress returns the gateway IP address. -func (n *NAT) GetDeviceAddress() (net.IP, error) { - return n.client.Gateway(), nil -} - -// GetExternalAddress returns the external IP address. -func (n *NAT) GetExternalAddress() (net.IP, error) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - return n.client.GetExternalAddress(ctx) -} - -// GetInternalAddress returns the local IP address used to communicate with the gateway. -func (n *NAT) GetInternalAddress() (net.IP, error) { - addr, err := n.client.getLocalIP() - if err != nil { - return nil, err - } - return addr.AsSlice(), nil -} - -// AddPortMapping creates a port mapping on both IPv4 and IPv6 (if available). -func (n *NAT) AddPortMapping(ctx context.Context, protocol string, internalPort int, _ string, timeout time.Duration) (int, error) { - resp, err := n.client.AddPortMapping(ctx, protocol, internalPort, timeout) - if err != nil { - return 0, fmt.Errorf("add mapping: %w", err) - } - - n.mu.RLock() - client6 := n.client6 - localIP6 := n.localIP6 - n.mu.RUnlock() - - if client6 == nil { - return int(resp.ExternalPort), nil - } - - if _, err := client6.AddPortMapping(ctx, protocol, internalPort, timeout); err != nil { - log.Warnf("IPv6 PCP mapping failed (continuing with IPv4): %v", err) - return int(resp.ExternalPort), nil - } - - log.Infof("created IPv6 PCP pinhole: %s:%d", localIP6, internalPort) - return int(resp.ExternalPort), nil -} - -// DeletePortMapping removes a port mapping from both IPv4 and IPv6. -func (n *NAT) DeletePortMapping(ctx context.Context, protocol string, internalPort int) error { - err := n.client.DeletePortMapping(ctx, protocol, internalPort) - - n.mu.RLock() - client6 := n.client6 - n.mu.RUnlock() - - if client6 != nil { - if err6 := client6.DeletePortMapping(ctx, protocol, internalPort); err6 != nil { - log.Warnf("IPv6 PCP delete mapping failed: %v", err6) - } - } - - if err != nil { - return fmt.Errorf("delete mapping: %w", err) - } - return nil -} - -// CheckServerHealth sends an ANNOUNCE to verify the server is still responsive. -// Returns the current epoch and whether the server may have restarted (epoch state loss detected). -func (n *NAT) CheckServerHealth(ctx context.Context) (epoch uint32, serverRestarted bool, err error) { - epoch, err = n.client.Announce(ctx) - if err != nil { - return 0, false, fmt.Errorf("announce: %w", err) - } - return epoch, n.client.EpochStateLost(), nil -} - -// DiscoverPCP attempts to discover a PCP-capable gateway. -// Returns a NAT interface if PCP is supported, or an error otherwise. -// Discovers both IPv4 and IPv6 gateways when available. -func DiscoverPCP(ctx context.Context) (nat.NAT, error) { - gateway, localIP, err := getDefaultGateway() - if err != nil { - return nil, fmt.Errorf("get default gateway: %w", err) - } - - client := NewClient(gateway) - client.SetLocalIP(localIP) - if _, err := client.Announce(ctx); err != nil { - return nil, fmt.Errorf("PCP announce: %w", err) - } - - result := &NAT{client: client} - discoverIPv6(ctx, result) - - return result, nil -} - -func discoverIPv6(ctx context.Context, result *NAT) { - gateway6, localIP6, err := getDefaultGateway6() - if err != nil { - log.Debugf("IPv6 gateway discovery failed: %v", err) - return - } - - client6 := NewClient(gateway6) - client6.SetLocalIP(localIP6) - if _, err := client6.Announce(ctx); err != nil { - log.Debugf("PCP IPv6 announce failed: %v", err) - return - } - - addr, ok := netip.AddrFromSlice(localIP6) - if !ok { - log.Debugf("invalid IPv6 local IP: %v", localIP6) - return - } - result.mu.Lock() - result.client6 = client6 - result.localIP6 = addr - result.mu.Unlock() - log.Debugf("PCP IPv6 gateway discovered: %s (local: %s)", gateway6, localIP6) -} - -// getDefaultGateway returns the default IPv4 gateway and local IP using the system routing table. -func getDefaultGateway() (gateway net.IP, localIP net.IP, err error) { - router, err := netroute.New() - if err != nil { - return nil, nil, err - } - - _, gateway, localIP, err = router.Route(net.IPv4zero) - if err != nil { - return nil, nil, err - } - - if gateway == nil { - return nil, nil, nat.ErrNoNATFound - } - - return gateway, localIP, nil -} - -// getDefaultGateway6 returns the default IPv6 gateway IP address using the system routing table. -func getDefaultGateway6() (gateway net.IP, localIP net.IP, err error) { - router, err := netroute.New() - if err != nil { - return nil, nil, err - } - - _, gateway, localIP, err = router.Route(net.IPv6zero) - if err != nil { - return nil, nil, err - } - - if gateway == nil { - return nil, nil, nat.ErrNoNATFound - } - - return gateway, localIP, nil -} diff --git a/client/internal/portforward/pcp/protocol.go b/client/internal/portforward/pcp/protocol.go deleted file mode 100644 index d81c50c8c..000000000 --- a/client/internal/portforward/pcp/protocol.go +++ /dev/null @@ -1,225 +0,0 @@ -// Package pcp implements the Port Control Protocol (RFC 6887). -// -// # Implemented Features -// -// - ANNOUNCE opcode: Discovers PCP server support -// - MAP opcode: Creates/deletes port mappings (IPv4 NAT) and firewall pinholes (IPv6) -// - Dual-stack: Simultaneous IPv4 and IPv6 support via separate clients -// - Nonce validation: Prevents response spoofing -// - Epoch tracking: Detects server restarts per Section 8.5 -// - RFC-compliant retry timing: 3s initial, exponential backoff to 1024s max (Section 8.1.1) -// -// # Not Implemented -// -// - PEER opcode: For outbound peer connections (not needed for inbound NAT traversal) -// - THIRD_PARTY option: For managing mappings on behalf of other devices -// - PREFER_FAILURE option: Requires exact external port or fail (IPv4 NAT only, not needed for IPv6 pinholing) -// - FILTER option: To restrict remote peer addresses -// -// These optional features are omitted because the primary use case is simple -// port forwarding for WireGuard, which only requires MAP with default behavior. -package pcp - -import ( - "encoding/binary" - "fmt" - "net/netip" -) - -const ( - // Version is the PCP protocol version (RFC 6887). - Version = 2 - - // Port is the standard PCP server port. - Port = 5351 - - // DefaultLifetime is the default requested mapping lifetime in seconds. - DefaultLifetime = 7200 // 2 hours - - // Header sizes - headerSize = 24 - mapPayloadSize = 36 - mapRequestSize = headerSize + mapPayloadSize // 60 bytes -) - -// Opcodes -const ( - OpAnnounce = 0 - OpMap = 1 - OpPeer = 2 - OpReply = 0x80 // OR'd with opcode in responses -) - -// Protocol numbers for MAP requests -const ( - ProtoUDP = 17 - ProtoTCP = 6 -) - -// Result codes (RFC 6887 Section 7.4) -const ( - ResultSuccess = 0 - ResultUnsuppVersion = 1 - ResultNotAuthorized = 2 - ResultMalformedRequest = 3 - ResultUnsuppOpcode = 4 - ResultUnsuppOption = 5 - ResultMalformedOption = 6 - ResultNetworkFailure = 7 - ResultNoResources = 8 - ResultUnsuppProtocol = 9 - ResultUserExQuota = 10 - ResultCannotProvideExt = 11 - ResultAddressMismatch = 12 - ResultExcessiveRemotePeers = 13 -) - -// ResultCodeString returns a human-readable string for a result code. -func ResultCodeString(code uint8) string { - switch code { - case ResultSuccess: - return "SUCCESS" - case ResultUnsuppVersion: - return "UNSUPP_VERSION" - case ResultNotAuthorized: - return "NOT_AUTHORIZED" - case ResultMalformedRequest: - return "MALFORMED_REQUEST" - case ResultUnsuppOpcode: - return "UNSUPP_OPCODE" - case ResultUnsuppOption: - return "UNSUPP_OPTION" - case ResultMalformedOption: - return "MALFORMED_OPTION" - case ResultNetworkFailure: - return "NETWORK_FAILURE" - case ResultNoResources: - return "NO_RESOURCES" - case ResultUnsuppProtocol: - return "UNSUPP_PROTOCOL" - case ResultUserExQuota: - return "USER_EX_QUOTA" - case ResultCannotProvideExt: - return "CANNOT_PROVIDE_EXTERNAL" - case ResultAddressMismatch: - return "ADDRESS_MISMATCH" - case ResultExcessiveRemotePeers: - return "EXCESSIVE_REMOTE_PEERS" - default: - return fmt.Sprintf("UNKNOWN(%d)", code) - } -} - -// Response represents a parsed PCP response header. -type Response struct { - Version uint8 - Opcode uint8 - ResultCode uint8 - Lifetime uint32 - Epoch uint32 -} - -// MapResponse contains the full response to a MAP request. -type MapResponse struct { - Response - Nonce [12]byte - Protocol uint8 - InternalPort uint16 - ExternalPort uint16 - ExternalIP netip.Addr -} - -// addrTo16 converts an address to its 16-byte IPv4-mapped IPv6 representation. -func addrTo16(addr netip.Addr) [16]byte { - if addr.Is4() { - return netip.AddrFrom4(addr.As4()).As16() - } - return addr.As16() -} - -// addrFrom16 extracts an address from a 16-byte representation, unmapping IPv4. -func addrFrom16(b [16]byte) netip.Addr { - return netip.AddrFrom16(b).Unmap() -} - -// buildAnnounceRequest creates a PCP ANNOUNCE request packet. -func buildAnnounceRequest(clientIP netip.Addr) []byte { - req := make([]byte, headerSize) - req[0] = Version - req[1] = OpAnnounce - mapped := addrTo16(clientIP) - copy(req[8:24], mapped[:]) - return req -} - -// buildMapRequest creates a PCP MAP request packet. -func buildMapRequest(clientIP netip.Addr, nonce [12]byte, protocol uint8, internalPort, suggestedExtPort uint16, suggestedExtIP netip.Addr, lifetime uint32) []byte { - req := make([]byte, mapRequestSize) - - // Header - req[0] = Version - req[1] = OpMap - binary.BigEndian.PutUint32(req[4:8], lifetime) - mapped := addrTo16(clientIP) - copy(req[8:24], mapped[:]) - - // MAP payload - copy(req[24:36], nonce[:]) - req[36] = protocol - binary.BigEndian.PutUint16(req[40:42], internalPort) - binary.BigEndian.PutUint16(req[42:44], suggestedExtPort) - if suggestedExtIP.IsValid() { - extMapped := addrTo16(suggestedExtIP) - copy(req[44:60], extMapped[:]) - } - - return req -} - -// parseResponse parses the common PCP response header. -func parseResponse(data []byte) (*Response, error) { - if len(data) < headerSize { - return nil, fmt.Errorf("response too short: %d bytes", len(data)) - } - - resp := &Response{ - Version: data[0], - Opcode: data[1], - ResultCode: data[3], // Byte 2 is reserved, byte 3 is result code (RFC 6887 §7.2) - Lifetime: binary.BigEndian.Uint32(data[4:8]), - Epoch: binary.BigEndian.Uint32(data[8:12]), - } - - if resp.Version != Version { - return nil, fmt.Errorf("unsupported PCP version: %d", resp.Version) - } - - if resp.Opcode&OpReply == 0 { - return nil, fmt.Errorf("response missing reply bit: opcode=0x%02x", resp.Opcode) - } - - return resp, nil -} - -// parseMapResponse parses a complete MAP response. -func parseMapResponse(data []byte) (*MapResponse, error) { - if len(data) < mapRequestSize { - return nil, fmt.Errorf("MAP response too short: %d bytes", len(data)) - } - - resp, err := parseResponse(data) - if err != nil { - return nil, fmt.Errorf("parse header: %w", err) - } - - mapResp := &MapResponse{ - Response: *resp, - Protocol: data[36], - InternalPort: binary.BigEndian.Uint16(data[40:42]), - ExternalPort: binary.BigEndian.Uint16(data[42:44]), - ExternalIP: addrFrom16([16]byte(data[44:60])), - } - copy(mapResp.Nonce[:], data[24:36]) - - return mapResp, nil -} diff --git a/client/internal/portforward/pinhole_test.go b/client/internal/portforward/pinhole_test.go new file mode 100644 index 000000000..46b07a9e7 --- /dev/null +++ b/client/internal/portforward/pinhole_test.go @@ -0,0 +1,116 @@ +//go:build !js + +package portforward + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/netbirdio/go-nat" + log "github.com/sirupsen/logrus" + "github.com/sirupsen/logrus/hooks/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockPinholeNAT is a gateway that also reports an IPv6 pinhole outcome, the +// shape a dual-stack gateway has. +type mockPinholeNAT struct { + *mockNAT + pinholeErr error +} + +func (m *mockPinholeNAT) IPv6PinholeError() error { + return m.pinholeErr +} + +func TestSetupLogsPinholeOutcome(t *testing.T) { + pinholeErr := errors.New("pcp ipv6: NOT_AUTHORIZED") + + tests := []struct { + name string + pinholeErr error + mappingErr error + wantLevel log.Level + wantText string + }{ + { + name: "an open pinhole is reported", + wantLevel: log.InfoLevel, + wantText: "IPv6 pinhole open", + }, + { + name: "a failed pinhole is reported without failing the mapping", + // The IPv4 mapping is what the caller asked for, so the pinhole + // failure surfaces only in the log. + pinholeErr: pinholeErr, + wantLevel: log.WarnLevel, + wantText: pinholeErr.Error(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gateway := &mockPinholeNAT{mockNAT: newMockNAT(), pinholeErr: tt.pinholeErr} + hook := stubGatewayDiscovery(t, gateway) + + m := NewManager() + m.wgPort = 51820 + + _, mapping, err := m.setup(context.Background()) + + require.NoError(t, err) + require.NotNil(t, mapping) + + entry := findEntry(hook, tt.wantText) + require.NotNil(t, entry, "no log entry mentioning %q", tt.wantText) + assert.Equal(t, tt.wantLevel, entry.Level) + }) + } + + t.Run("a failed mapping reports no pinhole outcome", func(t *testing.T) { + // Nothing opened the pinhole, so whatever it currently reports says + // nothing about this attempt. + gateway := &mockPinholeNAT{mockNAT: newMockNAT()} + gateway.addMappingErr = errors.New("gateway refused") + hook := stubGatewayDiscovery(t, gateway) + + m := NewManager() + m.wgPort = 51820 + + _, _, err := m.setup(context.Background()) + + require.Error(t, err) + assert.Nil(t, findEntry(hook, "IPv6 pinhole")) + }) +} + +// stubGatewayDiscovery makes discovery return gateway and captures log output. +func stubGatewayDiscovery(t *testing.T, gateway nat.NAT) *test.Hook { + t.Helper() + + orig := discoverGateway + discoverGateway = func(context.Context) (nat.NAT, error) { return gateway, nil } + t.Cleanup(func() { discoverGateway = orig }) + + hook := test.NewGlobal() + origLevel := log.GetLevel() + log.SetLevel(log.DebugLevel) + t.Cleanup(func() { + hook.Reset() + log.SetLevel(origLevel) + }) + + return hook +} + +func findEntry(hook *test.Hook, substr string) *log.Entry { + for _, entry := range hook.AllEntries() { + if strings.Contains(entry.Message, substr) { + return entry + } + } + return nil +} diff --git a/client/internal/portforward/state.go b/client/internal/portforward/state.go index b1315cdc0..a21368e58 100644 --- a/client/internal/portforward/state.go +++ b/client/internal/portforward/state.go @@ -4,27 +4,94 @@ package portforward import ( "context" + "errors" "fmt" + "time" - "github.com/libp2p/go-nat" + "github.com/netbirdio/go-nat" + "github.com/netbirdio/go-nat/pcp" log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/internal/portforward/pcp" ) // discoverGateway is the function used for NAT gateway discovery. // It can be replaced in tests to avoid real network operations. -// Tries PCP first, then falls back to NAT-PMP/UPnP. var discoverGateway = defaultDiscoverGateway -func defaultDiscoverGateway(ctx context.Context) (nat.NAT, error) { - pcpGateway, err := pcp.DiscoverPCP(ctx) - if err == nil { - return pcpGateway, nil - } - log.Debugf("PCP discovery failed: %v, trying NAT-PMP/UPnP", err) +// pinholeDiscoveryTimeout is the slice of the discovery budget held back for +// the IPv6 pinhole probe. +// +// Sizing it is coarser than it looks: PCP retransmits on a 3s socket timeout +// and a 3s first backoff, so a second attempt needs about 9s. Anything from +// roughly 1s to 8s therefore buys exactly one attempt, and this only sets how +// long that attempt waits. A PCP server sits on the local link and answers in +// milliseconds, so 3s is margin rather than need, and the rest is left to +// gateway discovery, whose multicast SSDP search alone takes 5s. A probe lost +// to a dropped packet is retried by the next discovery round. +// +// It is a variable so tests can shorten it. +var pinholeDiscoveryTimeout = 3 * time.Second - return nat.DiscoverGateway(ctx) +// Discovery entry points, as variables so tests can drive the fallback without +// touching the network. +var ( + discoverNATGateway = nat.DiscoverGateway + + discoverPCPPinhole = func(ctx context.Context) (nat.NAT, error) { + pinhole, err := pcp.DiscoverPCP(ctx) + if err != nil { + return nil, err + } + return pinhole, nil + } +) + +// defaultDiscoverGateway finds a gateway that can make the WireGuard port +// reachable. DiscoverGateway prefers PCP for IPv4, races UPnP and NAT-PMP +// behind it, and attaches an IPv6 pinhole independently of which IPv4 protocol +// wins. +// +// It reports no gateway on a network offering only IPv6, having no IPv4 mapping +// to attach a pinhole to. Such a network still needs one: there is no +// translation to traverse, but the router drops inbound IPv6 until something +// opens it. Fall back to PCP alone, which yields a gateway holding just the +// pinhole. +func defaultDiscoverGateway(ctx context.Context) (nat.NAT, error) { + gatewayCtx, cancel := reserveForPinhole(ctx) + defer cancel() + + gateway, err := discoverNATGateway(gatewayCtx) + if err == nil { + return gateway, nil + } + if !errors.Is(err, nat.ErrNoNATFound) { + return nil, err + } + + pinhole, pinholeErr := discoverPCPPinhole(ctx) + if pinholeErr != nil { + log.Debugf("no IPv6 pinhole after %v: %v", err, pinholeErr) + return nil, err + } + + log.Infof("no IPv4 gateway, continuing with an IPv6 pinhole only") + return pinhole, nil +} + +// reserveForPinhole shortens ctx so that a pinhole probe still has time to run +// afterwards. Finding nothing takes gateway discovery everything it is given, +// so on the unshortened context the probe would start already expired. A budget +// too small to divide is left to gateway discovery, which is the likelier win. +func reserveForPinhole(ctx context.Context) (context.Context, context.CancelFunc) { + deadline, ok := ctx.Deadline() + if !ok { + return context.WithCancel(ctx) + } + + remaining := time.Until(deadline) + if remaining <= pinholeDiscoveryTimeout { + return context.WithCancel(ctx) + } + return context.WithTimeout(ctx, remaining-pinholeDiscoveryTimeout) } // State is persisted only for crash recovery cleanup diff --git a/client/internal/portforward/state_test.go b/client/internal/portforward/state_test.go new file mode 100644 index 000000000..8a584eecb --- /dev/null +++ b/client/internal/portforward/state_test.go @@ -0,0 +1,140 @@ +//go:build !js + +package portforward + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/netbirdio/go-nat" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// stubDiscovery replaces both discovery entry points for the duration of a +// test. gatewayDelay simulates gateway discovery spending everything it is +// given before reporting that it found nothing. +func stubDiscovery(t *testing.T, gateway nat.NAT, gatewayErr error, gatewayDelay time.Duration, pinhole nat.NAT, pinholeErr error) { + t.Helper() + + origGateway, origPinhole := discoverNATGateway, discoverPCPPinhole + discoverNATGateway = func(ctx context.Context) (nat.NAT, error) { + if gatewayDelay > 0 { + select { + case <-time.After(gatewayDelay): + case <-ctx.Done(): + } + } + return gateway, gatewayErr + } + discoverPCPPinhole = func(ctx context.Context) (nat.NAT, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + return pinhole, pinholeErr + } + + t.Cleanup(func() { discoverNATGateway, discoverPCPPinhole = origGateway, origPinhole }) +} + +func TestDefaultDiscoverGateway(t *testing.T) { + ipv4Gateway := &mockNAT{natType: "PCP+PCPv6"} + ipv6Pinhole := &mockNAT{natType: "PCP"} + otherErr := errors.New("routing table unavailable") + + t.Run("an IPv4 gateway is used as is", func(t *testing.T) { + stubDiscovery(t, ipv4Gateway, nil, 0, ipv6Pinhole, nil) + + got, err := defaultDiscoverGateway(context.Background()) + + require.NoError(t, err) + assert.Same(t, ipv4Gateway, got) + }) + + t.Run("no IPv4 gateway still opens an IPv6 pinhole", func(t *testing.T) { + stubDiscovery(t, nil, nat.ErrNoNATFound, 0, ipv6Pinhole, nil) + + got, err := defaultDiscoverGateway(context.Background()) + + require.NoError(t, err) + assert.Same(t, ipv6Pinhole, got) + }) + + t.Run("no gateway and no pinhole reports the original failure", func(t *testing.T) { + stubDiscovery(t, nil, nat.ErrNoNATFound, 0, nil, errors.New("no IPv6 route")) + + got, err := defaultDiscoverGateway(context.Background()) + + assert.Nil(t, got) + assert.ErrorIs(t, err, nat.ErrNoNATFound, "the pinhole failure must not mask why no gateway was found") + }) + + t.Run("a failure other than no-gateway is reported as is", func(t *testing.T) { + stubDiscovery(t, nil, otherErr, 0, ipv6Pinhole, nil) + + got, err := defaultDiscoverGateway(context.Background()) + + assert.Nil(t, got) + assert.ErrorIs(t, err, otherErr) + }) + + t.Run("the pinhole survives gateway discovery using its whole budget", func(t *testing.T) { + // On one shared context the probe would start already expired, which is + // how this failed against a real gateway. + reserve := 50 * time.Millisecond + origReserve := pinholeDiscoveryTimeout + pinholeDiscoveryTimeout = reserve + t.Cleanup(func() { pinholeDiscoveryTimeout = origReserve }) + + budget := 4 * reserve + ctx, cancel := context.WithTimeout(context.Background(), budget) + defer cancel() + + stubDiscovery(t, nil, nat.ErrNoNATFound, budget, ipv6Pinhole, nil) + + got, err := defaultDiscoverGateway(ctx) + + require.NoError(t, err) + assert.Same(t, ipv6Pinhole, got) + }) +} + +func TestReserveForPinhole(t *testing.T) { + origReserve := pinholeDiscoveryTimeout + pinholeDiscoveryTimeout = time.Second + t.Cleanup(func() { pinholeDiscoveryTimeout = origReserve }) + + t.Run("a budget is divided", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + gatewayCtx, cancelGateway := reserveForPinhole(ctx) + defer cancelGateway() + + deadline, ok := gatewayCtx.Deadline() + require.True(t, ok) + assert.InDelta(t, 9*time.Second, time.Until(deadline), float64(500*time.Millisecond)) + }) + + t.Run("a budget too small to divide is left whole", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + gatewayCtx, cancelGateway := reserveForPinhole(ctx) + defer cancelGateway() + + deadline, ok := gatewayCtx.Deadline() + require.True(t, ok) + assert.InDelta(t, 500*time.Millisecond, time.Until(deadline), float64(100*time.Millisecond)) + }) + + t.Run("no deadline stays unbounded", func(t *testing.T) { + gatewayCtx, cancelGateway := reserveForPinhole(context.Background()) + defer cancelGateway() + + _, ok := gatewayCtx.Deadline() + assert.False(t, ok) + }) +} diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index 20c615d57..412f81b5c 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -5,6 +5,7 @@ import ( "crypto/tls" "encoding/json" "fmt" + "net" "net/url" "os" "os/user" @@ -21,6 +22,7 @@ import ( "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/internal/routemanager/dynamic" + "github.com/netbirdio/netbird/client/mdm" "github.com/netbirdio/netbird/client/ssh" mgm "github.com/netbirdio/netbird/shared/management/client" "github.com/netbirdio/netbird/shared/management/domain" @@ -64,6 +66,7 @@ type ConfigInput struct { StateFilePath string PreSharedKey *string ServerSSHAllowed *bool + RemoteJobsAllowed *bool EnableSSHRoot *bool EnableSSHSFTP *bool EnableSSHLocalPortForwarding *bool @@ -89,18 +92,25 @@ type ConfigInput struct { DisableFirewall *bool BlockLANAccess *bool BlockInbound *bool + DisableIPv6 *bool + SyncMessageVersion *int DisableNotifications *bool DNSLabels domain.List - LazyConnectionEnabled *bool - MTU *uint16 + + LocalMetricsEnabled *bool + LocalMetricsAddress *string } // Config Configuration type type Config struct { + // Name is the human-readable profile name shown in CLI/UI listings. + // It is independent of the profile's on-disk filename (which is the ID). + Name string + // Wireguard private key of local peer PrivateKey string PreSharedKey string @@ -114,6 +124,7 @@ type Config struct { RosenpassEnabled bool RosenpassPermissive bool ServerSSHAllowed *bool + RemoteJobsAllowed *bool EnableSSHRoot *bool EnableSSHSFTP *bool EnableSSHLocalPortForwarding *bool @@ -127,11 +138,18 @@ type Config struct { DisableFirewall bool BlockLANAccess bool BlockInbound bool + DisableIPv6 bool + SyncMessageVersion *int DisableNotifications *bool DNSLabels domain.List + // LocalMetricsEnabled enables the local Prometheus /metrics endpoint. + LocalMetricsEnabled bool + // LocalMetricsAddress is the listen address of the local /metrics endpoint. + LocalMetricsAddress string + // SSHKey is a private SSH key in a PEM format SSHKey string @@ -168,9 +186,46 @@ type Config struct { ClientCertKeyPair *tls.Certificate `json:"-"` - LazyConnectionEnabled bool + // LazyConnection is the MDM-managed lazy-connection override ("on"/"off"/""). + // Runtime-only: re-derived from MDM policy on each load, never persisted. + LazyConnection string `json:"-"` + + // DebugBundleUploadURL is the MDM-managed debug-bundle upload URL override. + // When set, it takes precedence over the management-supplied upload URL for + // remote debug bundle jobs. Runtime-only: re-derived from MDM policy on each + // load, never persisted. + DebugBundleUploadURL string `json:"-"` MTU uint16 + + // policy is the MDM policy that produced the currently-set values + // for any MDM-enforced fields. Set by ApplyMDMPolicy on every + // invocation. Never persisted to disk. Callers query enforcement + // state via Policy() and the mdm.Policy API (HasKey, ManagedKeys, + // IsEmpty). + policy *mdm.Policy `json:"-"` +} + +// ApplyMDMPolicy overlays the supplied MDM Policy on top of the current +// Config values and records it as Policy(). The overlay is not reversible: +// an empty Policy only clears the enforcement metadata, so resolve the base +// Config again (from disk or JSON) before applying a changed policy, the way +// the lifecycle owners do on every load. +func (config *Config) ApplyMDMPolicy(policy *mdm.Policy) { + if config == nil { + return + } + config.applyMDMPolicy(policy) +} + +// Policy returns the MDM policy applied to this Config. Returns a non-nil +// empty Policy when MDM enforcement is inactive; callers can always invoke +// HasKey / ManagedKeys / IsEmpty without a nil check. +func (config *Config) Policy() *mdm.Policy { + if config == nil || config.policy == nil { + return mdm.NewPolicy(nil) + } + return config.policy } var ConfigDirOverride string @@ -186,6 +241,12 @@ func getConfigDir() (string, error) { } configDir := filepath.Join(base, "netbird") + // Under sudo this is the invoking user's directory and strictly read-only: + // anything root creates in it would be root-owned and break the user's own + // runs. Reads of a missing directory fall through to defaults. + if sudoActive() { + return configDir, nil + } if err := os.MkdirAll(configDir, 0o755); err != nil { return "", err } @@ -193,6 +254,16 @@ func getConfigDir() (string, error) { } func baseConfigDir() (string, error) { + if u, ok := sudoInvokingUser(); ok { + return userBaseConfigDir(u) + } + // Fail closed instead of falling through to root's own config directory: + // reading root's active-profile and email state for what is actually the + // invoking user's invocation is the very confusion this resolution exists + // to prevent. + if sudoActive() { + return "", fmt.Errorf("resolve sudo invoking user %q: refusing to fall back to root's config directory", os.Getenv(envSudoUser)) + } if runtime.GOOS == "darwin" { if u, err := user.Current(); err == nil && u.HomeDir != "" { return filepath.Join(u.HomeDir, "Library", "Application Support"), nil @@ -234,7 +305,10 @@ func createNewConfig(input ConfigInput) (*Config, error) { config := &Config{ // defaults to false only for new (post 0.26) configurations ServerSSHAllowed: util.False(), - WgPort: iface.DefaultWgPort, + // Remote jobs are an explicit opt-in and default off, including for + // legacy configs (a nil value materializes to false at connect time). + RemoteJobsAllowed: util.False(), + WgPort: iface.DefaultWgPort, } if _, err := config.apply(input); err != nil { @@ -245,6 +319,16 @@ func createNewConfig(input ConfigInput) (*Config, error) { } func (config *Config) apply(input ConfigInput) (updated bool, err error) { + if config.Name != "" { + sanitized, err := sanitizeDisplayName(config.Name) + if err != nil { + return false, fmt.Errorf("invalid profile name: %w", err) + } + if sanitized != config.Name { + config.Name = sanitized + updated = true + } + } if config.ManagementURL == nil { log.Infof("using default Management URL %s", DefaultManagementURL) config.ManagementURL, err = parseURL("Management URL", DefaultManagementURL) @@ -347,7 +431,19 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.NetworkMonitor != nil && input.NetworkMonitor != config.NetworkMonitor { + if input.LocalMetricsEnabled != nil && *input.LocalMetricsEnabled != config.LocalMetricsEnabled { + log.Infof("switching local metrics to %t", *input.LocalMetricsEnabled) + config.LocalMetricsEnabled = *input.LocalMetricsEnabled + updated = true + } + + if input.LocalMetricsAddress != nil && *input.LocalMetricsAddress != config.LocalMetricsAddress { + log.Infof("switching local metrics address to %s", *input.LocalMetricsAddress) + config.LocalMetricsAddress = *input.LocalMetricsAddress + updated = true + } + + if input.NetworkMonitor != nil && (config.NetworkMonitor == nil || *input.NetworkMonitor != *config.NetworkMonitor) { log.Infof("switching Network Monitor to %t", *input.NetworkMonitor) config.NetworkMonitor = input.NetworkMonitor updated = true @@ -394,7 +490,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.ServerSSHAllowed != nil && *input.ServerSSHAllowed != *config.ServerSSHAllowed { + if input.ServerSSHAllowed != nil && (config.ServerSSHAllowed == nil || *input.ServerSSHAllowed != *config.ServerSSHAllowed) { if *input.ServerSSHAllowed { log.Infof("enabling SSH server") } else { @@ -415,7 +511,22 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.EnableSSHRoot != nil && input.EnableSSHRoot != config.EnableSSHRoot { + if input.RemoteJobsAllowed != nil && (config.RemoteJobsAllowed == nil || *input.RemoteJobsAllowed != *config.RemoteJobsAllowed) { + if *input.RemoteJobsAllowed { + log.Infof("enabling remote jobs") + } else { + log.Infof("disabling remote jobs") + } + config.RemoteJobsAllowed = input.RemoteJobsAllowed + updated = true + } else if config.RemoteJobsAllowed == nil { + // Remote jobs are an explicit opt-in: unlike SSH, a pre-existing config + // with no value defaults to disabled rather than being turned on. + config.RemoteJobsAllowed = util.False() + updated = true + } + + if input.EnableSSHRoot != nil && (config.EnableSSHRoot == nil || *input.EnableSSHRoot != *config.EnableSSHRoot) { if *input.EnableSSHRoot { log.Infof("enabling SSH root login") } else { @@ -425,7 +536,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.EnableSSHSFTP != nil && input.EnableSSHSFTP != config.EnableSSHSFTP { + if input.EnableSSHSFTP != nil && (config.EnableSSHSFTP == nil || *input.EnableSSHSFTP != *config.EnableSSHSFTP) { if *input.EnableSSHSFTP { log.Infof("enabling SSH SFTP subsystem") } else { @@ -435,7 +546,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.EnableSSHLocalPortForwarding != nil && input.EnableSSHLocalPortForwarding != config.EnableSSHLocalPortForwarding { + if input.EnableSSHLocalPortForwarding != nil && (config.EnableSSHLocalPortForwarding == nil || *input.EnableSSHLocalPortForwarding != *config.EnableSSHLocalPortForwarding) { if *input.EnableSSHLocalPortForwarding { log.Infof("enabling SSH local port forwarding") } else { @@ -445,7 +556,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.EnableSSHRemotePortForwarding != nil && input.EnableSSHRemotePortForwarding != config.EnableSSHRemotePortForwarding { + if input.EnableSSHRemotePortForwarding != nil && (config.EnableSSHRemotePortForwarding == nil || *input.EnableSSHRemotePortForwarding != *config.EnableSSHRemotePortForwarding) { if *input.EnableSSHRemotePortForwarding { log.Infof("enabling SSH remote port forwarding") } else { @@ -455,7 +566,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.DisableSSHAuth != nil && input.DisableSSHAuth != config.DisableSSHAuth { + if input.DisableSSHAuth != nil && (config.DisableSSHAuth == nil || *input.DisableSSHAuth != *config.DisableSSHAuth) { if *input.DisableSSHAuth { log.Infof("disabling SSH authentication") } else { @@ -465,7 +576,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.SSHJWTCacheTTL != nil && input.SSHJWTCacheTTL != config.SSHJWTCacheTTL { + if input.SSHJWTCacheTTL != nil && (config.SSHJWTCacheTTL == nil || *input.SSHJWTCacheTTL != *config.SSHJWTCacheTTL) { log.Infof("updating SSH JWT cache TTL to %d seconds", *input.SSHJWTCacheTTL) config.SSHJWTCacheTTL = input.SSHJWTCacheTTL updated = true @@ -542,7 +653,19 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.DisableNotifications != nil && input.DisableNotifications != config.DisableNotifications { + if input.DisableIPv6 != nil && *input.DisableIPv6 != config.DisableIPv6 { + log.Infof("setting IPv6 overlay disabled=%v", *input.DisableIPv6) + config.DisableIPv6 = *input.DisableIPv6 + updated = true + } + + if input.SyncMessageVersion != nil && *input.SyncMessageVersion != *config.SyncMessageVersion { + log.Infof("setting SyncMessageVersion to %v", *input.SyncMessageVersion) + *config.SyncMessageVersion = *input.SyncMessageVersion + updated = true + } + + if input.DisableNotifications != nil && (config.DisableNotifications == nil || *input.DisableNotifications != *config.DisableNotifications) { if *input.DisableNotifications { log.Infof("disabling notifications") } else { @@ -587,12 +710,6 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.LazyConnectionEnabled != nil && *input.LazyConnectionEnabled != config.LazyConnectionEnabled { - log.Infof("switching lazy connection to %t", *input.LazyConnectionEnabled) - config.LazyConnectionEnabled = *input.LazyConnectionEnabled - updated = true - } - if input.MTU != nil && *input.MTU != config.MTU { log.Infof("updating MTU to %d (old value %d)", *input.MTU, config.MTU) config.MTU = *input.MTU @@ -603,10 +720,172 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } + // Initialise the MDM overlay to "no enforcement" so Config.Policy() + // never returns a stale or nil policy on a freshly applied Config. + // Lifecycle owners that want to enforce a real MDM policy invoke + // Config.ApplyMDMPolicy(loader.Load()) after this returns. + config.applyMDMPolicy(mdm.NewPolicy(nil)) + return updated, nil } -// parseURL parses and validates a service URL +// applyMDMPolicy overlays MDM-supplied values on top of the resolved Config. +// The provided Policy is also stored on the Config so callers can later query +// which fields are enforced. Invalid values (e.g. malformed URLs) are logged +// and skipped to avoid bricking the client; the field keeps its previous +// resolved value but is still marked as managed (Policy.HasKey returns true +// for the key, so per-field rejection of user writes still applies). +func (config *Config) applyMDMPolicy(policy *mdm.Policy) { + config.policy = policy + + // DebugBundleUploadURL is a runtime-only override re-derived from MDM on + // every apply. Resolve it unconditionally (before the IsEmpty early return) + // so a policy that drops the key, becomes empty, or carries an invalid + // value can never leave a previously-enforced upload target active on a + // reused Config instance. + config.DebugBundleUploadURL = mdmDebugBundleUploadURL(policy) + + if policy.IsEmpty() { + return + } + + // Helper: log the application of a single MDM-managed key. Values for + // keys in mdm.SecretKeys are redacted. + logApplied := func(key string, displayValue any) { + if _, secret := mdm.SecretKeys[key]; secret { + log.Infof("MDM override %s = ********** (secret)", key) + return + } + log.Infof("MDM override %s = %v", key, displayValue) + } + + if v, ok := policy.GetString(mdm.KeyManagementURL); ok { + if u, err := parseURL("Management URL", v); err != nil { + log.Warnf("MDM management URL %q invalid: %v; keeping previous value", v, err) + } else { + config.ManagementURL = u + logApplied(mdm.KeyManagementURL, u.String()) + } + } + + if v, ok := policy.GetString(mdm.KeyPreSharedKey); ok { + // Defensive: refuse the redaction mask in case it round-tripped + // through a manifest by mistake. + if !isPreSharedKeyHidden(&v) { + config.PreSharedKey = v + logApplied(mdm.KeyPreSharedKey, "") + } + } + + // applyBool collapses the per-key "read + set + log" boilerplate + // for every plain bool MDM key into a single helper. Keeps the + // outer function's cognitive complexity below SonarCube's + // threshold; functional behaviour is identical to the inlined + // branches it replaces. + applyBool := func(key string, setter func(bool)) { + v, ok := policy.GetBool(key) + if !ok { + return + } + setter(v) + logApplied(key, v) + } + + applyBool(mdm.KeyAllowServerSSH, func(v bool) { bv := v; config.ServerSSHAllowed = &bv }) + applyBool(mdm.KeyRemoteJobsAllowed, func(v bool) { bv := v; config.RemoteJobsAllowed = &bv }) + applyBool(mdm.KeyDisableClientRoutes, func(v bool) { config.DisableClientRoutes = v }) + applyBool(mdm.KeyDisableServerRoutes, func(v bool) { config.DisableServerRoutes = v }) + applyBool(mdm.KeyBlockInbound, func(v bool) { config.BlockInbound = v }) + applyBool(mdm.KeyDisableAutoConnect, func(v bool) { config.DisableAutoConnect = v }) + applyBool(mdm.KeyRosenpassEnabled, func(v bool) { config.RosenpassEnabled = v }) + applyBool(mdm.KeyRosenpassPermissive, func(v bool) { config.RosenpassPermissive = v }) + applyBool(mdm.KeyEnableLocalMetrics, func(v bool) { config.LocalMetricsEnabled = v }) + + if v, ok := policy.GetString(mdm.KeyLocalMetricsAddress); ok { + config.LocalMetricsAddress = v + logApplied(mdm.KeyLocalMetricsAddress, v) + } + + if v, ok := policy.GetInt(mdm.KeyWireguardPort); ok { + // REG_DWORD is 32-bit; UDP port range is 1-65535. Clamp at the + // upper bound and reject obviously-invalid values to avoid the + // engine binding to an unusable port if the admin pushes garbage. + if v >= 1 && v <= 65535 { + config.WgPort = int(v) + logApplied(mdm.KeyWireguardPort, v) + } else { + log.Warnf("MDM wireguard port %d out of range [1,65535]; keeping previous value", v) + } + } + + if v, ok := policy.GetBool(mdm.KeyLazyConnection); ok { + state := "off" + if v { + state = "on" + } + config.LazyConnection = state + logApplied(mdm.KeyLazyConnection, state) + } + +} + +// ValidateBundleUploadURL sanity-checks a debug-bundle upload URL. An empty +// value is accepted — the executor falls back to the default upload service. A +// non-empty value must be a well-formed https URL with a host; a malformed +// value or a plaintext scheme is rejected. It deliberately does not constrain +// which host may receive the bundle. This is the single source of truth for the +// rule, shared by the remote-job executor (client/internal) and the MDM policy +// override below so the two validation paths cannot drift. +func ValidateBundleUploadURL(raw string) error { + if raw == "" { + return nil + } + parsed, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("parse upload URL: %w", err) + } + // Hostname(), not Host: an authority like ":443" is non-empty but has no + // host, and would fail the actual upload. + if parsed.Scheme != "https" || parsed.Hostname() == "" { + return fmt.Errorf("upload URL must be an https URL with a host") + } + return nil +} + +// mdmDebugBundleUploadURL resolves the MDM-enforced debug-bundle upload URL +// override from the policy, returning the empty string when the policy does +// not carry a valid KeyBundleUploadURL. An absent or invalid value fails +// closed to "" so it falls back to the management-supplied or default upload +// target rather than a previously-enforced one. The URL is never logged: it +// can embed credentials or signed query tokens (KeyBundleUploadURL is in +// mdm.SecretKeys). +func mdmDebugBundleUploadURL(policy *mdm.Policy) string { + v, ok := policy.GetString(mdm.KeyBundleUploadURL) + if !ok || v == "" { + return "" + } + // Must be a well-formed https URL with a host, matching the client's + // remote-job upload-URL validation (shared validator, single source of truth). + if err := ValidateBundleUploadURL(v); err != nil { + log.Warnf("MDM debug bundle upload URL is invalid (must be an https URL with a host); ignoring the override") + return "" + } + log.Infof("MDM override %s = ********** (secret)", mdm.KeyBundleUploadURL) + return v +} + +// parseURL parses and validates the URL for the named service. The URL +// must use the http or https scheme; if no port is present, ":443" is +// appended for https or ":80" for http. The serviceName parameter is +// used to contextualise error messages. On success returns the parsed +// *url.URL; on failure returns a non-nil error. +// ParseServiceURL normalises a service URL exactly as the config layer does when +// it stores one, so callers comparing a requested URL against a stored one do not +// have to reimplement the scheme validation and default-port handling. +func ParseServiceURL(serviceName, serviceURL string) (*url.URL, error) { + return parseURL(serviceName, serviceURL) +} + func parseURL(serviceName, serviceURL string) (*url.URL, error) { parsedMgmtURL, err := url.ParseRequestURI(serviceURL) if err != nil { @@ -751,8 +1030,7 @@ func UpdateOldManagementURL(ctx context.Context, config *Config, configPath stri return config, nil } - newURL, err := parseURL("Management URL", fmt.Sprintf("%s://%s:%d", - config.ManagementURL.Scheme, defaultManagementURL.Hostname(), 443)) + newURL, err := parseURL("Management URL", fmt.Sprintf("%s://%s", config.ManagementURL.Scheme, net.JoinHostPort(defaultManagementURL.Hostname(), "443"))) if err != nil { return nil, err } diff --git a/client/internal/profilemanager/config_mdm.go b/client/internal/profilemanager/config_mdm.go new file mode 100644 index 000000000..25b9f18f7 --- /dev/null +++ b/client/internal/profilemanager/config_mdm.go @@ -0,0 +1,52 @@ +package profilemanager + +import ( + "errors" + "fmt" + + "github.com/netbirdio/netbird/client/mdm" +) + +// ErrMDMManagedFields marks a config change rejected because it diverges from +// MDM-enforced values. +var ErrMDMManagedFields = errors.New("fields managed by MDM cannot be modified") + +// MDMConflicts returns the names of MDM-managed keys whose requested value in +// the ConfigInput differs from the policy-enforced value; a field set to the +// enforced value is a no-op echo, not a conflict. +func MDMConflicts(input ConfigInput, policy *mdm.Policy) []string { + pskGot := input.PreSharedKey + if isPreSharedKeyHidden(pskGot) { + pskGot = nil + } + var port *int64 + if input.WireguardPort != nil { + v := int64(*input.WireguardPort) + port = &v + } + return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{ + mdm.ConflictURL(mdm.KeyManagementURL, input.ManagementURL), + mdm.ConflictStringPtr(mdm.KeyPreSharedKey, pskGot), + mdm.ConflictBool(mdm.KeyRosenpassEnabled, input.RosenpassEnabled), + mdm.ConflictBool(mdm.KeyRosenpassPermissive, input.RosenpassPermissive), + mdm.ConflictBool(mdm.KeyDisableAutoConnect, input.DisableAutoConnect), + mdm.ConflictBool(mdm.KeyAllowServerSSH, input.ServerSSHAllowed), + mdm.ConflictBool(mdm.KeyRemoteJobsAllowed, input.RemoteJobsAllowed), + mdm.ConflictBool(mdm.KeyDisableClientRoutes, input.DisableClientRoutes), + mdm.ConflictBool(mdm.KeyDisableServerRoutes, input.DisableServerRoutes), + mdm.ConflictBool(mdm.KeyBlockInbound, input.BlockInbound), + mdm.ConflictInt64(mdm.KeyWireguardPort, port), + mdm.ConflictBool(mdm.KeyEnableLocalMetrics, input.LocalMetricsEnabled), + mdm.ConflictStringPtr(mdm.KeyLocalMetricsAddress, input.LocalMetricsAddress), + }) +} + +// CheckMDMConflicts returns an ErrMDMManagedFields-wrapped error naming the +// conflicting keys, or nil when the input does not fight the policy. +func CheckMDMConflicts(input ConfigInput, policy *mdm.Policy) error { + conflicts := MDMConflicts(input, policy) + if len(conflicts) == 0 { + return nil + } + return fmt.Errorf("%w: %v", ErrMDMManagedFields, conflicts) +} diff --git a/client/internal/profilemanager/config_mdm_test.go b/client/internal/profilemanager/config_mdm_test.go new file mode 100644 index 000000000..716b7a553 --- /dev/null +++ b/client/internal/profilemanager/config_mdm_test.go @@ -0,0 +1,282 @@ +package profilemanager + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/mdm" +) + +// fakeFetcher implements mdm.PolicyFetcher returning a pre-set policy +// map. Test helper used to construct a Loader without touching the OS +// or any package-level state. +type fakeFetcher struct{ values map[string]any } + +func (f *fakeFetcher) Fetch() map[string]any { return f.values } + +// loaderFor builds an mdm.Loader whose loadPlatform returns the +// supplied Policy's underlying values. +func loaderFor(policy *mdm.Policy) *mdm.Loader { + if policy == nil || policy.IsEmpty() { + return mdm.NewLoader(&fakeFetcher{values: nil}) + } + values := make(map[string]any) + for _, k := range policy.ManagedKeys() { + if v, ok := policy.GetString(k); ok { + values[k] = v + continue + } + if v, ok := policy.GetInt(k); ok { + values[k] = v + continue + } + if v, ok := policy.GetBool(k); ok { + values[k] = v + continue + } + if v, ok := policy.GetStringSlice(k); ok { + values[k] = v + } + } + return mdm.NewLoader(&fakeFetcher{values: values}) +} + +// configWithMDM is the test convenience that builds a Config via +// UpdateOrCreateConfig and overlays the supplied MDM policy on top — +// mirrors the production pattern (Server.getConfig / Client.applyMDMOverlay) +// where the Loader lives outside Config and the apply step is driven +// by the lifecycle owner. +func configWithMDM(t *testing.T, input ConfigInput, policy *mdm.Policy) *Config { + t.Helper() + cfg, err := UpdateOrCreateConfig(input) + require.NoError(t, err) + require.NotNil(t, cfg) + cfg.ApplyMDMPolicy(loaderFor(policy).Load()) + return cfg +} + +func TestApply_MDMEmpty_NoEnforcement(t *testing.T) { + cfg := configWithMDM(t, ConfigInput{ + ConfigPath: filepath.Join(t.TempDir(), "config.json"), + }, mdm.NewPolicy(nil)) + + assert.True(t, cfg.Policy().IsEmpty(), "no MDM source ⇒ empty Policy") + assert.False(t, cfg.Policy().HasKey(mdm.KeyManagementURL)) + assert.Empty(t, cfg.Policy().ManagedKeys()) + + // Default management URL still resolves. + assert.Equal(t, DefaultManagementURL, cfg.ManagementURL.String()) +} + +func TestApply_MDMOnly_OverridesDefaults(t *testing.T) { + const mdmURL = "https://corp.mdm.example.com:443" + + cfg := configWithMDM(t, ConfigInput{ + ConfigPath: filepath.Join(t.TempDir(), "config.json"), + }, mdm.NewPolicy(map[string]any{ + mdm.KeyManagementURL: mdmURL, + mdm.KeyDisableClientRoutes: true, + mdm.KeyBlockInbound: true, + })) + + assert.Equal(t, mdmURL, cfg.ManagementURL.String()) + assert.True(t, cfg.DisableClientRoutes) + assert.True(t, cfg.BlockInbound) + + assert.True(t, cfg.Policy().HasKey(mdm.KeyManagementURL)) + assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableClientRoutes)) + assert.True(t, cfg.Policy().HasKey(mdm.KeyBlockInbound)) + assert.False(t, cfg.Policy().HasKey(mdm.KeyAllowServerSSH)) +} + +func TestApply_MDMBeatsCLIInput(t *testing.T) { + const mdmURL = "https://mdm.example.com:443" + const cliURL = "https://cli.example.com:443" + + cfg := configWithMDM(t, ConfigInput{ + ConfigPath: filepath.Join(t.TempDir(), "config.json"), + ManagementURL: cliURL, + }, mdm.NewPolicy(map[string]any{ + mdm.KeyManagementURL: mdmURL, + })) + + // MDM wins over CLI-supplied management URL. + assert.Equal(t, mdmURL, cfg.ManagementURL.String()) + assert.True(t, cfg.Policy().HasKey(mdm.KeyManagementURL)) +} + +func TestApply_MDMInvalidURL_KeepsPreviousValue(t *testing.T) { + cfg := configWithMDM(t, ConfigInput{ + ConfigPath: filepath.Join(t.TempDir(), "config.json"), + }, mdm.NewPolicy(map[string]any{ + mdm.KeyManagementURL: "not-a-url", + })) + + // Invalid MDM URL is logged and skipped: default URL stays in place + // to keep the client functional. + assert.Equal(t, DefaultManagementURL, cfg.ManagementURL.String()) + + // But the key is still considered MDM-managed (admin intent is to + // enforce, daemon rejects user writes to this field — phase-1 scaffolding + // reflects this by keeping Policy.HasKey true even on parse failure). + assert.True(t, cfg.Policy().HasKey(mdm.KeyManagementURL)) +} + +func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) { + tmp := filepath.Join(t.TempDir(), "config.json") + + // Seed without MDM. + configWithMDM(t, ConfigInput{ + ConfigPath: tmp, + DisableClientRoutes: boolPtr(false), + RosenpassEnabled: boolPtr(false), + }, mdm.NewPolicy(nil)) + + // Now enable MDM enforcement for these keys. + cfg := configWithMDM(t, ConfigInput{ + ConfigPath: tmp, + }, mdm.NewPolicy(map[string]any{ + mdm.KeyDisableClientRoutes: true, + mdm.KeyRosenpassEnabled: true, + })) + + assert.True(t, cfg.DisableClientRoutes, "MDM override should flip on-disk false to true") + assert.True(t, cfg.RosenpassEnabled) + assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableClientRoutes)) + assert.True(t, cfg.Policy().HasKey(mdm.KeyRosenpassEnabled)) +} + +func TestApply_MDMLocalMetrics(t *testing.T) { + tmp := filepath.Join(t.TempDir(), "config.json") + + // Seed without MDM. + configWithMDM(t, ConfigInput{ + ConfigPath: tmp, + LocalMetricsEnabled: boolPtr(false), + }, mdm.NewPolicy(nil)) + + // Now enable MDM enforcement for these keys. + cfg := configWithMDM(t, ConfigInput{ + ConfigPath: tmp, + }, mdm.NewPolicy(map[string]any{ + mdm.KeyEnableLocalMetrics: true, + mdm.KeyLocalMetricsAddress: "127.0.0.1:9292", + })) + + assert.True(t, cfg.LocalMetricsEnabled, "MDM override should flip on-disk false to true") + assert.Equal(t, "127.0.0.1:9292", cfg.LocalMetricsAddress) + assert.True(t, cfg.Policy().HasKey(mdm.KeyEnableLocalMetrics)) + assert.True(t, cfg.Policy().HasKey(mdm.KeyLocalMetricsAddress)) +} + +func TestApply_MDMLazyConnection(t *testing.T) { + cases := []struct { + name string + raw any + want string + }{ + {"native true", true, "on"}, + {"native false", false, "off"}, + {"string on", "on", "on"}, + {"string off", "off", "off"}, + {"string yes", "yes", "on"}, + {"string no", "no", "off"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + cfg := configWithMDM(t, ConfigInput{ + ConfigPath: filepath.Join(t.TempDir(), "config.json"), + }, mdm.NewPolicy(map[string]any{ + mdm.KeyLazyConnection: c.raw, + })) + + assert.Equal(t, c.want, cfg.LazyConnection) + assert.True(t, cfg.Policy().HasKey(mdm.KeyLazyConnection)) + }) + } +} + +func TestApply_MDMPreSharedKeyRedactionSentinelRejected(t *testing.T) { + const maskSentinel = mdm.PreSharedKeyRedactedSentinel + + cfg := configWithMDM(t, ConfigInput{ + ConfigPath: filepath.Join(t.TempDir(), "config.json"), + }, mdm.NewPolicy(map[string]any{ + mdm.KeyPreSharedKey: maskSentinel, + })) + + // Mask sentinel must not be persisted as the actual PSK. + assert.NotEqual(t, maskSentinel, cfg.PreSharedKey) + // Key still marked managed so user writes are still rejected. + assert.True(t, cfg.Policy().HasKey(mdm.KeyPreSharedKey)) +} + +func TestMDMConflicts_PreSharedKey(t *testing.T) { + policy := mdm.NewPolicy(map[string]any{ + mdm.KeyPreSharedKey: "mdm-enforced-psk", + }) + empty := "" + sentinel := mdm.PreSharedKeyRedactedSentinel + same := "mdm-enforced-psk" + other := "user-psk" + + tests := []struct { + name string + psk *string + want []string + }{ + {name: "unset", psk: nil, want: nil}, + {name: "explicit empty", psk: &empty, want: []string{mdm.KeyPreSharedKey}}, + {name: "sentinel echo", psk: &sentinel, want: nil}, + {name: "same value", psk: &same, want: nil}, + {name: "divergent", psk: &other, want: []string{mdm.KeyPreSharedKey}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, MDMConflicts(ConfigInput{PreSharedKey: tc.psk}, policy)) + }) + } +} + +func TestMDMConflicts_RemoteJobsAndLocalMetrics(t *testing.T) { + policy := mdm.NewPolicy(map[string]any{ + mdm.KeyRemoteJobsAllowed: false, + mdm.KeyEnableLocalMetrics: true, + mdm.KeyLocalMetricsAddress: "127.0.0.1:9999", + }) + sameAddr := "127.0.0.1:9999" + otherAddr := "0.0.0.0:9999" + emptyAddr := "" + + tests := []struct { + name string + input ConfigInput + want []string + }{ + {name: "unset", input: ConfigInput{}, want: nil}, + {name: "echo", input: ConfigInput{ + RemoteJobsAllowed: boolPtr(false), + LocalMetricsEnabled: boolPtr(true), + LocalMetricsAddress: &sameAddr, + }, want: nil}, + {name: "remote jobs divergent", input: ConfigInput{RemoteJobsAllowed: boolPtr(true)}, want: []string{mdm.KeyRemoteJobsAllowed}}, + {name: "metrics disabled", input: ConfigInput{LocalMetricsEnabled: boolPtr(false)}, want: []string{mdm.KeyEnableLocalMetrics}}, + {name: "metrics address divergent", input: ConfigInput{LocalMetricsAddress: &otherAddr}, want: []string{mdm.KeyLocalMetricsAddress}}, + {name: "metrics address explicit empty", input: ConfigInput{LocalMetricsAddress: &emptyAddr}, want: []string{mdm.KeyLocalMetricsAddress}}, + {name: "all divergent", input: ConfigInput{ + RemoteJobsAllowed: boolPtr(true), + LocalMetricsEnabled: boolPtr(false), + LocalMetricsAddress: &otherAddr, + }, want: []string{mdm.KeyRemoteJobsAllowed, mdm.KeyEnableLocalMetrics, mdm.KeyLocalMetricsAddress}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, MDMConflicts(tc.input, policy)) + }) + } +} + +func boolPtr(b bool) *bool { return &b } diff --git a/client/internal/profilemanager/config_test.go b/client/internal/profilemanager/config_test.go index 5216f2423..248920b5e 100644 --- a/client/internal/profilemanager/config_test.go +++ b/client/internal/profilemanager/config_test.go @@ -14,6 +14,7 @@ import ( "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/internal/routemanager/dynamic" + "github.com/netbirdio/netbird/client/mdm" "github.com/netbirdio/netbird/util" ) @@ -242,6 +243,112 @@ func TestWireguardPortDefaultVsExplicit(t *testing.T) { } } +func TestUpdateConfigServerSSHAllowedNotSet(t *testing.T) { + // Configs written before ServerSSHAllowed was introduced lack the field and + // unmarshal to nil. Supplying the SSH server flag on top of such a config must + // apply the value instead of panicking on a nil pointer dereference. + tests := []struct { + name string + input *bool + want bool + }{ + {"enable", util.True(), true}, + {"disable", util.False(), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + require.NoError(t, os.WriteFile(configPath, []byte("{}"), 0600)) + + config, err := UpdateConfig(ConfigInput{ + ConfigPath: configPath, + ServerSSHAllowed: tt.input, + }) + require.NoError(t, err) + require.NotNil(t, config.ServerSSHAllowed, "ServerSSHAllowed should be set from input") + assert.Equal(t, tt.want, *config.ServerSSHAllowed) + }) + } +} + +func TestUpdateConfigRemoteJobsAllowed(t *testing.T) { + // Unlike SSH (which defaults on for legacy configs), remote jobs are an + // explicit opt-in: a pre-existing config with no value materializes to off. + t.Run("legacy config defaults off", func(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + require.NoError(t, os.WriteFile(configPath, []byte("{}"), 0600)) + + config, err := UpdateConfig(ConfigInput{ConfigPath: configPath}) + require.NoError(t, err) + require.NotNil(t, config.RemoteJobsAllowed, "RemoteJobsAllowed should be materialized") + assert.False(t, *config.RemoteJobsAllowed, "remote jobs must default off") + }) + + for _, tt := range []struct { + name string + input *bool + want bool + }{ + {"enable", util.True(), true}, + {"disable", util.False(), false}, + } { + t.Run(tt.name, func(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + require.NoError(t, os.WriteFile(configPath, []byte("{}"), 0600)) + + config, err := UpdateConfig(ConfigInput{ConfigPath: configPath, RemoteJobsAllowed: tt.input}) + require.NoError(t, err) + require.NotNil(t, config.RemoteJobsAllowed) + assert.Equal(t, tt.want, *config.RemoteJobsAllowed) + }) + } +} + +func TestApplyMDMPolicyRemoteJobs(t *testing.T) { + t.Run("enables remote jobs and sets the upload URL override", func(t *testing.T) { + cfg := &Config{} + cfg.applyMDMPolicy(mdm.NewPolicy(map[string]any{ + mdm.KeyRemoteJobsAllowed: true, + mdm.KeyBundleUploadURL: "https://upload.example.com", + })) + require.NotNil(t, cfg.RemoteJobsAllowed) + assert.True(t, *cfg.RemoteJobsAllowed, "MDM allowRemoteJobs must enable the flag") + assert.Equal(t, "https://upload.example.com", cfg.DebugBundleUploadURL, "MDM upload URL override must be applied") + }) + + t.Run("a non-https upload URL is rejected", func(t *testing.T) { + cfg := &Config{} + cfg.applyMDMPolicy(mdm.NewPolicy(map[string]any{ + mdm.KeyBundleUploadURL: "http://insecure.example.com", + })) + assert.Empty(t, cfg.DebugBundleUploadURL, "a non-https upload URL must be skipped") + }) + + t.Run("dropping the key clears a previously-applied override", func(t *testing.T) { + cfg := &Config{DebugBundleUploadURL: "https://old.example.com"} + // A replacement policy that no longer carries the key must not leave + // the old upload target directing bundles. + cfg.applyMDMPolicy(mdm.NewPolicy(map[string]any{mdm.KeyRemoteJobsAllowed: true})) + assert.Empty(t, cfg.DebugBundleUploadURL, "the stale upload URL override must be cleared") + }) + + t.Run("an empty replacement policy clears a previously-applied override", func(t *testing.T) { + cfg := &Config{DebugBundleUploadURL: "https://old.example.com"} + // A policy that becomes empty entirely hits the IsEmpty early return; + // the override must still be cleared rather than surviving on the + // reused Config instance. + cfg.applyMDMPolicy(mdm.NewPolicy(map[string]any{})) + assert.Empty(t, cfg.DebugBundleUploadURL, "the stale upload URL override must be cleared when the policy empties") + }) + + t.Run("an invalid upload URL clears a previously-applied override (fail closed)", func(t *testing.T) { + cfg := &Config{DebugBundleUploadURL: "https://old.example.com"} + cfg.applyMDMPolicy(mdm.NewPolicy(map[string]any{mdm.KeyBundleUploadURL: "not-a-url"})) + assert.Empty(t, cfg.DebugBundleUploadURL, "an invalid override must fail closed, not keep the stale target") + }) +} + func TestUpdateOldManagementURL(t *testing.T) { origProber := newMgmProber newMgmProber = func(_ context.Context, _ string, _ wgtypes.Key, _ bool) (mgmProber, error) { diff --git a/client/internal/profilemanager/id.go b/client/internal/profilemanager/id.go new file mode 100644 index 000000000..3b82c8779 --- /dev/null +++ b/client/internal/profilemanager/id.go @@ -0,0 +1,118 @@ +package profilemanager + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "path/filepath" + "strings" + "unicode" + "unicode/utf8" +) + +const ( + // profileIDByteLen is the number of random bytes generated for a new + // profile ID. The resulting hex string is twice this length. + profileIDByteLen = 16 + + // shortIDLen is the number of leading characters of an ID we render in + // list output. Profiles per device are few, so 8 chars is collision-safe + // in practice and easy to type as a prefix. + shortIDLen = 8 + + // maxProfileNameLen caps the human-readable profile name to keep table + // output legible and prevent denial-of-service via huge JSON fields. + maxProfileNameLen = 128 + + // maxProfileIDLen bounds the on-disk filename we'll accept. New + // IDs are 32 hex chars, legacy stems are sanitized profile names. The + // cap is generous enough to cover both without permitting absurdly + // long filenames. + maxProfileIDLen = 64 +) + +type ID string + +// generateProfileID returns a new random hex ID for a profile file. +func generateProfileID() (ID, error) { + buf := make([]byte, profileIDByteLen) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("read random bytes: %w", err) + } + return ID(hex.EncodeToString(buf)), nil +} + +// IsValidProfileFilenameStem reports whether id is safe to use as the stem +// of a profile JSON filename. +func IsValidProfileFilenameStem(id ID) bool { + s := id.String() + if s == "" || len(s) > maxProfileIDLen { + return false + } + if s == defaultProfileName { + return true + } + if strings.ContainsAny(s, `/\`) || strings.Contains(s, "..") { + return false + } + // filepath.Base catches any leftover separators on platforms with + // exotic path conventions. + if filepath.Base(s) != s { + return false + } + for _, r := range s { + if !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-') { + return false + } + } + return true +} + +// sanitizeDisplayName normalizes a user-supplied profile display name for +// storage. It strips ASCII control characters, rejects invalid UTF-8, and +// caps the length. Emojis, spaces, punctuation, and non-ASCII letters are +// preserved. Returns an error if nothing usable remains. +func sanitizeDisplayName(name string) (string, error) { + if !utf8.ValidString(name) { + return "", fmt.Errorf("name is not valid UTF-8") + } + name = StripCtrlChars(name) + name = strings.TrimSpace(name) + if name == "" { + return "", fmt.Errorf("name is empty after sanitization") + } + if utf8.RuneCountInString(name) > maxProfileNameLen { + return "", fmt.Errorf("name exceeds %d characters", maxProfileNameLen) + } + return name, nil +} + +// StripCtrlChars control characters from a name before printing it. +func StripCtrlChars(name string) string { + var b strings.Builder + b.Grow(len(name)) + for _, r := range name { + // Skip C0 controls and DEL, plus C1 controls (0x80–0x9F). + if r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F) { + continue + } + b.WriteRune(r) + } + return b.String() +} + +// ShortID truncates an ID for display. +func (id ID) ShortID() string { + if id == DefaultProfileName { + return DefaultProfileName + } + runes := []rune(id) + if len(runes) <= shortIDLen { + return id.String() + } + return string(runes[:shortIDLen]) +} + +func (id ID) String() string { + return string(id) +} diff --git a/client/internal/profilemanager/invoking_user.go b/client/internal/profilemanager/invoking_user.go new file mode 100644 index 000000000..7ba612ffb --- /dev/null +++ b/client/internal/profilemanager/invoking_user.go @@ -0,0 +1,121 @@ +package profilemanager + +import ( + "fmt" + "os" + "os/user" + "path/filepath" + "runtime" + "strconv" + + log "github.com/sirupsen/logrus" +) + +const envSudoUser = "SUDO_USER" + +var ( + currentUser = user.Current + getegid = os.Getegid + geteuid = os.Geteuid + lookupUser = user.Lookup +) + +// InvokingUser returns the user a CLI invocation acts for. Under sudo that is +// the user who ran sudo, not root: privileged flags force commands through +// sudo, and resolving profiles as root would silently switch the daemon to +// root's (default) profile instead of the invoking user's. An unmapped positive +// process UID uses its numeric kernel identity; root, sudo lookup failures, and +// unavailable platform identities still fail closed. Privilege decisions stay +// on the kernel credentials of the daemon connection, which SUDO_USER (a plain +// environment variable) can never influence; a forged value only selects a +// profile root could select anyway. +func InvokingUser() (*user.User, error) { + if u, ok := sudoInvokingUser(); ok { + return u, nil + } + // Fail closed instead of falling through to root: every caller feeds this + // username into profile-path resolution, so a lookup failure would resolve + // (and create) a root-owned profile namespace and switch the daemon onto it + // behind the invoking user's back. + if sudoActive() { + return nil, fmt.Errorf("resolve sudo invoking user %q: refusing to fall back to root", os.Getenv(envSudoUser)) + } + u, err := currentUser() + if err == nil { + return u, nil + } + + uid := geteuid() + if uid <= 0 { + return nil, err + } + + log.Debugf("current user lookup for UID %d: %v; using numeric UID", uid, err) + uidString := strconv.Itoa(uid) + return &user.User{ + Username: uidString, + Uid: uidString, + Gid: strconv.Itoa(getegid()), + }, nil +} + +// IsPlainRoot reports that the process runs as root with no usable sudo +// context: there is no invoking user to act for, so per-user resolution falls +// back to root's own (empty) state. Callers use it to refuse ambiguous +// operations instead of silently acting on the wrong profile. +func IsPlainRoot() bool { + if geteuid() != 0 { + return false + } + _, ok := sudoInvokingUser() + return !ok +} + +// MirrorIsAuthoritative reports whether the invoking user's local +// active-profile mirror can be trusted as the profile selector. It cannot under +// sudo (writes to it are skipped, so it goes stale) or as plain root (there is +// no invoking user, so it falls back to root's own default). Callers use it to +// decide whether to read the profile from the mirror or from the daemon. +func MirrorIsAuthoritative() bool { + return !sudoActive() && !IsPlainRoot() +} + +// sudoInvokingUser resolves SUDO_USER when the process runs as root under +// sudo. Returns false whenever the sudo context is absent or unusable, in +// which case callers fall back to the process user. +func sudoInvokingUser() (*user.User, bool) { + if !sudoActive() { + return nil, false + } + name := os.Getenv(envSudoUser) + u, err := lookupUser(name) + if err != nil { + log.Warnf("sudo invoking user %q lookup: %v", name, err) + return nil, false + } + return u, true +} + +// sudoActive reports a sudo context from the environment alone: write-skip +// decisions key off it so a transient user lookup failure can never flip a +// run from read-only to writing root-owned files into the user's directory. +func sudoActive() bool { + if geteuid() != 0 { + return false + } + name := os.Getenv(envSudoUser) + return name != "" && name != "root" +} + +// userBaseConfigDir mirrors os.UserConfigDir for a user other than the process +// owner. Environment overrides (XDG_CONFIG_HOME) cannot be honoured here: under +// sudo the environment is root's, not the invoking user's. +func userBaseConfigDir(u *user.User) (string, error) { + if u.HomeDir == "" { + return "", fmt.Errorf("user %s has no home directory", u.Username) + } + if runtime.GOOS == "darwin" { + return filepath.Join(u.HomeDir, "Library", "Application Support"), nil + } + return filepath.Join(u.HomeDir, ".config"), nil +} diff --git a/client/internal/profilemanager/invoking_user_test.go b/client/internal/profilemanager/invoking_user_test.go new file mode 100644 index 000000000..159d2616b --- /dev/null +++ b/client/internal/profilemanager/invoking_user_test.go @@ -0,0 +1,298 @@ +package profilemanager + +import ( + "errors" + "fmt" + "io/fs" + "os" + "os/user" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInvokingUserFallsBackToProcessUser(t *testing.T) { + t.Setenv(envSudoUser, "") + + got, err := InvokingUser() + require.NoError(t, err) + + current, err := user.Current() + require.NoError(t, err) + assert.Equal(t, current.Username, got.Username, "invoking user should match the process user without sudo") +} + +func TestInvokingUserFailsClosedWithoutPositiveUID(t *testing.T) { + for _, uid := range []int{0, -1} { + t.Run(fmt.Sprintf("UID%d", uid), func(t *testing.T) { + t.Setenv(envSudoUser, "") + lookupErr := errors.New("current user unavailable") + fakeUnmappedUser(t, uid, 0, lookupErr) + + got, err := InvokingUser() + require.ErrorIs(t, err, lookupErr) + assert.Nil(t, got, "root or unavailable UID must not become a synthetic identity") + }) + } +} + +func TestProfileFilePathUsesNumericIdentityForUnmappedNonRoot(t *testing.T) { + t.Setenv(envSudoUser, "") + fakeUnmappedUser(t, 1001230000, 0, errors.New("user: unknown userid 1001230000")) + + profilesRoot := t.TempDir() + origDir := DefaultConfigPathDir + origOverride := ConfigDirOverride + DefaultConfigPathDir = profilesRoot + ConfigDirOverride = "" + t.Cleanup(func() { + DefaultConfigPathDir = origDir + ConfigDirOverride = origOverride + }) + + profileID := ID("0123456789abcdef0123456789abcdef") + got, err := (&Profile{ID: profileID}).FilePath() + require.NoError(t, err) + assert.Equal(t, + filepath.Join(profilesRoot, "1001230000", profileID.String()+".json"), + got, + "profile path should use the numeric UID namespace", + ) + + entries, err := os.ReadDir(profilesRoot) + require.NoError(t, err) + require.Len(t, entries, 1, "only the numeric UID directory should be created") + assert.Equal(t, "1001230000", entries[0].Name(), "profile namespace should be numeric") + assert.True(t, entries[0].IsDir(), "profile namespace should be a directory") +} + +func TestSudoInvokingUserInactiveWithoutSudoContext(t *testing.T) { + t.Setenv(envSudoUser, "") + _, ok := sudoInvokingUser() + assert.False(t, ok) +} + +func TestSudoInvokingUserIgnoresRoot(t *testing.T) { + t.Setenv(envSudoUser, "root") + origEuid := geteuid + geteuid = func() int { return 0 } + t.Cleanup(func() { geteuid = origEuid }) + + _, ok := sudoInvokingUser() + assert.False(t, ok, "sudo from a root shell must not redirect anything") + assert.False(t, sudoActive()) + assert.True(t, IsPlainRoot()) +} + +func TestSudoInvokingUserResolvesInvokingUser(t *testing.T) { + fakeSudo(t, filepath.Join("/home", "misha")) + + u, ok := sudoInvokingUser() + require.True(t, ok) + assert.Equal(t, "misha", u.Username) + + got, err := InvokingUser() + require.NoError(t, err) + assert.Equal(t, "misha", got.Username) + + assert.False(t, IsPlainRoot()) +} + +func TestInvokingUserFailsClosedWhenSudoLookupFails(t *testing.T) { + fakeSudo(t, filepath.Join("/home", "misha")) + lookupUser = func(string) (*user.User, error) { return nil, errors.New("nss unavailable") } + + origCurrentUser := currentUser + currentUser = func() (*user.User, error) { + t.Fatal("currentUser must not be called after a sudo lookup failure") + return nil, errors.New("currentUser called unexpectedly") + } + t.Cleanup(func() { currentUser = origCurrentUser }) + + got, err := InvokingUser() + require.Error(t, err) + assert.Nil(t, got, "must not resolve to the root process user") +} + +func TestProfileFilePathFailsClosedWhenSudoLookupFails(t *testing.T) { + profilesRoot := t.TempDir() + fakeSudo(t, filepath.Join("/home", "misha")) + lookupUser = func(string) (*user.User, error) { return nil, errors.New("nss unavailable") } + + origDir := DefaultConfigPathDir + DefaultConfigPathDir = profilesRoot + t.Cleanup(func() { DefaultConfigPathDir = origDir }) + + p := &Profile{ID: "0123456789abcdef0123456789abcdef"} + _, err := p.FilePath() + require.Error(t, err) + assertNoEntries(t, profilesRoot) +} + +func TestSudoActiveSurvivesLookupFailure(t *testing.T) { + fakeSudo(t, filepath.Join("/home", "misha")) + lookupUser = func(string) (*user.User, error) { return nil, errors.New("nss unavailable") } + + _, ok := sudoInvokingUser() + assert.False(t, ok) + assert.True(t, sudoActive()) + assert.True(t, IsPlainRoot()) +} + +func TestGetConfigDirUnderSudoIsReadOnly(t *testing.T) { + home := t.TempDir() + fakeSudo(t, home) + + base, err := baseConfigDir() + require.NoError(t, err) + if runtime.GOOS == "darwin" { + assert.Equal(t, filepath.Join(home, "Library", "Application Support"), base) + } else { + assert.Equal(t, filepath.Join(home, ".config"), base) + } + + dir, err := getConfigDir() + require.NoError(t, err) + assert.Equal(t, filepath.Join(base, "netbird"), dir) + assert.NoDirExists(t, dir) +} + +func TestBaseConfigDirFailsClosedWhenSudoLookupFails(t *testing.T) { + fakeSudo(t, filepath.Join("/home", "misha")) + lookupUser = func(string) (*user.User, error) { return nil, errors.New("nss unavailable") } + + _, err := baseConfigDir() + require.Error(t, err) + + _, err = getConfigDir() + require.Error(t, err) +} + +func TestSwitchProfileSkipsStateWriteUnderSudo(t *testing.T) { + home := t.TempDir() + fakeSudo(t, home) + + pm := NewProfileManager() + require.NoError(t, pm.SwitchProfile(defaultProfileName)) + assertNoEntries(t, home) +} + +func TestSetProfileStateSkipsWriteUnderSudo(t *testing.T) { + home := t.TempDir() + fakeSudo(t, home) + + pm := NewProfileManager() + require.NoError(t, pm.SetProfileState(defaultProfileName, &ProfileState{Email: "misha@example.com"})) + assertNoEntries(t, home) +} + +func TestRemoveProfileStateSkipsRemoveUnderSudo(t *testing.T) { + home := t.TempDir() + stateDir := filepath.Join(home, ".config", "netbird") + if runtime.GOOS == "darwin" { + stateDir = filepath.Join(home, "Library", "Application Support", "netbird") + } + require.NoError(t, os.MkdirAll(stateDir, 0o700)) + stateFile := filepath.Join(stateDir, "default.state.json") + require.NoError(t, os.WriteFile(stateFile, []byte(`{"email":"misha@example.com"}`), 0o600)) + + fakeSudo(t, home) + pm := NewProfileManager() + require.NoError(t, pm.RemoveProfileState("default")) + assert.FileExists(t, stateFile) +} + +func TestUserBaseConfigDir(t *testing.T) { + u := &user.User{Username: "misha", HomeDir: filepath.Join("/home", "misha")} + dir, err := userBaseConfigDir(u) + require.NoError(t, err) + if runtime.GOOS == "darwin" { + assert.Equal(t, filepath.Join(u.HomeDir, "Library", "Application Support"), dir) + } else { + assert.Equal(t, filepath.Join(u.HomeDir, ".config"), dir) + } + + _, err = userBaseConfigDir(&user.User{Username: "nohome"}) + require.Error(t, err) +} + +func TestIsPlainRoot(t *testing.T) { + t.Setenv(envSudoUser, "") + origEuid := geteuid + t.Cleanup(func() { geteuid = origEuid }) + + geteuid = func() int { return 1000 } + assert.False(t, IsPlainRoot()) + + geteuid = func() int { return 0 } + assert.True(t, IsPlainRoot()) +} + +func TestMirrorIsAuthoritative(t *testing.T) { + t.Setenv(envSudoUser, "") + origEuid := geteuid + t.Cleanup(func() { geteuid = origEuid }) + + geteuid = func() int { return 1000 } + assert.True(t, MirrorIsAuthoritative(), "a normal user's own mirror is authoritative") + + geteuid = func() int { return 0 } + assert.False(t, MirrorIsAuthoritative(), "plain root has no authoritative mirror") +} + +func TestMirrorIsAuthoritativeFalseUnderSudo(t *testing.T) { + fakeSudo(t, filepath.Join("/home", "misha")) + assert.False(t, MirrorIsAuthoritative(), "the sudo mirror is frozen, so it is not authoritative") +} + +func fakeSudo(t *testing.T, home string) { + t.Helper() + t.Setenv(envSudoUser, "misha") + + origEuid := geteuid + origLookup := lookupUser + origOverride := ConfigDirOverride + geteuid = func() int { return 0 } + lookupUser = func(name string) (*user.User, error) { + return &user.User{Username: name, Uid: "1234", Gid: "1234", HomeDir: home}, nil + } + ConfigDirOverride = "" + t.Cleanup(func() { + geteuid = origEuid + lookupUser = origLookup + ConfigDirOverride = origOverride + }) +} + +func fakeUnmappedUser(t *testing.T, uid, gid int, lookupErr error) { + t.Helper() + + origCurrentUser := currentUser + origEuid := geteuid + origEgid := getegid + currentUser = func() (*user.User, error) { return nil, lookupErr } + geteuid = func() int { return uid } + getegid = func() int { return gid } + t.Cleanup(func() { + currentUser = origCurrentUser + geteuid = origEuid + getegid = origEgid + }) +} + +func assertNoEntries(t *testing.T, root string) { + t.Helper() + err := filepath.WalkDir(root, func(path string, _ fs.DirEntry, err error) error { + if err != nil { + return err + } + if path != root { + t.Errorf("unexpected entry created under %s: %s", root, path) + } + return nil + }) + require.NoError(t, err) +} diff --git a/client/internal/profilemanager/prefs.go b/client/internal/profilemanager/prefs.go new file mode 100644 index 000000000..5613b0be3 --- /dev/null +++ b/client/internal/profilemanager/prefs.go @@ -0,0 +1,130 @@ +package profilemanager + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + + "github.com/netbirdio/netbird/util" +) + +const prefsFileSuffix = ".prefs.json" + +var prefsMu sync.Mutex + +// Prefs is a namespaced per-profile preference store backed by a single JSON +// file next to the profile config; it is deleted together with the profile. +type Prefs struct { + path string +} + +// ProfilePrefs returns the preference store of the profile identified by id. +func (s *ServiceManager) ProfilePrefs(id ID, username string) (*Prefs, error) { + if !IsValidProfileFilenameStem(id) { + return nil, fmt.Errorf("invalid profile ID: %q", id) + } + if id == defaultProfileName { + return &Prefs{path: filepath.Join(filepath.Dir(DefaultConfigPath), id.String()+prefsFileSuffix)}, nil + } + configDir, err := s.getConfigDir(username) + if err != nil { + return nil, fmt.Errorf("get config directory for user %s: %w", username, err) + } + return &Prefs{path: filepath.Join(configDir, id.String()+prefsFileSuffix)}, nil +} + +// Get unmarshals the namespace section into v and reports whether it exists. +func (p *Prefs) Get(namespace string, v any) (bool, error) { + if namespace == "" { + return false, fmt.Errorf("empty prefs namespace") + } + + prefsMu.Lock() + defer prefsMu.Unlock() + + sections, err := readPrefsFile(p.path) + if err != nil { + return false, err + } + raw, ok := sections[namespace] + if !ok { + return false, nil + } + if err := json.Unmarshal(raw, v); err != nil { + return false, fmt.Errorf("decode prefs namespace %q: %w", namespace, err) + } + return true, nil +} + +// Put stores v as the namespace section, replacing any previous value. +func (p *Prefs) Put(namespace string, v any) error { + if namespace == "" { + return fmt.Errorf("empty prefs namespace") + } + raw, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("encode prefs namespace %q: %w", namespace, err) + } + + prefsMu.Lock() + defer prefsMu.Unlock() + + sections, err := readPrefsFile(p.path) + if err != nil { + return err + } + sections[namespace] = raw + return writePrefsFile(p.path, sections) +} + +// Remove deletes the namespace section; a missing one is not an error. +func (p *Prefs) Remove(namespace string) error { + if namespace == "" { + return fmt.Errorf("empty prefs namespace") + } + + prefsMu.Lock() + defer prefsMu.Unlock() + + sections, err := readPrefsFile(p.path) + if err != nil { + return err + } + if _, ok := sections[namespace]; !ok { + return nil + } + delete(sections, namespace) + return writePrefsFile(p.path, sections) +} + +func removePrefsFile(path string) error { + prefsMu.Lock() + defer prefsMu.Unlock() + return os.Remove(path) +} + +func readPrefsFile(path string) (map[string]json.RawMessage, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return map[string]json.RawMessage{}, nil + } + if err != nil { + return nil, fmt.Errorf("read prefs: %w", err) + } + + sections := map[string]json.RawMessage{} + if err := json.Unmarshal(data, §ions); err != nil { + return nil, fmt.Errorf("decode prefs: %w", err) + } + return sections, nil +} + +func writePrefsFile(path string, sections map[string]json.RawMessage) error { + if err := util.WriteJsonWithRestrictedPermission(context.Background(), path, sections); err != nil { + return fmt.Errorf("write prefs: %w", err) + } + return nil +} diff --git a/client/internal/profilemanager/prefs_test.go b/client/internal/profilemanager/prefs_test.go new file mode 100644 index 000000000..692ade70f --- /dev/null +++ b/client/internal/profilemanager/prefs_test.go @@ -0,0 +1,138 @@ +package profilemanager + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type testPrefsSection struct { + Mode uint8 `json:"mode"` + Dest string `json:"dest"` +} + +func TestProfilePrefs_RoundTrip(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + + prefs, err := sm.ProfilePrefs(created.ID, username) + require.NoError(t, err) + + require.NoError(t, prefs.Put("filedrop", testPrefsSection{Mode: 2, Dest: "/tmp/x"})) + require.NoError(t, prefs.Put("other", map[string]int{"n": 1})) + + var got testPrefsSection + found, err := prefs.Get("filedrop", &got) + require.NoError(t, err) + assert.True(t, found) + assert.Equal(t, testPrefsSection{Mode: 2, Dest: "/tmp/x"}, got) + + var other map[string]int + found, err = prefs.Get("other", &other) + require.NoError(t, err) + assert.True(t, found) + assert.Equal(t, map[string]int{"n": 1}, other) + }) +} + +func TestProfilePrefs_GetMissingNamespace(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + + prefs, err := sm.ProfilePrefs(created.ID, username) + require.NoError(t, err) + + var got testPrefsSection + found, err := prefs.Get("filedrop", &got) + require.NoError(t, err) + assert.False(t, found) + }) +} + +func TestProfilePrefs_RemoveNamespace(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + + prefs, err := sm.ProfilePrefs(created.ID, username) + require.NoError(t, err) + + require.NoError(t, prefs.Put("filedrop", testPrefsSection{Mode: 1})) + require.NoError(t, prefs.Put("other", map[string]int{"n": 1})) + require.NoError(t, prefs.Remove("filedrop")) + require.NoError(t, prefs.Remove("missing")) + + var got testPrefsSection + found, err := prefs.Get("filedrop", &got) + require.NoError(t, err) + assert.False(t, found) + + var other map[string]int + found, err = prefs.Get("other", &other) + require.NoError(t, err) + assert.True(t, found) + assert.Equal(t, map[string]int{"n": 1}, other) + }) +} + +func TestProfilePrefs_RejectsInvalidID(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + _, err := sm.ProfilePrefs("../escape", username) + assert.Error(t, err) + }) +} + +func TestProfilePrefs_RejectsEmptyNamespace(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + + prefs, err := sm.ProfilePrefs(created.ID, username) + require.NoError(t, err) + + _, err = prefs.Get("", &testPrefsSection{}) + assert.Error(t, err) + assert.Error(t, prefs.Put("", testPrefsSection{})) + assert.Error(t, prefs.Remove("")) + }) +} + +func TestProfilePrefs_DefaultProfile(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + prefs, err := sm.ProfilePrefs(defaultProfileName, username) + require.NoError(t, err) + + require.NoError(t, prefs.Put("filedrop", testPrefsSection{Mode: 1})) + + expected := filepath.Join(filepath.Dir(DefaultConfigPath), "default"+prefsFileSuffix) + _, err = os.Stat(expected) + require.NoError(t, err) + }) +} + +func TestRemoveProfile_DeletesPrefsFile(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + + prefs, err := sm.ProfilePrefs(created.ID, username) + require.NoError(t, err) + require.NoError(t, prefs.Put("filedrop", testPrefsSection{Mode: 2})) + + configDir, err := sm.getConfigDir(username) + require.NoError(t, err) + prefsPath := filepath.Join(configDir, created.ID.String()+prefsFileSuffix) + _, err = os.Stat(prefsPath) + require.NoError(t, err) + + require.NoError(t, sm.RemoveProfile(created.ID, username)) + _, err = os.Stat(prefsPath) + assert.True(t, errors.Is(err, os.ErrNotExist), "prefs file should be removed") + }) +} diff --git a/client/internal/profilemanager/profilemanager.go b/client/internal/profilemanager/profilemanager.go index c87f521cb..d2ed92bc5 100644 --- a/client/internal/profilemanager/profilemanager.go +++ b/client/internal/profilemanager/profilemanager.go @@ -3,7 +3,6 @@ package profilemanager import ( "fmt" "os" - "os/user" "path/filepath" "strings" "sync" @@ -19,20 +18,42 @@ const ( ) type Profile struct { - Name string + // ID is the on-disk filename stem (without .json). For new profiles + // it is a 32-char hex string; legacy profiles created before the + // ID-keyed layout keep their original name as their ID. The reserved + // value "default" identifies the special default profile. + ID ID + // Name is the human-readable display name. Falls back to ID when the + // underlying JSON has no "name" field set. + Name string + // Path is the absolute path to the profile JSON. Populated by the + // loader so callers do not have to reconstruct it from ID + dir. + Path string IsActive bool } func (p *Profile) FilePath() (string, error) { - if p.Name == "" { - return "", fmt.Errorf("active profile name is empty") + if p.Path != "" { + return p.Path, nil } - if p.Name == defaultProfileName { + id := p.ID + if id == "" { + id = ID(p.Name) + } + if id == "" { + return "", fmt.Errorf("profile ID is empty") + } + + if id == defaultProfileName { return DefaultConfigPath, nil } - username, err := user.Current() + if !IsValidProfileFilenameStem(id) { + return "", fmt.Errorf("invalid profile ID: %q", id) + } + + username, err := InvokingUser() if err != nil { return "", fmt.Errorf("failed to get current user: %w", err) } @@ -42,10 +63,13 @@ func (p *Profile) FilePath() (string, error) { return "", fmt.Errorf("failed to get config directory for user %s: %w", username.Username, err) } - return filepath.Join(configDir, p.Name+".json"), nil + return filepath.Join(configDir, id.String()+".json"), nil } func (p *Profile) IsDefault() bool { + if p.ID != "" { + return p.ID == defaultProfileName + } return p.Name == defaultProfileName } @@ -57,18 +81,24 @@ func NewProfileManager() *ProfileManager { return &ProfileManager{} } +// GetActiveProfile returns the active profile as recorded in the local +// user state file. Only ID is populated. func (pm *ProfileManager) GetActiveProfile() (*Profile, error) { pm.mu.Lock() defer pm.mu.Unlock() - prof := pm.getActiveProfileState() - return &Profile{Name: prof}, nil + id := pm.getActiveProfileState() + return &Profile{ID: id}, nil } -func (pm *ProfileManager) SwitchProfile(profileName string) error { - profileName = sanitizeProfileName(profileName) +// SwitchProfile records the given profile ID as active in the local user +// state file. +func (pm *ProfileManager) SwitchProfile(id ID) error { + if id != defaultProfileName && !IsValidProfileFilenameStem(id) { + return fmt.Errorf("invalid profile ID: %q", id) + } - if err := pm.setActiveProfileState(profileName); err != nil { + if err := pm.setActiveProfileState(id); err != nil { return fmt.Errorf("failed to switch profile: %w", err) } return nil @@ -85,7 +115,7 @@ func sanitizeProfileName(name string) string { }, name) } -func (pm *ProfileManager) getActiveProfileState() string { +func (pm *ProfileManager) getActiveProfileState() ID { configDir, err := getConfigDir() if err != nil { @@ -99,7 +129,7 @@ func (pm *ProfileManager) getActiveProfileState() string { if err != nil { if !os.IsNotExist(err) { log.Warnf("failed to read active profile state: %v", err) - } else { + } else if !sudoActive() { if err := pm.setActiveProfileState(defaultProfileName); err != nil { log.Warnf("failed to set default profile state: %v", err) } @@ -113,10 +143,17 @@ func (pm *ProfileManager) getActiveProfileState() string { return defaultProfileName } - return profileName + return ID(profileName) } -func (pm *ProfileManager) setActiveProfileState(profileName string) error { +func (pm *ProfileManager) setActiveProfileState(id ID) error { + // The invoking user's state is read-only under sudo — a root-owned file in + // the user's directory would break their own runs. The daemon still records + // the switch on its side; only the user-local bookkeeping is skipped. + if sudoActive() { + log.Infof("running under sudo: not persisting active profile %q for user %s", id, os.Getenv(envSudoUser)) + return nil + } configDir, err := getConfigDir() if err != nil { @@ -125,7 +162,7 @@ func (pm *ProfileManager) setActiveProfileState(profileName string) error { statePath := filepath.Join(configDir, activeProfileStateFilename) - err = os.WriteFile(statePath, []byte(profileName), 0600) + err = os.WriteFile(statePath, []byte(id), 0600) if err != nil { return fmt.Errorf("failed to write active profile state: %w", err) } @@ -142,7 +179,7 @@ func GetLoginHint() string { return "" } - profileState, err := pm.GetProfileState(activeProf.Name) + profileState, err := pm.GetProfileState(activeProf.ID) if err != nil { log.Debugf("failed to get profile state for login hint: %v", err) return "" diff --git a/client/internal/profilemanager/profilemanager_test.go b/client/internal/profilemanager/profilemanager_test.go index 79a7ae650..882a71d0a 100644 --- a/client/internal/profilemanager/profilemanager_test.go +++ b/client/internal/profilemanager/profilemanager_test.go @@ -50,14 +50,14 @@ func TestServiceManager_CreateAndGetDefaultProfile(t *testing.T) { state, err := sm.GetActiveProfileState() assert.NoError(t, err) - assert.Equal(t, state.Name, defaultProfileName) // No active profile state yet + assert.Equal(t, defaultProfileName, state.ID.String()) // No active profile state yet err = sm.SetActiveProfileStateToDefault() assert.NoError(t, err) active, err := sm.GetActiveProfileState() assert.NoError(t, err) - assert.Equal(t, "default", active.Name) + assert.Equal(t, "default", active.ID.String()) }) }) } @@ -92,14 +92,14 @@ func TestServiceManager_SetActiveProfileState(t *testing.T) { currUser, err := user.Current() assert.NoError(t, err) sm := &ServiceManager{} - state := &ActiveProfileState{Name: "foo", Username: currUser.Username} + state := &ActiveProfileState{ID: "foo", Username: currUser.Username} err = sm.SetActiveProfileState(state) assert.NoError(t, err) // Should error on nil or incomplete state err = sm.SetActiveProfileState(nil) assert.Error(t, err) - err = sm.SetActiveProfileState(&ActiveProfileState{Name: "", Username: ""}) + err = sm.SetActiveProfileState(&ActiveProfileState{ID: "", Username: ""}) assert.Error(t, err) }) }) diff --git a/client/internal/profilemanager/service.go b/client/internal/profilemanager/service.go index ef3eb1114..ec287f01a 100644 --- a/client/internal/profilemanager/service.go +++ b/client/internal/profilemanager/service.go @@ -2,6 +2,7 @@ package profilemanager import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -10,6 +11,7 @@ import ( "runtime" "sort" "strings" + "syscall" log "github.com/sirupsen/logrus" @@ -23,12 +25,43 @@ var ( DefaultConfigPathDir = "" DefaultConfigPath = "" ActiveProfileStatePath = "" -) -var ( ErrorOldDefaultConfigNotFound = errors.New("old default config not found") ) +// ErrAmbiguousHandle is returned when a profile handle (ID prefix or name) +// matches more than one profile. Callers can render Candidates to help the +// user disambiguate. +type ErrAmbiguousHandle struct { + Handle string + Candidates []Profile + Kind AmbiguityKind +} + +// AmbiguityKind describes which matcher produced the ambiguity, so callers +// can tailor the error message. +type AmbiguityKind int + +const ( + AmbiguityKindIDPrefix AmbiguityKind = iota + AmbiguityKindName +) + +// profileMeta is the minimal slice of a profile JSON we need, so we avoid +// reading all fields +type profileMeta struct { + Name string +} + +func (e *ErrAmbiguousHandle) Error() string { + switch e.Kind { + case AmbiguityKindIDPrefix: + return fmt.Sprintf("ID prefix %q is ambiguous (matches %d profiles)", e.Handle, len(e.Candidates)) + default: + return fmt.Sprintf("name %q is ambiguous (%d profiles share this name)", e.Handle, len(e.Candidates)) + } +} + func init() { DefaultConfigPathDir = "/var/lib/netbird/" @@ -54,25 +87,34 @@ func init() { } type ActiveProfileState struct { - Name string `json:"name"` + // ID is the on-disk filename stem of the active profile. The JSON tag stays + // as "name" for backwards compatibility with active state files written + // before the ID-based config files. Legacy values were profile names, which + // were also the legacy filename stems, so they still resolve to the correct + // file on disk. + ID ID `json:"name"` Username string `json:"username"` } func (a *ActiveProfileState) FilePath() (string, error) { - if a.Name == "" { - return "", fmt.Errorf("active profile name is empty") + if a.ID == "" { + return "", fmt.Errorf("active profile ID is empty") } - if a.Name == defaultProfileName { + if a.ID == defaultProfileName { return DefaultConfigPath, nil } + if !IsValidProfileFilenameStem(a.ID) { + return "", fmt.Errorf("invalid profile ID: %q", a.ID) + } + configDir, err := getConfigDirForUser(a.Username) if err != nil { return "", fmt.Errorf("failed to get config directory for user %s: %w", a.Username, err) } - return filepath.Join(configDir, a.Name+".json"), nil + return filepath.Join(configDir, a.ID.String()+".json"), nil } type ServiceManager struct { @@ -178,7 +220,7 @@ func (s *ServiceManager) GetActiveProfileState() (*ActiveProfileState, error) { return nil, fmt.Errorf("failed to set active profile to default: %w", err) } return &ActiveProfileState{ - Name: "default", + ID: defaultProfileName, Username: "", }, nil } else { @@ -186,12 +228,12 @@ func (s *ServiceManager) GetActiveProfileState() (*ActiveProfileState, error) { } } - if activeProfile.Name == "" { + if activeProfile.ID == "" { if err := s.SetActiveProfileStateToDefault(); err != nil { return nil, fmt.Errorf("failed to set active profile to default: %w", err) } return &ActiveProfileState{ - Name: "default", + ID: defaultProfileName, Username: "", }, nil } @@ -216,25 +258,29 @@ func (s *ServiceManager) setDefaultActiveState() error { } func (s *ServiceManager) SetActiveProfileState(a *ActiveProfileState) error { - if a == nil || a.Name == "" { + if a == nil || a.ID == "" { return errors.New("invalid active profile state") } - if a.Name != defaultProfileName && a.Username == "" { - return fmt.Errorf("username must be set for non-default profiles, got: %s", a.Name) + if a.ID != defaultProfileName && a.Username == "" { + return fmt.Errorf("username must be set for non-default profiles, got: %s", a.ID) + } + + if a.ID != defaultProfileName && !IsValidProfileFilenameStem(a.ID) { + return fmt.Errorf("invalid profile ID: %q", a.ID) } if err := util.WriteJsonWithRestrictedPermission(context.Background(), ActiveProfileStatePath, a); err != nil { return fmt.Errorf("failed to write active profile state: %w", err) } - log.Infof("active profile set to %s for %s", a.Name, a.Username) + log.Infof("active profile set to %s for %s", a.ID, a.Username) return nil } func (s *ServiceManager) SetActiveProfileStateToDefault() error { return s.SetActiveProfileState(&ActiveProfileState{ - Name: "default", + ID: defaultProfileName, Username: "", }) } @@ -243,57 +289,117 @@ func (s *ServiceManager) DefaultProfilePath() string { return DefaultConfigPath } -func (s *ServiceManager) AddProfile(profileName, username string) error { +// AddProfile creates a new profile with a generated ID. The user-supplied +// displayName is stored inside the JSON's name field, the on-disk filename +// uses the generated ID. +// +// The returned Profile carries the freshly-generated ID so callers can +// show it to the user (and so the gRPC AddProfileResponse can include +// it). +func (s *ServiceManager) AddProfile(displayName, username string) (*Profile, error) { configDir, err := s.getConfigDir(username) if err != nil { - return fmt.Errorf("failed to get config directory: %w", err) + return nil, fmt.Errorf("failed to get config directory: %w", err) } - profileName = sanitizeProfileName(profileName) - - if profileName == defaultProfileName { - return fmt.Errorf("cannot create profile with reserved name: %s", defaultProfileName) - } - - profPath := filepath.Join(configDir, profileName+".json") - profileExists, err := fileExists(profPath) + displayName, err = sanitizeDisplayName(displayName) if err != nil { - return fmt.Errorf("failed to check if profile exists: %w", err) - } - if profileExists { - return ErrProfileAlreadyExists + return nil, fmt.Errorf("invalid profile name: %w", err) } + id, err := generateProfileID() + if err != nil { + return nil, fmt.Errorf("generate profile id: %w", err) + } + + profPath := filepath.Join(configDir, id.String()+".json") cfg, err := createNewConfig(ConfigInput{ConfigPath: profPath}) if err != nil { - return fmt.Errorf("failed to create new config: %w", err) + return nil, fmt.Errorf("failed to create new config: %w", err) + } + cfg.Name = displayName + + if err := util.WriteJson(context.Background(), profPath, cfg); err != nil { + return nil, fmt.Errorf("failed to write profile config: %w", err) } - err = util.WriteJson(context.Background(), profPath, cfg) + return &Profile{ + ID: id, + Name: displayName, + Path: profPath, + }, nil +} + +func (s *ServiceManager) RenameProfile(id ID, username string, newName string) error { + displayName, err := sanitizeDisplayName(newName) if err != nil { - return fmt.Errorf("failed to write profile config: %w", err) + return fmt.Errorf("invalid profile name: %w", err) } + if !IsValidProfileFilenameStem(id) { + return fmt.Errorf("invalid profile ID: %q", id) + } + + profiles, err := s.loadAllProfiles(username) + if err != nil { + return fmt.Errorf("load profiles: %w", err) + } + + var target *Profile + for i := range profiles { + if profiles[i].ID == id { + target = &profiles[i] + break + } + } + if target == nil { + return ErrProfileNotFound + } + + data, err := os.ReadFile(target.Path) + if err != nil { + return err + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return err + } + cfg.Name = displayName + + if err := util.WriteJson(context.Background(), target.Path, cfg); err != nil { + return fmt.Errorf("failed to write profile name: %w", err) + } return nil } -func (s *ServiceManager) RemoveProfile(profileName, username string) error { - configDir, err := s.getConfigDir(username) - if err != nil { - return fmt.Errorf("failed to get config directory: %w", err) +// RemoveProfile deletes the profile identified by id. Callers must have +// already resolved any user-supplied handle to a concrete ID via +// ResolveProfile. +func (s *ServiceManager) RemoveProfile(id ID, username string) error { + if id == defaultProfileName { + defaultName := readProfileName(DefaultConfigPath) + if defaultName == "" { + defaultName = defaultProfileName + } + return fmt.Errorf("cannot remove default profile with name: %s", defaultName) + } + if !IsValidProfileFilenameStem(id) { + return fmt.Errorf("invalid profile ID: %q", id) } - profileName = sanitizeProfileName(profileName) - - if profileName == defaultProfileName { - return fmt.Errorf("cannot remove profile with reserved name: %s", defaultProfileName) - } - profPath := filepath.Join(configDir, profileName+".json") - profileExists, err := fileExists(profPath) + profiles, err := s.loadAllProfiles(username) if err != nil { - return fmt.Errorf("failed to check if profile exists: %w", err) + return fmt.Errorf("load profiles: %w", err) } - if !profileExists { + + var target *Profile + for i := range profiles { + if profiles[i].ID == id { + target = &profiles[i] + break + } + } + if target == nil { return ErrProfileNotFound } @@ -301,57 +407,31 @@ func (s *ServiceManager) RemoveProfile(profileName, username string) error { if err != nil && !errors.Is(err, ErrNoActiveProfile) { return fmt.Errorf("failed to get active profile: %w", err) } - - if activeProf != nil && activeProf.Name == profileName { - return fmt.Errorf("cannot remove active profile: %s", profileName) + if activeProf != nil && activeProf.ID == id { + return fmt.Errorf("cannot remove active profile: %s", id) } - err = util.RemoveJson(profPath) - if err != nil { + if err := util.RemoveJson(target.Path); err != nil { return fmt.Errorf("failed to remove profile config: %w", err) } + + stateFile := filepath.Join(filepath.Dir(target.Path), id.String()+".state.json") + if err := os.Remove(stateFile); err != nil && !os.IsNotExist(err) { + log.Warnf("failed to remove profile state file %s: %v", stateFile, err) + } + + prefsFile := filepath.Join(filepath.Dir(target.Path), id.String()+prefsFileSuffix) + if err := removePrefsFile(prefsFile); err != nil && !os.IsNotExist(err) { + log.Warnf("failed to remove profile prefs file %s: %v", prefsFile, err) + } + return nil } +// ListProfiles returns every profile for the given user, including the +// default profile, with IsActive flags set. func (s *ServiceManager) ListProfiles(username string) ([]Profile, error) { - configDir, err := s.getConfigDir(username) - if err != nil { - return nil, fmt.Errorf("failed to get config directory: %w", err) - } - - files, err := util.ListFiles(configDir, "*.json") - if err != nil { - return nil, fmt.Errorf("failed to list profile files: %w", err) - } - - var filtered []string - for _, file := range files { - if strings.HasSuffix(file, "state.json") { - continue // skip state files - } - filtered = append(filtered, file) - } - sort.Strings(filtered) - - var activeProfName string - activeProf, err := s.GetActiveProfileState() - if err == nil { - activeProfName = activeProf.Name - } - - var profiles []Profile - // add default profile always - profiles = append(profiles, Profile{Name: defaultProfileName, IsActive: activeProfName == "" || activeProfName == defaultProfileName}) - for _, file := range filtered { - profileName := strings.TrimSuffix(filepath.Base(file), ".json") - var isActive bool - if activeProfName != "" && activeProfName == profileName { - isActive = true - } - profiles = append(profiles, Profile{Name: profileName, IsActive: isActive}) - } - - return profiles, nil + return s.loadAllProfiles(username) } // GetStatePath returns the path to the state file based on the operating system @@ -365,11 +445,20 @@ func (s *ServiceManager) GetStatePath() string { activeProf, err := s.GetActiveProfileState() if err != nil { - log.Warnf("failed to get active profile state: %v", err) + if errors.Is(err, syscall.ENOSYS) { + log.Debugf("active profile state unavailable on this platform: %v", err) + } else { + log.Warnf("failed to get active profile state: %v", err) + } return defaultStatePath } - if activeProf.Name == defaultProfileName { + if activeProf.ID == defaultProfileName { + return defaultStatePath + } + + if !IsValidProfileFilenameStem(activeProf.ID) { + log.Warnf("invalid active profile ID %q, using default state path", activeProf.ID) return defaultStatePath } @@ -379,7 +468,7 @@ func (s *ServiceManager) GetStatePath() string { return defaultStatePath } - return filepath.Join(configDir, activeProf.Name+".state.json") + return filepath.Join(configDir, activeProf.ID.String()+".state.json") } // getConfigDir returns the profiles directory, using profilesDir if set, otherwise getConfigDirForUser @@ -390,3 +479,169 @@ func (s *ServiceManager) getConfigDir(username string) (string, error) { return getConfigDirForUser(username) } + +// loadAllProfiles returns every profile visible to the daemon for the +// given user, including the default profile. The returned slice is sorted +// by ID for a stable display order. +// +// Each Profile is fully populated: ID is the filename stem, Name comes +// from the JSON's "name" field (falling back to the filename stem when absent) +// and Path is built from a basename read off disk. +func (s *ServiceManager) loadAllProfiles(username string) ([]Profile, error) { + activeID, activeIsDefault := s.activeProfileID() + defaultName := readProfileName(DefaultConfigPath) + if defaultName == "" { + defaultName = defaultProfileName + } + + profiles := []Profile{{ + ID: defaultProfileName, + Name: defaultName, + Path: DefaultConfigPath, + IsActive: activeIsDefault, + }} + + configDir, err := s.getConfigDir(username) + if err != nil { + return nil, fmt.Errorf("get config directory: %w", err) + } + + entries, err := os.ReadDir(configDir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return profiles, nil + } + return nil, fmt.Errorf("read profile directory: %w", err) + } + + var fileProfiles []Profile + for _, entry := range entries { + if entry.IsDir() { + continue + } + base := entry.Name() + if !strings.HasSuffix(base, ".json") { + continue + } + if strings.HasSuffix(base, ".state.json") { + continue + } + stem := ID(strings.TrimSuffix(base, ".json")) + if stem == defaultProfileName { + // default lives at the top-level config dir, not under / + continue + } + if !IsValidProfileFilenameStem(ID(stem)) { + continue + } + path := filepath.Join(configDir, base) + name := readProfileName(path) + if name == "" { + name = stem.String() + } + fileProfiles = append(fileProfiles, Profile{ + ID: stem, + Name: name, + Path: path, + IsActive: stem == ID(activeID), + }) + } + + sort.Slice(fileProfiles, func(i, j int) bool { + if fileProfiles[i].Name != fileProfiles[j].Name { + return fileProfiles[i].Name < fileProfiles[j].Name + } + // Sort tie-break on ID so duplicate names always render in the same order. + return fileProfiles[i].ID < fileProfiles[j].ID + }) + profiles = append(profiles, fileProfiles...) + return profiles, nil +} + +// readProfileName parses just the "name" field from the profile Json. +func readProfileName(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + var meta profileMeta + if err := json.Unmarshal(data, &meta); err != nil { + return "" + } + return meta.Name +} + +// activeProfileID returns the currently-active profile's ID. The second +// return value is true when the active profile is the default one. +func (s *ServiceManager) activeProfileID() (ID, bool) { + state, err := s.GetActiveProfileState() + if err != nil || state == nil { + return defaultProfileName, true + } + if state.ID == "" || state.ID == defaultProfileName { + return defaultProfileName, true + } + return state.ID, false +} + +// ResolveProfile turns a user-supplied handle into a Profile. Resolution +// precedence is: exact ID match, then unique exact name, then unique ID +// prefix. Ambiguous matches return *ErrAmbiguousHandle so callers can +// surface the candidates. +func (s *ServiceManager) ResolveProfile(handle, username string) (*Profile, error) { + if handle == "" { + return nil, fmt.Errorf("profile handle is empty") + } + + profiles, err := s.loadAllProfiles(username) + if err != nil { + return nil, err + } + + for i := range profiles { + if profiles[i].ID == ID(handle) { + return &profiles[i], nil + } + } + + var nameMatches []Profile + for i := range profiles { + if profiles[i].Name == handle { + nameMatches = append(nameMatches, profiles[i]) + } + } + if len(nameMatches) == 1 { + return &nameMatches[0], nil + } + if len(nameMatches) > 1 { + return nil, &ErrAmbiguousHandle{ + Handle: handle, + Candidates: nameMatches, + Kind: AmbiguityKindName, + } + } + + // ID prefix match. Skip the default profile so `select d` does not + // accidentally pick it via prefix. + var prefixMatches []Profile + for i := range profiles { + if profiles[i].ID == defaultProfileName { + continue + } + if strings.HasPrefix(profiles[i].ID.String(), handle) { + prefixMatches = append(prefixMatches, profiles[i]) + } + } + if len(prefixMatches) == 1 { + return &prefixMatches[0], nil + } + if len(prefixMatches) > 1 { + return nil, &ErrAmbiguousHandle{ + Handle: handle, + Candidates: prefixMatches, + Kind: AmbiguityKindIDPrefix, + } + } + + return nil, ErrProfileNotFound +} diff --git a/client/internal/profilemanager/service_test.go b/client/internal/profilemanager/service_test.go new file mode 100644 index 000000000..5e051b15d --- /dev/null +++ b/client/internal/profilemanager/service_test.go @@ -0,0 +1,230 @@ +package profilemanager + +import ( + "context" + "errors" + "os" + "os/user" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/util" +) + +// withTestSM wires up patched globals + a clean config dir and returns a +// fully initialized ServiceManager plus the username we are scoped to. +func withTestSM(t *testing.T, fn func(sm *ServiceManager, username string)) { + t.Helper() + withTempConfigDir(t, func(configDir string) { + withPatchedGlobals(t, configDir, func() { + u, err := user.Current() + require.NoError(t, err) + sm := &ServiceManager{} + require.NoError(t, sm.CreateDefaultProfile()) + fn(sm, u.Username) + }) + }) +} + +func TestServiceProfile_ExactID(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + + got, err := sm.ResolveProfile(created.ID.String(), username) + require.NoError(t, err) + assert.Equal(t, created.ID, got.ID) + assert.Equal(t, "work", got.Name) + }) +} + +func TestServiceProfile_IDPrefix(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + + prefix := created.ID[:4] + got, err := sm.ResolveProfile(prefix.String(), username) + require.NoError(t, err) + assert.Equal(t, created.ID, got.ID) + }) +} + +func TestServiceProfile_AmbiguousPrefix(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + // Plant two profiles whose IDs share a known prefix by writing + // the files directly, since generated IDs are random. + configDir, err := sm.getConfigDir(username) + require.NoError(t, err) + for _, id := range []string{"abcd1111aaaa", "abcd2222bbbb"} { + path := filepath.Join(configDir, id+".json") + require.NoError(t, util.WriteJson(context.Background(), path, &Config{Name: id})) + } + + _, err = sm.ResolveProfile("abcd", username) + var amb *ErrAmbiguousHandle + require.ErrorAs(t, err, &amb) + assert.Equal(t, AmbiguityKindIDPrefix, amb.Kind) + assert.Len(t, amb.Candidates, 2) + }) +} + +func TestServiceProfile_ExactNameUnique(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + _, err := sm.AddProfile("work", username) + require.NoError(t, err) + + got, err := sm.ResolveProfile("work", username) + require.NoError(t, err) + assert.Equal(t, "work", got.Name) + }) +} + +func TestServiceProfile_AmbiguousName(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + _, err := sm.AddProfile("work", username) + require.NoError(t, err) + _, err = sm.AddProfile("work", username) + require.NoError(t, err) + + _, err = sm.ResolveProfile("work", username) + var amb *ErrAmbiguousHandle + require.ErrorAs(t, err, &amb) + assert.Equal(t, AmbiguityKindName, amb.Kind) + assert.Len(t, amb.Candidates, 2) + }) +} + +func TestServiceProfile_NotFound(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + _, err := sm.ResolveProfile("nope", username) + assert.ErrorIs(t, err, ErrProfileNotFound) + }) +} + +func TestServiceProfile_DefaultByExactID(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + got, err := sm.ResolveProfile(defaultProfileName, username) + require.NoError(t, err) + assert.Equal(t, defaultProfileName, got.ID.String()) + }) +} + +func TestServiceProfile_LegacyFilenameCoexists(t *testing.T) { + // Legacy profiles stored as .json with no "name" JSON field + // should still be discoverable by name and removable by name. + withTestSM(t, func(sm *ServiceManager, username string) { + configDir, err := sm.getConfigDir(username) + require.NoError(t, err) + path := filepath.Join(configDir, "legacy.json") + require.NoError(t, util.WriteJson(context.Background(), path, &Config{})) + + got, err := sm.ResolveProfile("legacy", username) + require.NoError(t, err) + assert.Equal(t, "legacy", got.ID.String()) + // Name falls back to the filename stem when JSON omits it. + assert.Equal(t, "legacy", got.Name) + }) +} + +func TestAddProfile_AllowsDuplicateWithFlag(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + first, err := sm.AddProfile("work", username) + require.NoError(t, err) + + second, err := sm.AddProfile("work", username) + require.NoError(t, err) + assert.NotEqual(t, first.ID, second.ID) + assert.Equal(t, "work", second.Name) + }) +} + +func TestAddProfile_RejectsInvalidNames(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + cases := []string{ + "", // empty + "\x00\x01", // only control chars (becomes empty) + strings.Repeat("a", maxProfileNameLen+1), // too long + } + for _, name := range cases { + _, err := sm.AddProfile(name, username) + assert.Error(t, err, "expected error for %q", name) + } + }) +} + +func TestRemoveProfile_RejectsInvalidID(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + err := sm.RemoveProfile("../escape", username) + assert.Error(t, err) + }) +} + +func TestSanitizeDisplayName(t *testing.T) { + cases := []struct { + in string + want string + wantErr bool + }{ + {"work", "work", false}, + {"My Work Account", "My Work Account", false}, + {"emoji 🚀 ok", "emoji 🚀 ok", false}, + {"漢字テスト", "漢字テスト", false}, + {"with\x00null", "withnull", false}, + {"\x01\x02\x03", "", true}, + {"", "", true}, + } + for _, tc := range cases { + got, err := sanitizeDisplayName(tc.in) + if tc.wantErr { + assert.Error(t, err, "case %q", tc.in) + continue + } + assert.NoError(t, err, "case %q", tc.in) + assert.Equal(t, tc.want, got, "case %q", tc.in) + } +} + +func TestIsValidProfileFilenameStem(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"default", true}, + {"abc123def456", true}, + {"legacy-name", true}, + {"legacy_name", true}, + {"", false}, + {"..", false}, + {"../etc", false}, + {"foo/bar", false}, + {`foo\bar`, false}, + {"with space", false}, + {"with.dot", false}, + {strings.Repeat("a", maxProfileIDLen+1), false}, + } + for _, tc := range cases { + got := IsValidProfileFilenameStem(ID(tc.in)) + assert.Equal(t, tc.want, got, "case %q", tc.in) + } +} + +func TestRemoveProfile_DeletesStateFile(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + + configDir, err := sm.getConfigDir(username) + require.NoError(t, err) + statePath := filepath.Join(configDir, created.ID.String()+".state.json") + require.NoError(t, os.WriteFile(statePath, []byte(`{"email":"a@b"}`), 0600)) + + require.NoError(t, sm.RemoveProfile(created.ID, username)) + _, err = os.Stat(statePath) + assert.True(t, errors.Is(err, os.ErrNotExist), "state file should be removed") + }) +} diff --git a/client/internal/profilemanager/state.go b/client/internal/profilemanager/state.go index f09391ede..81e6c085f 100644 --- a/client/internal/profilemanager/state.go +++ b/client/internal/profilemanager/state.go @@ -4,8 +4,11 @@ import ( "context" "errors" "fmt" + "os" "path/filepath" + log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/util" ) @@ -13,13 +16,20 @@ type ProfileState struct { Email string `json:"email"` } -func (pm *ProfileManager) GetProfileState(profileName string) (*ProfileState, error) { +// GetProfileState reads the per-profile state file keyed by profile ID. +// The state file lives in the user's config directory. Legacy state files +// keyed by the old profile name remain readable. +func (pm *ProfileManager) GetProfileState(id ID) (*ProfileState, error) { configDir, err := getConfigDir() if err != nil { return nil, fmt.Errorf("get config directory: %w", err) } - stateFile := filepath.Join(configDir, profileName+".state.json") + if id != defaultProfileName && !IsValidProfileFilenameStem(id) { + return nil, fmt.Errorf("invalid profile ID: %q", id) + } + + stateFile := filepath.Join(configDir, id.String()+".state.json") stateFileExists, err := fileExists(stateFile) if err != nil { return nil, fmt.Errorf("failed to check if profile state file exists: %w", err) @@ -37,12 +47,44 @@ func (pm *ProfileManager) GetProfileState(profileName string) (*ProfileState, er return &state, nil } -func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error { +// SetProfileState writes the state file of the profile identified by id. Prefer +// it over SetActiveProfileState whenever the caller knows which profile the data +// belongs to: an SSO login spans seconds of user interaction, and the active +// profile can change during it, which would file the account email under +// whichever profile happened to be active when the flow returned. +func (pm *ProfileManager) SetProfileState(id ID, state *ProfileState) error { configDir, err := getConfigDir() if err != nil { return fmt.Errorf("get config directory: %w", err) } + if id == "" { + return fmt.Errorf("empty profile ID") + } + if id != defaultProfileName && !IsValidProfileFilenameStem(id) { + return fmt.Errorf("invalid profile ID: %q", id) + } + + // The invoking user's state is read-only under sudo. The file only carries + // the account email for the login hint and display, so skipping the write + // costs at most one extra account prompt later — a root-owned file in the + // user's directory would cost every later update instead. + if sudoActive() { + log.Debugf("running under sudo: not persisting profile state for user %s", os.Getenv(envSudoUser)) + return nil + } + + stateFile := filepath.Join(configDir, id.String()+".state.json") + if err := util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state); err != nil { + return fmt.Errorf("write profile state: %w", err) + } + + return nil +} + +// SetActiveProfileState writes the state file of whichever profile is active at +// call time. Use SetProfileState when the target profile is known. +func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error { activeProf, err := pm.GetActiveProfile() if err != nil { if errors.Is(err, ErrNoActiveProfile) { @@ -51,10 +93,29 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error { return fmt.Errorf("get active profile: %w", err) } - stateFile := filepath.Join(configDir, activeProf.Name+".state.json") - err = util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state) + return pm.SetProfileState(activeProf.ID, state) +} + +// RemoveProfileState deletes the per-profile state file (which holds the +// account email used for the SSO login hint and the UI display). Called after +// profile removal; logout keeps the file so the next login can pass the email +// as the login_hint. The state file only stores the email, so deleting it is +// equivalent to clearing it; the next SSO login recreates it. A missing file +// is not an error. +func (pm *ProfileManager) RemoveProfileState(profileName string) error { + if sudoActive() { + log.Debugf("running under sudo: not removing profile state for user %s", os.Getenv(envSudoUser)) + return nil + } + + configDir, err := getConfigDir() if err != nil { - return fmt.Errorf("write profile state: %w", err) + return fmt.Errorf("get config directory: %w", err) + } + + stateFile := filepath.Join(configDir, profileName+".state.json") + if err := os.Remove(stateFile); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove profile state: %w", err) } return nil diff --git a/client/internal/relay/relay.go b/client/internal/relay/relay.go index 59be5b0a7..051717608 100644 --- a/client/internal/relay/relay.go +++ b/client/internal/relay/relay.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net" + "strconv" "sync" "time" @@ -31,6 +32,9 @@ type ProbeResult struct { URI string Err error Addr string + // Transport is the negotiated relay transport, empty + // for stun/turn probes or when not connected. + Transport string } type StunTurnProbe struct { @@ -257,7 +261,7 @@ func (p *StunTurnProbe) probeTURN(ctx context.Context, uri *stun.URI) (addr stri } }() - turnServerAddr := fmt.Sprintf("%s:%d", uri.Host, uri.Port) + turnServerAddr := net.JoinHostPort(uri.Host, strconv.Itoa(uri.Port)) var conn net.PacketConn switch uri.Proto { diff --git a/client/internal/rosenpass/manager.go b/client/internal/rosenpass/manager.go index 1faa22dc5..21dd751df 100644 --- a/client/internal/rosenpass/manager.go +++ b/client/internal/rosenpass/manager.go @@ -28,22 +28,33 @@ func hashRosenpassKey(key []byte) string { return hex.EncodeToString(hasher.Sum(nil)) } +// rpServer is the subset of rp.Server used by Manager. Defined as an interface +// so tests can substitute a mock without spinning up a real UDP server. +type rpServer interface { + AddPeer(rp.PeerConfig) (rp.PeerID, error) + RemovePeer(rp.PeerID) error + Run() error + Close() error +} + type Manager struct { ifaceName string + localWgKey wgtypes.Key spk []byte ssk []byte rpKeyHash string preSharedKey *[32]byte rpPeerIDs map[string]*rp.PeerID rpWgHandler *NetbirdHandler - server *rp.Server + server rpServer lock sync.Mutex port int wgIface PresharedKeySetter } -// NewManager creates a new Rosenpass manager -func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error) { +// NewManager creates a new Rosenpass manager. localWgKey is the local +// WireGuard public key, used to derive the per-peer rendezvous key. +func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string, localWgKey wgtypes.Key) (*Manager, error) { public, secret, err := rp.GenerateKeyPair() if err != nil { return nil, err @@ -51,7 +62,23 @@ func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error) rpKeyHash := hashRosenpassKey(public) log.Tracef("generated new rosenpass key pair with public key %s", rpKeyHash) - return &Manager{ifaceName: wgIfaceName, rpKeyHash: rpKeyHash, spk: public, ssk: secret, preSharedKey: (*[32]byte)(preSharedKey), rpPeerIDs: make(map[string]*rp.PeerID), lock: sync.Mutex{}}, nil + return &Manager{ + ifaceName: wgIfaceName, + localWgKey: localWgKey, + rpKeyHash: rpKeyHash, + spk: public, + ssk: secret, + preSharedKey: (*[32]byte)(preSharedKey), + rpPeerIDs: make(map[string]*rp.PeerID), + // rpWgHandler is created here (instead of only in generateConfig) so it + // is never nil between NewManager and Run(). Otherwise an early + // OnConnected call (race observed on Android, issue #4341) panics on + // nil receiver in addPeer -> m.rpWgHandler.AddPeer. generateConfig will + // replace it with a fresh handler on each Run() to clear stale peer + // state from previous engine sessions. + rpWgHandler: NewNetbirdHandler((*[32]byte)(preSharedKey), localWgKey), + lock: sync.Mutex{}, + }, nil } func (m *Manager) GetPubKey() []byte { @@ -65,6 +92,16 @@ func (m *Manager) GetAddress() *net.UDPAddr { // addPeer adds a new peer to the Rosenpass server func (m *Manager) addPeer(rosenpassPubKey []byte, rosenpassAddr string, wireGuardIP string, wireGuardPubKey string) error { + // Defense in depth against issue #4341 (Android crash): if Run() has not + // completed yet, m.server / m.rpWgHandler may be nil. Return an explicit + // error instead of panicking on nil-receiver dereference. + if m.server == nil { + return fmt.Errorf("rosenpass server not initialized") + } + if m.rpWgHandler == nil { + return fmt.Errorf("rosenpass wg handler not initialized") + } + var err error pcfg := rp.PeerConfig{PublicKey: rosenpassPubKey} if m.preSharedKey != nil { @@ -75,10 +112,20 @@ func (m *Manager) addPeer(rosenpassPubKey []byte, rosenpassAddr string, wireGuar if err != nil { return fmt.Errorf("failed to parse rosenpass address: %w", err) } - peerAddr := fmt.Sprintf("%s:%s", wireGuardIP, strPort) + peerAddr := net.JoinHostPort(wireGuardIP, strPort) if pcfg.Endpoint, err = net.ResolveUDPAddr("udp", peerAddr); err != nil { return fmt.Errorf("failed to resolve peer endpoint address: %w", err) } + // Our local Rosenpass UDP server binds on the IPv6 wildcard ([::]) — see + // GetAddress(). The remote peer's endpoint (pcfg.Endpoint) is the destination + // our server will sendto when initiating handshakes. ResolveUDPAddr returns a + // 4-byte IPv4 for IPv4 hosts, which the kernel rejects (EDESTADDRREQ) when + // sent from an AF_INET6 socket. Normalize the remote endpoint to IPv4-mapped + // IPv6 so its address family matches our listening socket. + // TODO: maybe bind the Rosenpass UDP server to the peer wg IP addr + if v4 := pcfg.Endpoint.IP.To4(); v4 != nil { + pcfg.Endpoint.IP = v4.To16() + } } peerID, err := m.server.AddPeer(pcfg) if err != nil { @@ -117,7 +164,7 @@ func (m *Manager) generateConfig() (rp.Config, error) { cfg.Peers = []rp.PeerConfig{} m.lock.Lock() - m.rpWgHandler = NewNetbirdHandler() + m.rpWgHandler = NewNetbirdHandler(m.preSharedKey, m.localWgKey) if m.wgIface != nil { m.rpWgHandler.SetInterface(m.wgIface) } @@ -182,24 +229,31 @@ func (m *Manager) Run() error { return err } - m.server, err = rp.NewUDPServer(conf) + server, err := rp.NewUDPServer(conf) if err != nil { return err } + m.lock.Lock() + m.server = server + m.lock.Unlock() + log.Infof("starting rosenpass server on port %d", m.port) - return m.server.Run() + return server.Run() } // Close closes the Rosenpass server func (m *Manager) Close() error { - if m.server != nil { - err := m.server.Close() - if err != nil { - log.Errorf("failed closing local rosenpass server") - } - m.server = nil + m.lock.Lock() + server := m.server + m.server = nil + m.lock.Unlock() + if server == nil { + return nil + } + if err := server.Close(); err != nil { + log.Errorf("failed closing local rosenpass server: %v", err) } return nil } @@ -259,6 +313,9 @@ func findRandomAvailableUDPPort() (int, error) { } defer conn.Close() - splitAddress := strings.Split(conn.LocalAddr().String(), ":") - return strconv.Atoi(splitAddress[len(splitAddress)-1]) + _, portStr, err := net.SplitHostPort(conn.LocalAddr().String()) + if err != nil { + return 0, fmt.Errorf("parse local address %s: %w", conn.LocalAddr(), err) + } + return strconv.Atoi(portStr) } diff --git a/client/internal/rosenpass/manager_test.go b/client/internal/rosenpass/manager_test.go new file mode 100644 index 000000000..69e18ac88 --- /dev/null +++ b/client/internal/rosenpass/manager_test.go @@ -0,0 +1,412 @@ +package rosenpass + +import ( + "errors" + "os" + "sync" + "testing" + + rp "cunicu.li/go-rosenpass" + "github.com/stretchr/testify/require" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +// --- test doubles ----------------------------------------------------------- + +type addPeerCall struct { + cfg rp.PeerConfig +} + +type removePeerCall struct { + id rp.PeerID +} + +type mockServer struct { + mu sync.Mutex + addCalls []addPeerCall + removed []removePeerCall + nextID rp.PeerID + addErr error + removeErr error + closed bool + ran bool +} + +func (m *mockServer) AddPeer(cfg rp.PeerConfig) (rp.PeerID, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.addCalls = append(m.addCalls, addPeerCall{cfg: cfg}) + if m.addErr != nil { + return rp.PeerID{}, m.addErr + } + // Increment a byte in nextID so distinct peers get distinct IDs. + m.nextID[0]++ + return m.nextID, nil +} + +func (m *mockServer) RemovePeer(id rp.PeerID) error { + m.mu.Lock() + defer m.mu.Unlock() + m.removed = append(m.removed, removePeerCall{id: id}) + return m.removeErr +} + +func (m *mockServer) Run() error { m.ran = true; return nil } +func (m *mockServer) Close() error { m.closed = true; return nil } + +type setPSKCall struct { + peerKey string + psk wgtypes.Key + updateOnly bool +} + +type mockIface struct { + mu sync.Mutex + calls []setPSKCall + err error +} + +func (m *mockIface) SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error { + m.mu.Lock() + defer m.mu.Unlock() + m.calls = append(m.calls, setPSKCall{peerKey: peerKey, psk: psk, updateOnly: updateOnly}) + return m.err +} + +// newTestManager builds a Manager with deterministic spk so tie-break +// against a peer pubkey is controllable from tests. The provided spk byte +// becomes the first byte; remaining bytes are zero. +func newTestManager(spkFirstByte byte, mock *mockServer) *Manager { + spk := make([]byte, 32) + spk[0] = spkFirstByte + return &Manager{ + ifaceName: "wt0", + spk: spk, + ssk: make([]byte, 32), + rpKeyHash: "test-hash", + rpPeerIDs: make(map[string]*rp.PeerID), + rpWgHandler: NewNetbirdHandler(nil, wgtypes.Key{0x01}), + server: mock, + } +} + +// validWGKey returns a deterministic 32-byte wireguard public key (base64). +func validWGKey(t *testing.T, lastByte byte) string { + t.Helper() + var k wgtypes.Key + k[31] = lastByte + return k.String() +} + +// --- pure helpers ---------------------------------------------------------- + +func TestHashRosenpassKey_Deterministic(t *testing.T) { + key := []byte("hello-rosenpass") + require.Equal(t, hashRosenpassKey(key), hashRosenpassKey(key)) + require.Len(t, hashRosenpassKey(key), 64) // sha256 hex +} + +func TestHashRosenpassKey_DifferentInputsDifferOutputs(t *testing.T) { + require.NotEqual(t, hashRosenpassKey([]byte("a")), hashRosenpassKey([]byte("b"))) +} + +func TestGetLogLevel_DefaultWhenUnset(t *testing.T) { + // Snapshot + unset to exercise the LookupEnv ok=false branch. t.Setenv + // can only set, not delete, so do it manually with restore via t.Cleanup. + prev, hadPrev := os.LookupEnv(defaultLogLevelVar) + require.NoError(t, os.Unsetenv(defaultLogLevelVar)) + t.Cleanup(func() { + if hadPrev { + _ = os.Setenv(defaultLogLevelVar, prev) + } else { + _ = os.Unsetenv(defaultLogLevelVar) + } + }) + require.Equal(t, defaultLog.String(), getLogLevel().String()) +} + +func TestGetLogLevel_Cases(t *testing.T) { + cases := map[string]string{ + "debug": "DEBUG", + "info": "INFO", + "warn": "WARN", + "error": "ERROR", + "unknown": "INFO", // default fallback + } + for input, wantStr := range cases { + input, wantStr := input, wantStr + t.Run(input, func(t *testing.T) { + t.Setenv(defaultLogLevelVar, input) + require.Equal(t, wantStr, getLogLevel().String()) + }) + } +} + +func TestFindRandomAvailableUDPPort(t *testing.T) { + port, err := findRandomAvailableUDPPort() + require.NoError(t, err) + require.Greater(t, port, 0) + require.LessOrEqual(t, port, 65535) +} + +// --- addPeer --------------------------------------------------------------- + +func TestAddPeer_HigherLocalPubkey_SetsEndpoint(t *testing.T) { + srv := &mockServer{} + m := newTestManager(0xFF, srv) // local spk lexicographically larger + + remotePubKey := make([]byte, 32) // remote spk = all zeros (smaller) + err := m.addPeer(remotePubKey, "rosenpass-host:7000", "100.1.1.1", validWGKey(t, 1)) + require.NoError(t, err) + require.Len(t, srv.addCalls, 1) + + ep := srv.addCalls[0].cfg.Endpoint + require.NotNil(t, ep, "initiator side must set Endpoint") + require.Equal(t, 7000, ep.Port) + require.Equal(t, "100.1.1.1", ep.IP.String()) +} + +func TestAddPeer_HigherLocalPubkey_EndpointIPIsIPv4Mapped(t *testing.T) { + // Regression guard for the EDESTADDRREQ fix: Endpoint.IP must be 16-byte + // (IPv4-mapped IPv6) so it matches the AF_INET6 listening socket family. + srv := &mockServer{} + m := newTestManager(0xFF, srv) + + err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 1)) + require.NoError(t, err) + + ep := srv.addCalls[0].cfg.Endpoint + require.NotNil(t, ep) + require.Len(t, ep.IP, 16, "IPv4 endpoint must be normalized to 16-byte v4-mapped form") + require.True(t, ep.IP.To4() != nil, "Endpoint must still be detected as IPv4") +} + +func TestAddPeer_LowerLocalPubkey_LeavesEndpointNil(t *testing.T) { + srv := &mockServer{} + m := newTestManager(0x00, srv) // local spk smaller + + remotePubKey := make([]byte, 32) + remotePubKey[0] = 0xFF + err := m.addPeer(remotePubKey, "rp:5000", "100.1.1.1", validWGKey(t, 2)) + require.NoError(t, err) + + require.Nil(t, srv.addCalls[0].cfg.Endpoint, "responder side must NOT set Endpoint") +} + +func TestAddPeer_PresharedKeyPropagated(t *testing.T) { + srv := &mockServer{} + psk := &wgtypes.Key{0x42} + m := newTestManager(0xFF, srv) + m.preSharedKey = (*[32]byte)(psk) + + err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 3)) + require.NoError(t, err) + require.Equal(t, [32]byte(*psk), [32]byte(srv.addCalls[0].cfg.PresharedKey)) +} + +func TestAddPeer_InvalidRosenpassAddr_ReturnsError(t *testing.T) { + srv := &mockServer{} + m := newTestManager(0xFF, srv) // initiator path → parses rosenpassAddr + + err := m.addPeer(make([]byte, 32), "not-a-host-port", "100.1.1.1", validWGKey(t, 1)) + require.Error(t, err) + require.Empty(t, srv.addCalls, "server.AddPeer must not run when address parse fails") +} + +func TestAddPeer_InvalidWireGuardPubKey_ReturnsError(t *testing.T) { + srv := &mockServer{} + m := newTestManager(0xFF, srv) + + err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", "not-a-valid-key") + require.Error(t, err) +} + +func TestAddPeer_ServerError_Propagates(t *testing.T) { + srv := &mockServer{addErr: errors.New("boom")} + m := newTestManager(0xFF, srv) + + err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 1)) + require.Error(t, err) +} + +// Regression guard for issue #4341 (Android crash). If Run() has not completed +// before OnConnected fires, m.rpWgHandler or m.server may be nil. Without the +// nil guards, m.rpWgHandler.AddPeer panics on nil receiver. +func TestAddPeer_NilHandler_ReturnsErrorNoCrash(t *testing.T) { + srv := &mockServer{} + m := newTestManager(0xFF, srv) + m.rpWgHandler = nil // simulate Run() not yet completed + + err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 1)) + require.Error(t, err) + require.Contains(t, err.Error(), "wg handler not initialized") +} + +func TestAddPeer_NilServer_ReturnsErrorNoCrash(t *testing.T) { + m := newTestManager(0xFF, nil) + m.server = nil // simulate Run() not yet completed + + err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 1)) + require.Error(t, err) + require.Contains(t, err.Error(), "server not initialized") +} + +// NewManager must pre-initialize rpWgHandler so the nil-receiver crash from +// issue #4341 cannot occur in the window between NewManager and Run(). +func TestNewManager_PreInitializesHandler(t *testing.T) { + psk := wgtypes.Key{} + m, err := NewManager(&psk, "wt0", wgtypes.Key{0x01}) + require.NoError(t, err) + require.NotNil(t, m.rpWgHandler, "rpWgHandler must be initialized in NewManager") +} + +func TestAddPeer_RecordsPeerID(t *testing.T) { + srv := &mockServer{} + m := newTestManager(0xFF, srv) + + wgKey := validWGKey(t, 5) + err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", wgKey) + require.NoError(t, err) + require.Contains(t, m.rpPeerIDs, wgKey) +} + +// --- OnConnected / OnDisconnected ------------------------------------------ + +func TestOnConnected_NilRemotePubKey_NoAddPeer(t *testing.T) { + srv := &mockServer{} + m := newTestManager(0xFF, srv) + + m.OnConnected(validWGKey(t, 1), nil, "100.1.1.1", "rp:5000") + require.Empty(t, srv.addCalls, "nil remote rosenpass pubkey must skip AddPeer") + require.Empty(t, m.rpPeerIDs) +} + +func TestOnConnected_ValidPubKey_CallsAddPeer(t *testing.T) { + srv := &mockServer{} + m := newTestManager(0xFF, srv) + + wgKey := validWGKey(t, 1) + m.OnConnected(wgKey, make([]byte, 32), "100.1.1.1", "rp:5000") + require.Len(t, srv.addCalls, 1) + require.Contains(t, m.rpPeerIDs, wgKey) +} + +func TestOnDisconnected_UnknownPeer_NoOp(t *testing.T) { + srv := &mockServer{} + m := newTestManager(0xFF, srv) + + m.OnDisconnected(validWGKey(t, 99)) + require.Empty(t, srv.removed, "unknown peer key must not call RemovePeer") +} + +func TestOnDisconnected_KnownPeer_CallsRemoveAndForgets(t *testing.T) { + srv := &mockServer{} + m := newTestManager(0xFF, srv) + + wgKey := validWGKey(t, 1) + require.NoError(t, m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", wgKey)) + require.Contains(t, m.rpPeerIDs, wgKey) + + m.OnDisconnected(wgKey) + require.Len(t, srv.removed, 1) + require.NotContains(t, m.rpPeerIDs, wgKey, "peer must be forgotten after disconnect") +} + +// --- IsPresharedKeyInitialized --------------------------------------------- + +func TestIsPresharedKeyInitialized_UnknownPeer_ReturnsFalse(t *testing.T) { + srv := &mockServer{} + m := newTestManager(0xFF, srv) + require.False(t, m.IsPresharedKeyInitialized(validWGKey(t, 1))) +} + +func TestIsPresharedKeyInitialized_AddedButNotHandshaken_ReturnsFalse(t *testing.T) { + srv := &mockServer{} + m := newTestManager(0xFF, srv) + + wgKey := validWGKey(t, 2) + require.NoError(t, m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", wgKey)) + require.False(t, m.IsPresharedKeyInitialized(wgKey)) +} + +// --- NetbirdHandler.applyKey ---------------------------------------------- + +func TestHandler_ApplyKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) { + h := NewNetbirdHandler(nil, wgtypes.Key{0x01}) + iface := &mockIface{} + h.SetInterface(iface) + + pid := rp.PeerID{0x01} + wgKey := wgtypes.Key{0xAA} + h.AddPeer(pid, "wt0", rp.Key(wgKey)) + + psk := rp.Key{0xBB} + h.HandshakeCompleted(pid, psk) + + require.Len(t, iface.calls, 1) + require.False(t, iface.calls[0].updateOnly, "first PSK rotation must use updateOnly=false") + require.Equal(t, wgKey.String(), iface.calls[0].peerKey) +} + +func TestHandler_ApplyKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) { + h := NewNetbirdHandler(nil, wgtypes.Key{0x01}) + iface := &mockIface{} + h.SetInterface(iface) + + pid := rp.PeerID{0x02} + h.AddPeer(pid, "wt0", rp.Key(wgtypes.Key{0xCC})) + + h.HandshakeCompleted(pid, rp.Key{0x01}) // first + h.HandshakeCompleted(pid, rp.Key{0x02}) // second + + require.Len(t, iface.calls, 2) + require.False(t, iface.calls[0].updateOnly) + require.True(t, iface.calls[1].updateOnly, "subsequent rotations must use updateOnly=true") +} + +func TestHandler_ApplyKey_NilInterface_NoCrashNoCall(t *testing.T) { + h := NewNetbirdHandler(nil, wgtypes.Key{0x01}) + // no SetInterface — iface remains nil + pid := rp.PeerID{0x03} + h.AddPeer(pid, "wt0", rp.Key(wgtypes.Key{})) + + // Must not panic. + h.HandshakeCompleted(pid, rp.Key{}) +} + +func TestHandler_ApplyKey_UnknownPeer_NoCall(t *testing.T) { + h := NewNetbirdHandler(nil, wgtypes.Key{0x01}) + iface := &mockIface{} + h.SetInterface(iface) + + h.HandshakeCompleted(rp.PeerID{0xFF}, rp.Key{}) + require.Empty(t, iface.calls, "unknown peer id must not trigger SetPresharedKey") +} + +func TestHandler_RemovePeer_ClearsInitializedState(t *testing.T) { + h := NewNetbirdHandler(nil, wgtypes.Key{0x01}) + iface := &mockIface{} + h.SetInterface(iface) + + pid := rp.PeerID{0x04} + h.AddPeer(pid, "wt0", rp.Key(wgtypes.Key{0xDD})) + h.HandshakeCompleted(pid, rp.Key{0x01}) + require.True(t, h.IsPeerInitialized(pid)) + + h.RemovePeer(pid) + require.False(t, h.IsPeerInitialized(pid), "RemovePeer must clear initialized flag") +} + +func TestHandler_SetInterfaceAfterAddPeer_StillReceivesKey(t *testing.T) { + h := NewNetbirdHandler(nil, wgtypes.Key{0x01}) + pid := rp.PeerID{0x05} + wgKey := wgtypes.Key{0xEE} + h.AddPeer(pid, "wt0", rp.Key(wgKey)) + + iface := &mockIface{} + h.SetInterface(iface) // set after AddPeer + + h.HandshakeCompleted(pid, rp.Key{0x42}) + require.Len(t, iface.calls, 1) + require.Equal(t, wgKey.String(), iface.calls[0].peerKey) +} diff --git a/client/internal/rosenpass/netbird_handler.go b/client/internal/rosenpass/netbird_handler.go index 9de2409ef..672650ca7 100644 --- a/client/internal/rosenpass/netbird_handler.go +++ b/client/internal/rosenpass/netbird_handler.go @@ -18,19 +18,34 @@ type PresharedKeySetter interface { type wireGuardPeer struct { Interface string PublicKey rp.Key + // initialized is true once a completed exchange has set a + // Rosenpass-managed PSK for this peer. + initialized bool + // chainKey is the key output by the last completed exchange, advanced by + // one ratchet step on expiry. Nil until the first exchange completes and + // after the peer has fallen back to the rendezvous key. + chainKey *wgtypes.Key + // expiries counts failed renewals since the last completed exchange. + expiries int } type NetbirdHandler struct { - mu sync.Mutex - iface PresharedKeySetter - peers map[rp.PeerID]wireGuardPeer - initializedPeers map[rp.PeerID]bool + mu sync.Mutex + iface PresharedKeySetter + // preSharedKey is the account-level preshared key, used as the rendezvous + // key when set. Nil means the deterministic seed key is used instead. + preSharedKey *[32]byte + // localWgKey is the local WireGuard public key, one of the two inputs to + // the deterministic seed key. + localWgKey wgtypes.Key + peers map[rp.PeerID]*wireGuardPeer } -func NewNetbirdHandler() *NetbirdHandler { +func NewNetbirdHandler(preSharedKey *[32]byte, localWgKey wgtypes.Key) *NetbirdHandler { return &NetbirdHandler{ - peers: map[rp.PeerID]wireGuardPeer{}, - initializedPeers: map[rp.PeerID]bool{}, + preSharedKey: preSharedKey, + localWgKey: localWgKey, + peers: map[rp.PeerID]*wireGuardPeer{}, } } @@ -42,10 +57,16 @@ func (h *NetbirdHandler) SetInterface(iface PresharedKeySetter) { h.iface = iface } +// AddPeer registers a peer with the handler. Re-adding a known peer (every +// reconnection does) keeps its key recovery state. func (h *NetbirdHandler) AddPeer(pid rp.PeerID, intf string, pk rp.Key) { h.mu.Lock() defer h.mu.Unlock() - h.peers[pid] = wireGuardPeer{ + if existing, ok := h.peers[pid]; ok && existing.PublicKey == pk { + existing.Interface = intf + return + } + h.peers[pid] = &wireGuardPeer{ Interface: intf, PublicKey: pk, } @@ -55,7 +76,6 @@ func (h *NetbirdHandler) RemovePeer(pid rp.PeerID) { h.mu.Lock() defer h.mu.Unlock() delete(h.peers, pid) - delete(h.initializedPeers, pid) } // IsPeerInitialized returns true if Rosenpass has completed a handshake @@ -63,50 +83,120 @@ func (h *NetbirdHandler) RemovePeer(pid rp.PeerID) { func (h *NetbirdHandler) IsPeerInitialized(pid rp.PeerID) bool { h.mu.Lock() defer h.mu.Unlock() - return h.initializedPeers[pid] + peer, ok := h.peers[pid] + return ok && peer.initialized } +// HandshakeCompleted programs the freshly exchanged output key and resets the +// peer's key recovery state. func (h *NetbirdHandler) HandshakeCompleted(pid rp.PeerID, key rp.Key) { - h.outputKey(rp.KeyOutputReasonStale, pid, key) -} + psk := wgtypes.Key(key) -func (h *NetbirdHandler) HandshakeExpired(pid rp.PeerID) { - key, _ := rp.GeneratePresharedKey() - h.outputKey(rp.KeyOutputReasonStale, pid, key) -} - -func (h *NetbirdHandler) outputKey(_ rp.KeyOutputReason, pid rp.PeerID, psk rp.Key) { h.mu.Lock() - iface := h.iface - wg, ok := h.peers[pid] - isInitialized := h.initializedPeers[pid] - h.mu.Unlock() + defer h.mu.Unlock() - if iface == nil { - log.Warn("rosenpass: interface not set, cannot update preshared key") + peer, ok := h.peers[pid] + if !ok { return } + if peer.expiries > 0 { + log.Infof("rosenpass exchange completed for peer %s after %d expired renewals", wgtypes.Key(peer.PublicKey), peer.expiries) + } + // chainKey tracks the shared exchange output regardless of the local write + // outcome, so both ends still converge on the next expiry. + peer.chainKey = &psk + peer.expiries = 0 + if !h.applyKeyLocked(pid, psk, peer.initialized) { + return + } + peer.initialized = true +} +// HandshakeExpired replaces the expired key. The renewal exchange runs over +// the tunnel keyed by the PSK itself, so the replacement must be derivable on +// both ends without communication: the first expiry ratchets the last shared +// key forward, repeated expiries (and expiries without a completed exchange) +// fall back to the rendezvous key and drop the peer out of the initialized +// state so connection reconfigurations reprogram the rendezvous key as well. +func (h *NetbirdHandler) HandshakeExpired(pid rp.PeerID) { + h.mu.Lock() + defer h.mu.Unlock() + + peer, ok := h.peers[pid] if !ok { return } - peerKey := wgtypes.Key(wg.PublicKey).String() - pskKey := wgtypes.Key(psk) + peer.expiries++ - // Use updateOnly=true for later rotations (peer already has Rosenpass PSK) - // Use updateOnly=false for first rotation (peer has original/empty PSK) - if err := iface.SetPresharedKey(peerKey, pskKey, isInitialized); err != nil { + var psk wgtypes.Key + if peer.chainKey != nil && peer.expiries == 1 { + log.Infof("rosenpass key for peer %s expired without renewal, advancing to ratcheted key", wgtypes.Key(peer.PublicKey)) + psk = RatchetKey(*peer.chainKey) + peer.chainKey = &psk + } else { + rendezvous, err := h.rendezvousKey(peer) + if err != nil { + // Fail closed: without a rendezvous key the expired key must + // still be rotated out, even if the replacement is unusable. + log.Errorf("failed to derive rendezvous key, replacing expired key with a random one: %v", err) + h.applyRandomKeyLocked(pid) + return + } + log.Warnf("rosenpass key for peer %s expired %d times without renewal, falling back to the rendezvous key", wgtypes.Key(peer.PublicKey), peer.expiries) + psk = rendezvous + peer.chainKey = nil + peer.initialized = false + } + + h.applyKeyLocked(pid, psk, true) +} + +// rendezvousKey returns the key both ends converge on without communication: +// the account-level preshared key when configured, the deterministic seed key +// otherwise. It mirrors the key that peer connections program when Rosenpass +// does not manage the peer yet. +func (h *NetbirdHandler) rendezvousKey(peer *wireGuardPeer) (wgtypes.Key, error) { + if h.preSharedKey != nil { + return *h.preSharedKey, nil + } + + seed, err := DeterministicSeedKey(h.localWgKey.String(), wgtypes.Key(peer.PublicKey).String()) + if err != nil { + return wgtypes.Key{}, err + } + return *seed, nil +} + +// applyKeyLocked writes the preshared key for the peer to the WireGuard +// interface and reports whether the write succeeded. Callers must hold h.mu +// for the whole state-mutation-plus-write so that a concurrent completion and +// expiry cannot reorder their writes relative to the in-memory chain key. +func (h *NetbirdHandler) applyKeyLocked(pid rp.PeerID, psk wgtypes.Key, updateOnly bool) bool { + peer, ok := h.peers[pid] + if !ok { + return false + } + + if h.iface == nil { + log.Warn("rosenpass: interface not set, cannot update preshared key") + return false + } + + peerKey := wgtypes.Key(peer.PublicKey).String() + if err := h.iface.SetPresharedKey(peerKey, psk, updateOnly); err != nil { log.Errorf("Failed to apply rosenpass key: %v", err) + return false + } + + return true +} + +func (h *NetbirdHandler) applyRandomKeyLocked(pid rp.PeerID) { + key, err := rp.GeneratePresharedKey() + if err != nil { + log.Errorf("failed to generate random preshared key: %v", err) return } - - // Mark peer as isInitialized after the successful first rotation - if !isInitialized { - h.mu.Lock() - if _, exists := h.peers[pid]; exists { - h.initializedPeers[pid] = true - } - h.mu.Unlock() - } + h.applyKeyLocked(pid, wgtypes.Key(key), true) } diff --git a/client/internal/rosenpass/netbird_handler_test.go b/client/internal/rosenpass/netbird_handler_test.go new file mode 100644 index 000000000..9d91ba93b --- /dev/null +++ b/client/internal/rosenpass/netbird_handler_test.go @@ -0,0 +1,250 @@ +package rosenpass + +import ( + "testing" + + rp "cunicu.li/go-rosenpass" + "github.com/stretchr/testify/require" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +// handlerTestLink wires two NetbirdHandlers as the two ends of a single +// tunnel: handler A manages the rosenpass peer B and vice versa, the way two +// NetBird clients see each other. +type handlerTestLink struct { + handlerA, handlerB *NetbirdHandler + ifaceA, ifaceB *mockIface + pidA, pidB rp.PeerID + wgKeyA, wgKeyB wgtypes.Key +} + +func newHandlerTestLink(t *testing.T, preSharedKey *[32]byte) *handlerTestLink { + t.Helper() + + link := &handlerTestLink{ + ifaceA: &mockIface{}, + ifaceB: &mockIface{}, + } + link.pidA[0] = 0xaa + link.pidB[0] = 0xbb + link.wgKeyA[31] = 1 + link.wgKeyB[31] = 2 + + link.handlerA = NewNetbirdHandler(preSharedKey, link.wgKeyA) + link.handlerB = NewNetbirdHandler(preSharedKey, link.wgKeyB) + + link.handlerA.SetInterface(link.ifaceA) + link.handlerB.SetInterface(link.ifaceB) + + link.handlerA.AddPeer(link.pidB, "wt0", rp.Key(link.wgKeyB)) + link.handlerB.AddPeer(link.pidA, "wt0", rp.Key(link.wgKeyA)) + + return link +} + +// complete simulates a completed rosenpass exchange: both ends derive the +// same output key. +func (l *handlerTestLink) complete(osk rp.Key) { + l.handlerA.HandshakeCompleted(l.pidB, osk) + l.handlerB.HandshakeCompleted(l.pidA, osk) +} + +// expire simulates a failed key renewal on both ends. +func (l *handlerTestLink) expire() { + l.handlerA.HandshakeExpired(l.pidB) + l.handlerB.HandshakeExpired(l.pidA) +} + +func lastPSK(t *testing.T, m *mockIface) wgtypes.Key { + t.Helper() + m.mu.Lock() + defer m.mu.Unlock() + require.NotEmpty(t, m.calls, "expected at least one SetPresharedKey call") + return m.calls[len(m.calls)-1].psk +} + +func TestHandshakeCompleted_SetsKeyAndInitializes(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + require.Equal(t, wgtypes.Key(osk), lastPSK(t, link.ifaceA), "completed exchange must program the osk") + require.False(t, link.ifaceA.calls[0].updateOnly, "first rotation must not be update-only") + require.True(t, link.handlerA.IsPeerInitialized(link.pidB), "peer must be initialized after first completed exchange") + + link.complete(osk) + require.True(t, link.ifaceA.calls[1].updateOnly, "later rotations must be update-only") +} + +// TestHandshakeExpired_BothSidesConverge encodes the core recovery invariant: +// rosenpass renewals run over the tunnel that the PSK itself keys, so when a +// renewal fails on both ends, both ends must fall back to the same key or the +// tunnel can never handshake again. +func TestHandshakeExpired_BothSidesConverge(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + link.expire() + keyA := lastPSK(t, link.ifaceA) + keyB := lastPSK(t, link.ifaceB) + require.NotEqual(t, wgtypes.Key(osk), keyA, "expired key must be rotated out") + require.Equal(t, keyA, keyB, "both ends must converge on the same key after expiry") + + link.expire() + require.Equal(t, lastPSK(t, link.ifaceA), lastPSK(t, link.ifaceB), + "both ends must still converge after repeated expiries") +} + +// TestHandshakeExpired_ExpiryWithoutCompletionConverges covers the bootstrap +// case: the initial exchange never completed (the tunnel ran on the rendezvous +// key), so an expiry must not replace the working key with an unrecoverable +// one on either end. +func TestHandshakeExpired_ExpiryWithoutCompletionConverges(t *testing.T) { + link := newHandlerTestLink(t, nil) + + link.expire() + require.Equal(t, lastPSK(t, link.ifaceA), lastPSK(t, link.ifaceB), + "both ends must converge when the exchange never completed") +} + +// TestHandshakeExpired_RepeatedExpiryClearsInitialized: once renewals keep +// failing, the peer must drop out of the initialized state so the next +// connection reconfiguration reprograms the rendezvous key instead of +// preserving a poisoned rosenpass-managed key. +func TestHandshakeExpired_RepeatedExpiryClearsInitialized(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + link.expire() + link.expire() + + require.False(t, link.handlerA.IsPeerInitialized(link.pidB), + "repeated expiries must clear the initialized state") + require.False(t, link.handlerB.IsPeerInitialized(link.pidA), + "repeated expiries must clear the initialized state") +} + +// TestHandshakeCompleted_AfterExpiryRecovers: a completed exchange after a +// desync must fully reset the recovery state. +func TestHandshakeCompleted_AfterExpiryRecovers(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk1, osk2 rp.Key + osk1[0] = 1 + osk2[0] = 2 + + link.complete(osk1) + link.expire() + link.expire() + + link.complete(osk2) + require.Equal(t, wgtypes.Key(osk2), lastPSK(t, link.ifaceA), "new exchange must program the fresh osk") + require.True(t, link.handlerA.IsPeerInitialized(link.pidB), "peer must be initialized again after recovery") + + link.expire() + require.Equal(t, lastPSK(t, link.ifaceA), lastPSK(t, link.ifaceB), + "recovered link must converge again on the next expiry") + require.NotEqual(t, wgtypes.Key(osk2), lastPSK(t, link.ifaceA), "expired key must be rotated out") +} + +// TestHandshakeExpired_FirstExpiryRatchetsLastKey: the first expiry must +// derive the replacement from the last shared key, so an attacker who only +// blocks the renewal exchange gains nothing over the previous key. +func TestHandshakeExpired_FirstExpiryRatchetsLastKey(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + link.expire() + require.Equal(t, RatchetKey(wgtypes.Key(osk)), lastPSK(t, link.ifaceA), + "first expiry must program the ratcheted key") + require.True(t, link.handlerA.IsPeerInitialized(link.pidB), + "ratchet step must keep the peer initialized so reconfigurations preserve the key") +} + +// TestHandshakeExpired_RepeatedExpiryFallsBackToSeed: once the ratchet key +// also fails, both ends must land on the same key that peer connections +// program for uninitialized peers, so a reconnect completes the recovery. +func TestHandshakeExpired_RepeatedExpiryFallsBackToSeed(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + link.expire() + link.expire() + + seed, err := DeterministicSeedKey(link.wgKeyA.String(), link.wgKeyB.String()) + require.NoError(t, err) + require.Equal(t, *seed, lastPSK(t, link.ifaceA), "repeated expiry must fall back to the seed key") + require.Equal(t, *seed, lastPSK(t, link.ifaceB), "repeated expiry must fall back to the seed key") +} + +// TestHandshakeExpired_ConfiguredPSKUsedAsRendezvous: with an account-level +// preshared key configured, the fallback must be that key, matching what peer +// connections program for uninitialized peers. +func TestHandshakeExpired_ConfiguredPSKUsedAsRendezvous(t *testing.T) { + psk := &[32]byte{0x77} + link := newHandlerTestLink(t, psk) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + link.expire() + link.expire() + + require.Equal(t, wgtypes.Key(*psk), lastPSK(t, link.ifaceA), + "fallback must be the configured preshared key") + require.Equal(t, wgtypes.Key(*psk), lastPSK(t, link.ifaceB), + "fallback must be the configured preshared key on both ends") +} + +// TestHandshakeExpired_ExpiryWritesAreUpdateOnly: expiry replacements must +// never create a WireGuard peer that connection management has removed. +func TestHandshakeExpired_ExpiryWritesAreUpdateOnly(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + link.expire() + link.expire() + + for _, call := range link.ifaceA.calls[1:] { + require.True(t, call.updateOnly, "expiry writes must be update-only") + } +} + +// TestAddPeer_ReAddKeepsRecoveryState: reconnections re-add the peer on every +// OnConnected; that must not reset the expiry chain state. +func TestAddPeer_ReAddKeepsRecoveryState(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + link.expire() + + link.handlerA.AddPeer(link.pidB, "wt0", rp.Key(link.wgKeyB)) + require.True(t, link.handlerA.IsPeerInitialized(link.pidB), + "re-adding a known peer must keep its state") + + link.expire() + seed, err := DeterministicSeedKey(link.wgKeyA.String(), link.wgKeyB.String()) + require.NoError(t, err) + require.Equal(t, *seed, lastPSK(t, link.ifaceA), + "second expiry after re-add must continue to the seed fallback") +} diff --git a/client/internal/rosenpass/seed.go b/client/internal/rosenpass/seed.go new file mode 100644 index 000000000..052c11ed4 --- /dev/null +++ b/client/internal/rosenpass/seed.go @@ -0,0 +1,59 @@ +package rosenpass + +import ( + "crypto/sha256" + "fmt" + + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +// ratchetLabel domain-separates the expiry ratchet from other uses of the +// rosenpass output key. +const ratchetLabel = "netbird-rosenpass-expiry-ratchet" + +// RatchetKey derives the successor preshared key from the previous Rosenpass +// output key. When a key expires without a completed renewal, both peers +// advance their last shared key by one ratchet step: the expired key is +// rotated out while both ends still converge on an identical, non-public +// replacement without communicating. +func RatchetKey(prev wgtypes.Key) wgtypes.Key { + input := make([]byte, 0, len(ratchetLabel)+len(prev)) + input = append(input, ratchetLabel...) + input = append(input, prev[:]...) + return sha256.Sum256(input) +} + +// DeterministicSeedKey derives a 32-byte WireGuard preshared key from a pair +// of peer public keys. Both peers, given the same key pair, produce the same +// output regardless of which side runs the function: the inputs are ordered +// lexicographically before concatenation. +// +// NetBird uses this value as the initial Rosenpass-side preshared key when no +// explicit account-level PSK is configured, so both peers converge on the same +// PSK before the first post-quantum handshake completes. +// +// The resulting key MUST NOT be treated as quantum-safe: it is deterministic +// from public keys and exists only to seed WireGuard until Rosenpass rotates +// in a real post-quantum PSK. +func DeterministicSeedKey(localKey, remoteKey string) (*wgtypes.Key, error) { + lk := []byte(localKey) + rk := []byte(remoteKey) + if len(lk) < 16 || len(rk) < 16 { + return nil, fmt.Errorf("rosenpass: peer keys must be at least 16 bytes (got local=%d, remote=%d)", len(lk), len(rk)) + } + + var keyInput []byte + if localKey > remoteKey { + keyInput = append(keyInput, lk[:16]...) + keyInput = append(keyInput, rk[:16]...) + } else { + keyInput = append(keyInput, rk[:16]...) + keyInput = append(keyInput, lk[:16]...) + } + + key, err := wgtypes.NewKey(keyInput) + if err != nil { + return nil, fmt.Errorf("rosenpass: deterministic seed key: %w", err) + } + return &key, nil +} diff --git a/client/internal/rosenpass/seed_test.go b/client/internal/rosenpass/seed_test.go new file mode 100644 index 000000000..b6a9a5991 --- /dev/null +++ b/client/internal/rosenpass/seed_test.go @@ -0,0 +1,43 @@ +package rosenpass + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDeterministicSeedKey_SameForBothSides(t *testing.T) { + // Peer A and peer B must derive the same PSK regardless of which side + // computes it: the function orders inputs internally. + a := strings.Repeat("a", 32) + b := strings.Repeat("b", 32) + + keyAB, err := DeterministicSeedKey(a, b) + require.NoError(t, err) + keyBA, err := DeterministicSeedKey(b, a) + require.NoError(t, err) + require.Equal(t, keyAB.String(), keyBA.String(), "swapping arguments must yield identical key") +} + +func TestDeterministicSeedKey_ChangesWithKeys(t *testing.T) { + a := strings.Repeat("a", 32) + b := strings.Repeat("b", 32) + c := strings.Repeat("c", 32) + + keyAB, err := DeterministicSeedKey(a, b) + require.NoError(t, err) + keyAC, err := DeterministicSeedKey(a, c) + require.NoError(t, err) + require.NotEqual(t, keyAB.String(), keyAC.String(), "different peer pair must yield different key") +} + +func TestDeterministicSeedKey_TooShortKey_ReturnsError(t *testing.T) { + short := "short" // < 16 bytes + long := strings.Repeat("x", 32) + + _, err := DeterministicSeedKey(short, long) + require.Error(t, err) + _, err = DeterministicSeedKey(long, short) + require.Error(t, err) +} diff --git a/client/internal/routemanager/client/client.go b/client/internal/routemanager/client/client.go index e6ef8b876..c691c54f8 100644 --- a/client/internal/routemanager/client/client.go +++ b/client/internal/routemanager/client/client.go @@ -3,9 +3,8 @@ package client import ( "context" "fmt" - "net" + "net/netip" "reflect" - "strconv" "time" log "github.com/sirupsen/logrus" @@ -566,7 +565,7 @@ func HandlerFromRoute(params common.HandlerParams) RouteHandler { return dnsinterceptor.New(params) case handlerTypeDynamic: dns := nbdns.NewServiceViaMemory(params.WgInterface) - dnsAddr := net.JoinHostPort(dns.RuntimeIP().String(), strconv.Itoa(dns.RuntimePort())) + dnsAddr := netip.AddrPortFrom(dns.RuntimeIP(), uint16(dns.RuntimePort())) return dynamic.NewRoute(params, dnsAddr) default: return static.NewRoute(params) diff --git a/client/internal/routemanager/client/client_bench_test.go b/client/internal/routemanager/client/client_bench_test.go index 591042ac5..668aec427 100644 --- a/client/internal/routemanager/client/client_bench_test.go +++ b/client/internal/routemanager/client/client_bench_test.go @@ -46,7 +46,7 @@ func generateBenchmarkData(tier benchmarkTier) (*peer.Status, map[route.ID]*rout fqdn := fmt.Sprintf("peer-%d.example.com", i) ip := fmt.Sprintf("10.0.%d.%d", i/256, i%256) - err := statusRecorder.AddPeer(peerKey, fqdn, ip) + err := statusRecorder.AddPeer(peerKey, fqdn, ip, "") if err != nil { panic(fmt.Sprintf("failed to add peer: %v", err)) } diff --git a/client/internal/routemanager/dnsinterceptor/handler.go b/client/internal/routemanager/dnsinterceptor/handler.go index 64f2a8789..d20f4b944 100644 --- a/client/internal/routemanager/dnsinterceptor/handler.go +++ b/client/internal/routemanager/dnsinterceptor/handler.go @@ -95,7 +95,7 @@ func (d *DnsInterceptor) RemoveRoute() error { // AllowedIPs should use real IPs if d.currentPeerKey != "" { - if _, err := d.allowedIPsRefcounter.Decrement(prefix); err != nil { + if _, err := d.allowedIPsRefcounter.Decrement(prefix, d.currentPeerKey); err != nil { merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %v", prefix, err)) } } @@ -172,7 +172,7 @@ func (d *DnsInterceptor) removeAllowedIP(realPrefix netip.Prefix) error { } // AllowedIPs use real IPs - if _, err := d.allowedIPsRefcounter.Decrement(realPrefix); err != nil { + if _, err := d.allowedIPsRefcounter.Decrement(realPrefix, d.currentPeerKey); err != nil { return fmt.Errorf("remove allowed IP %s: %v", realPrefix, err) } @@ -205,7 +205,7 @@ func (d *DnsInterceptor) RemoveAllowedIPs() error { for _, prefixes := range d.interceptedDomains { for _, prefix := range prefixes { // AllowedIPs use real IPs - if _, err := d.allowedIPsRefcounter.Decrement(prefix); err != nil { + if _, err := d.allowedIPsRefcounter.Decrement(prefix, d.currentPeerKey); err != nil { merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %v", prefix, err)) } } @@ -226,12 +226,11 @@ func (d *DnsInterceptor) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { return } - // pass if non A/AAAA query - if r.Question[0].Qtype != dns.TypeA && r.Question[0].Qtype != dns.TypeAAAA { - d.continueToNextHandler(w, r, logger, "non A/AAAA query") - return - } - + // All query types for an intercepted domain are forwarded to the peer's + // DNS forwarder, which owns the name. Falling through to the system + // resolver would let it answer NXDOMAIN for a name it isn't authoritative + // for, poisoning the whole name (including the A/AAAA records the route + // does serve). The forwarder answers NODATA for types it cannot resolve. d.mu.RLock() peerKey := d.currentPeerKey d.mu.RUnlock() @@ -251,6 +250,14 @@ func (d *DnsInterceptor) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { r.MsgHdr.AuthenticatedData = true } + // Advertise EDNS0 to the forwarder so it may return an Extended DNS Error + // describing why a lookup failed. The OPT is stripped from the reply when + // the original client did not request EDNS0. + hadEdns := r.IsEdns0() != nil + if !hadEdns { + r.SetEdns0(dns.DefaultMsgSize, false) + } + upstream := net.JoinHostPort(upstreamIP.String(), strconv.FormatUint(uint64(d.forwarderPort.Load()), 10)) ctx, cancel := context.WithTimeout(context.Background(), dnsTimeout) defer cancel() @@ -260,6 +267,13 @@ func (d *DnsInterceptor) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { return } + if ede, ok := resutil.ExtractEDE(reply); ok { + resutil.SetMeta(w, "ede", fmt.Sprintf("%d %s", ede.InfoCode, ede.ExtraText)) + } + if !hadEdns { + resutil.StripOPT(reply) + } + resutil.SetMeta(w, "peer", peerKey) reply.Id = r.Id @@ -278,19 +292,6 @@ func (d *DnsInterceptor) writeDNSError(w dns.ResponseWriter, r *dns.Msg, logger } } -// continueToNextHandler signals the handler chain to try the next handler -func (d *DnsInterceptor) continueToNextHandler(w dns.ResponseWriter, r *dns.Msg, logger *log.Entry, reason string) { - logger.Tracef("continuing to next handler for domain=%s reason=%s", r.Question[0].Name, reason) - - resp := new(dns.Msg) - resp.SetRcode(r, dns.RcodeNameError) - // Set Zero bit to signal handler chain to continue - resp.MsgHdr.Zero = true - if err := w.WriteMsg(resp); err != nil { - logger.Errorf("failed writing DNS continue response: %v", err) - } -} - func (d *DnsInterceptor) getUpstreamIP(peerKey string) (netip.Addr, error) { peerAllowedIP, exists := d.peerStore.AllowedIP(peerKey) if !exists { @@ -478,7 +479,7 @@ func (d *DnsInterceptor) removeDNATMappings(realPrefixes []netip.Prefix, logger // internalDnatFw checks if the firewall supports internal DNAT func (d *DnsInterceptor) internalDnatFw() (internalDNATer, bool) { - if d.firewall == nil || runtime.GOOS != "android" { + if d.firewall == nil || d.fakeIPManager == nil || runtime.GOOS != "android" { return nil, false } fw, ok := d.firewall.(internalDNATer) @@ -582,7 +583,7 @@ func (d *DnsInterceptor) queryUpstreamDNS(ctx context.Context, w dns.ResponseWri if nsNet != nil { reply, err = nbdns.ExchangeWithNetstack(ctx, nsNet, r, upstream) } else { - client, clientErr := nbdns.GetClientPrivate(d.wgInterface.Address().IP, d.wgInterface.Name(), dnsTimeout) + client, clientErr := nbdns.GetClientPrivate(d.wgInterface, upstreamIP, dnsTimeout) if clientErr != nil { d.writeDNSError(w, r, logger, fmt.Sprintf("create DNS client: %v", clientErr)) return nil diff --git a/client/internal/routemanager/dynamic/route.go b/client/internal/routemanager/dynamic/route.go index 8d1398a7a..bb3b1c59c 100644 --- a/client/internal/routemanager/dynamic/route.go +++ b/client/internal/routemanager/dynamic/route.go @@ -50,10 +50,10 @@ type Route struct { cancel context.CancelFunc statusRecorder *peer.Status wgInterface iface.WGIface - resolverAddr string + resolverAddr netip.AddrPort } -func NewRoute(params common.HandlerParams, resolverAddr string) *Route { +func NewRoute(params common.HandlerParams, resolverAddr netip.AddrPort) *Route { return &Route{ route: params.Route, routeRefCounter: params.RouteRefCounter, @@ -135,7 +135,7 @@ func (r *Route) RemoveAllowedIPs() error { var merr *multierror.Error for _, domainPrefixes := range r.dynamicDomains { for _, prefix := range domainPrefixes { - if _, err := r.allowedIPsRefcounter.Decrement(prefix); err != nil { + if _, err := r.allowedIPsRefcounter.Decrement(prefix, r.currentPeerKey); err != nil { merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %w", prefix, err)) } } @@ -185,7 +185,7 @@ func (r *Route) startResolver(ctx context.Context) { } func (r *Route) update(ctx context.Context) error { - resolved, err := r.resolveDomains() + resolved, err := r.resolveDomains(ctx) if err != nil { if len(resolved) == 0 { return fmt.Errorf("resolve domains: %w", err) @@ -199,9 +199,9 @@ func (r *Route) update(ctx context.Context) error { return nil } -func (r *Route) resolveDomains() (domainMap, error) { +func (r *Route) resolveDomains(ctx context.Context) (domainMap, error) { results := make(chan resolveResult) - go r.resolve(results) + go r.resolve(ctx, results) resolved := domainMap{} var merr *multierror.Error @@ -217,7 +217,7 @@ func (r *Route) resolveDomains() (domainMap, error) { return resolved, nberrors.FormatErrorOrNil(merr) } -func (r *Route) resolve(results chan resolveResult) { +func (r *Route) resolve(ctx context.Context, results chan resolveResult) { var wg sync.WaitGroup for _, d := range r.route.Domains { @@ -225,10 +225,10 @@ func (r *Route) resolve(results chan resolveResult) { go func(domain domain.Domain) { defer wg.Done() - ips, err := r.getIPsFromResolver(domain) + ips, err := r.getIPsFromResolver(ctx, domain) if err != nil { log.Tracef("Failed to resolve domain %s with private resolver: %v", domain.SafeString(), err) - ips, err = net.LookupIP(domain.PunycodeString()) + ips, err = lookupHostIPs(ctx, domain) if err != nil { results <- resolveResult{domain: domain, err: fmt.Errorf("resolve d %s: %w", domain.SafeString(), err)} return @@ -320,7 +320,7 @@ func (r *Route) removeRoutes(prefixes []netip.Prefix) ([]netip.Prefix, error) { merr = multierror.Append(merr, fmt.Errorf("remove dynamic route for IP %s: %w", prefix, err)) } if r.currentPeerKey != "" { - if _, err := r.allowedIPsRefcounter.Decrement(prefix); err != nil { + if _, err := r.allowedIPsRefcounter.Decrement(prefix, r.currentPeerKey); err != nil { merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %w", prefix, err)) } } @@ -364,6 +364,20 @@ func determinePrefixChanges(oldPrefixes, newPrefixes []netip.Prefix) (toAdd, toR return } +// lookupHostIPs resolves d via the system resolver, honoring ctx cancellation. +func lookupHostIPs(ctx context.Context, d domain.Domain) ([]net.IP, error) { + addrs, err := net.DefaultResolver.LookupIPAddr(ctx, d.PunycodeString()) + if err != nil { + return nil, err + } + + ips := make([]net.IP, 0, len(addrs)) + for _, addr := range addrs { + ips = append(ips, addr.IP) + } + return ips, nil +} + func combinePrefixes(oldPrefixes, removedPrefixes, addedPrefixes []netip.Prefix) []netip.Prefix { prefixSet := make(map[netip.Prefix]struct{}) for _, prefix := range oldPrefixes { diff --git a/client/internal/routemanager/dynamic/route_generic.go b/client/internal/routemanager/dynamic/route_generic.go index 56fd63fba..8bc2dd3df 100644 --- a/client/internal/routemanager/dynamic/route_generic.go +++ b/client/internal/routemanager/dynamic/route_generic.go @@ -3,11 +3,12 @@ package dynamic import ( + "context" "net" "github.com/netbirdio/netbird/shared/management/domain" ) -func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) { - return net.LookupIP(domain.PunycodeString()) +func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) { + return lookupHostIPs(ctx, domain) } diff --git a/client/internal/routemanager/dynamic/route_ios.go b/client/internal/routemanager/dynamic/route_ios.go index 8fed1c8f9..6a3d262b8 100644 --- a/client/internal/routemanager/dynamic/route_ios.go +++ b/client/internal/routemanager/dynamic/route_ios.go @@ -3,6 +3,7 @@ package dynamic import ( + "context" "fmt" "net" "time" @@ -16,38 +17,48 @@ import ( const dialTimeout = 10 * time.Second -func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) { - privateClient, err := nbdns.GetClientPrivate(r.wgInterface.Address().IP, r.wgInterface.Name(), dialTimeout) +func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) { + privateClient, err := nbdns.GetClientPrivate(r.wgInterface, r.resolverAddr.Addr(), dialTimeout) if err != nil { return nil, fmt.Errorf("error while creating private client: %s", err) } - msg := new(dns.Msg) - msg.SetQuestion(dns.Fqdn(domain.PunycodeString()), dns.TypeA) - + fqdn := dns.Fqdn(domain.PunycodeString()) startTime := time.Now() - response, _, err := nbdns.ExchangeWithFallback(nil, privateClient, msg, r.resolverAddr) - if err != nil { - return nil, fmt.Errorf("DNS query for %s failed after %s: %s ", domain.SafeString(), time.Since(startTime), err) - } + var ips []net.IP + var queryErr error - if response.Rcode != dns.RcodeSuccess { - return nil, fmt.Errorf("dns response code: %s", dns.RcodeToString[response.Rcode]) - } + for _, qtype := range []uint16{dns.TypeA, dns.TypeAAAA} { + msg := new(dns.Msg) + msg.SetQuestion(fqdn, qtype) - ips := make([]net.IP, 0) - - for _, answ := range response.Answer { - if aRecord, ok := answ.(*dns.A); ok { - ips = append(ips, aRecord.A) + response, _, err := nbdns.ExchangeWithFallback(ctx, privateClient, msg, r.resolverAddr.String()) + if err != nil { + if queryErr == nil { + queryErr = fmt.Errorf("DNS query for %s (type %d) after %s: %w", domain.SafeString(), qtype, time.Since(startTime), err) + } + continue } - if aaaaRecord, ok := answ.(*dns.AAAA); ok { - ips = append(ips, aaaaRecord.AAAA) + + if response.Rcode != dns.RcodeSuccess { + continue + } + + for _, answ := range response.Answer { + if aRecord, ok := answ.(*dns.A); ok { + ips = append(ips, aRecord.A) + } + if aaaaRecord, ok := answ.(*dns.AAAA); ok { + ips = append(ips, aaaaRecord.AAAA) + } } } if len(ips) == 0 { + if queryErr != nil { + return nil, queryErr + } return nil, fmt.Errorf("no A or AAAA records found for %s", domain.SafeString()) } diff --git a/client/internal/routemanager/exit_node_selection_test.go b/client/internal/routemanager/exit_node_selection_test.go new file mode 100644 index 000000000..28dd0a640 --- /dev/null +++ b/client/internal/routemanager/exit_node_selection_test.go @@ -0,0 +1,191 @@ +package routemanager + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/routeselector" + "github.com/netbirdio/netbird/route" +) + +func newExitNodeTestManager() *DefaultManager { + return &DefaultManager{routeSelector: routeselector.NewRouteSelector()} +} + +func exitRoute(netID, peer string, skipAutoApply bool) *route.Route { + return &route.Route{ + NetID: route.NetID(netID), + Network: netip.MustParsePrefix("0.0.0.0/0"), + Peer: peer, + SkipAutoApply: skipAutoApply, + } +} + +func TestPickPreferredExitNode(t *testing.T) { + tests := []struct { + name string + info exitNodeInfo + want route.NetID + }{ + { + name: "persisted user selection wins over management", + info: exitNodeInfo{ + allIDs: []route.NetID{"a", "b", "c"}, + userSelected: []route.NetID{"b"}, + selectedByManagement: []route.NetID{"a"}, + }, + want: "b", + }, + { + name: "multiple user-selected self-heal to deterministic min", + info: exitNodeInfo{ + allIDs: []route.NetID{"a", "b", "c"}, + userSelected: []route.NetID{"c", "a"}, + }, + want: "a", + }, + { + name: "explicit opt-out keeps none", + info: exitNodeInfo{ + allIDs: []route.NetID{"a", "b"}, + userDeselected: []route.NetID{"a", "b"}, + }, + want: "", + }, + { + name: "fresh defaults to management auto-apply pick", + info: exitNodeInfo{ + allIDs: []route.NetID{"a", "b", "c"}, + selectedByManagement: []route.NetID{"b"}, + }, + want: "b", + }, + { + name: "no user pick and no management auto-apply selects none", + info: exitNodeInfo{ + allIDs: []route.NetID{"c", "a", "b"}, + }, + want: "", + }, + { + name: "user-deselect does not block a management auto-apply sibling", + info: exitNodeInfo{ + allIDs: []route.NetID{"a", "b"}, + userDeselected: []route.NetID{"a"}, + selectedByManagement: []route.NetID{"b"}, + }, + want: "b", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, pickPreferredExitNode(tt.info), "preferred exit node") + }) + } +} + +func TestEnforceSingleExitNode(t *testing.T) { + m := newExitNodeTestManager() + all := []route.NetID{"a", "b", "c"} + + m.enforceSingleExitNode("b", all) + assert.False(t, m.routeSelector.IsSelected("a"), "a should be deselected") + assert.True(t, m.routeSelector.IsSelected("b"), "b should be the only selected exit node") + assert.False(t, m.routeSelector.IsSelected("c"), "c should be deselected") + + // Switching the preferred node moves the single selection. + m.enforceSingleExitNode("c", all) + assert.False(t, m.routeSelector.IsSelected("a"), "a stays deselected") + assert.False(t, m.routeSelector.IsSelected("b"), "b should now be deselected") + assert.True(t, m.routeSelector.IsSelected("c"), "c should now be selected") + + // Empty preferred turns every exit node off. + m.enforceSingleExitNode("", all) + for _, id := range all { + assert.False(t, m.routeSelector.IsSelected(id), "no exit node should be selected") + } +} + +func TestEnforceSingleExitNode_RespectsDeselectAll(t *testing.T) { + m := newExitNodeTestManager() + m.routeSelector.DeselectAllRoutes() + + m.enforceSingleExitNode("b", []route.NetID{"a", "b"}) + + assert.True(t, m.routeSelector.IsDeselectAll(), "global deselect-all must stay in effect") + assert.False(t, m.routeSelector.IsSelected("b"), "no exit node should be forced on while deselect-all is set") +} + +func TestUpdateRouteSelectorFromManagement_FreshSelectsOne(t *testing.T) { + m := newExitNodeTestManager() + routes := route.HAMap{ + "exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)}, + "exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)}, + "lan|192.168.1.0/24": {{NetID: "lan", Network: netip.MustParsePrefix("192.168.1.0/24"), Peer: "p3"}}, + "exitC|0.0.0.0/0": {exitRoute("exitC", "p4", false)}, + } + + m.updateRouteSelectorFromManagement(routes) + + // Exactly one exit node (the deterministic first) is selected. + assert.True(t, m.routeSelector.IsSelected("exitA"), "exitA is the deterministic default") + assert.False(t, m.routeSelector.IsSelected("exitB"), "exitB must not also be selected") + assert.False(t, m.routeSelector.IsSelected("exitC"), "exitC must not also be selected") + // Non-exit routes are left at their default-on state. + assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit route selection is untouched") +} + +func TestUpdateRouteSelectorFromManagement_HonorsPersistedPick(t *testing.T) { + m := newExitNodeTestManager() + routes := route.HAMap{ + "exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)}, + "exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)}, + } + all := []route.NetID{"exitA", "exitB"} + + // Simulate the state the runtime select path leaves behind: exactly one + // exit node explicitly selected, its sibling deselected. + require.NoError(t, m.routeSelector.SelectRoutes([]route.NetID{"exitB"}, true, all)) + require.NoError(t, m.routeSelector.DeselectRoutes([]route.NetID{"exitA"}, all)) + + m.updateRouteSelectorFromManagement(routes) + + assert.True(t, m.routeSelector.IsSelected("exitB"), "persisted pick must stay selected") + assert.False(t, m.routeSelector.IsSelected("exitA"), "the other exit node stays deselected") +} + +func TestUpdateRouteSelectorFromManagement_OptOutKeepsNone(t *testing.T) { + m := newExitNodeTestManager() + routes := route.HAMap{ + "exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)}, + "exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)}, + } + all := []route.NetID{"exitA", "exitB"} + + // User deselected exit nodes and selected none. + require.NoError(t, m.routeSelector.DeselectRoutes(all, all)) + + m.updateRouteSelectorFromManagement(routes) + + assert.False(t, m.routeSelector.IsSelected("exitA"), "opt-out keeps exitA off") + assert.False(t, m.routeSelector.IsSelected("exitB"), "opt-out keeps exitB off") +} + +func TestUpdateRouteSelectorFromManagement_NoAutoApplySelectsNone(t *testing.T) { + m := newExitNodeTestManager() + // SkipAutoApply=true: management offers the exit nodes but doesn't request + // auto-activation, so none should be selected until the user picks one. + routes := route.HAMap{ + "exitA|0.0.0.0/0": {exitRoute("exitA", "p1", true)}, + "exitB|0.0.0.0/0": {exitRoute("exitB", "p2", true)}, + } + + m.updateRouteSelectorFromManagement(routes) + + assert.False(t, m.routeSelector.IsSelected("exitA"), "no auto-apply keeps exitA off") + assert.False(t, m.routeSelector.IsSelected("exitB"), "no auto-apply keeps exitB off") +} diff --git a/client/internal/routemanager/fakeip/fakeip.go b/client/internal/routemanager/fakeip/fakeip.go index 1592045d2..5be4ca12e 100644 --- a/client/internal/routemanager/fakeip/fakeip.go +++ b/client/internal/routemanager/fakeip/fakeip.go @@ -1,93 +1,145 @@ package fakeip import ( + "errors" "fmt" "net/netip" "sync" ) -// Manager manages allocation of fake IPs from the 240.0.0.0/8 block -type Manager struct { - mu sync.Mutex - nextIP netip.Addr // Next IP to allocate +var ( + // 240.0.0.1 - 240.255.255.254, block 240.0.0.0/8 (reserved, RFC 1112) + v4Base = netip.AddrFrom4([4]byte{240, 0, 0, 1}) + v4Max = netip.AddrFrom4([4]byte{240, 255, 255, 254}) + v4Block = netip.PrefixFrom(netip.AddrFrom4([4]byte{240, 0, 0, 0}), 8) + + // 0100::1 - 0100::ffff:ffff:ffff:fffe, block 0100::/64 (discard, RFC 6666) + v6Base = netip.AddrFrom16([16]byte{0x01, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01}) + v6Max = netip.AddrFrom16([16]byte{0x01, 0x00, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe}) + v6Block = netip.PrefixFrom(netip.AddrFrom16([16]byte{0x01, 0x00}), 64) +) + +// fakeIPPool holds the allocation state for a single address family. +type fakeIPPool struct { + nextIP netip.Addr + baseIP netip.Addr + maxIP netip.Addr + block netip.Prefix allocated map[netip.Addr]netip.Addr // real IP -> fake IP fakeToReal map[netip.Addr]netip.Addr // fake IP -> real IP - baseIP netip.Addr // First usable IP: 240.0.0.1 - maxIP netip.Addr // Last usable IP: 240.255.255.254 } -// NewManager creates a new fake IP manager using 240.0.0.0/8 block -func NewManager() *Manager { - baseIP := netip.AddrFrom4([4]byte{240, 0, 0, 1}) - maxIP := netip.AddrFrom4([4]byte{240, 255, 255, 254}) - - return &Manager{ - nextIP: baseIP, +func newPool(base, maxAddr netip.Addr, block netip.Prefix) *fakeIPPool { + return &fakeIPPool{ + nextIP: base, + baseIP: base, + maxIP: maxAddr, + block: block, allocated: make(map[netip.Addr]netip.Addr), fakeToReal: make(map[netip.Addr]netip.Addr), - baseIP: baseIP, - maxIP: maxIP, } } -// AllocateFakeIP allocates a fake IP for the given real IP -// Returns the fake IP, or existing fake IP if already allocated -func (m *Manager) AllocateFakeIP(realIP netip.Addr) (netip.Addr, error) { - if !realIP.Is4() { - return netip.Addr{}, fmt.Errorf("only IPv4 addresses supported") - } - - m.mu.Lock() - defer m.mu.Unlock() - - if fakeIP, exists := m.allocated[realIP]; exists { +// allocate allocates a fake IP for the given real IP. +// Returns the existing fake IP if already allocated. +func (p *fakeIPPool) allocate(realIP netip.Addr) (netip.Addr, error) { + if fakeIP, exists := p.allocated[realIP]; exists { return fakeIP, nil } - startIP := m.nextIP + startIP := p.nextIP for { - currentIP := m.nextIP + currentIP := p.nextIP // Advance to next IP, wrapping at boundary - if m.nextIP.Compare(m.maxIP) >= 0 { - m.nextIP = m.baseIP + if p.nextIP.Compare(p.maxIP) >= 0 { + p.nextIP = p.baseIP } else { - m.nextIP = m.nextIP.Next() + p.nextIP = p.nextIP.Next() } - // Check if current IP is available - if _, inUse := m.fakeToReal[currentIP]; !inUse { - m.allocated[realIP] = currentIP - m.fakeToReal[currentIP] = realIP + if _, inUse := p.fakeToReal[currentIP]; !inUse { + p.allocated[realIP] = currentIP + p.fakeToReal[currentIP] = realIP return currentIP, nil } - // Prevent infinite loop if all IPs exhausted - if m.nextIP.Compare(startIP) == 0 { - return netip.Addr{}, fmt.Errorf("no more fake IPs available in 240.0.0.0/8 block") + if p.nextIP.Compare(startIP) == 0 { + return netip.Addr{}, fmt.Errorf("no more fake IPs available in %s block", p.block) } } } -// GetFakeIP returns the fake IP for a real IP if it exists +// Manager manages allocation of fake IPs for dynamic DNS routes. +// IPv4 uses 240.0.0.0/8 (reserved), IPv6 uses 0100::/64 (discard, RFC 6666). +type Manager struct { + mu sync.Mutex + v4 *fakeIPPool + v6 *fakeIPPool +} + +// NewManager creates a new fake IP manager. +func NewManager() *Manager { + return &Manager{ + v4: newPool(v4Base, v4Max, v4Block), + v6: newPool(v6Base, v6Max, v6Block), + } +} + +func (m *Manager) pool(ip netip.Addr) *fakeIPPool { + if ip.Is6() { + return m.v6 + } + return m.v4 +} + +// AllocateFakeIP allocates a fake IP for the given real IP. +func (m *Manager) AllocateFakeIP(realIP netip.Addr) (netip.Addr, error) { + realIP = realIP.Unmap() + if !realIP.IsValid() { + return netip.Addr{}, errors.New("invalid IP address") + } + + m.mu.Lock() + defer m.mu.Unlock() + + return m.pool(realIP).allocate(realIP) +} + +// GetFakeIP returns the fake IP for a real IP if it exists. func (m *Manager) GetFakeIP(realIP netip.Addr) (netip.Addr, bool) { + realIP = realIP.Unmap() + if !realIP.IsValid() { + return netip.Addr{}, false + } + m.mu.Lock() defer m.mu.Unlock() - fakeIP, exists := m.allocated[realIP] - return fakeIP, exists + fakeIP, ok := m.pool(realIP).allocated[realIP] + return fakeIP, ok } -// GetRealIP returns the real IP for a fake IP if it exists, otherwise false +// GetRealIP returns the real IP for a fake IP if it exists. func (m *Manager) GetRealIP(fakeIP netip.Addr) (netip.Addr, bool) { + fakeIP = fakeIP.Unmap() + if !fakeIP.IsValid() { + return netip.Addr{}, false + } + m.mu.Lock() defer m.mu.Unlock() - realIP, exists := m.fakeToReal[fakeIP] - return realIP, exists + realIP, ok := m.pool(fakeIP).fakeToReal[fakeIP] + return realIP, ok } -// GetFakeIPBlock returns the fake IP block used by this manager +// GetFakeIPBlock returns the v4 fake IP block used by this manager. func (m *Manager) GetFakeIPBlock() netip.Prefix { - return netip.MustParsePrefix("240.0.0.0/8") + return m.v4.block +} + +// GetFakeIPv6Block returns the v6 fake IP block used by this manager. +func (m *Manager) GetFakeIPv6Block() netip.Prefix { + return m.v6.block } diff --git a/client/internal/routemanager/fakeip/fakeip_test.go b/client/internal/routemanager/fakeip/fakeip_test.go index ad3e4bd4e..f554f970d 100644 --- a/client/internal/routemanager/fakeip/fakeip_test.go +++ b/client/internal/routemanager/fakeip/fakeip_test.go @@ -9,16 +9,16 @@ import ( func TestNewManager(t *testing.T) { manager := NewManager() - if manager.baseIP.String() != "240.0.0.1" { - t.Errorf("Expected base IP 240.0.0.1, got %s", manager.baseIP.String()) + if manager.v4.baseIP.String() != "240.0.0.1" { + t.Errorf("Expected v4 base IP 240.0.0.1, got %s", manager.v4.baseIP.String()) } - if manager.maxIP.String() != "240.255.255.254" { - t.Errorf("Expected max IP 240.255.255.254, got %s", manager.maxIP.String()) + if manager.v4.maxIP.String() != "240.255.255.254" { + t.Errorf("Expected v4 max IP 240.255.255.254, got %s", manager.v4.maxIP.String()) } - if manager.nextIP.Compare(manager.baseIP) != 0 { - t.Errorf("Expected nextIP to start at baseIP") + if manager.v6.baseIP.String() != "100::1" { + t.Errorf("Expected v6 base IP 100::1, got %s", manager.v6.baseIP.String()) } } @@ -35,7 +35,6 @@ func TestAllocateFakeIP(t *testing.T) { t.Error("Fake IP should be IPv4") } - // Check it's in the correct range if fakeIP.As4()[0] != 240 { t.Errorf("Fake IP should be in 240.0.0.0/8 range, got %s", fakeIP.String()) } @@ -51,13 +50,31 @@ func TestAllocateFakeIP(t *testing.T) { } } -func TestAllocateFakeIPIPv6Rejection(t *testing.T) { +func TestAllocateFakeIPv6(t *testing.T) { manager := NewManager() - realIPv6 := netip.MustParseAddr("2001:db8::1") + realIP := netip.MustParseAddr("2001:db8::1") - _, err := manager.AllocateFakeIP(realIPv6) - if err == nil { - t.Error("Expected error for IPv6 address") + fakeIP, err := manager.AllocateFakeIP(realIP) + if err != nil { + t.Fatalf("Failed to allocate fake IPv6: %v", err) + } + + if !fakeIP.Is6() { + t.Error("Fake IP should be IPv6") + } + + if !netip.MustParsePrefix("100::/64").Contains(fakeIP) { + t.Errorf("Fake IP should be in 100::/64 range, got %s", fakeIP.String()) + } + + // Should return same fake IP for same real IP + fakeIP2, err := manager.AllocateFakeIP(realIP) + if err != nil { + t.Fatalf("Failed to get existing fake IPv6: %v", err) + } + + if fakeIP.Compare(fakeIP2) != 0 { + t.Errorf("Expected same fake IP, got %s and %s", fakeIP.String(), fakeIP2.String()) } } @@ -65,13 +82,11 @@ func TestGetFakeIP(t *testing.T) { manager := NewManager() realIP := netip.MustParseAddr("1.1.1.1") - // Should not exist initially _, exists := manager.GetFakeIP(realIP) if exists { t.Error("Fake IP should not exist before allocation") } - // Allocate and check expectedFakeIP, err := manager.AllocateFakeIP(realIP) if err != nil { t.Fatalf("Failed to allocate: %v", err) @@ -87,12 +102,30 @@ func TestGetFakeIP(t *testing.T) { } } +func TestGetRealIPv6(t *testing.T) { + manager := NewManager() + realIP := netip.MustParseAddr("2001:db8::1") + + fakeIP, err := manager.AllocateFakeIP(realIP) + if err != nil { + t.Fatalf("Failed to allocate: %v", err) + } + + gotReal, exists := manager.GetRealIP(fakeIP) + if !exists { + t.Error("Real IP should exist for allocated fake IP") + } + + if gotReal.Compare(realIP) != 0 { + t.Errorf("Expected real IP %s, got %s", realIP, gotReal) + } +} + func TestMultipleAllocations(t *testing.T) { manager := NewManager() allocations := make(map[netip.Addr]netip.Addr) - // Allocate multiple IPs for i := 1; i <= 100; i++ { realIP := netip.AddrFrom4([4]byte{10, 0, byte(i / 256), byte(i % 256)}) fakeIP, err := manager.AllocateFakeIP(realIP) @@ -100,7 +133,6 @@ func TestMultipleAllocations(t *testing.T) { t.Fatalf("Failed to allocate fake IP for %s: %v", realIP.String(), err) } - // Check for duplicates for _, existingFake := range allocations { if fakeIP.Compare(existingFake) == 0 { t.Errorf("Duplicate fake IP allocated: %s", fakeIP.String()) @@ -110,7 +142,6 @@ func TestMultipleAllocations(t *testing.T) { allocations[realIP] = fakeIP } - // Verify all allocations can be retrieved for realIP, expectedFake := range allocations { actualFake, exists := manager.GetFakeIP(realIP) if !exists { @@ -124,11 +155,13 @@ func TestMultipleAllocations(t *testing.T) { func TestGetFakeIPBlock(t *testing.T) { manager := NewManager() - block := manager.GetFakeIPBlock() - expected := "240.0.0.0/8" - if block.String() != expected { - t.Errorf("Expected %s, got %s", expected, block.String()) + if block := manager.GetFakeIPBlock(); block.String() != "240.0.0.0/8" { + t.Errorf("Expected 240.0.0.0/8, got %s", block.String()) + } + + if block := manager.GetFakeIPv6Block(); block.String() != "100::/64" { + t.Errorf("Expected 100::/64, got %s", block.String()) } } @@ -141,7 +174,6 @@ func TestConcurrentAccess(t *testing.T) { var wg sync.WaitGroup results := make(chan netip.Addr, numGoroutines*allocationsPerGoroutine) - // Concurrent allocations for i := 0; i < numGoroutines; i++ { wg.Add(1) go func(goroutineID int) { @@ -161,7 +193,6 @@ func TestConcurrentAccess(t *testing.T) { wg.Wait() close(results) - // Check for duplicates seen := make(map[netip.Addr]bool) count := 0 for fakeIP := range results { @@ -178,47 +209,61 @@ func TestConcurrentAccess(t *testing.T) { } func TestIPExhaustion(t *testing.T) { - // Create a manager with limited range for testing manager := &Manager{ - nextIP: netip.AddrFrom4([4]byte{240, 0, 0, 1}), - allocated: make(map[netip.Addr]netip.Addr), - fakeToReal: make(map[netip.Addr]netip.Addr), - baseIP: netip.AddrFrom4([4]byte{240, 0, 0, 1}), - maxIP: netip.AddrFrom4([4]byte{240, 0, 0, 3}), // Only 3 IPs available + v4: newPool( + netip.AddrFrom4([4]byte{240, 0, 0, 1}), + netip.AddrFrom4([4]byte{240, 0, 0, 3}), + netip.MustParsePrefix("240.0.0.0/8"), + ), + v6: newPool( + netip.MustParseAddr("100::1"), + netip.MustParseAddr("100::3"), + netip.MustParsePrefix("100::/64"), + ), } - // Allocate all available IPs - realIPs := []netip.Addr{ - netip.MustParseAddr("1.0.0.1"), - netip.MustParseAddr("1.0.0.2"), - netip.MustParseAddr("1.0.0.3"), - } - - for _, realIP := range realIPs { - _, err := manager.AllocateFakeIP(realIP) + for _, realIP := range []string{"1.0.0.1", "1.0.0.2", "1.0.0.3"} { + _, err := manager.AllocateFakeIP(netip.MustParseAddr(realIP)) if err != nil { t.Fatalf("Failed to allocate fake IP: %v", err) } } - // Try to allocate one more - should fail _, err := manager.AllocateFakeIP(netip.MustParseAddr("1.0.0.4")) if err == nil { - t.Error("Expected exhaustion error") + t.Error("Expected v4 exhaustion error") + } + + // Same for v6 + for _, realIP := range []string{"2001:db8::1", "2001:db8::2", "2001:db8::3"} { + _, err := manager.AllocateFakeIP(netip.MustParseAddr(realIP)) + if err != nil { + t.Fatalf("Failed to allocate fake IPv6: %v", err) + } + } + + _, err = manager.AllocateFakeIP(netip.MustParseAddr("2001:db8::4")) + if err == nil { + t.Error("Expected v6 exhaustion error") } } func TestWrapAround(t *testing.T) { - // Create manager starting near the end of range manager := &Manager{ - nextIP: netip.AddrFrom4([4]byte{240, 0, 0, 254}), - allocated: make(map[netip.Addr]netip.Addr), - fakeToReal: make(map[netip.Addr]netip.Addr), - baseIP: netip.AddrFrom4([4]byte{240, 0, 0, 1}), - maxIP: netip.AddrFrom4([4]byte{240, 0, 0, 254}), + v4: newPool( + netip.AddrFrom4([4]byte{240, 0, 0, 1}), + netip.AddrFrom4([4]byte{240, 0, 0, 254}), + netip.MustParsePrefix("240.0.0.0/8"), + ), + v6: newPool( + netip.MustParseAddr("100::1"), + netip.MustParseAddr("100::ffff:ffff:ffff:fffe"), + netip.MustParsePrefix("100::/64"), + ), } + // Start near the end + manager.v4.nextIP = netip.AddrFrom4([4]byte{240, 0, 0, 254}) - // Allocate the last IP fakeIP1, err := manager.AllocateFakeIP(netip.MustParseAddr("1.0.0.1")) if err != nil { t.Fatalf("Failed to allocate first IP: %v", err) @@ -228,7 +273,6 @@ func TestWrapAround(t *testing.T) { t.Errorf("Expected 240.0.0.254, got %s", fakeIP1.String()) } - // Next allocation should wrap around to the beginning fakeIP2, err := manager.AllocateFakeIP(netip.MustParseAddr("1.0.0.2")) if err != nil { t.Fatalf("Failed to allocate second IP: %v", err) @@ -238,3 +282,32 @@ func TestWrapAround(t *testing.T) { t.Errorf("Expected 240.0.0.1 after wrap, got %s", fakeIP2.String()) } } + +func TestMixedV4V6(t *testing.T) { + manager := NewManager() + + v4Fake, err := manager.AllocateFakeIP(netip.MustParseAddr("8.8.8.8")) + if err != nil { + t.Fatalf("Failed to allocate v4: %v", err) + } + + v6Fake, err := manager.AllocateFakeIP(netip.MustParseAddr("2001:db8::1")) + if err != nil { + t.Fatalf("Failed to allocate v6: %v", err) + } + + if !v4Fake.Is4() || !v6Fake.Is6() { + t.Errorf("Wrong families: v4=%s v6=%s", v4Fake, v6Fake) + } + + // Reverse lookups should work for both + gotV4, ok := manager.GetRealIP(v4Fake) + if !ok || gotV4.String() != "8.8.8.8" { + t.Errorf("v4 reverse lookup failed: got %s, ok=%v", gotV4, ok) + } + + gotV6, ok := manager.GetRealIP(v6Fake) + if !ok || gotV6.String() != "2001:db8::1" { + t.Errorf("v6 reverse lookup failed: got %s, ok=%v", gotV6, ok) + } +} diff --git a/client/internal/routemanager/ipfwdstate/ipfwdstate.go b/client/internal/routemanager/ipfwdstate/ipfwdstate.go index da81c18f9..3d571e16b 100644 --- a/client/internal/routemanager/ipfwdstate/ipfwdstate.go +++ b/client/internal/routemanager/ipfwdstate/ipfwdstate.go @@ -2,50 +2,183 @@ package ipfwdstate import ( "fmt" + "sync" log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/client/internal/routemanager/systemops" ) -// IPForwardingState is a struct that keeps track of the IP forwarding state. -// todo: read initial state of the IP forwarding from the system and reset the state based on it +// IPForwardingState tracks v4 and v6 IP-forwarding sysctl enables with +// independent refcounts so a v4-only routing setup doesn't flip v6 sysctls. type IPForwardingState struct { - enabledCounter int + mu sync.Mutex + + v4Count int + v6Count int + + // routingV4/routingV6 track whether the routing path currently holds a + // reference, so repeated EnableRouting calls (one per network-map update) + // hold at most one reference per family and an unpaired DisableRouting + // can't release references held by DNAT rules. + routingV4 bool + routingV6 bool + + wgIfaceName string + v6Saved map[string]int } -func NewIPForwardingState() *IPForwardingState { - return &IPForwardingState{} +// NewIPForwardingState returns a state tracker for the IP-forwarding sysctls. +// wgIfaceName is excluded from the per-interface accept_ra handling. +func NewIPForwardingState(wgIfaceName string) *IPForwardingState { + return &IPForwardingState{wgIfaceName: wgIfaceName} } -func (f *IPForwardingState) RequestForwarding() error { - if f.enabledCounter != 0 { - f.enabledCounter++ +// Counts returns the current v4 and v6 refcounts. Intended for diagnostics +// and tests. +func (f *IPForwardingState) Counts() (v4, v6 int) { + f.mu.Lock() + defer f.mu.Unlock() + return f.v4Count, f.v6Count +} + +// RequestRouting takes the forwarding references for the routing path. It is +// idempotent: while routing already holds a reference, further calls don't +// increment the refcounts, and a v4-only request releases a previously held v6 +// reference. A v6 sysctl failure is logged and not returned so it can't take +// down v4 routing (the sysctl may be unwritable, e.g. read-only /proc/sys or +// IPv6 disabled on the kernel command line); v6 is retried on the next call. +func (f *IPForwardingState) RequestRouting(v6 bool) error { + f.mu.Lock() + defer f.mu.Unlock() + + if !f.routingV4 { + if err := f.requestV4(); err != nil { + return err + } + f.routingV4 = true + } + + if !v6 { + if !f.routingV6 { + return nil + } + f.routingV6 = false + return f.releaseV6() + } + + if f.routingV6 { return nil } - - if err := systemops.EnableIPForwarding(); err != nil { - return fmt.Errorf("failed to enable IP forwarding with sysctl: %w", err) + if err := f.requestV6(); err != nil { + log.Warnf("enable IPv6 forwarding for routing: %v", err) + return nil } - f.enabledCounter = 1 - log.Info("IP forwarding enabled") - + f.routingV6 = true return nil } -func (f *IPForwardingState) ReleaseForwarding() error { - if f.enabledCounter == 0 { - return nil +// ReleaseRouting releases the references RequestRouting holds. Calls without a +// held reference are no-ops. +func (f *IPForwardingState) ReleaseRouting() error { + f.mu.Lock() + defer f.mu.Unlock() + + if f.routingV4 { + f.routingV4 = false + f.releaseV4() } - - if f.enabledCounter > 1 { - f.enabledCounter-- - return nil + if f.routingV6 { + f.routingV6 = false + return f.releaseV6() } + return nil +} - // if failed to disable IP forwarding we anyway decrement the counter - f.enabledCounter = 0 +// RequestForwarding enables the family's forwarding sysctl on first request. +func (f *IPForwardingState) RequestForwarding(v6 bool) error { + f.mu.Lock() + defer f.mu.Unlock() - // todo call systemops.DisableIPForwarding() + if v6 { + return f.requestV6() + } + return f.requestV4() +} + +// ReleaseForwarding decrements the family counter. The last v6 release restores +// what enable captured. v4 stays on: net.ipv4.ip_forward is co-owned by other +// tooling (docker, k8s, libvirt). +func (f *IPForwardingState) ReleaseForwarding(v6 bool) error { + f.mu.Lock() + defer f.mu.Unlock() + + if v6 { + return f.releaseV6() + } + f.releaseV4() + return nil +} + +func (f *IPForwardingState) requestV4() error { + if f.v4Count == 0 { + if err := systemops.EnableV4IPForwarding(); err != nil { + return fmt.Errorf("enable IPv4 forwarding: %w", err) + } + log.Info("IPv4 forwarding enabled") + } + f.v4Count++ + return nil +} + +func (f *IPForwardingState) releaseV4() { + if f.v4Count > 0 { + f.v4Count-- + } +} + +func (f *IPForwardingState) requestV6() error { + if f.v6Count == 0 { + saved, err := systemops.EnableV6IPForwarding(f.wgIfaceName) + if err != nil { + if rerr := systemops.DisableV6IPForwarding(saved); rerr != nil { + log.Warnf("rollback partial v6 sysctls: %v", rerr) + } + return fmt.Errorf("enable IPv6 forwarding: %w", err) + } + // A failed restore on a previous release keeps its saved values; those + // are the true originals, so keep them over what this enable captured. + if f.v6Saved == nil { + f.v6Saved = saved + } else { + for k, v := range saved { + if _, ok := f.v6Saved[k]; !ok { + f.v6Saved[k] = v + } + } + } + log.Info("IPv6 forwarding enabled") + } + f.v6Count++ + return nil +} + +func (f *IPForwardingState) releaseV6() error { + if f.v6Count == 0 { + return nil + } + f.v6Count-- + if f.v6Count > 0 { + return nil + } + + // Keep the saved values on failure so a later release or enable/release + // cycle can still restore them; re-restoring an already-restored key is a + // no-op since the sysctl already holds the desired value. + if err := systemops.DisableV6IPForwarding(f.v6Saved); err != nil { + return fmt.Errorf("disable IPv6 forwarding: %w", err) + } + f.v6Saved = nil + log.Info("IPv6 forwarding disabled") return nil } diff --git a/client/internal/routemanager/ipfwdstate/ipfwdstate_privileged_linux_test.go b/client/internal/routemanager/ipfwdstate/ipfwdstate_privileged_linux_test.go new file mode 100644 index 000000000..b4615ff02 --- /dev/null +++ b/client/internal/routemanager/ipfwdstate/ipfwdstate_privileged_linux_test.go @@ -0,0 +1,39 @@ +//go:build privileged + +package ipfwdstate + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRequestRoutingV6ToV4Transition verifies that a v4-only routing request +// releases a previously held routing-owned v6 reference without touching +// references held by DNAT rules. +func TestRequestRoutingV6ToV4Transition(t *testing.T) { + f := NewIPForwardingState("wt-fwd-test") + + require.NoError(t, f.RequestRouting(true), "request routing with v6") + v4, v6 := f.Counts() + assert.Equal(t, 1, v4, "v4 reference held") + assert.Equal(t, 1, v6, "v6 reference held") + + require.NoError(t, f.RequestRouting(false), "request routing v4-only") + v4, v6 = f.Counts() + assert.Equal(t, 1, v4, "v4 reference kept") + assert.Equal(t, 0, v6, "routing-owned v6 reference released") + + // A DNAT-held reference survives a v4-only routing request. + require.NoError(t, f.RequestForwarding(true), "dnat v6 reference") + require.NoError(t, f.RequestRouting(false), "repeat v4-only request") + _, v6 = f.Counts() + assert.Equal(t, 1, v6, "dnat-held v6 reference survives") + require.NoError(t, f.ReleaseForwarding(true), "release dnat v6 reference") + + require.NoError(t, f.ReleaseRouting(), "release routing") + v4, v6 = f.Counts() + assert.Equal(t, 0, v4, "all v4 references released") + assert.Equal(t, 0, v6, "all v6 references released") +} diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go index 3923e153b..981b0c987 100644 --- a/client/internal/routemanager/manager.go +++ b/client/internal/routemanager/manager.go @@ -9,11 +9,13 @@ import ( "net/url" "runtime" "slices" + "sort" + "strings" "sync" "sync/atomic" + "syscall" "time" - "github.com/google/uuid" "github.com/hashicorp/go-multierror" log "github.com/sirupsen/logrus" "golang.org/x/exp/maps" @@ -50,14 +52,20 @@ type Manager interface { UpdateRoutes(updateSerial uint64, serverRoutes map[route.ID]*route.Route, clientRoutes route.HAMap, useNewDNSRoute bool) error ClassifyRoutes(newRoutes []*route.Route) (map[route.ID]*route.Route, route.HAMap) TriggerSelection(route.HAMap) + SelectRoutes(ids []route.NetID, appendRoute bool) error + DeselectRoutes(ids []route.NetID) error + SelectAllRoutes() + DeselectAllRoutes() GetRouteSelector() *routeselector.RouteSelector GetClientRoutes() route.HAMap GetSelectedClientRoutes() route.HAMap + GetActiveClientRoutes() route.HAMap GetClientRoutesWithNetID() map[route.NetID][]*route.Route SetRouteChangeListener(listener listener.NetworkChangeListener) - InitialRouteRange() []string + CurrentRouteRange() []string SetFirewall(firewall.Manager) error SetDNSForwarderPort(port uint16) + ReconcilePeerAllowedIPs(peerKey string) error Stop(stateManager *statemanager.Manager) } @@ -68,10 +76,8 @@ type ManagerConfig struct { WGInterface iface.WGIface StatusRecorder *peer.Status RelayManager *relayClient.Manager - InitialRoutes []*route.Route StateManager *statemanager.Manager DNSServer dns.Server - DNSFeatureFlag bool PeerStore *peerstore.Store DisableClientRoutes bool DisableServerRoutes bool @@ -141,37 +147,12 @@ func NewManager(config ManagerConfig) *DefaultManager { useNoop := netstack.IsEnabled() || config.DisableClientRoutes dm.setupRefCounters(useNoop) - // don't proceed with client routes if it is disabled - if config.DisableClientRoutes { - return dm - } - - if runtime.GOOS == "android" { - dm.setupAndroidRoutes(config) - } return dm } -func (m *DefaultManager) setupAndroidRoutes(config ManagerConfig) { - cr := m.initialClientRoutes(config.InitialRoutes) - routesForComparison := slices.Clone(cr) - - if config.DNSFeatureFlag { - m.fakeIPManager = fakeip.NewManager() - - id := uuid.NewString() - fakeIPRoute := &route.Route{ - ID: route.ID(id), - Network: m.fakeIPManager.GetFakeIPBlock(), - NetID: route.NetID(id), - Peer: m.pubKey, - NetworkType: route.IPv4Network, - } - cr = append(cr, fakeIPRoute) - m.notifier.SetFakeIPRoute(fakeIPRoute) - } - - m.notifier.SetInitialClientRoutes(cr, routesForComparison) +func (m *DefaultManager) enableFakeIPRoutes() { + m.fakeIPManager = fakeip.NewManager() + m.notifier.NotifyRouteChange() } func (m *DefaultManager) setupRefCounters(useNoop bool) { @@ -204,7 +185,7 @@ func (m *DefaultManager) setupRefCounters(useNoop bool) { ) } - m.allowedIPsRefCounter = refcounter.New( + m.allowedIPsRefCounter = refcounter.NewAllowedIPs( func(prefix netip.Prefix, peerKey string) (string, error) { // save peerKey to use it in the remove function return peerKey, m.wgInterface.AddAllowedIP(peerKey, prefix) @@ -221,6 +202,30 @@ func (m *DefaultManager) setupRefCounters(useNoop bool) { ) } +// ReconcilePeerAllowedIPs re-applies every routed allowed IP currently tracked for the peer +// onto the WireGuard device. The allowed-IP refcounter only calls its AddFunc (which pushes to +// the device) on a prefix's 0->1 transition, so a peer whose device entry was rebuilt without a +// matching refcounter change — e.g. a lazy connection cycling through idle->wake, which recreates +// the WireGuard peer with the overlay /32 only — ends up missing routed prefixes the refcounter +// still considers installed, and nothing retries. Calling this when the peer's WireGuard entry is +// (re)created restores convergence. It is add-only and idempotent: AddAllowedIP is update-only, so +// prefixes are re-added to an existing peer and an absent peer is left untouched. +func (m *DefaultManager) ReconcilePeerAllowedIPs(peerKey string) error { + if m.allowedIPsRefCounter == nil { + return nil + } + + return m.allowedIPsRefCounter.ReapplyMatching( + func(out string) bool { return out == peerKey }, + func(prefix netip.Prefix) error { + if err := m.wgInterface.AddAllowedIP(peerKey, prefix); err != nil { + return fmt.Errorf("add allowed IP %s for peer %s: %w", prefix, peerKey, err) + } + return nil + }, + ) +} + // Init sets up the routing func (m *DefaultManager) Init() error { m.routeSelector = m.initSelector() @@ -254,7 +259,11 @@ func (m *DefaultManager) initSelector() *routeselector.RouteSelector { // restore selector state if it exists if err := m.stateManager.LoadState(state); err != nil { - log.Warnf("failed to load state: %v", err) + if errors.Is(err, syscall.ENOSYS) { + log.Debugf("route selector state unavailable on this platform: %v", err) + } else { + log.Warnf("failed to load state: %v", err) + } return routeselector.NewRouteSelector() } @@ -323,6 +332,8 @@ func (m *DefaultManager) Stop(stateManager *statemanager.Manager) { } } + m.notifier.Close() + m.mux.Lock() defer m.mux.Unlock() m.clientRoutes = nil @@ -418,6 +429,9 @@ func (m *DefaultManager) UpdateRoutes( var merr *multierror.Error if !m.disableClientRoutes { + if runtime.GOOS == "android" && useNewDNSRoute && m.fakeIPManager == nil { + m.enableFakeIPRoutes() + } // Update route selector based on management server's isSelected status m.updateRouteSelectorFromManagement(clientRoutes) @@ -430,6 +444,11 @@ func (m *DefaultManager) UpdateRoutes( m.updateClientNetworks(updateSerial, filteredClientRoutes) m.notifier.OnNewRoutes(filteredClientRoutes) + // A new network map can add or drop route/exit-node candidates without + // touching any peer's chosen-route state, so the peer status alone + // wouldn't notify SubscribeStatus subscribers. Bump the revision so the + // UI re-fetches ListNetworks. + m.statusRecorder.BumpNetworksRevision() } m.clientRoutes = clientRoutes @@ -449,9 +468,18 @@ func (m *DefaultManager) SetRouteChangeListener(listener listener.NetworkChangeL m.notifier.SetListener(listener) } -// InitialRouteRange return the list of initial routes. It used by mobile systems -func (m *DefaultManager) InitialRouteRange() []string { - return m.notifier.GetInitialRouteRanges() +// CurrentRouteRange returns the current TUN route list. It is used by mobile systems +func (m *DefaultManager) CurrentRouteRange() []string { + m.mux.Lock() + defer m.mux.Unlock() + + nets := m.overlayNetworks() + if !m.disableClientRoutes { + nets = append(nets, m.clientRouteRange()...) + } + + sort.Strings(nets) + return slices.Compact(nets) } // GetRouteSelector returns the route selector @@ -477,6 +505,39 @@ func (m *DefaultManager) GetSelectedClientRoutes() route.HAMap { return m.routeSelector.FilterSelectedExitNodes(maps.Clone(m.clientRoutes)) } +// GetActiveClientRoutes returns the subset of selected client routes +// that are currently reachable: the route's peer is Connected and is +// the one actively carrying the route (not just an HA sibling). +func (m *DefaultManager) GetActiveClientRoutes() route.HAMap { + m.mux.Lock() + selected := m.routeSelector.FilterSelectedExitNodes(maps.Clone(m.clientRoutes)) + recorder := m.statusRecorder + m.mux.Unlock() + + if recorder == nil { + return selected + } + + out := make(route.HAMap, len(selected)) + for id, routes := range selected { + for _, r := range routes { + st, err := recorder.GetPeer(r.Peer) + if err != nil { + continue + } + if st.ConnStatus != peer.StatusConnected { + continue + } + if _, hasRoute := st.GetRoutes()[r.Network.String()]; !hasRoute { + continue + } + out[id] = routes + break + } + } + return out +} + // GetClientRoutesWithNetID returns the current routes from the route map, but the keys consist of the network ID only func (m *DefaultManager) GetClientRoutesWithNetID() map[route.NetID][]*route.Route { m.mux.Lock() @@ -537,6 +598,10 @@ func (m *DefaultManager) TriggerSelection(networks route.HAMap) { if err := m.stateManager.UpdateState((*SelectorState)(m.routeSelector)); err != nil { log.Errorf("failed to update state: %v", err) } + + // A selection change flips Network.selected without altering the candidate + // set, so bump the revision to push the new state to the UI. + m.statusRecorder.BumpNetworksRevision() } // stopObsoleteClients stops the client network watcher for the networks that are not in the new list @@ -612,16 +677,6 @@ func (m *DefaultManager) ClassifyRoutes(newRoutes []*route.Route) (map[route.ID] return newServerRoutesMap, newClientRoutesIDMap } -func (m *DefaultManager) initialClientRoutes(initialRoutes []*route.Route) []*route.Route { - _, crMap := m.ClassifyRoutes(initialRoutes) - rs := make([]*route.Route, 0, len(crMap)) - for _, routes := range crMap { - rs = append(rs, routes...) - } - - return rs -} - func isRouteSupported(route *route.Route) bool { if netstack.IsEnabled() || !nbnet.CustomRoutingDisabled() || route.IsDynamic() { return true @@ -656,15 +711,49 @@ func resolveURLsToIPs(urls []string) []net.IP { return ips } -// updateRouteSelectorFromManagement updates the route selector based on the isSelected status from the management server +// updateRouteSelectorFromManagement reconciles exit-node selection on every +// network map: it keeps at most one exit node selected — the user's persisted +// pick, else whatever management marks for auto-apply (SkipAutoApply=false), +// else none. We never auto-activate an exit node the map doesn't request; it +// stays off until the user picks it. Exit nodes are mutually exclusive, but the +// RouteSelector stores routes with default-on semantics, so without this every +// available exit node would report selected at once. func (m *DefaultManager) updateRouteSelectorFromManagement(clientRoutes route.HAMap) { - exitNodeInfo := m.collectExitNodeInfo(clientRoutes) - if len(exitNodeInfo.allIDs) == 0 { + m.mirrorV6ExitPairSelections(clientRoutes) + + // An explicit user "deselect all" must not be overridden by management auto-apply. + // Auto-applying an exit node here would call SelectRoutes, which clears the + // deselect-all flag and re-enables every route the user turned off. + if m.routeSelector.IsDeselectAll() { return } - m.updateExitNodeSelections(exitNodeInfo) - m.logExitNodeUpdate(exitNodeInfo) + info := m.collectExitNodeInfo(clientRoutes) + if len(info.allIDs) == 0 { + return + } + + preferred := pickPreferredExitNode(info) + m.enforceSingleExitNode(preferred, info.allIDs) + m.logExitNodeUpdate(info, preferred) +} + +// mirrorV6ExitPairSelections keeps every synthesized "-v6" exit route's selection +// consistent with its v4 base. The v4/v6 exit pair is a single toggle, so the v6 +// entry always follows the base: deselecting the v4 exit node also drops its ::/0 +// pair, and any stale (orphaned) explicit selection on the v6 entry is reset. This +// runs before selection is read so both collectExitNodeInfo and FilterSelectedExitNodes +// see consistent state, including pairs loaded from persisted selector state. +func (m *DefaultManager) mirrorV6ExitPairSelections(clientRoutes route.HAMap) { + routesByNetID := make(map[route.NetID][]*route.Route, len(clientRoutes)) + for haID, routes := range clientRoutes { + routesByNetID[haID.NetID()] = routes + } + + for v6ID := range route.V6ExitMergeSet(routesByNetID) { + baseID := route.NetID(strings.TrimSuffix(string(v6ID), route.V6ExitSuffix)) + m.routeSelector.SyncPairedSelection(baseID, v6ID) + } } type exitNodeInfo struct { @@ -674,15 +763,22 @@ type exitNodeInfo struct { userDeselected []route.NetID } +// collectExitNodeInfo categorises the available exit nodes by their persisted +// selection state. It keys on the base (v4) NetID and skips the synthesized +// "-v6" partner, which inherits its base's selection through the RouteSelector +// — counting it separately would double-count the pair. func (m *DefaultManager) collectExitNodeInfo(clientRoutes route.HAMap) exitNodeInfo { var info exitNodeInfo for haID, routes := range clientRoutes { - if !m.isExitNodeRoute(routes) { + if !isExitNodeRoutes(routes) { continue } netID := haID.NetID() + if strings.HasSuffix(string(netID), route.V6ExitSuffix) { + continue + } info.allIDs = append(info.allIDs, netID) if m.routeSelector.HasUserSelectionForRoute(netID) { @@ -695,10 +791,6 @@ func (m *DefaultManager) collectExitNodeInfo(clientRoutes route.HAMap) exitNodeI return info } -func (m *DefaultManager) isExitNodeRoute(routes []*route.Route) bool { - return len(routes) > 0 && routes[0].Network.String() == vars.ExitNodeCIDR -} - func (m *DefaultManager) categorizeUserSelection(netID route.NetID, info *exitNodeInfo) { if m.routeSelector.IsSelected(netID) { info.userSelected = append(info.userSelected, netID) @@ -716,45 +808,88 @@ func (m *DefaultManager) checkManagementSelection(routes []*route.Route, netID r } } -func (m *DefaultManager) updateExitNodeSelections(info exitNodeInfo) { - routesToDeselect := m.getRoutesToDeselect(info.allIDs) - m.deselectExitNodes(routesToDeselect) - m.selectExitNodesByManagement(info.selectedByManagement, info.allIDs) +// pickPreferredExitNode chooses the single exit node to keep selected. In order: +// - a persisted user selection wins (deterministic if several survive from +// legacy state, so the set self-heals down to one); +// - otherwise activate only what management marks for auto-apply +// (SkipAutoApply=false); the lexicographically first if it marks several. +// +// Returns "" when neither holds — we never force an arbitrary exit node on. A +// route the map doesn't auto-apply stays off until the user selects it. +// info.userDeselected is informational only: an explicit deselect simply keeps +// that route out of both lists above, so it can't be picked. +func pickPreferredExitNode(info exitNodeInfo) route.NetID { + if len(info.userSelected) > 0 { + return minNetID(info.userSelected) + } + if len(info.selectedByManagement) > 0 { + return minNetID(info.selectedByManagement) + } + return "" } -func (m *DefaultManager) getRoutesToDeselect(allIDs []route.NetID) []route.NetID { - var routesToDeselect []route.NetID - for _, netID := range allIDs { - if !m.routeSelector.HasUserSelectionForRoute(netID) { - routesToDeselect = append(routesToDeselect, netID) +// enforceSingleExitNode makes preferred the only selected exit node: every other +// available exit node is deselected and preferred (if any) is selected, without +// disturbing non-exit route selections. The whole reconciliation runs under a +// single RouteSelector lock (SetExclusiveExitNode) so a concurrent deselect-all +// cannot interleave and get undone; a global deselect-all is left untouched so +// the user's "all off" stays in effect. +func (m *DefaultManager) enforceSingleExitNode(preferred route.NetID, allIDs []route.NetID) { + m.routeSelector.SetExclusiveExitNode(preferred, allIDs) +} + +func (m *DefaultManager) logExitNodeUpdate(info exitNodeInfo, preferred route.NetID) { + log.Debugf("Exit node selection: %d available, preferred=%q (%d user-selected, %d user-deselected, %d management-selected)", + len(info.allIDs), preferred, len(info.userSelected), len(info.userDeselected), len(info.selectedByManagement)) +} + +// overlayNetworks returns the v4 and v6 overlay networks of the WireGuard interface, each only when it is set. +func (m *DefaultManager) overlayNetworks() []string { + if m.wgInterface == nil { + return nil + } + + addr := m.wgInterface.Address() + var nets []string + if addr.Network.IsValid() { + nets = append(nets, addr.Network.String()) + } + if addr.IPv6Net.IsValid() { + nets = append(nets, addr.IPv6Net.String()) + } + return nets +} + +// clientRouteRange returns the static client route networks of the selected exit nodes together with the fake IP blocks. +func (m *DefaultManager) clientRouteRange() []string { + filtered := m.routeSelector.FilterSelectedExitNodes(m.clientRoutes) + var nets []string + for _, routes := range filtered { + for _, r := range routes { + if r.IsDynamic() { + continue + } + nets = append(nets, r.NetString()) } } - return routesToDeselect + + if m.fakeIPManager != nil { + nets = append(nets, m.fakeIPManager.GetFakeIPBlock().String(), m.fakeIPManager.GetFakeIPv6Block().String()) + } + return nets } -func (m *DefaultManager) deselectExitNodes(routesToDeselect []route.NetID) { - if len(routesToDeselect) == 0 { - return +// minNetID returns the lexicographically smallest NetID, for a deterministic +// default pick that stays stable across restarts. +func minNetID(ids []route.NetID) route.NetID { + if len(ids) == 0 { + return "" } - - err := m.routeSelector.DeselectRoutes(routesToDeselect, routesToDeselect) - if err != nil { - log.Warnf("Failed to deselect exit nodes: %v", err) + best := ids[0] + for _, id := range ids[1:] { + if id < best { + best = id + } } -} - -func (m *DefaultManager) selectExitNodesByManagement(selectedByManagement []route.NetID, allIDs []route.NetID) { - if len(selectedByManagement) == 0 { - return - } - - err := m.routeSelector.SelectRoutes(selectedByManagement, true, allIDs) - if err != nil { - log.Warnf("Failed to select exit nodes: %v", err) - } -} - -func (m *DefaultManager) logExitNodeUpdate(info exitNodeInfo) { - log.Debugf("Updated route selector: %d exit nodes available, %d selected by management, %d user-selected, %d user-deselected", - len(info.allIDs), len(info.selectedByManagement), len(info.userSelected), len(info.userDeselected)) + return best } diff --git a/client/internal/routemanager/manager_test.go b/client/internal/routemanager/manager_test.go index 3697545ae..18b44820a 100644 --- a/client/internal/routemanager/manager_test.go +++ b/client/internal/routemanager/manager_test.go @@ -1,3 +1,5 @@ +//go:build privileged + package routemanager import ( @@ -12,6 +14,7 @@ import ( "github.com/stretchr/testify/require" "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/route" ) @@ -409,7 +412,7 @@ func TestManagerUpdateRoutes(t *testing.T) { } opts := iface.WGIFaceOpts{ IFaceName: fmt.Sprintf("utun43%d", n), - Address: "100.65.65.2/24", + Address: wgaddr.MustParseWGAddress("100.65.65.2/24"), WGPort: 33100, WGPrivKey: peerPrivateKey.String(), MTU: iface.DefaultMTU, diff --git a/client/internal/routemanager/manager_v6exit_test.go b/client/internal/routemanager/manager_v6exit_test.go new file mode 100644 index 000000000..15ab99cbd --- /dev/null +++ b/client/internal/routemanager/manager_v6exit_test.go @@ -0,0 +1,47 @@ +package routemanager + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/routeselector" + "github.com/netbirdio/netbird/route" +) + +// TestUpdateRouteSelectorFromManagement_MirrorsV6ExitPair reproduces the bug seen +// in netbird-engine.log: persisted selector state has the v4 exit node deselected +// but its synthesized "-v6" pair explicitly selected (orphaned), so the ::/0 route +// leaked onto the tunnel. The management update must mirror the v4 deselect onto the +// v6 pair so FilterSelectedExitNodes drops it. +func TestUpdateRouteSelectorFromManagement_MirrorsV6ExitPair(t *testing.T) { + const ( + v4ID = route.NetID("Exit Node (raspberrypi)") + v6ID = route.NetID("Exit Node (raspberrypi)-v6") + ) + all := []route.NetID{v4ID, v6ID} + + rs := routeselector.NewRouteSelector() + // Orphan the v6 selection: select the pair, then deselect only the v4 base. + require.NoError(t, rs.SelectRoutes([]route.NetID{v4ID, v6ID}, true, all)) + require.NoError(t, rs.DeselectRoutes([]route.NetID{v4ID}, all)) + require.True(t, rs.IsSelected(v6ID), "precondition: orphaned v6 selection survives v4 deselect") + + m := &DefaultManager{routeSelector: rs} + + v4Route := &route.Route{NetID: v4ID, Network: netip.MustParsePrefix("0.0.0.0/0")} + v6Route := &route.Route{NetID: v6ID, Network: netip.MustParsePrefix("::/0")} + clientRoutes := route.HAMap{ + "Exit Node (raspberrypi)|0.0.0.0/0": {v4Route}, + "Exit Node (raspberrypi)-v6|::/0": {v6Route}, + } + + m.updateRouteSelectorFromManagement(clientRoutes) + + assert.False(t, rs.IsSelected(v6ID), "v6 pair must follow the v4 base deselect after the management update") + + filtered := rs.FilterSelectedExitNodes(clientRoutes) + assert.Empty(t, filtered, "deselected v4 exit node must not leak its ::/0 pair onto the tunnel") +} diff --git a/client/internal/routemanager/mock.go b/client/internal/routemanager/mock.go index 66b5e30dd..2a8398b95 100644 --- a/client/internal/routemanager/mock.go +++ b/client/internal/routemanager/mock.go @@ -16,9 +16,12 @@ type MockManager struct { ClassifyRoutesFunc func(routes []*route.Route) (map[route.ID]*route.Route, route.HAMap) UpdateRoutesFunc func(updateSerial uint64, serverRoutes map[route.ID]*route.Route, clientRoutes route.HAMap, useNewDNSRoute bool) error TriggerSelectionFunc func(haMap route.HAMap) + SelectRoutesFunc func(ids []route.NetID, appendRoute bool) error + DeselectRoutesFunc func(ids []route.NetID) error GetRouteSelectorFunc func() *routeselector.RouteSelector GetClientRoutesFunc func() route.HAMap GetSelectedClientRoutesFunc func() route.HAMap + GetActiveClientRoutesFunc func() route.HAMap GetClientRoutesWithNetIDFunc func() map[route.NetID][]*route.Route StopFunc func(manager *statemanager.Manager) } @@ -27,8 +30,8 @@ func (m *MockManager) Init() error { return nil } -// InitialRouteRange mock implementation of InitialRouteRange from Manager interface -func (m *MockManager) InitialRouteRange() []string { +// CurrentRouteRange mock implementation of CurrentRouteRange from Manager interface +func (m *MockManager) CurrentRouteRange() []string { return nil } @@ -54,6 +57,30 @@ func (m *MockManager) TriggerSelection(networks route.HAMap) { } } +// SelectRoutes mock implementation of SelectRoutes from Manager interface +func (m *MockManager) SelectRoutes(ids []route.NetID, appendRoute bool) error { + if m.SelectRoutesFunc != nil { + return m.SelectRoutesFunc(ids, appendRoute) + } + return nil +} + +// DeselectRoutes mock implementation of DeselectRoutes from Manager interface +func (m *MockManager) DeselectRoutes(ids []route.NetID) error { + if m.DeselectRoutesFunc != nil { + return m.DeselectRoutesFunc(ids) + } + return nil +} + +// SelectAllRoutes mock implementation of SelectAllRoutes from Manager interface +func (m *MockManager) SelectAllRoutes() { +} + +// DeselectAllRoutes mock implementation of DeselectAllRoutes from Manager interface +func (m *MockManager) DeselectAllRoutes() { +} + // GetRouteSelector mock implementation of GetRouteSelector from Manager interface func (m *MockManager) GetRouteSelector() *routeselector.RouteSelector { if m.GetRouteSelectorFunc != nil { @@ -78,6 +105,14 @@ func (m *MockManager) GetSelectedClientRoutes() route.HAMap { return nil } +// GetActiveClientRoutes mock implementation of GetActiveClientRoutes from the Manager interface +func (m *MockManager) GetActiveClientRoutes() route.HAMap { + if m.GetActiveClientRoutesFunc != nil { + return m.GetActiveClientRoutesFunc() + } + return nil +} + // GetClientRoutesWithNetID mock implementation of GetClientRoutesWithNetID from Manager interface func (m *MockManager) GetClientRoutesWithNetID() map[route.NetID][]*route.Route { if m.GetClientRoutesWithNetIDFunc != nil { @@ -103,6 +138,11 @@ func (m *MockManager) SetFirewall(firewall.Manager) error { func (m *MockManager) SetDNSForwarderPort(port uint16) { } +// ReconcilePeerAllowedIPs mock implementation of ReconcilePeerAllowedIPs from Manager interface +func (m *MockManager) ReconcilePeerAllowedIPs(peerKey string) error { + return nil +} + // Stop mock implementation of Stop from Manager interface func (m *MockManager) Stop(stateManager *statemanager.Manager) { if m.StopFunc != nil { diff --git a/client/internal/routemanager/notifier/notifier_android.go b/client/internal/routemanager/notifier/notifier_android.go index 55e0b7421..24cbb94db 100644 --- a/client/internal/routemanager/notifier/notifier_android.go +++ b/client/internal/routemanager/notifier/notifier_android.go @@ -4,9 +4,6 @@ package notifier import ( "net/netip" - "slices" - "sort" - "strings" "sync" "github.com/netbirdio/netbird/client/internal/listener" @@ -14,12 +11,15 @@ import ( ) type Notifier struct { - initialRoutes []*route.Route - currentRoutes []*route.Route - fakeIPRoute *route.Route + mu sync.Mutex - listener listener.NetworkChangeListener - listenerMux sync.Mutex + // currentRoutes is the last announced route set. It exists only to + // suppress noise: without it every network map sync would trigger the + // Java side, even when the routes did not change. The actual TUN route + // state is owned by the route manager and pulled from there. + currentRoutes []*route.Route + + listener listener.NetworkChangeListener } func NewNotifier() *Notifier { @@ -27,20 +27,15 @@ func NewNotifier() *Notifier { } func (n *Notifier) SetListener(listener listener.NetworkChangeListener) { - n.listenerMux.Lock() - defer n.listenerMux.Unlock() + n.mu.Lock() + defer n.mu.Unlock() n.listener = listener } -// SetInitialClientRoutes stores the initial route sets for TUN configuration. -func (n *Notifier) SetInitialClientRoutes(initialRoutes []*route.Route, routesForComparison []*route.Route) { - n.initialRoutes = filterStatic(initialRoutes) - n.currentRoutes = filterStatic(routesForComparison) -} - -// SetFakeIPRoute stores the fake IP route to be included in every TUN rebuild. -func (n *Notifier) SetFakeIPRoute(r *route.Route) { - n.fakeIPRoute = r +func (n *Notifier) NotifyRouteChange() { + n.mu.Lock() + defer n.mu.Unlock() + n.notifyLocked() } func (n *Notifier) OnNewRoutes(idMap route.HAMap) { @@ -54,79 +49,27 @@ func (n *Notifier) OnNewRoutes(idMap route.HAMap) { } } - if !n.hasRouteDiff(n.currentRoutes, newRoutes) { + n.mu.Lock() + defer n.mu.Unlock() + if !hasRouteDiff(n.currentRoutes, newRoutes) { return } n.currentRoutes = newRoutes - n.notify() + n.notifyLocked() } func (n *Notifier) OnNewPrefixes([]netip.Prefix) { // Not used on Android } -func (n *Notifier) notify() { - n.listenerMux.Lock() - defer n.listenerMux.Unlock() +func (n *Notifier) notifyLocked() { if n.listener == nil { return } - - allRoutes := slices.Clone(n.currentRoutes) - if n.fakeIPRoute != nil { - allRoutes = append(allRoutes, n.fakeIPRoute) - } - - routeStrings := n.routesToStrings(allRoutes) - sort.Strings(routeStrings) - go func(l listener.NetworkChangeListener) { - l.OnNetworkChanged(strings.Join(n.addIPv6RangeIfNeeded(routeStrings, allRoutes), ",")) - }(n.listener) + n.listener.OnNetworkChanged("") } -func filterStatic(routes []*route.Route) []*route.Route { - out := make([]*route.Route, 0, len(routes)) - for _, r := range routes { - if !r.IsDynamic() { - out = append(out, r) - } - } - return out -} - -func (n *Notifier) routesToStrings(routes []*route.Route) []string { - nets := make([]string, 0, len(routes)) - for _, r := range routes { - nets = append(nets, r.NetString()) - } - return nets -} - -func (n *Notifier) hasRouteDiff(a []*route.Route, b []*route.Route) bool { - slices.SortFunc(a, func(x, y *route.Route) int { - return strings.Compare(x.NetString(), y.NetString()) - }) - slices.SortFunc(b, func(x, y *route.Route) int { - return strings.Compare(x.NetString(), y.NetString()) - }) - - return !slices.EqualFunc(a, b, func(x, y *route.Route) bool { - return x.NetString() == y.NetString() - }) -} - -func (n *Notifier) GetInitialRouteRanges() []string { - initialStrings := n.routesToStrings(n.initialRoutes) - sort.Strings(initialStrings) - return n.addIPv6RangeIfNeeded(initialStrings, n.initialRoutes) -} - -func (n *Notifier) addIPv6RangeIfNeeded(inputRanges []string, routes []*route.Route) []string { - for _, r := range routes { - if r.Network.Addr().Is4() && r.Network.Bits() == 0 { - return append(slices.Clone(inputRanges), "::/0") - } - } - return inputRanges +func (n *Notifier) Close() { + // unused } diff --git a/client/internal/routemanager/notifier/notifier_ios.go b/client/internal/routemanager/notifier/notifier_ios.go index 68c85067a..d663dd471 100644 --- a/client/internal/routemanager/notifier/notifier_ios.go +++ b/client/internal/routemanager/notifier/notifier_ios.go @@ -14,10 +14,9 @@ import ( ) type Notifier struct { + mu sync.Mutex currentPrefixes []string - - listener listener.NetworkChangeListener - listenerMux sync.Mutex + listener listener.NetworkChangeListener } func NewNotifier() *Notifier { @@ -25,16 +24,12 @@ func NewNotifier() *Notifier { } func (n *Notifier) SetListener(listener listener.NetworkChangeListener) { - n.listenerMux.Lock() - defer n.listenerMux.Unlock() + n.mu.Lock() + defer n.mu.Unlock() n.listener = listener } -func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) { - // iOS doesn't care about initial routes -} - -func (n *Notifier) SetFakeIPRoute(*route.Route) { +func (n *Notifier) NotifyRouteChange() { // Not used on iOS } @@ -43,41 +38,27 @@ func (n *Notifier) OnNewRoutes(route.HAMap) { } func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) { - newNets := make([]string, 0) + newNets := make([]string, 0, len(prefixes)) for _, prefix := range prefixes { newNets = append(newNets, prefix.String()) } sort.Strings(newNets) + n.mu.Lock() + defer n.mu.Unlock() if slices.Equal(n.currentPrefixes, newNets) { return } - n.currentPrefixes = newNets - n.notify() -} -func (n *Notifier) notify() { - n.listenerMux.Lock() - defer n.listenerMux.Unlock() - if n.listener == nil { - return + if n.listener != nil { + n.listener.OnNetworkChanged(strings.Join(n.currentPrefixes, ",")) } +} - go func(l listener.NetworkChangeListener) { - l.OnNetworkChanged(strings.Join(n.addIPv6RangeIfNeeded(n.currentPrefixes), ",")) - }(n.listener) +func (n *Notifier) Close() { } func (n *Notifier) GetInitialRouteRanges() []string { return nil } - -func (n *Notifier) addIPv6RangeIfNeeded(inputRanges []string) []string { - for _, r := range inputRanges { - if r == "0.0.0.0/0" { - return append(slices.Clone(inputRanges), "::/0") - } - } - return inputRanges -} diff --git a/client/internal/routemanager/notifier/notifier_other.go b/client/internal/routemanager/notifier/notifier_other.go index 97c815cf0..fe48e07b3 100644 --- a/client/internal/routemanager/notifier/notifier_other.go +++ b/client/internal/routemanager/notifier/notifier_other.go @@ -19,11 +19,7 @@ func (n *Notifier) SetListener(listener listener.NetworkChangeListener) { // Not used on non-mobile platforms } -func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) { - // Not used on non-mobile platforms -} - -func (n *Notifier) SetFakeIPRoute(*route.Route) { +func (n *Notifier) NotifyRouteChange() { // Not used on non-mobile platforms } @@ -35,6 +31,6 @@ func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) { // Not used on non-mobile platforms } -func (n *Notifier) GetInitialRouteRanges() []string { - return []string{} +func (n *Notifier) Close() { + // unused } diff --git a/client/internal/routemanager/notifier/route_diff.go b/client/internal/routemanager/notifier/route_diff.go new file mode 100644 index 000000000..52abddf36 --- /dev/null +++ b/client/internal/routemanager/notifier/route_diff.go @@ -0,0 +1,27 @@ +package notifier + +import ( + "slices" + "sort" + + "github.com/netbirdio/netbird/route" +) + +// routePrefixes returns the distinct prefixes a route set covers, sorted. +// Duplicates are dropped deliberately: an HA group hands us one route per +// peer serving the same prefix, and the platform is given the prefix, not the +// candidates. Counting them would report a change every time a peer joins or +// leaves a group, and on Android each report renews the TUN. +func routePrefixes(routes []*route.Route) []string { + nets := make([]string, 0, len(routes)) + for _, r := range routes { + nets = append(nets, r.NetString()) + } + sort.Strings(nets) + return slices.Compact(nets) +} + +// hasRouteDiff reports whether the prefixes the two route sets cover differ. +func hasRouteDiff(a []*route.Route, b []*route.Route) bool { + return !slices.Equal(routePrefixes(a), routePrefixes(b)) +} diff --git a/client/internal/routemanager/notifier/route_diff_test.go b/client/internal/routemanager/notifier/route_diff_test.go new file mode 100644 index 000000000..80df69d9d --- /dev/null +++ b/client/internal/routemanager/notifier/route_diff_test.go @@ -0,0 +1,88 @@ +package notifier + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/route" +) + +func routeFor(id route.ID, prefix string) *route.Route { + return &route.Route{ + ID: id, + NetID: "net", + Network: netip.MustParsePrefix(prefix), + } +} + +// TestHasRouteDiff_IgnoresHACandidateCount is the reason the comparison +// deduplicates. Every notification renews the TUN, and a renewed TUN +// invalidates the sockets the embedded servers are listening on, so a peer +// joining or leaving an HA group must not count as a route change when the +// prefixes the TUN carries are identical. +func TestHasRouteDiff_IgnoresHACandidateCount(t *testing.T) { + onePeer := []*route.Route{routeFor("a", "10.0.0.0/24")} + twoPeers := []*route.Route{ + routeFor("a", "10.0.0.0/24"), + routeFor("b", "10.0.0.0/24"), + } + + assert.False(t, hasRouteDiff(onePeer, twoPeers), + "a second peer serving the same prefix is not a route change") + assert.False(t, hasRouteDiff(twoPeers, onePeer), + "losing one of two peers serving the same prefix is not a route change") +} + +func TestHasRouteDiff_ReportsRealChanges(t *testing.T) { + tests := []struct { + name string + a []*route.Route + b []*route.Route + want bool + }{ + { + name: "added prefix", + a: []*route.Route{routeFor("a", "10.0.0.0/24")}, + b: []*route.Route{routeFor("a", "10.0.0.0/24"), routeFor("b", "10.0.1.0/24")}, + want: true, + }, + { + name: "removed prefix", + a: []*route.Route{routeFor("a", "10.0.0.0/24"), routeFor("b", "10.0.1.0/24")}, + b: []*route.Route{routeFor("a", "10.0.0.0/24")}, + want: true, + }, + { + name: "replaced prefix", + a: []*route.Route{routeFor("a", "10.0.0.0/24")}, + b: []*route.Route{routeFor("a", "10.0.1.0/24")}, + want: true, + }, + { + name: "same prefix, different order", + a: []*route.Route{routeFor("a", "10.0.1.0/24"), routeFor("b", "10.0.0.0/24")}, + b: []*route.Route{routeFor("b", "10.0.0.0/24"), routeFor("a", "10.0.1.0/24")}, + want: false, + }, + { + name: "all routes gone", + a: []*route.Route{routeFor("a", "10.0.0.0/24")}, + b: nil, + want: true, + }, + { + name: "both empty", + a: nil, + b: nil, + want: false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, hasRouteDiff(tc.a, tc.b), + "route diff for %s", tc.name) + }) + } +} diff --git a/client/internal/routemanager/reconcile_test.go b/client/internal/routemanager/reconcile_test.go new file mode 100644 index 000000000..bc9693229 --- /dev/null +++ b/client/internal/routemanager/reconcile_test.go @@ -0,0 +1,91 @@ +//go:build !windows + +package routemanager + +import ( + "net" + "net/netip" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.zx2c4.com/wireguard/tun/netstack" + + "github.com/netbirdio/netbird/client/iface/device" + "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/internal/routemanager/refcounter" +) + +// reconcileWGMock is a minimal iface.WGIface that records AddAllowedIP calls and reports the +// configured address; every other method is an inert stub because the tests exercise none of them. +type reconcileWGMock struct { + mu sync.Mutex + adds map[string][]netip.Prefix + addr wgaddr.Address +} + +func (m *reconcileWGMock) AddAllowedIP(peerKey string, allowedIP netip.Prefix) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.adds == nil { + m.adds = map[string][]netip.Prefix{} + } + m.adds[peerKey] = append(m.adds[peerKey], allowedIP) + return nil +} + +func (m *reconcileWGMock) added(peerKey string) []netip.Prefix { + m.mu.Lock() + defer m.mu.Unlock() + return m.adds[peerKey] +} + +func (m *reconcileWGMock) RemoveAllowedIP(string, netip.Prefix) error { return nil } +func (m *reconcileWGMock) Name() string { return "utun-test" } +func (m *reconcileWGMock) Address() wgaddr.Address { return m.addr } +func (m *reconcileWGMock) ToInterface() *net.Interface { return nil } +func (m *reconcileWGMock) IsUserspaceBind() bool { return false } +func (m *reconcileWGMock) GetFilter() device.PacketFilter { return nil } +func (m *reconcileWGMock) GetDevice() *device.FilteredDevice { return nil } +func (m *reconcileWGMock) GetNet() *netstack.Net { return nil } + +// TestReconcilePeerAllowedIPs verifies the declarative reconcile re-applies every routed prefix +// tracked for the peer (self-heal, independent of refcount level) and stays scoped to that peer. +func TestReconcilePeerAllowedIPs(t *testing.T) { + wg := &reconcileWGMock{} + m := &DefaultManager{wgInterface: wg} + m.allowedIPsRefCounter = refcounter.NewAllowedIPs( + func(_ netip.Prefix, peerKey string) (string, error) { return peerKey, nil }, + func(netip.Prefix, string) error { return nil }, + ) + + peerA1 := netip.MustParsePrefix("10.0.0.0/24") + peerA2 := netip.MustParsePrefix("10.1.0.0/24") + peerB1 := netip.MustParsePrefix("10.2.0.0/24") + + for prefix, peer := range map[netip.Prefix]string{peerA1: "peerA", peerA2: "peerA", peerB1: "peerB"} { + _, err := m.allowedIPsRefCounter.Increment(prefix, peer) + require.NoError(t, err) + } + // Extra reference: reconcile must still re-apply the prefix even though its refcount never + // hit 0 again (the exact case the plain incremental path skips). + _, err := m.allowedIPsRefCounter.Increment(peerA1, "peerA") + require.NoError(t, err) + + require.NoError(t, m.ReconcilePeerAllowedIPs("peerA")) + + assert.ElementsMatch(t, []netip.Prefix{peerA1, peerA2}, wg.added("peerA"), + "reconcile must re-apply all routed prefixes of the peer") + assert.Empty(t, wg.added("peerB"), "reconcile must not touch another peer's prefixes") +} + +// TestReconcilePeerAllowedIPsNoCounter verifies reconcile is a safe no-op before the refcounter is +// set up. +func TestReconcilePeerAllowedIPsNoCounter(t *testing.T) { + wg := &reconcileWGMock{} + m := &DefaultManager{wgInterface: wg} + + require.NoError(t, m.ReconcilePeerAllowedIPs("peerA")) + assert.Empty(t, wg.added("peerA")) +} diff --git a/client/internal/routemanager/refcounter/allowedips.go b/client/internal/routemanager/refcounter/allowedips.go new file mode 100644 index 000000000..6d682e8a9 --- /dev/null +++ b/client/internal/routemanager/refcounter/allowedips.go @@ -0,0 +1,206 @@ +package refcounter + +import ( + "errors" + "fmt" + "net/netip" + "sort" + "sync" + + "github.com/hashicorp/go-multierror" + + nberrors "github.com/netbirdio/netbird/client/errors" +) + +// allowedIPsEntry holds the per-peer reference counts for a single prefix and which peer is +// currently installed in WireGuard. WireGuard allows a prefix on exactly one peer, so at most +// one peer is active at a time even when several peers reference the prefix. +type allowedIPsEntry struct { + // peers maps a peerKey to the number of references holding the prefix for that peer. + peers map[string]int + // active is the peerKey currently installed in WireGuard for this prefix ("" if none). + active string + // total is the sum of all per-peer reference counts (kept in sync with peers). + total int +} + +// AllowedIPsRefCounter is a peer-aware reference counter for WireGuard AllowedIPs. +// +// The generic Counter keys only by prefix and remembers a single Out value set by the first +// caller, which it never changes. That is wrong for AllowedIPs: two independent watchers (or +// multiple resolved domains) can reference the same prefix through different peers, and when the +// peer currently installed in WireGuard releases its last reference the prefix must be handed over +// to a surviving peer instead of being left pointing at the released one. +// +// It calls add/remove (which program WireGuard) only on the transitions that matter: +// - add on the first reference for a prefix, or when swapping the active peer; +// - remove on the last reference for a prefix, or on the old peer during a swap. +type AllowedIPsRefCounter struct { + mu sync.Mutex + entries map[netip.Prefix]*allowedIPsEntry + add AddFunc[netip.Prefix, string, string] + remove RemoveFunc[netip.Prefix, string] +} + +// NewAllowedIPs creates a new peer-aware AllowedIPs reference counter. +// add programs a prefix on a peer in WireGuard and returns the peerKey to store as the active peer. +// remove unprograms the prefix from the given peer. +func NewAllowedIPs(add AddFunc[netip.Prefix, string, string], remove RemoveFunc[netip.Prefix, string]) *AllowedIPsRefCounter { + return &AllowedIPsRefCounter{ + entries: map[netip.Prefix]*allowedIPsEntry{}, + add: add, + remove: remove, + } +} + +// Increment adds a reference to prefix for peerKey. WireGuard is programmed only for the first +// reference to a prefix; while a different peer is already installed the prefix is left with it +// (first peer wins, HA at the WireGuard layer is not possible) and only the reference count is kept. +func (rm *AllowedIPsRefCounter) Increment(prefix netip.Prefix, peerKey string) (Ref[string], error) { + rm.mu.Lock() + defer rm.mu.Unlock() + + e, ok := rm.entries[prefix] + if !ok { + e = &allowedIPsEntry{peers: map[string]int{}} + rm.entries[prefix] = e + } + + logCallerF("Increasing allowed IP ref count for prefix %v peer %s [peer %d -> %d, total %d -> %d, active %q]", + prefix, peerKey, e.peers[peerKey], e.peers[peerKey]+1, e.total, e.total+1, e.active) + + // Program WireGuard only when nothing is installed yet for this prefix. + if e.active == "" { + out, err := rm.add(prefix, peerKey) + if errors.Is(err, ErrIgnore) { + if e.total == 0 { + delete(rm.entries, prefix) + } + return Ref[string]{Count: e.total, Out: e.active}, nil + } + if err != nil { + if e.total == 0 { + delete(rm.entries, prefix) + } + return Ref[string]{}, fmt.Errorf("failed to add allowed IP %v for peer %s: %w", prefix, peerKey, err) + } + e.active = out + } + + e.peers[peerKey]++ + e.total++ + + return Ref[string]{Count: e.total, Out: e.active}, nil +} + +// Decrement removes a reference to prefix for peerKey. When the peer currently installed in +// WireGuard releases its last reference, the prefix is swapped to a surviving peer if one exists, +// otherwise it is removed from WireGuard. +func (rm *AllowedIPsRefCounter) Decrement(prefix netip.Prefix, peerKey string) (Ref[string], error) { + rm.mu.Lock() + defer rm.mu.Unlock() + + e, ok := rm.entries[prefix] + if !ok { + logCallerF("No allowed IP reference found for prefix %v", prefix) + return Ref[string]{}, nil + } + + if e.peers[peerKey] > 0 { + logCallerF("Decreasing allowed IP ref count for prefix %v peer %s [peer %d -> %d, total %d -> %d, active %q]", + prefix, peerKey, e.peers[peerKey], e.peers[peerKey]-1, e.total, e.total-1, e.active) + e.peers[peerKey]-- + e.total-- + if e.peers[peerKey] == 0 { + delete(e.peers, peerKey) + } + } else { + logCallerF("No allowed IP reference found for prefix %v peer %s", prefix, peerKey) + } + + // If the peer currently installed in WireGuard still holds references, nothing to reprogram. + // Keying the check on the active peer (not the one just released) makes this self-healing: + // a prior swap whose remove/add failed leaves e.active pointing at a peer with no references, + // and this retries the hand-off on the next Decrement instead of getting stuck. + if e.active != "" && e.peers[e.active] > 0 { + return Ref[string]{Count: e.total, Out: e.active}, nil + } + + // Detach the stale/gone active peer from WireGuard before reprogramming. + if e.active != "" { + if err := rm.remove(prefix, e.active); err != nil { + return Ref[string]{Count: e.total, Out: e.active}, fmt.Errorf("remove allowed IP %v for peer %s: %w", prefix, e.active, err) + } + e.active = "" + } + + // Hand the prefix over to a surviving peer, or drop the entry when none remain. + if survivor, ok := pickSurvivor(e.peers); ok { + out, err := rm.add(prefix, survivor) + if err != nil { + return Ref[string]{Count: e.total, Out: ""}, fmt.Errorf("swap allowed IP %v to peer %s: %w", prefix, survivor, err) + } + e.active = out + return Ref[string]{Count: e.total, Out: e.active}, nil + } + + delete(rm.entries, prefix) + return Ref[string]{Count: 0, Out: ""}, nil +} + +// Flush removes all prefixes from WireGuard and clears the counter. +func (rm *AllowedIPsRefCounter) Flush() error { + rm.mu.Lock() + defer rm.mu.Unlock() + + var merr *multierror.Error + for prefix, e := range rm.entries { + if e.active == "" { + continue + } + logCallerF("Flushing allowed IP for prefix %v peer %s", prefix, e.active) + if err := rm.remove(prefix, e.active); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %v for peer %s: %w", prefix, e.active, err)) + } + } + + clear(rm.entries) + + return nberrors.FormatErrorOrNil(merr) +} + +// ReapplyMatching calls apply for every prefix whose currently installed (active) peer satisfies +// pred, holding the lock for the whole pass. It is used to re-push allowed IPs onto a peer whose +// WireGuard entry was rebuilt (e.g. a lazy connection cycling idle->wake) without a matching +// refcounter change, which would otherwise leave the prefix installed in the counter but missing +// on the device. Only the active peer is considered — a prefix that lost its installed peer to a +// failed swap is skipped here and reconciled by the next Increment/Decrement. +func (rm *AllowedIPsRefCounter) ReapplyMatching(pred func(out string) bool, apply func(key netip.Prefix) error) error { + rm.mu.Lock() + defer rm.mu.Unlock() + + var merr *multierror.Error + for prefix, e := range rm.entries { + if e.active != "" && pred(e.active) { + if err := apply(prefix); err != nil { + merr = multierror.Append(merr, err) + } + } + } + return nberrors.FormatErrorOrNil(merr) +} + +// pickSurvivor deterministically selects a peer still referencing the prefix. WireGuard cannot do +// multipath for a single prefix, so any surviving peer is a valid winner; the choice is made stable +// (lowest peerKey) for predictable behavior and testability. +func pickSurvivor(peers map[string]int) (string, bool) { + if len(peers) == 0 { + return "", false + } + keys := make([]string, 0, len(peers)) + for k := range peers { + keys = append(keys, k) + } + sort.Strings(keys) + return keys[0], true +} diff --git a/client/internal/routemanager/refcounter/allowedips_test.go b/client/internal/routemanager/refcounter/allowedips_test.go new file mode 100644 index 000000000..835142083 --- /dev/null +++ b/client/internal/routemanager/refcounter/allowedips_test.go @@ -0,0 +1,241 @@ +package refcounter + +import ( + "errors" + "net/netip" + "testing" +) + +// fakeWG models WireGuard's cryptokey routing: a prefix can be installed on exactly one peer. +// failAdd/failRemove make the next add/remove fail once, to exercise the self-healing error paths. +type fakeWG struct { + installed map[netip.Prefix]string + adds int + removes int + failAdd bool + failRemove bool +} + +func newFakeWG() *fakeWG { + return &fakeWG{installed: map[netip.Prefix]string{}} +} + +func (f *fakeWG) counter() *AllowedIPsRefCounter { + return NewAllowedIPs( + func(prefix netip.Prefix, peerKey string) (string, error) { + if f.failAdd { + f.failAdd = false + return "", errors.New("add failed") + } + f.adds++ + f.installed[prefix] = peerKey + return peerKey, nil + }, + func(prefix netip.Prefix, peerKey string) error { + if f.failRemove { + f.failRemove = false + return errors.New("remove failed") + } + f.removes++ + // only clear if this peer is the one installed, mirroring wg semantics + if f.installed[prefix] == peerKey { + delete(f.installed, prefix) + } + return nil + }, + ) +} + +func mustPrefix(t *testing.T, s string) netip.Prefix { + t.Helper() + p, err := netip.ParsePrefix(s) + if err != nil { + t.Fatalf("parse prefix %q: %v", s, err) + } + return p +} + +func mustIncrement(t *testing.T, c *AllowedIPsRefCounter, p netip.Prefix, peer string) Ref[string] { + t.Helper() + ref, err := c.Increment(p, peer) + if err != nil { + t.Fatalf("Increment(%v, %s): %v", p, peer, err) + } + return ref +} + +func mustDecrement(t *testing.T, c *AllowedIPsRefCounter, p netip.Prefix, peer string) Ref[string] { + t.Helper() + ref, err := c.Decrement(p, peer) + if err != nil { + t.Fatalf("Decrement(%v, %s): %v", p, peer, err) + } + return ref +} + +// TestAllowedIPs_SwapOnActivePeerRemoval reproduces the reported bug: two networks with the same +// prefix routed by different peers. Removing the network whose peer is installed must hand the +// prefix over to the surviving peer instead of leaving it on the removed one. +func TestAllowedIPs_SwapOnActivePeerRemoval(t *testing.T) { + f := newFakeWG() + c := f.counter() + p := mustPrefix(t, "10.44.8.0/24") + + mustIncrement(t, c, p, "peerA") + mustIncrement(t, c, p, "peerB") + // First peer wins while both are present. + if got := f.installed[p]; got != "peerA" { + t.Fatalf("expected peerA installed, got %q", got) + } + + // Remove the active peer's network -> must swap to peerB. + mustDecrement(t, c, p, "peerA") + if got := f.installed[p]; got != "peerB" { + t.Fatalf("BUG: prefix stuck on removed peer, want peerB got %q", got) + } + + // Remove the last one -> prefix gone. + mustDecrement(t, c, p, "peerB") + if _, ok := f.installed[p]; ok { + t.Fatalf("expected prefix removed, still installed on %q", f.installed[p]) + } +} + +// TestAllowedIPs_RemoveNonActivePeer removing a non-installed peer must not touch WireGuard. +func TestAllowedIPs_RemoveNonActivePeer(t *testing.T) { + f := newFakeWG() + c := f.counter() + p := mustPrefix(t, "10.44.8.0/24") + + mustIncrement(t, c, p, "peerA") + mustIncrement(t, c, p, "peerB") + removesBefore := f.removes + + mustDecrement(t, c, p, "peerB") + if f.installed[p] != "peerA" { + t.Fatalf("active peer must stay peerA, got %q", f.installed[p]) + } + if f.removes != removesBefore { + t.Fatalf("removing a non-active peer must not call wg remove") + } +} + +// TestAllowedIPs_SamePeerMultipleRefs two references via the same peer must keep the prefix until +// the last reference is released (the reason the per-peer count must be an int, not a set). +func TestAllowedIPs_SamePeerMultipleRefs(t *testing.T) { + f := newFakeWG() + c := f.counter() + p := mustPrefix(t, "10.44.8.0/24") + + mustIncrement(t, c, p, "peerA") + mustIncrement(t, c, p, "peerA") + if f.adds != 1 { + t.Fatalf("expected a single wg add for the same peer, got %d", f.adds) + } + + mustDecrement(t, c, p, "peerA") + if f.installed[p] != "peerA" { + t.Fatalf("prefix must stay while a reference remains, got %q", f.installed[p]) + } + if f.removes != 0 { + t.Fatalf("no wg remove expected while a reference remains, got %d", f.removes) + } + + mustDecrement(t, c, p, "peerA") + if _, ok := f.installed[p]; ok { + t.Fatalf("prefix must be removed after last reference") + } +} + +// TestAllowedIPs_RefCountAndActive checks the Ref returned to callers (used for the HA-disabled log). +func TestAllowedIPs_RefCountAndActive(t *testing.T) { + f := newFakeWG() + c := f.counter() + p := mustPrefix(t, "10.44.8.0/24") + + ref := mustIncrement(t, c, p, "peerA") + if ref.Count != 1 || ref.Out != "peerA" { + t.Fatalf("want {1, peerA}, got {%d, %q}", ref.Count, ref.Out) + } + ref = mustIncrement(t, c, p, "peerB") + if ref.Count != 2 || ref.Out != "peerA" { + t.Fatalf("want {2, peerA}, got {%d, %q}", ref.Count, ref.Out) + } +} + +// TestAllowedIPs_Flush removes everything installed and clears the counter. +func TestAllowedIPs_Flush(t *testing.T) { + f := newFakeWG() + c := f.counter() + p1 := mustPrefix(t, "10.44.8.0/24") + p2 := mustPrefix(t, "10.44.9.0/24") + + mustIncrement(t, c, p1, "peerA") + mustIncrement(t, c, p2, "peerB") + + if err := c.Flush(); err != nil { + t.Fatal(err) + } + if len(f.installed) != 0 { + t.Fatalf("expected all prefixes removed, got %v", f.installed) + } + // After flush, a fresh increment must add again. + mustIncrement(t, c, p1, "peerC") + if f.installed[p1] != "peerC" { + t.Fatalf("counter not reset after flush") + } +} + +// TestAllowedIPs_SelfHealAfterSwapAddError ensures a failed add during a swap does not permanently +// strand the prefix: the next Decrement (or Increment) must retry and install a surviving peer. +func TestAllowedIPs_SelfHealAfterSwapAddError(t *testing.T) { + f := newFakeWG() + c := f.counter() + p := mustPrefix(t, "10.44.8.0/24") + + mustIncrement(t, c, p, "peerA") + mustIncrement(t, c, p, "peerB") + mustIncrement(t, c, p, "peerC") + + // Removing the active peerA triggers a swap to a survivor; make the add fail once. + f.failAdd = true + if _, err := c.Decrement(p, "peerA"); err == nil { + t.Fatalf("expected error from failed swap add") + } + if _, ok := f.installed[p]; ok { + t.Fatalf("nothing should be installed after a failed swap add, got %q", f.installed[p]) + } + + // A later Decrement of a non-active survivor must retry the hand-off (self-heal), not stay stuck. + ref := mustDecrement(t, c, p, "peerC") + if got := f.installed[p]; got == "" { + t.Fatalf("self-heal failed: prefix left unrouted after add recovered") + } + if ref.Out == "" { + t.Fatalf("expected an active peer after self-heal, got empty") + } +} + +// TestAllowedIPs_SelfHealAfterRemoveError ensures a failed remove during a swap is retried instead +// of leaving e.active stuck on a peer that no longer holds references. +func TestAllowedIPs_SelfHealAfterRemoveError(t *testing.T) { + f := newFakeWG() + c := f.counter() + p := mustPrefix(t, "10.44.8.0/24") + + mustIncrement(t, c, p, "peerA") + mustIncrement(t, c, p, "peerB") + + // Releasing active peerA must detach it (remove) then add peerB; fail the remove once. + f.failRemove = true + if _, err := c.Decrement(p, "peerA"); err == nil { + t.Fatalf("expected error from failed remove") + } + + // Next Decrement of the non-active survivor retries: removes stale peerA, installs peerB. + mustDecrement(t, c, p, "peerB") + // peerB had only one ref, so after retry the prefix is fully released. + if _, ok := f.installed[p]; ok { + t.Fatalf("expected prefix released after self-heal, still on %q", f.installed[p]) + } +} diff --git a/client/internal/routemanager/refcounter/refcounter.go b/client/internal/routemanager/refcounter/refcounter.go index 27a724f50..917120275 100644 --- a/client/internal/routemanager/refcounter/refcounter.go +++ b/client/internal/routemanager/refcounter/refcounter.go @@ -94,6 +94,26 @@ func (rm *Counter[Key, I, O]) Get(key Key) (Ref[O], bool) { return ref, ok } +// ReapplyMatching calls apply for every key whose stored Out satisfies pred, holding the +// counter lock for the whole pass. Running apply under the lock keeps it atomic with respect +// to Increment/Decrement: a prefix dropped to zero is removed from the map (and had its +// RemoveFunc called) before this pass observes it, so a stale key can never be re-applied. +// pred and apply are invoked under the lock, so they must not call back into the counter. +func (rm *Counter[Key, I, O]) ReapplyMatching(pred func(out O) bool, apply func(key Key) error) error { + rm.mu.Lock() + defer rm.mu.Unlock() + + var merr *multierror.Error + for key, ref := range rm.refCountMap { + if pred(ref.Out) { + if err := apply(key); err != nil { + merr = multierror.Append(merr, err) + } + } + } + return nberrors.FormatErrorOrNil(merr) +} + // Increment increments the reference count for the given key. // If this is the first reference to the key, the AddFunc is called. func (rm *Counter[Key, I, O]) Increment(key Key, in I) (Ref[O], error) { diff --git a/client/internal/routemanager/refcounter/refcounter_test.go b/client/internal/routemanager/refcounter/refcounter_test.go new file mode 100644 index 000000000..79a99c388 --- /dev/null +++ b/client/internal/routemanager/refcounter/refcounter_test.go @@ -0,0 +1,47 @@ +package refcounter + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestReapplyMatching verifies ReapplyMatching invokes apply for exactly the keys whose stored +// Out satisfies the predicate (no duplicates for multiply-referenced keys) — the primitive +// ReconcilePeerAllowedIPs relies on to re-apply a single peer's routed prefixes. +func TestReapplyMatching(t *testing.T) { + rc := New[netip.Prefix, string, string]( + func(_ netip.Prefix, peerKey string) (string, error) { return peerKey, nil }, + func(netip.Prefix, string) error { return nil }, + ) + + peerA1 := netip.MustParsePrefix("10.0.0.0/24") + peerA2 := netip.MustParsePrefix("10.1.0.0/24") + peerB1 := netip.MustParsePrefix("10.2.0.0/24") + + for prefix, peer := range map[netip.Prefix]string{peerA1: "peerA", peerA2: "peerA", peerB1: "peerB"} { + _, err := rc.Increment(prefix, peer) + require.NoError(t, err) + } + // a second reference must not make the key applied twice + _, err := rc.Increment(peerA1, "peerA") + require.NoError(t, err) + + var applied []netip.Prefix + err = rc.ReapplyMatching( + func(out string) bool { return out == "peerA" }, + func(key netip.Prefix) error { applied = append(applied, key); return nil }, + ) + require.NoError(t, err) + assert.ElementsMatch(t, []netip.Prefix{peerA1, peerA2}, applied) + + var none []netip.Prefix + err = rc.ReapplyMatching( + func(out string) bool { return out == "missing" }, + func(key netip.Prefix) error { none = append(none, key); return nil }, + ) + require.NoError(t, err) + assert.Empty(t, none) +} diff --git a/client/internal/routemanager/refcounter/types.go b/client/internal/routemanager/refcounter/types.go index aadac3e25..7da0e17e3 100644 --- a/client/internal/routemanager/refcounter/types.go +++ b/client/internal/routemanager/refcounter/types.go @@ -5,5 +5,7 @@ import "net/netip" // RouteRefCounter is a Counter for Route, it doesn't take any input on Increment and doesn't use any output on Decrement type RouteRefCounter = Counter[netip.Prefix, struct{}, struct{}] -// AllowedIPsRefCounter is a Counter for AllowedIPs, it takes a peer key on Increment and passes it back to Decrement -type AllowedIPsRefCounter = Counter[netip.Prefix, string, string] +// AllowedIPsRefCounter tracks WireGuard AllowedIPs per prefix. Unlike the generic Counter it is peer-aware: +// a prefix can be claimed by several peers at once and WireGuard allows a given prefix on exactly one peer, +// so the counter records the per-peer reference count and swaps the installed peer when the active one is released. +// See allowedips.go. diff --git a/client/internal/routemanager/route_range_test.go b/client/internal/routemanager/route_range_test.go new file mode 100644 index 000000000..b51b5747a --- /dev/null +++ b/client/internal/routemanager/route_range_test.go @@ -0,0 +1,95 @@ +//go:build !windows + +package routemanager + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/internal/routeselector" + "github.com/netbirdio/netbird/route" +) + +func TestCurrentRouteRange_OverlayNetworkWithClientRoutesDisabled(t *testing.T) { + m := &DefaultManager{ + wgInterface: &reconcileWGMock{addr: wgaddr.MustParseWGAddress("100.91.96.107/16")}, + disableClientRoutes: true, + } + + assert.Equal(t, []string{"100.91.0.0/16"}, m.CurrentRouteRange(), "overlay network must be routed even when client routes are disabled") +} + +func TestCurrentRouteRange_OverlayNetworksAndClientRoutes(t *testing.T) { + addr := wgaddr.MustParseWGAddress("100.91.96.107/16") + addr.IPv6 = netip.MustParseAddr("fd00:1234::1") + addr.IPv6Net = netip.MustParsePrefix("fd00:1234::/64") + + static := &route.Route{ID: "static", NetID: "lan", Network: netip.MustParsePrefix("192.168.50.0/24"), NetworkType: route.IPv4Network} + dynamic := &route.Route{ID: "dynamic", NetID: "dyn", NetworkType: route.DomainNetwork} + + m := &DefaultManager{ + wgInterface: &reconcileWGMock{addr: addr}, + routeSelector: routeselector.NewRouteSelector(), + clientRoutes: route.HAMap{ + static.GetHAUniqueID(): {static}, + dynamic.GetHAUniqueID(): {dynamic}, + }, + } + + assert.Equal(t, []string{"100.91.0.0/16", "192.168.50.0/24", "fd00:1234::/64"}, m.CurrentRouteRange(), "overlay networks and static client routes must be listed, dynamic routes skipped") +} + +func TestCurrentRouteRange_NoInterfaceAddress(t *testing.T) { + m := &DefaultManager{ + wgInterface: &reconcileWGMock{}, + disableClientRoutes: true, + } + + assert.Empty(t, m.CurrentRouteRange(), "an unset interface address must not produce a route entry") +} + +func TestCurrentRouteRange_IPv6WithoutIPv4Network(t *testing.T) { + addr := wgaddr.Address{ + IPv6: netip.MustParseAddr("fd00:1234::1"), + IPv6Net: netip.MustParsePrefix("fd00:1234::/64"), + } + m := &DefaultManager{ + wgInterface: &reconcileWGMock{addr: addr}, + disableClientRoutes: true, + } + + assert.Equal(t, []string{"fd00:1234::/64"}, m.CurrentRouteRange(), "a v6 overlay network must not depend on a v4 network being set") +} + +func TestCurrentRouteRange_IPv6AddressWithoutNetwork(t *testing.T) { + addr := wgaddr.MustParseWGAddress("100.91.96.107/16") + addr.IPv6 = netip.MustParseAddr("fd00:1234::1") + + m := &DefaultManager{ + wgInterface: &reconcileWGMock{addr: addr}, + disableClientRoutes: true, + } + + assert.Equal(t, []string{"100.91.0.0/16"}, m.CurrentRouteRange(), "a v6 address without a network must not produce a route entry") +} + +func TestCurrentRouteRange_DeduplicatesPrefixes(t *testing.T) { + // Two HA peers serve the same prefix, and a client route announces the overlay network itself. + haPeerA := &route.Route{ID: "ha-a", NetID: "lan", Peer: "peer-a", Network: netip.MustParsePrefix("192.168.50.0/24"), NetworkType: route.IPv4Network} + haPeerB := &route.Route{ID: "ha-b", NetID: "lan", Peer: "peer-b", Network: netip.MustParsePrefix("192.168.50.0/24"), NetworkType: route.IPv4Network} + overlay := &route.Route{ID: "overlay", NetID: "overlay", Network: netip.MustParsePrefix("100.91.0.0/16"), NetworkType: route.IPv4Network} + + m := &DefaultManager{ + wgInterface: &reconcileWGMock{addr: wgaddr.MustParseWGAddress("100.91.96.107/16")}, + routeSelector: routeselector.NewRouteSelector(), + clientRoutes: route.HAMap{ + haPeerA.GetHAUniqueID(): {haPeerA, haPeerB}, + overlay.GetHAUniqueID(): {overlay}, + }, + } + + assert.Equal(t, []string{"100.91.0.0/16", "192.168.50.0/24"}, m.CurrentRouteRange(), "every prefix must be listed once regardless of how many routes carry it") +} diff --git a/client/internal/routemanager/selection.go b/client/internal/routemanager/selection.go new file mode 100644 index 000000000..b81d51b67 --- /dev/null +++ b/client/internal/routemanager/selection.go @@ -0,0 +1,145 @@ +package routemanager + +import ( + "fmt" + "slices" + + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + "golang.org/x/exp/maps" + + nberrors "github.com/netbirdio/netbird/client/errors" + "github.com/netbirdio/netbird/route" +) + +// SelectRoutes selects the routes with the given network IDs and applies the +// new selection. V4/v6 exit-node pairs are expanded automatically. Exit nodes +// are mutually exclusive: if the selection activates an exit node, every other +// available exit node is deselected so two can't be active at once. With +// appendRoute=false the previous selection is replaced instead of extended. +// A partial failure (e.g. an unknown ID mixed with valid ones) still applies +// the valid IDs to the routing table; the unknown ones are reported in the +// returned error. +func (m *DefaultManager) SelectRoutes(ids []route.NetID, appendRoute bool) error { + err := m.selectRoutes(ids, appendRoute) + // Apply regardless of err: selectRoutes already selects the valid part of a + // partial request, and skipping this on error would leave those routes + // selected in the selector but never installed in the routing table. + m.TriggerSelection(m.GetClientRoutes()) + return err +} + +// DeselectRoutes removes the routes with the given network IDs from the +// selection and applies the change. V4/v6 exit-node pairs are expanded +// automatically. A partial failure (e.g. an unknown ID mixed with valid ones) +// still applies the valid IDs to the routing table; the unknown ones are +// reported in the returned error. +func (m *DefaultManager) DeselectRoutes(ids []route.NetID) error { + err := m.deselectRoutes(ids) + // Apply regardless of err: deselectRoutes already deselects the valid part + // of a partial request, and skipping this on error would leave those routes + // installed in the routing table despite being marked deselected. + m.TriggerSelection(m.GetClientRoutes()) + return err +} + +func (m *DefaultManager) deselectRoutes(ids []route.NetID) error { + routesMap := m.GetClientRoutesWithNetID() + routes := route.ExpandV6ExitPairs(slices.Clone(ids), routesMap) + + log.Debugf("deselecting routes with ids: %v", routes) + + if err := m.routeSelector.DeselectRoutes(routes, maps.Keys(routesMap)); err != nil { + return fmt.Errorf("deselect routes: %w", err) + } + + return nil +} + +// SelectAllRoutes selects every available route and applies the selection. +// Exit nodes stay mutually exclusive: at most one remains active. +func (m *DefaultManager) SelectAllRoutes() { + m.selectAllRoutes() + m.TriggerSelection(m.GetClientRoutes()) +} + +func (m *DefaultManager) selectAllRoutes() { + m.routeSelector.SelectAllRoutes() + + // Select-all wipes every explicit selection, so exit nodes fall back to + // management's auto-apply flags — which may mark several at once. + // Reconcile immediately so at most one exit node stays active instead of + // waiting for the next network map to enforce it. + m.mux.Lock() + defer m.mux.Unlock() + m.updateRouteSelectorFromManagement(m.clientRoutes) +} + +// DeselectAllRoutes deselects every route and applies the change. +func (m *DefaultManager) DeselectAllRoutes() { + m.routeSelector.DeselectAllRoutes() + m.TriggerSelection(m.GetClientRoutes()) +} + +func (m *DefaultManager) selectRoutes(ids []route.NetID, appendRoute bool) error { + routesMap := m.GetClientRoutesWithNetID() + routes := route.ExpandV6ExitPairs(slices.Clone(ids), routesMap) + allIDs := maps.Keys(routesMap) + + log.Debugf("selecting routes with ids: %v", routes) + + // A partial failure (e.g. an unknown ID in the request) still selects the + // valid routes, so exclusivity below must run regardless of the error. + var merr *multierror.Error + if err := m.routeSelector.SelectRoutes(routes, appendRoute, allIDs); err != nil { + merr = multierror.Append(merr, fmt.Errorf("select routes: %w", err)) + } + + // Exit nodes are mutually exclusive: if this selection activates an + // exit node, deselect every other available exit node so two can't be + // selected at once. Non-exit route selections are left untouched. + if requestActivatesExitNode(routes, routesMap) { + if others := otherExitNodeIDs(routesMap, routes); len(others) > 0 { + if err := m.routeSelector.DeselectRoutes(others, allIDs); err != nil { + merr = multierror.Append(merr, fmt.Errorf("deselect sibling exit nodes: %w", err)) + } + } + } + + return nberrors.FormatErrorOrNil(merr) +} + +func isExitNodeRoutes(routes []*route.Route) bool { + return len(routes) > 0 && (route.IsV4DefaultRoute(routes[0].Network) || route.IsV6DefaultRoute(routes[0].Network)) +} + +// requestActivatesExitNode reports whether any requested NetID maps to an exit +// node (default route) in the current route table. +func requestActivatesExitNode(requested []route.NetID, routesMap map[route.NetID][]*route.Route) bool { + for _, id := range requested { + if isExitNodeRoutes(routesMap[id]) { + return true + } + } + return false +} + +// otherExitNodeIDs returns every available exit-node NetID that is not in the +// requested set — the siblings to deselect so a single exit node stays active. +func otherExitNodeIDs(routesMap map[route.NetID][]*route.Route, requested []route.NetID) []route.NetID { + keep := make(map[route.NetID]struct{}, len(requested)) + for _, id := range requested { + keep[id] = struct{}{} + } + var others []route.NetID + for id, routes := range routesMap { + if !isExitNodeRoutes(routes) { + continue + } + if _, ok := keep[id]; ok { + continue + } + others = append(others, id) + } + return others +} diff --git a/client/internal/routemanager/selection_test.go b/client/internal/routemanager/selection_test.go new file mode 100644 index 000000000..4ef9ddb88 --- /dev/null +++ b/client/internal/routemanager/selection_test.go @@ -0,0 +1,203 @@ +package routemanager + +import ( + "context" + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/exp/maps" + + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/routemanager/client" + "github.com/netbirdio/netbird/client/internal/routemanager/notifier" + "github.com/netbirdio/netbird/client/internal/routeselector" + "github.com/netbirdio/netbird/route" +) + +func v6ExitRoute(netID, peer string) *route.Route { + return &route.Route{ + NetID: route.NetID(netID), + Network: netip.MustParsePrefix("::/0"), + Peer: peer, + } +} + +func newSelectionTestManager() *DefaultManager { + return &DefaultManager{ + routeSelector: routeselector.NewRouteSelector(), + clientRoutes: route.HAMap{ + "exitA|0.0.0.0/0": {exitRoute("exitA", "p1", true)}, + "exitA-v6|::/0": {v6ExitRoute("exitA-v6", "p1")}, + "exitB|0.0.0.0/0": {exitRoute("exitB", "p2", true)}, + "lan|192.168.1.0/24": {{NetID: "lan", Network: netip.MustParsePrefix("192.168.1.0/24"), Peer: "p3"}}, + }, + } +} + +func TestSelectRoutes_ExitNodeExclusivity(t *testing.T) { + m := newSelectionTestManager() + + // Selecting an exit node selects its v6 pair and deselects the sibling. + require.NoError(t, m.selectRoutes([]route.NetID{"exitA"}, true)) + assert.True(t, m.routeSelector.IsSelected("exitA"), "exitA should be selected") + assert.True(t, m.routeSelector.IsSelected("exitA-v6"), "the v6 pair follows its v4 base") + assert.False(t, m.routeSelector.IsSelected("exitB"), "the sibling exit node must be deselected") + + // Switching to the sibling deselects the previous exit node and its v6 pair. + require.NoError(t, m.selectRoutes([]route.NetID{"exitB"}, true)) + assert.True(t, m.routeSelector.IsSelected("exitB"), "exitB should now be selected") + assert.False(t, m.routeSelector.IsSelected("exitA"), "the previous exit node must be deselected") + assert.False(t, m.routeSelector.IsSelected("exitA-v6"), "the previous exit node's v6 pair must be deselected") + assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit route selection is untouched") + + // Selecting a non-exit route leaves the active exit node alone. + require.NoError(t, m.selectRoutes([]route.NetID{"lan"}, true)) + assert.True(t, m.routeSelector.IsSelected("exitB"), "selecting a non-exit route keeps the exit node") + + // Deselecting the active exit node turns every exit node off. + require.NoError(t, m.deselectRoutes([]route.NetID{"exitB"})) + assert.False(t, m.routeSelector.IsSelected("exitB"), "exitB should be deselected") + assert.False(t, m.routeSelector.IsSelected("exitA"), "exitA stays deselected") + assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit route selection is untouched") +} + +func TestSelectRoutes_PartialErrorStillEnforcesExclusivity(t *testing.T) { + // The unknown ID must be reported, but the valid exit node in the same + // request is still selected — so its sibling must still be deselected. + // Both orderings are covered: processing must continue past the invalid + // ID wherever it sits in the request. + requests := map[string][]route.NetID{ + "invalid id first": {"missing", "exitB"}, + "invalid id last": {"exitB", "missing"}, + } + + for name, ids := range requests { + t.Run(name, func(t *testing.T) { + m := newSelectionTestManager() + + require.NoError(t, m.selectRoutes([]route.NetID{"exitA"}, true)) + + err := m.selectRoutes(ids, true) + assert.Error(t, err, "unknown id must be reported") + assert.True(t, m.routeSelector.IsSelected("exitB"), "valid exit node from the request is selected") + assert.False(t, m.routeSelector.IsSelected("exitA"), "sibling exit node must be deselected despite the error") + assert.False(t, m.routeSelector.IsSelected("exitA-v6"), "sibling's v6 pair must be deselected too") + }) + } +} + +func TestSelectAllRoutes_KeepsSingleExitNode(t *testing.T) { + // Both exit nodes are marked for auto-apply by management + // (SkipAutoApply=false), the state where select-all could turn on two at + // once without the immediate reconciliation. + m := &DefaultManager{ + routeSelector: routeselector.NewRouteSelector(), + clientRoutes: route.HAMap{ + "exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)}, + "exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)}, + "lan|192.168.1.0/24": {{NetID: "lan", Network: netip.MustParsePrefix("192.168.1.0/24"), Peer: "p3"}}, + }, + } + + require.NoError(t, m.selectRoutes([]route.NetID{"exitB"}, true)) + + m.selectAllRoutes() + + assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit routes are all selected") + assert.True(t, m.routeSelector.IsSelected("exitA"), "the deterministic management pick stays active") + assert.False(t, m.routeSelector.IsSelected("exitB"), "select-all must not leave a second exit node active") +} + +func TestSelectRoutes_UnknownRoute(t *testing.T) { + m := newSelectionTestManager() + + assert.Error(t, m.selectRoutes([]route.NetID{"missing"}, true), "selecting an unavailable route must fail") + assert.Error(t, m.deselectRoutes([]route.NetID{"missing"}), "deselecting an unavailable route must fail") +} + +// newPartialFailureTestManager exercises the real install/remove path without +// touching the system: the noop refcounter absorbs the route changes, and every +// route already has a watcher, so none is started. +func newPartialFailureTestManager() *DefaultManager { + ctx := context.Background() + + m := &DefaultManager{ + ctx: ctx, + clientRoutes: route.HAMap{ + "lan|192.168.1.0/24": {{NetID: "lan", Network: netip.MustParsePrefix("192.168.1.0/24"), Peer: "p1"}}, + "other|10.1.2.0/24": {{NetID: "other", Network: netip.MustParsePrefix("10.1.2.0/24"), Peer: "p2"}}, + }, + routeSelector: routeselector.NewRouteSelector(), + notifier: notifier.NewNotifier(), + statusRecorder: peer.NewRecorder("https://mgm"), + activeRoutes: make(map[route.HAUniqueID]client.RouteHandler), + clientNetworks: map[route.HAUniqueID]*client.Watcher{ + "lan|192.168.1.0/24": client.NewWatcher(client.WatcherConfig{Context: ctx}), + "other|10.1.2.0/24": client.NewWatcher(client.WatcherConfig{Context: ctx}), + }, + } + m.setupRefCounters(true) + return m +} + +// Regression for the reported symptom: a partial failure returned before +// TriggerSelection ran, so the valid route was marked selected while never +// reaching the routing table (activeRoutes/ip route). +func TestSelectRoutes_PartialFailureStillInstallsValidRoute(t *testing.T) { + m := newPartialFailureTestManager() + + err := m.SelectRoutes([]route.NetID{"missing", "lan"}, false) + + assert.Error(t, err, "the unknown id must still be reported") + assert.Contains(t, m.activeRoutes, route.HAUniqueID("lan|192.168.1.0/24"), "the valid route must be installed despite the error") + assert.NotContains(t, m.activeRoutes, route.HAUniqueID("other|10.1.2.0/24"), "the deselected route must not be installed") +} + +// Mirror of the case above: a partial failure must remove the valid route from +// the routing table, not just mark it deselected in the selector. +func TestDeselectRoutes_PartialFailureStillRemovesValidRoute(t *testing.T) { + m := newPartialFailureTestManager() + + require.NoError(t, m.SelectRoutes([]route.NetID{"lan", "other"}, false)) + require.Contains(t, m.activeRoutes, route.HAUniqueID("lan|192.168.1.0/24")) + require.Contains(t, m.activeRoutes, route.HAUniqueID("other|10.1.2.0/24")) + + err := m.DeselectRoutes([]route.NetID{"missing", "other"}) + + assert.Error(t, err, "the unknown id must still be reported") + assert.NotContains(t, m.activeRoutes, route.HAUniqueID("other|10.1.2.0/24"), "the deselected route must be removed") + assert.Contains(t, m.activeRoutes, route.HAUniqueID("lan|192.168.1.0/24"), "the untouched route stays installed") +} + +// The selection now runs on every request, including one where no ID is known +// and the selector stays untouched. Nothing may be torn down or reinstalled on +// that path. +func TestSelectRoutes_TotalFailureLeavesInstalledRoutesAlone(t *testing.T) { + m := newPartialFailureTestManager() + + require.NoError(t, m.SelectRoutes([]route.NetID{"lan", "other"}, false)) + installed := maps.Keys(m.activeRoutes) + + err := m.SelectRoutes([]route.NetID{"missing"}, false) + + assert.Error(t, err, "the unknown id must still be reported") + assert.ElementsMatch(t, installed, maps.Keys(m.activeRoutes), "a fully invalid request must not disturb the routing table") +} + +func TestExitNodeSelectionHelpers(t *testing.T) { + routesMap := map[route.NetID][]*route.Route{ + "exitA": {{Network: netip.MustParsePrefix("0.0.0.0/0")}}, + "exitB": {{Network: netip.MustParsePrefix("::/0")}}, + "lan": {{Network: netip.MustParsePrefix("192.168.0.0/16")}}, + } + + assert.True(t, requestActivatesExitNode([]route.NetID{"exitA"}, routesMap), "v4 default route is an exit node") + assert.True(t, requestActivatesExitNode([]route.NetID{"exitB"}, routesMap), "v6 default route is an exit node") + assert.False(t, requestActivatesExitNode([]route.NetID{"lan"}, routesMap), "lan route is not an exit node") + assert.False(t, requestActivatesExitNode([]route.NetID{"missing"}, routesMap), "unknown id is not an exit node") + + others := otherExitNodeIDs(routesMap, []route.NetID{"exitB"}) + assert.ElementsMatch(t, []route.NetID{"exitA"}, others, "only the other exit node is a sibling; the lan route is ignored") +} diff --git a/client/internal/routemanager/selector_management_test.go b/client/internal/routemanager/selector_management_test.go new file mode 100644 index 000000000..04659db65 --- /dev/null +++ b/client/internal/routemanager/selector_management_test.go @@ -0,0 +1,71 @@ +package routemanager + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/routeselector" + "github.com/netbirdio/netbird/route" +) + +func exitNodeRoutes(netID route.NetID, skipAutoApply bool) route.HAMap { + haID := route.HAUniqueID(string(netID) + "|0.0.0.0/0") + return route.HAMap{ + haID: []*route.Route{ + { + ID: "r-" + route.ID(netID), + NetID: netID, + Network: netip.MustParsePrefix("0.0.0.0/0"), + NetworkType: route.IPv4Network, + Enabled: true, + SkipAutoApply: skipAutoApply, + }, + }, + } +} + +func TestUpdateRouteSelectorFromManagement(t *testing.T) { + t.Run("management auto-apply selects exit node without user selection", func(t *testing.T) { + m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()} + routes := exitNodeRoutes("exit1", false) + + m.updateRouteSelectorFromManagement(routes) + + require.True(t, m.routeSelector.IsSelected("exit1"), "auto-apply exit node should be selected") + require.Len(t, m.routeSelector.FilterSelectedExitNodes(routes), 1, "selected exit node should pass the filter") + }) + + t.Run("management SkipAutoApply leaves exit node deselected", func(t *testing.T) { + m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()} + routes := exitNodeRoutes("exit1", true) + + m.updateRouteSelectorFromManagement(routes) + + require.False(t, m.routeSelector.IsSelected("exit1"), "SkipAutoApply exit node should not be selected") + require.Empty(t, m.routeSelector.FilterSelectedExitNodes(routes), "deselected exit node should be filtered out") + }) + + t.Run("user selection is not overridden by management", func(t *testing.T) { + m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()} + require.NoError(t, m.routeSelector.SelectRoutes([]route.NetID{"exit1"}, true, []route.NetID{"exit1"})) + routes := exitNodeRoutes("exit1", true) + + m.updateRouteSelectorFromManagement(routes) + + require.True(t, m.routeSelector.IsSelected("exit1"), "explicit user selection must survive a management sync that wants to skip auto-apply") + require.Len(t, m.routeSelector.FilterSelectedExitNodes(routes), 1, "user-selected exit node should pass the filter") + }) + + t.Run("deselect-all is preserved across a management sync", func(t *testing.T) { + m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()} + m.routeSelector.DeselectAllRoutes() + routes := exitNodeRoutes("exit1", false) + + m.updateRouteSelectorFromManagement(routes) + + require.True(t, m.routeSelector.IsDeselectAll(), "an explicit deselect-all must not be cleared by management auto-apply") + require.Empty(t, m.routeSelector.FilterSelectedExitNodes(routes), "no routes should be selected while deselect-all is set") + }) +} diff --git a/client/internal/routemanager/server/server.go b/client/internal/routemanager/server/server.go index e674c80cd..38d7f0db4 100644 --- a/client/internal/routemanager/server/server.go +++ b/client/internal/routemanager/server/server.go @@ -21,6 +21,7 @@ type Router struct { firewall firewall.Manager wgInterface iface.WGIface statusRecorder *peer.Status + useNewDNSRoute bool } func NewRouter(ctx context.Context, wgInterface iface.WGIface, firewall firewall.Manager, statusRecorder *peer.Status) (*Router, error) { @@ -37,6 +38,8 @@ func (r *Router) UpdateRoutes(routesMap map[route.ID]*route.Route, useNewDNSRout r.mux.Lock() defer r.mux.Unlock() + prevUseNewDNSRoute := r.useNewDNSRoute + serverRoutesToRemove := make([]route.ID, 0) for routeID := range r.routes { @@ -48,7 +51,7 @@ func (r *Router) UpdateRoutes(routesMap map[route.ID]*route.Route, useNewDNSRout for _, routeID := range serverRoutesToRemove { oldRoute := r.routes[routeID] - err := r.removeFromServerNetwork(oldRoute) + err := r.removeFromServerNetwork(oldRoute, prevUseNewDNSRoute) if err != nil { log.Errorf("Unable to remove route id: %s, network %s, from server, got: %v", oldRoute.ID, oldRoute.Network, err) @@ -56,6 +59,8 @@ func (r *Router) UpdateRoutes(routesMap map[route.ID]*route.Route, useNewDNSRout delete(r.routes, routeID) } + r.useNewDNSRoute = useNewDNSRoute + // If routing is to be disabled, do it after routes have been removed // If routing is to be enabled, do it before adding new routes; addToServerNetwork needs routing to be enabled if len(routesMap) > 0 { @@ -85,13 +90,13 @@ func (r *Router) UpdateRoutes(routesMap map[route.ID]*route.Route, useNewDNSRout return nil } -func (r *Router) removeFromServerNetwork(route *route.Route) error { +func (r *Router) removeFromServerNetwork(route *route.Route, useNewDNSRoute bool) error { if r.ctx.Err() != nil { log.Infof("Not removing from server network because context is done") return r.ctx.Err() } - routerPair := routeToRouterPair(route, false) + routerPair := routeToRouterPair(route, useNewDNSRoute) if err := r.firewall.RemoveNatRule(routerPair); err != nil { return fmt.Errorf("remove routing rules: %w", err) } @@ -124,12 +129,20 @@ func (r *Router) CleanUp() { defer r.mux.Unlock() for _, route := range r.routes { - routerPair := routeToRouterPair(route, false) + routerPair := routeToRouterPair(route, r.useNewDNSRoute) if err := r.firewall.RemoveNatRule(routerPair); err != nil { log.Errorf("Failed to remove cleanup route: %v", err) } } + // Give back the routing reference taken in UpdateRoutes, after the routes + // are gone as above. Without this the sysctls enabling it changed (IPv6 + // forwarding and the accept_ra values that keep RA handling alive next to + // it) stay applied once the client stops. + if err := r.firewall.DisableRouting(); err != nil { + log.Errorf("Failed to disable routing: %v", err) + } + r.statusRecorder.CleanLocalPeerStateRoutes() } @@ -146,8 +159,7 @@ func routeToRouterPair(route *route.Route, useNewDNSRoute bool) firewall.RouterP if useNewDNSRoute { destination.Set = firewall.NewDomainSet(route.Domains) } else { - // TODO: add ipv6 additionally - destination = getDefaultPrefix(destination.Prefix) + destination = getDefaultPrefix(route.Network) } } else { destination.Prefix = route.Network.Masked() @@ -158,6 +170,7 @@ func routeToRouterPair(route *route.Route, useNewDNSRoute bool) firewall.RouterP Source: source, Destination: destination, Masquerade: route.Masquerade, + Dynamic: route.IsDynamic(), } } diff --git a/client/internal/routemanager/server/server_test.go b/client/internal/routemanager/server/server_test.go new file mode 100644 index 000000000..1b42115e2 --- /dev/null +++ b/client/internal/routemanager/server/server_test.go @@ -0,0 +1,66 @@ +package server + +import ( + "context" + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + firewall "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/route" +) + +// routingFirewall records the routing lifecycle calls the router makes. The +// embedded interface covers the methods this test never reaches. +type routingFirewall struct { + firewall.Manager + + removed []firewall.RouterPair + enabled int + disabled int +} + +func (f *routingFirewall) RemoveNatRule(pair firewall.RouterPair) error { + f.removed = append(f.removed, pair) + return nil +} + +func (f *routingFirewall) EnableRouting() error { + f.enabled++ + return nil +} + +func (f *routingFirewall) DisableRouting() error { + f.disabled++ + return nil +} + +// TestRouterCleanUpReleasesRouting covers the shutdown path: the router holds a +// routing reference for as long as it serves routes, and CleanUp has to give it +// back. Without that the sysctls the reference enabled (IPv6 forwarding and the +// accept_ra values that keep RA handling working alongside it) stay applied +// after the client stops, leaving the host configured as a router. +func TestRouterCleanUpReleasesRouting(t *testing.T) { + fw := &routingFirewall{} + r := &Router{ + ctx: context.Background(), + firewall: fw, + statusRecorder: peer.NewRecorder("https://mgm"), + routes: map[route.ID]*route.Route{ + "route-1": { + ID: "route-1", + Network: netip.MustParsePrefix("192.168.55.0/24"), + NetworkType: route.IPv4Network, + Masquerade: true, + }, + }, + } + + r.CleanUp() + + require.Len(t, fw.removed, 1, "the route's NAT rule must be removed") + assert.Equal(t, 1, fw.disabled, "CleanUp must release the routing reference") +} diff --git a/client/internal/routemanager/static/route.go b/client/internal/routemanager/static/route.go index d480fdf00..8ba03d090 100644 --- a/client/internal/routemanager/static/route.go +++ b/client/internal/routemanager/static/route.go @@ -15,6 +15,11 @@ type Route struct { route *route.Route routeRefCounter *refcounter.RouteRefCounter allowedIPsRefcounter *refcounter.AllowedIPsRefCounter + // currentPeerKey is the routing peer this watcher currently has the prefix installed on + // (the HA winner elected by the watcher). It can differ from route.Peer and change on + // failover, so it is recorded on AddAllowedIPs and used on RemoveAllowedIPs to decrement + // the exact peer that was incremented. + currentPeerKey string } func NewRoute(params common.HandlerParams) *Route { @@ -52,12 +57,15 @@ func (r *Route) AddAllowedIPs(peerKey string) error { ref.Out, ) } + r.currentPeerKey = peerKey return nil } func (r *Route) RemoveAllowedIPs() error { - if _, err := r.allowedIPsRefcounter.Decrement(r.route.Network); err != nil { - return err + var err error + if _, decErr := r.allowedIPsRefcounter.Decrement(r.route.Network, r.currentPeerKey); decErr != nil { + err = fmt.Errorf("remove allowed IP %s: %w", r.route.Network, decErr) } - return nil + r.currentPeerKey = "" + return err } diff --git a/client/internal/routemanager/sysctl/sysctl_linux.go b/client/internal/routemanager/sysctl/sysctl_linux.go index f96a57f37..bb131c691 100644 --- a/client/internal/routemanager/sysctl/sysctl_linux.go +++ b/client/internal/routemanager/sysctl/sysctl_linux.go @@ -20,6 +20,8 @@ const ( rpFilterPath = "net.ipv4.conf.all.rp_filter" rpFilterInterfacePath = "net.ipv4.conf.%s.rp_filter" srcValidMarkPath = "net.ipv4.conf.all.src_valid_mark" + percentEscape = "%25" + dotEscape = "%2E" ) type iface interface { @@ -56,7 +58,7 @@ func Setup(wgIface iface) (map[string]int, error) { continue } - i := fmt.Sprintf(rpFilterInterfacePath, intf.Name) + i := fmt.Sprintf(rpFilterInterfacePath, EscapeInterfaceName(intf.Name)) oldVal, err := Set(i, 2, true) if err != nil { result = multierror.Append(result, err) @@ -68,9 +70,20 @@ func Setup(wgIface iface) (map[string]int, error) { return keys, nberrors.FormatErrorOrNil(result) } +// EscapeInterfaceName escapes '%' and '.' in an interface name (e.g. VLANs +// like eth0.100) so the name survives the dot-to-slash conversion in Set. +func EscapeInterfaceName(name string) string { + safe := strings.ReplaceAll(name, "%", percentEscape) + return strings.ReplaceAll(safe, ".", dotEscape) +} + // Set sets a sysctl configuration, if onlyIfOne is true it will only set the new value if it's set to 1 func Set(key string, desiredValue int, onlyIfOne bool) (int, error) { - path := fmt.Sprintf("/proc/sys/%s", strings.ReplaceAll(key, ".", "/")) + path := strings.ReplaceAll(key, ".", "/") + // Unescape interface dots and percent signs + path = strings.ReplaceAll(path, dotEscape, ".") + path = strings.ReplaceAll(path, percentEscape, "%") + path = fmt.Sprintf("/proc/sys/%s", path) currentValue, err := os.ReadFile(path) if err != nil { return -1, fmt.Errorf("read sysctl %s: %w", key, err) diff --git a/client/internal/routemanager/systemops/routeflags_addfilter_bsd.go b/client/internal/routemanager/systemops/routeflags_addfilter_bsd.go new file mode 100644 index 000000000..45a1bfceb --- /dev/null +++ b/client/internal/routemanager/systemops/routeflags_addfilter_bsd.go @@ -0,0 +1,9 @@ +//go:build dragonfly || freebsd || netbsd || openbsd + +package systemops + +// IgnoreAddedDefaultRoute reports whether an RTM_ADD default route with the +// given flags should be ignored by the network monitor. +func IgnoreAddedDefaultRoute(flags int) bool { + return filterRoutesByFlags(flags) +} diff --git a/client/internal/routemanager/systemops/routeflags_addfilter_darwin.go b/client/internal/routemanager/systemops/routeflags_addfilter_darwin.go new file mode 100644 index 000000000..e8f655387 --- /dev/null +++ b/client/internal/routemanager/systemops/routeflags_addfilter_darwin.go @@ -0,0 +1,21 @@ +//go:build darwin + +package systemops + +import "golang.org/x/sys/unix" + +// IgnoreAddedDefaultRoute reports whether an RTM_ADD default route with the +// given flags should be ignored by the network monitor. Scoped routes +// (RTF_IFSCOPE) are tied to a specific interface index and cannot replace the +// unscoped default the kernel uses for general egress, so flapping ones (e.g. +// Wi-Fi calling IMS tunnels on ipsec0, Docker bridges, scoped utun defaults) +// must not trigger an engine restart. +func IgnoreAddedDefaultRoute(flags int) bool { + if filterRoutesByFlags(flags) { + return true + } + if flags&unix.RTF_IFSCOPE != 0 { + return true + } + return false +} diff --git a/client/internal/routemanager/systemops/routeselection_windows_test.go b/client/internal/routemanager/systemops/routeselection_windows_test.go new file mode 100644 index 000000000..108338dd9 --- /dev/null +++ b/client/internal/routemanager/systemops/routeselection_windows_test.go @@ -0,0 +1,82 @@ +//go:build windows + +package systemops + +import ( + "math" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSortRouteCandidates(t *testing.T) { + tests := []struct { + name string + candidates []candidateRoute + wantOrder []uint32 + }{ + { + name: "longest prefix wins over metrics", + candidates: []candidateRoute{ + {interfaceIndex: 1, prefixLength: 0, routeMetric: 0, interfaceMetric: 5}, + {interfaceIndex: 2, prefixLength: 24, routeMetric: 100, interfaceMetric: 50}, + }, + wantOrder: []uint32{2, 1}, + }, + { + // Windows ranks equal-length prefixes by route metric + interface metric, + // so a higher route metric on a low metric interface can still win. + name: "combined metric beats route metric alone", + candidates: []candidateRoute{ + {interfaceIndex: 8, prefixLength: 0, routeMetric: 0, interfaceMetric: 100}, + {interfaceIndex: 5, prefixLength: 0, routeMetric: 10, interfaceMetric: 5}, + }, + wantOrder: []uint32{5, 8}, + }, + { + name: "lower combined metric wins", + candidates: []candidateRoute{ + {interfaceIndex: 5, prefixLength: 0, routeMetric: 300, interfaceMetric: 5}, + {interfaceIndex: 8, prefixLength: 0, routeMetric: 0, interfaceMetric: 100}, + }, + wantOrder: []uint32{8, 5}, + }, + { + name: "equal combined metric falls back to route metric", + candidates: []candidateRoute{ + {interfaceIndex: 1, prefixLength: 0, routeMetric: 20, interfaceMetric: 10}, + {interfaceIndex: 2, prefixLength: 0, routeMetric: 5, interfaceMetric: 25}, + }, + wantOrder: []uint32{2, 1}, + }, + { + // The metrics are uint32 on the Windows side, so the sum must not wrap. + name: "combined metric beyond the uint32 range", + candidates: []candidateRoute{ + {interfaceIndex: 1, prefixLength: 0, routeMetric: math.MaxUint32, interfaceMetric: 5}, + {interfaceIndex: 2, prefixLength: 0, routeMetric: math.MaxUint32 - 10, interfaceMetric: 5}, + }, + wantOrder: []uint32{2, 1}, + }, + { + name: "unknown interface metric ranks on route metric only", + candidates: []candidateRoute{ + {interfaceIndex: 1, prefixLength: 0, routeMetric: 30, interfaceMetric: -1}, + {interfaceIndex: 2, prefixLength: 0, routeMetric: 5, interfaceMetric: 10}, + }, + wantOrder: []uint32{2, 1}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sortRouteCandidates(tt.candidates) + + got := make([]uint32, 0, len(tt.candidates)) + for _, c := range tt.candidates { + got = append(got, c.interfaceIndex) + } + assert.Equal(t, tt.wantOrder, got) + }) + } +} diff --git a/client/internal/routemanager/systemops/rt_tables_linux_test.go b/client/internal/routemanager/systemops/rt_tables_linux_test.go new file mode 100644 index 000000000..bc9cca8b1 --- /dev/null +++ b/client/internal/routemanager/systemops/rt_tables_linux_test.go @@ -0,0 +1,69 @@ +//go:build linux && !android + +package systemops + +import ( + "fmt" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEntryExists(t *testing.T) { + tempDir := t.TempDir() + tempFilePath := fmt.Sprintf("%s/rt_tables", tempDir) + + content := []string{ + "1000 reserved", + fmt.Sprintf("%d %s", NetbirdVPNTableID, NetbirdVPNTableName), + "9999 other_table", + } + require.NoError(t, os.WriteFile(tempFilePath, []byte(strings.Join(content, "\n")), 0644)) + + file, err := os.Open(tempFilePath) + require.NoError(t, err) + defer func() { + assert.NoError(t, file.Close()) + }() + + tests := []struct { + name string + id int + shouldExist bool + err error + }{ + { + name: "ExistsWithNetbirdPrefix", + id: 7120, + shouldExist: true, + err: nil, + }, + { + name: "ExistsWithDifferentName", + id: 1000, + shouldExist: true, + err: ErrTableIDExists, + }, + { + name: "DoesNotExist", + id: 1234, + shouldExist: false, + err: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + exists, err := entryExists(file, tc.id) + if tc.err != nil { + assert.ErrorIs(t, err, tc.err) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tc.shouldExist, exists) + }) + } +} diff --git a/client/internal/routemanager/systemops/systemops.go b/client/internal/routemanager/systemops/systemops.go index c0ca21d22..165448b60 100644 --- a/client/internal/routemanager/systemops/systemops.go +++ b/client/internal/routemanager/systemops/systemops.go @@ -107,8 +107,16 @@ func (r *SysOps) validateRoute(prefix netip.Prefix) error { addr.IsInterfaceLocalMulticast(), addr.IsMulticast(), addr.IsUnspecified() && prefix.Bits() != 0, - r.wgInterface.Address().Network.Contains(addr): + r.isOwnAddress(addr): return vars.ErrRouteNotAllowed } return nil } + +func (r *SysOps) isOwnAddress(addr netip.Addr) bool { + if r.wgInterface == nil { + return false + } + wgAddr := r.wgInterface.Address() + return wgAddr.Network.Contains(addr) || (wgAddr.IPv6Net.IsValid() && wgAddr.IPv6Net.Contains(addr)) +} diff --git a/client/internal/routemanager/systemops/systemops_android.go b/client/internal/routemanager/systemops/systemops_android.go index 7cb8dae93..97b4ed8ec 100644 --- a/client/internal/routemanager/systemops/systemops_android.go +++ b/client/internal/routemanager/systemops/systemops_android.go @@ -32,8 +32,17 @@ func (r *SysOps) removeFromRouteTable(netip.Prefix, Nexthop) error { return nil } -func EnableIPForwarding() error { - log.Infof("Enable IP forwarding is not implemented on %s", runtime.GOOS) +func EnableV4IPForwarding() error { + log.Infof("Enable IPv4 forwarding is not implemented on %s", runtime.GOOS) + return nil +} + +func EnableV6IPForwarding(string) (map[string]int, error) { + log.Infof("Enable IPv6 forwarding is not implemented on %s", runtime.GOOS) + return map[string]int{}, nil +} + +func DisableV6IPForwarding(map[string]int) error { return nil } diff --git a/client/internal/routemanager/systemops/systemops_bsd_privileged_test.go b/client/internal/routemanager/systemops/systemops_bsd_privileged_test.go new file mode 100644 index 000000000..d45028c19 --- /dev/null +++ b/client/internal/routemanager/systemops/systemops_bsd_privileged_test.go @@ -0,0 +1,191 @@ +//go:build (darwin || dragonfly || freebsd || netbsd || openbsd) && privileged + +package systemops + +import ( + "fmt" + "net" + "net/netip" + "os/exec" + "regexp" + "runtime" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func init() { + testCases = append(testCases, []testCase{ + { + name: "To more specific route without custom dialer via vpn", + expectedInterface: expectedVPNint, + dialer: &net.Dialer{}, + expectedPacket: createPacketExpectation("100.64.0.1", 12345, "10.10.0.2", 53), + }, + }...) +} + +func TestConcurrentRoutes(t *testing.T) { + baseIP := netip.MustParseAddr("192.0.2.0") + + var intf *net.Interface + var nexthop Nexthop + + _, intf = setupDummyInterface(t) + nexthop = Nexthop{netip.Addr{}, intf} + + r := New(nil, nil) + + var wg sync.WaitGroup + for i := 0; i < 1024; i++ { + wg.Add(1) + go func(ip netip.Addr) { + defer wg.Done() + prefix := netip.PrefixFrom(ip, 32) + if err := r.addToRouteTable(prefix, nexthop); err != nil { + t.Errorf("Failed to add route for %s: %v", prefix, err) + } + }(baseIP) + baseIP = baseIP.Next() + } + + wg.Wait() + + baseIP = netip.MustParseAddr("192.0.2.0") + + for i := 0; i < 1024; i++ { + wg.Add(1) + go func(ip netip.Addr) { + defer wg.Done() + prefix := netip.PrefixFrom(ip, 32) + if err := r.removeFromRouteTable(prefix, nexthop); err != nil { + t.Errorf("Failed to remove route for %s: %v", prefix, err) + } + }(baseIP) + baseIP = baseIP.Next() + } + + wg.Wait() +} + +func createAndSetupDummyInterface(t *testing.T, intf string, ipAddressCIDR string) string { + t.Helper() + + if runtime.GOOS == "darwin" { + err := exec.Command("ifconfig", intf, "alias", ipAddressCIDR).Run() + require.NoError(t, err, "Failed to create loopback alias") + + t.Cleanup(func() { + err := exec.Command("ifconfig", intf, ipAddressCIDR, "-alias").Run() + assert.NoError(t, err, "Failed to remove loopback alias") + }) + + return intf + } + + prefix, err := netip.ParsePrefix(ipAddressCIDR) + require.NoError(t, err, "Failed to parse prefix") + + netIntf, err := net.InterfaceByName(intf) + require.NoError(t, err, "Failed to get interface by name") + + nexthop := Nexthop{netip.Addr{}, netIntf} + + r := New(nil, nil) + err = r.addToRouteTable(prefix, nexthop) + require.NoError(t, err, "Failed to add route to table") + + t.Cleanup(func() { + err := r.removeFromRouteTable(prefix, nexthop) + assert.NoError(t, err, "Failed to remove route from table") + }) + + return intf +} + +func addDummyRoute(t *testing.T, dstCIDR string, gw netip.Addr, _ string) { + t.Helper() + + var originalNexthop net.IP + if dstCIDR == "0.0.0.0/0" { + var err error + originalNexthop, err = fetchOriginalGateway() + if err != nil { + t.Logf("Failed to fetch original gateway: %v", err) + } + + if output, err := exec.Command("route", "delete", "-net", dstCIDR).CombinedOutput(); err != nil { + t.Logf("Failed to delete route: %v, output: %s", err, output) + } + } + + t.Cleanup(func() { + if originalNexthop != nil { + err := exec.Command("route", "add", "-net", dstCIDR, originalNexthop.String()).Run() + assert.NoError(t, err, "Failed to restore original route") + } + }) + + err := exec.Command("route", "add", "-net", dstCIDR, gw.String()).Run() + require.NoError(t, err, "Failed to add route") + + t.Cleanup(func() { + err := exec.Command("route", "delete", "-net", dstCIDR).Run() + assert.NoError(t, err, "Failed to remove route") + }) +} + +func fetchOriginalGateway() (net.IP, error) { + output, err := exec.Command("route", "-n", "get", "default").CombinedOutput() + if err != nil { + return nil, err + } + + matches := regexp.MustCompile(`gateway: (\S+)`).FindStringSubmatch(string(output)) + if len(matches) == 0 { + return nil, fmt.Errorf("gateway not found") + } + + return net.ParseIP(matches[1]), nil +} + +// setupDummyInterface creates a dummy tun interface for FreeBSD route testing +func setupDummyInterface(t *testing.T) (netip.Addr, *net.Interface) { + t.Helper() + + if runtime.GOOS == "darwin" { + return netip.AddrFrom4([4]byte{192, 168, 1, 2}), &net.Interface{Name: "lo0"} + } + + output, err := exec.Command("ifconfig", "tun", "create").CombinedOutput() + require.NoError(t, err, "Failed to create tun interface: %s", string(output)) + + tunName := strings.TrimSpace(string(output)) + + output, err = exec.Command("ifconfig", tunName, "192.168.1.1", "netmask", "255.255.0.0", "192.168.1.2", "up").CombinedOutput() + require.NoError(t, err, "Failed to configure tun interface: %s", string(output)) + + intf, err := net.InterfaceByName(tunName) + require.NoError(t, err, "Failed to get interface by name") + + t.Cleanup(func() { + if err := exec.Command("ifconfig", tunName, "destroy").Run(); err != nil { + t.Logf("Failed to destroy tun interface %s: %v", tunName, err) + } + }) + + return netip.AddrFrom4([4]byte{192, 168, 1, 2}), intf +} + +func setupDummyInterfacesAndRoutes(t *testing.T) { + t.Helper() + + defaultDummy := createAndSetupDummyInterface(t, expectedExternalInt, "192.168.0.1/24") + addDummyRoute(t, "0.0.0.0/0", netip.AddrFrom4([4]byte{192, 168, 0, 1}), defaultDummy) + + otherDummy := createAndSetupDummyInterface(t, expectedInternalInt, "192.168.1.1/24") + addDummyRoute(t, "10.0.0.0/8", netip.AddrFrom4([4]byte{192, 168, 1, 1}), otherDummy) +} diff --git a/client/internal/routemanager/systemops/systemops_bsd_test.go b/client/internal/routemanager/systemops/systemops_bsd_test.go index ec4fc406e..9650945b3 100644 --- a/client/internal/routemanager/systemops/systemops_bsd_test.go +++ b/client/internal/routemanager/systemops/systemops_bsd_test.go @@ -3,79 +3,24 @@ package systemops import ( - "fmt" - "net" - "net/netip" - "os/exec" - "regexp" - "runtime" - "strings" - "sync" "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "golang.org/x/net/route" ) +// Interface names used by the shared routing test fixtures. Kept untagged (no +// privileged build tag) so the non-privileged test files in this package compile. +// +//nolint:unused // consumed by the privileged-tagged routing tests var expectedVPNint = "utun100" + +//nolint:unused // consumed by the privileged-tagged routing tests var expectedExternalInt = "lo0" + +//nolint:unused // consumed by the privileged-tagged routing tests var expectedInternalInt = "lo0" -func init() { - testCases = append(testCases, []testCase{ - { - name: "To more specific route without custom dialer via vpn", - expectedInterface: expectedVPNint, - dialer: &net.Dialer{}, - expectedPacket: createPacketExpectation("100.64.0.1", 12345, "10.10.0.2", 53), - }, - }...) -} - -func TestConcurrentRoutes(t *testing.T) { - baseIP := netip.MustParseAddr("192.0.2.0") - - var intf *net.Interface - var nexthop Nexthop - - _, intf = setupDummyInterface(t) - nexthop = Nexthop{netip.Addr{}, intf} - - r := New(nil, nil) - - var wg sync.WaitGroup - for i := 0; i < 1024; i++ { - wg.Add(1) - go func(ip netip.Addr) { - defer wg.Done() - prefix := netip.PrefixFrom(ip, 32) - if err := r.addToRouteTable(prefix, nexthop); err != nil { - t.Errorf("Failed to add route for %s: %v", prefix, err) - } - }(baseIP) - baseIP = baseIP.Next() - } - - wg.Wait() - - baseIP = netip.MustParseAddr("192.0.2.0") - - for i := 0; i < 1024; i++ { - wg.Add(1) - go func(ip netip.Addr) { - defer wg.Done() - prefix := netip.PrefixFrom(ip, 32) - if err := r.removeFromRouteTable(prefix, nexthop); err != nil { - t.Errorf("Failed to remove route for %s: %v", prefix, err) - } - }(baseIP) - baseIP = baseIP.Next() - } - - wg.Wait() -} - func TestBits(t *testing.T) { tests := []struct { name string @@ -122,122 +67,3 @@ func TestBits(t *testing.T) { }) } } - -func createAndSetupDummyInterface(t *testing.T, intf string, ipAddressCIDR string) string { - t.Helper() - - if runtime.GOOS == "darwin" { - err := exec.Command("ifconfig", intf, "alias", ipAddressCIDR).Run() - require.NoError(t, err, "Failed to create loopback alias") - - t.Cleanup(func() { - err := exec.Command("ifconfig", intf, ipAddressCIDR, "-alias").Run() - assert.NoError(t, err, "Failed to remove loopback alias") - }) - - return intf - } - - prefix, err := netip.ParsePrefix(ipAddressCIDR) - require.NoError(t, err, "Failed to parse prefix") - - netIntf, err := net.InterfaceByName(intf) - require.NoError(t, err, "Failed to get interface by name") - - nexthop := Nexthop{netip.Addr{}, netIntf} - - r := New(nil, nil) - err = r.addToRouteTable(prefix, nexthop) - require.NoError(t, err, "Failed to add route to table") - - t.Cleanup(func() { - err := r.removeFromRouteTable(prefix, nexthop) - assert.NoError(t, err, "Failed to remove route from table") - }) - - return intf -} - -func addDummyRoute(t *testing.T, dstCIDR string, gw netip.Addr, _ string) { - t.Helper() - - var originalNexthop net.IP - if dstCIDR == "0.0.0.0/0" { - var err error - originalNexthop, err = fetchOriginalGateway() - if err != nil { - t.Logf("Failed to fetch original gateway: %v", err) - } - - if output, err := exec.Command("route", "delete", "-net", dstCIDR).CombinedOutput(); err != nil { - t.Logf("Failed to delete route: %v, output: %s", err, output) - } - } - - t.Cleanup(func() { - if originalNexthop != nil { - err := exec.Command("route", "add", "-net", dstCIDR, originalNexthop.String()).Run() - assert.NoError(t, err, "Failed to restore original route") - } - }) - - err := exec.Command("route", "add", "-net", dstCIDR, gw.String()).Run() - require.NoError(t, err, "Failed to add route") - - t.Cleanup(func() { - err := exec.Command("route", "delete", "-net", dstCIDR).Run() - assert.NoError(t, err, "Failed to remove route") - }) -} - -func fetchOriginalGateway() (net.IP, error) { - output, err := exec.Command("route", "-n", "get", "default").CombinedOutput() - if err != nil { - return nil, err - } - - matches := regexp.MustCompile(`gateway: (\S+)`).FindStringSubmatch(string(output)) - if len(matches) == 0 { - return nil, fmt.Errorf("gateway not found") - } - - return net.ParseIP(matches[1]), nil -} - -// setupDummyInterface creates a dummy tun interface for FreeBSD route testing -func setupDummyInterface(t *testing.T) (netip.Addr, *net.Interface) { - t.Helper() - - if runtime.GOOS == "darwin" { - return netip.AddrFrom4([4]byte{192, 168, 1, 2}), &net.Interface{Name: "lo0"} - } - - output, err := exec.Command("ifconfig", "tun", "create").CombinedOutput() - require.NoError(t, err, "Failed to create tun interface: %s", string(output)) - - tunName := strings.TrimSpace(string(output)) - - output, err = exec.Command("ifconfig", tunName, "192.168.1.1", "netmask", "255.255.0.0", "192.168.1.2", "up").CombinedOutput() - require.NoError(t, err, "Failed to configure tun interface: %s", string(output)) - - intf, err := net.InterfaceByName(tunName) - require.NoError(t, err, "Failed to get interface by name") - - t.Cleanup(func() { - if err := exec.Command("ifconfig", tunName, "destroy").Run(); err != nil { - t.Logf("Failed to destroy tun interface %s: %v", tunName, err) - } - }) - - return netip.AddrFrom4([4]byte{192, 168, 1, 2}), intf -} - -func setupDummyInterfacesAndRoutes(t *testing.T) { - t.Helper() - - defaultDummy := createAndSetupDummyInterface(t, expectedExternalInt, "192.168.0.1/24") - addDummyRoute(t, "0.0.0.0/0", netip.AddrFrom4([4]byte{192, 168, 0, 1}), defaultDummy) - - otherDummy := createAndSetupDummyInterface(t, expectedInternalInt, "192.168.1.1/24") - addDummyRoute(t, "10.0.0.0/8", netip.AddrFrom4([4]byte{192, 168, 1, 1}), otherDummy) -} diff --git a/client/internal/routemanager/systemops/systemops_darwin.go b/client/internal/routemanager/systemops/systemops_darwin.go index d6875ff95..3fcac4c6a 100644 --- a/client/internal/routemanager/systemops/systemops_darwin.go +++ b/client/internal/routemanager/systemops/systemops_darwin.go @@ -89,8 +89,16 @@ func (r *SysOps) installScopedDefaultFor(unspec netip.Addr) (bool, error) { return false, fmt.Errorf("unusable default nexthop for %s (no interface)", unspec) } + reused := false if err := r.addScopedDefault(unspec, nexthop); err != nil { - return false, fmt.Errorf("add scoped default on %s: %w", nexthop.Intf.Name, err) + if !errors.Is(err, unix.EEXIST) { + return false, fmt.Errorf("add scoped default on %s: %w", nexthop.Intf.Name, err) + } + // macOS installs its own RTF_IFSCOPE defaults for primary service + // selection on multi-NIC setups, so a route on this ifindex can + // already exist before we try. Binding to it via IP[V6]_BOUND_IF + // still produces the scoped lookup we need. + reused = true } af := unix.AF_INET @@ -102,7 +110,11 @@ func (r *SysOps) installScopedDefaultFor(unspec netip.Addr) (bool, error) { if nexthop.IP.IsValid() { via = nexthop.IP.String() } - log.Infof("installed scoped default route via %s on %s for %s", via, nexthop.Intf.Name, afOf(unspec)) + verb := "installed" + if reused { + verb = "reused existing" + } + log.Infof("%s scoped default route via %s on %s for %s", verb, via, nexthop.Intf.Name, afOf(unspec)) return true, nil } diff --git a/client/internal/routemanager/systemops/systemops_dialer_test.go b/client/internal/routemanager/systemops/systemops_dialer_test.go new file mode 100644 index 000000000..f00f9099c --- /dev/null +++ b/client/internal/routemanager/systemops/systemops_dialer_test.go @@ -0,0 +1,17 @@ +//go:build !android && !ios + +package systemops + +import ( + "context" + "net" +) + +// dialer is shared by the per-platform routing test cases. Kept untagged (no +// privileged build tag) so the non-privileged test files compile on every platform. +// +//nolint:unused // consumed by the privileged-tagged routing tests +type dialer interface { + Dial(network, address string) (net.Conn, error) + DialContext(ctx context.Context, network, address string) (net.Conn, error) +} diff --git a/client/internal/routemanager/systemops/systemops_generic.go b/client/internal/routemanager/systemops/systemops_generic.go index 4211eb057..bb9ac494d 100644 --- a/client/internal/routemanager/systemops/systemops_generic.go +++ b/client/internal/routemanager/systemops/systemops_generic.go @@ -121,9 +121,12 @@ func (r *SysOps) addRouteToNonVPNIntf(prefix netip.Prefix, vpnIntf wgIface, init return Nexthop{}, vars.ErrRouteNotAllowed } - // Check if the prefix is part of any local subnets - if isLocal, subnet := r.isPrefixInLocalSubnets(prefix); isLocal { - return Nexthop{}, fmt.Errorf("prefix %s is part of local subnet %s: %w", prefix, subnet, vars.ErrRouteNotAllowed) + // BSDs blackhole a /32 added inside a directly-connected subnet; Linux/Windows need it to beat the wt0 route. + switch runtime.GOOS { + case "darwin", "freebsd", "netbsd", "openbsd", "dragonfly": + if isLocal, subnet := r.isPrefixInLocalSubnets(prefix); isLocal { + return Nexthop{}, fmt.Errorf("prefix %s is part of local subnet %s: %w", prefix, subnet, vars.ErrRouteNotAllowed) + } } // Determine the exit interface and next hop for the prefix, so we can add a specific route @@ -221,30 +224,20 @@ func (r *SysOps) genericAddVPNRoute(prefix netip.Prefix, intf *net.Interface) er return err } - // TODO: remove once IPv6 is supported on the interface - if err := r.addToRouteTable(splitDefaultv6_1, nextHop); err != nil { - return fmt.Errorf("add unreachable route split 1: %w", err) - } - if err := r.addToRouteTable(splitDefaultv6_2, nextHop); err != nil { - if err2 := r.removeFromRouteTable(splitDefaultv6_1, nextHop); err2 != nil { - log.Warnf("Failed to rollback route addition: %s", err2) + // When the interface has no v6, add v6 split-default as blackhole so + // unroutable v6 goes to WG (dropped, no AllowedIPs) instead of leaking + // to the system default route. When v6 is active, management sends ::/0 + // as a separate route that the dedicated handler adds. + // Soft-fail: v6 blackhole is best-effort, don't abort v4 routing on failure. + if !r.wgInterface.Address().HasIPv6() { + if err := r.addV6SplitDefault(nextHop); err != nil { + log.Warnf("failed to add v6 split-default blackhole: %s", err) } - return fmt.Errorf("add unreachable route split 2: %w", err) } return nil case vars.Defaultv6: - if err := r.addToRouteTable(splitDefaultv6_1, nextHop); err != nil { - return fmt.Errorf("add unreachable route split 1: %w", err) - } - if err := r.addToRouteTable(splitDefaultv6_2, nextHop); err != nil { - if err2 := r.removeFromRouteTable(splitDefaultv6_1, nextHop); err2 != nil { - log.Warnf("Failed to rollback route addition: %s", err2) - } - return fmt.Errorf("add unreachable route split 2: %w", err) - } - - return nil + return r.addV6SplitDefault(nextHop) } return r.addToRouteTable(prefix, nextHop) @@ -265,30 +258,42 @@ func (r *SysOps) genericRemoveVPNRoute(prefix netip.Prefix, intf *net.Interface) result = multierror.Append(result, err) } - // TODO: remove once IPv6 is supported on the interface - if err := r.removeFromRouteTable(splitDefaultv6_1, nextHop); err != nil { - result = multierror.Append(result, err) - } - if err := r.removeFromRouteTable(splitDefaultv6_2, nextHop); err != nil { - result = multierror.Append(result, err) + if !r.wgInterface.Address().HasIPv6() { + result = multierror.Append(result, r.removeV6SplitDefault(nextHop)) } return nberrors.FormatErrorOrNil(result) case vars.Defaultv6: - var result *multierror.Error - if err := r.removeFromRouteTable(splitDefaultv6_1, nextHop); err != nil { - result = multierror.Append(result, err) - } - if err := r.removeFromRouteTable(splitDefaultv6_2, nextHop); err != nil { - result = multierror.Append(result, err) - } - - return nberrors.FormatErrorOrNil(result) + return nberrors.FormatErrorOrNil(r.removeV6SplitDefault(nextHop)) default: return r.removeFromRouteTable(prefix, nextHop) } } +func (r *SysOps) addV6SplitDefault(nextHop Nexthop) error { + if err := r.addToRouteTable(splitDefaultv6_1, nextHop); err != nil { + return fmt.Errorf("add split 1: %w", err) + } + if err := r.addToRouteTable(splitDefaultv6_2, nextHop); err != nil { + if err2 := r.removeFromRouteTable(splitDefaultv6_1, nextHop); err2 != nil { + log.Warnf("Failed to rollback v6 split-default: %s", err2) + } + return fmt.Errorf("add split 2: %w", err) + } + return nil +} + +func (r *SysOps) removeV6SplitDefault(nextHop Nexthop) *multierror.Error { + var result *multierror.Error + if err := r.removeFromRouteTable(splitDefaultv6_1, nextHop); err != nil { + result = multierror.Append(result, err) + } + if err := r.removeFromRouteTable(splitDefaultv6_2, nextHop); err != nil { + result = multierror.Append(result, err) + } + return result +} + func (r *SysOps) setupHooks(initAddresses []net.IP, stateManager *statemanager.Manager) error { beforeHook := func(connID hooks.ConnectionID, prefix netip.Prefix) error { if _, err := r.refCounter.IncrementWithID(string(connID), prefix, struct{}{}); err != nil { @@ -342,6 +347,22 @@ func GetNextHop(ip netip.Addr) (Nexthop, error) { if err != nil { return Nexthop{}, fmt.Errorf("new netroute: %w", err) } + + // go-netroute v0.4.0 rejects unspecified destinations on Linux with a hard + // client-side check. Substitute the lowest non-loopback address so the + // lookup falls through to the default route (::1 / 127.0.0.1 would match + // loopback, ::/0.0.0.0 are unspec). BSD/Windows pass the query straight to + // the kernel and need no substitution. + if runtime.GOOS == "linux" && ip.IsUnspecified() { + if ip.Is6() { + // ::2 + ip = netip.AddrFrom16([16]byte{15: 2}) + } else { + // 0.0.0.1 + ip = netip.AddrFrom4([4]byte{0, 0, 0, 1}) + } + } + intf, gateway, preferredSrc, err := r.Route(ip.AsSlice()) if err != nil { log.Debugf("Failed to get route for %s: %v", ip, err) diff --git a/client/internal/routemanager/systemops/systemops_generic_test.go b/client/internal/routemanager/systemops/systemops_generic_test.go index 01916fbe3..c4f739c30 100644 --- a/client/internal/routemanager/systemops/systemops_generic_test.go +++ b/client/internal/routemanager/systemops/systemops_generic_test.go @@ -1,4 +1,4 @@ -//go:build !android && !ios +//go:build !android && !ios && privileged package systemops @@ -21,15 +21,11 @@ import ( "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/internal/routemanager/vars" nbnet "github.com/netbirdio/netbird/client/net" ) -type dialer interface { - Dial(network, address string) (net.Conn, error) - DialContext(ctx context.Context, network, address string) (net.Conn, error) -} - func TestAddVPNRoute(t *testing.T) { testCases := []struct { name string @@ -354,9 +350,13 @@ func TestAddRouteToNonVPNIntf(t *testing.T) { require.NoError(t, err, "Should be able to get IPv4 default route") t.Logf("Initial IPv4 next hop: %s", initialNextHopV4) + if testCase.prefix.Addr().Is6() && !testCase.expectError { + ensureIPv6DefaultRoute(t) + } + initialNextHopV6, err := GetNextHop(netip.IPv6Unspecified()) if testCase.prefix.Addr().Is6() && - (errors.Is(err, vars.ErrRouteNotFound) || initialNextHopV6.Intf != nil && strings.HasPrefix(initialNextHopV6.Intf.Name, "utun")) { + initialNextHopV6.Intf != nil && strings.HasPrefix(initialNextHopV6.Intf.Name, "utun") { t.Skip("Skipping test as no ipv6 default route is available") } if err != nil && !errors.Is(err, vars.ErrRouteNotFound) { @@ -441,7 +441,7 @@ func createWGInterface(t *testing.T, interfaceName, ipAddressCIDR string, listen opts := iface.WGIFaceOpts{ IFaceName: interfaceName, - Address: ipAddressCIDR, + Address: wgaddr.MustParseWGAddress(ipAddressCIDR), WGPrivKey: peerPrivateKey.String(), WGPort: listenPort, MTU: iface.DefaultMTU, @@ -510,125 +510,3 @@ func setupTestEnv(t *testing.T) { // unique route in vpn table setupRouteAndCleanup(t, r, netip.MustParsePrefix("172.16.0.0/12"), intf) } - -func TestIsVpnRoute(t *testing.T) { - tests := []struct { - name string - addr string - vpnRoutes []string - localRoutes []string - expectedVpn bool - expectedPrefix netip.Prefix - }{ - { - name: "Match in VPN routes", - addr: "192.168.1.1", - vpnRoutes: []string{"192.168.1.0/24"}, - localRoutes: []string{"10.0.0.0/8"}, - expectedVpn: true, - expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"), - }, - { - name: "Match in local routes", - addr: "10.1.1.1", - vpnRoutes: []string{"192.168.1.0/24"}, - localRoutes: []string{"10.0.0.0/8"}, - expectedVpn: false, - expectedPrefix: netip.MustParsePrefix("10.0.0.0/8"), - }, - { - name: "No match", - addr: "172.16.0.1", - vpnRoutes: []string{"192.168.1.0/24"}, - localRoutes: []string{"10.0.0.0/8"}, - expectedVpn: false, - expectedPrefix: netip.Prefix{}, - }, - { - name: "Default route ignored", - addr: "192.168.1.1", - vpnRoutes: []string{"0.0.0.0/0", "192.168.1.0/24"}, - localRoutes: []string{"10.0.0.0/8"}, - expectedVpn: true, - expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"), - }, - { - name: "Default route matches but ignored", - addr: "172.16.1.1", - vpnRoutes: []string{"0.0.0.0/0", "192.168.1.0/24"}, - localRoutes: []string{"10.0.0.0/8"}, - expectedVpn: false, - expectedPrefix: netip.Prefix{}, - }, - { - name: "Longest prefix match local", - addr: "192.168.1.1", - vpnRoutes: []string{"192.168.0.0/16"}, - localRoutes: []string{"192.168.1.0/24"}, - expectedVpn: false, - expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"), - }, - { - name: "Longest prefix match local multiple", - addr: "192.168.0.1", - vpnRoutes: []string{"192.168.0.0/16", "192.168.0.0/25", "192.168.0.0/27"}, - localRoutes: []string{"192.168.0.0/24", "192.168.0.0/26", "192.168.0.0/28"}, - expectedVpn: false, - expectedPrefix: netip.MustParsePrefix("192.168.0.0/28"), - }, - { - name: "Longest prefix match vpn", - addr: "192.168.1.1", - vpnRoutes: []string{"192.168.1.0/24"}, - localRoutes: []string{"192.168.0.0/16"}, - expectedVpn: true, - expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"), - }, - { - name: "Longest prefix match vpn multiple", - addr: "192.168.0.1", - vpnRoutes: []string{"192.168.0.0/16", "192.168.0.0/25", "192.168.0.0/27"}, - localRoutes: []string{"192.168.0.0/24", "192.168.0.0/26"}, - expectedVpn: true, - expectedPrefix: netip.MustParsePrefix("192.168.0.0/27"), - }, - { - name: "Duplicate prefix in both", - addr: "192.168.1.1", - vpnRoutes: []string{"192.168.1.0/24"}, - localRoutes: []string{"192.168.1.0/24"}, - expectedVpn: false, - expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - addr, err := netip.ParseAddr(tt.addr) - if err != nil { - t.Fatalf("Failed to parse address %s: %v", tt.addr, err) - } - - var vpnRoutes, localRoutes []netip.Prefix - for _, route := range tt.vpnRoutes { - prefix, err := netip.ParsePrefix(route) - if err != nil { - t.Fatalf("Failed to parse VPN route %s: %v", route, err) - } - vpnRoutes = append(vpnRoutes, prefix) - } - - for _, route := range tt.localRoutes { - prefix, err := netip.ParsePrefix(route) - if err != nil { - t.Fatalf("Failed to parse local route %s: %v", route, err) - } - localRoutes = append(localRoutes, prefix) - } - - isVpn, matchedPrefix := isVpnRoute(addr, vpnRoutes, localRoutes) - assert.Equal(t, tt.expectedVpn, isVpn, "isVpnRoute should return expectedVpn value") - assert.Equal(t, tt.expectedPrefix, matchedPrefix, "isVpnRoute should return expectedVpn prefix") - }) - } -} diff --git a/client/internal/routemanager/systemops/systemops_ios.go b/client/internal/routemanager/systemops/systemops_ios.go index 99a363371..0cccd4962 100644 --- a/client/internal/routemanager/systemops/systemops_ios.go +++ b/client/internal/routemanager/systemops/systemops_ios.go @@ -58,8 +58,17 @@ func (r *SysOps) removeFromRouteTable(netip.Prefix, Nexthop) error { return nil } -func EnableIPForwarding() error { - log.Infof("Enable IP forwarding is not implemented on %s", runtime.GOOS) +func EnableV4IPForwarding() error { + log.Infof("Enable IPv4 forwarding is not implemented on %s", runtime.GOOS) + return nil +} + +func EnableV6IPForwarding(string) (map[string]int, error) { + log.Infof("Enable IPv6 forwarding is not implemented on %s", runtime.GOOS) + return map[string]int{}, nil +} + +func DisableV6IPForwarding(map[string]int) error { return nil } diff --git a/client/internal/routemanager/systemops/systemops_isvpnroute_test.go b/client/internal/routemanager/systemops/systemops_isvpnroute_test.go new file mode 100644 index 000000000..677fe1287 --- /dev/null +++ b/client/internal/routemanager/systemops/systemops_isvpnroute_test.go @@ -0,0 +1,132 @@ +//go:build !android && !ios + +package systemops + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsVpnRoute(t *testing.T) { + tests := []struct { + name string + addr string + vpnRoutes []string + localRoutes []string + expectedVpn bool + expectedPrefix netip.Prefix + }{ + { + name: "Match in VPN routes", + addr: "192.168.1.1", + vpnRoutes: []string{"192.168.1.0/24"}, + localRoutes: []string{"10.0.0.0/8"}, + expectedVpn: true, + expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"), + }, + { + name: "Match in local routes", + addr: "10.1.1.1", + vpnRoutes: []string{"192.168.1.0/24"}, + localRoutes: []string{"10.0.0.0/8"}, + expectedVpn: false, + expectedPrefix: netip.MustParsePrefix("10.0.0.0/8"), + }, + { + name: "No match", + addr: "172.16.0.1", + vpnRoutes: []string{"192.168.1.0/24"}, + localRoutes: []string{"10.0.0.0/8"}, + expectedVpn: false, + expectedPrefix: netip.Prefix{}, + }, + { + name: "Default route ignored", + addr: "192.168.1.1", + vpnRoutes: []string{"0.0.0.0/0", "192.168.1.0/24"}, + localRoutes: []string{"10.0.0.0/8"}, + expectedVpn: true, + expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"), + }, + { + name: "Default route matches but ignored", + addr: "172.16.1.1", + vpnRoutes: []string{"0.0.0.0/0", "192.168.1.0/24"}, + localRoutes: []string{"10.0.0.0/8"}, + expectedVpn: false, + expectedPrefix: netip.Prefix{}, + }, + { + name: "Longest prefix match local", + addr: "192.168.1.1", + vpnRoutes: []string{"192.168.0.0/16"}, + localRoutes: []string{"192.168.1.0/24"}, + expectedVpn: false, + expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"), + }, + { + name: "Longest prefix match local multiple", + addr: "192.168.0.1", + vpnRoutes: []string{"192.168.0.0/16", "192.168.0.0/25", "192.168.0.0/27"}, + localRoutes: []string{"192.168.0.0/24", "192.168.0.0/26", "192.168.0.0/28"}, + expectedVpn: false, + expectedPrefix: netip.MustParsePrefix("192.168.0.0/28"), + }, + { + name: "Longest prefix match vpn", + addr: "192.168.1.1", + vpnRoutes: []string{"192.168.1.0/24"}, + localRoutes: []string{"192.168.0.0/16"}, + expectedVpn: true, + expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"), + }, + { + name: "Longest prefix match vpn multiple", + addr: "192.168.0.1", + vpnRoutes: []string{"192.168.0.0/16", "192.168.0.0/25", "192.168.0.0/27"}, + localRoutes: []string{"192.168.0.0/24", "192.168.0.0/26"}, + expectedVpn: true, + expectedPrefix: netip.MustParsePrefix("192.168.0.0/27"), + }, + { + name: "Duplicate prefix in both", + addr: "192.168.1.1", + vpnRoutes: []string{"192.168.1.0/24"}, + localRoutes: []string{"192.168.1.0/24"}, + expectedVpn: false, + expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + addr, err := netip.ParseAddr(tt.addr) + if err != nil { + t.Fatalf("Failed to parse address %s: %v", tt.addr, err) + } + + var vpnRoutes, localRoutes []netip.Prefix + for _, route := range tt.vpnRoutes { + prefix, err := netip.ParsePrefix(route) + if err != nil { + t.Fatalf("Failed to parse VPN route %s: %v", route, err) + } + vpnRoutes = append(vpnRoutes, prefix) + } + + for _, route := range tt.localRoutes { + prefix, err := netip.ParsePrefix(route) + if err != nil { + t.Fatalf("Failed to parse local route %s: %v", route, err) + } + localRoutes = append(localRoutes, prefix) + } + + isVpn, matchedPrefix := isVpnRoute(addr, vpnRoutes, localRoutes) + assert.Equal(t, tt.expectedVpn, isVpn, "isVpnRoute should return expectedVpn value") + assert.Equal(t, tt.expectedPrefix, matchedPrefix, "isVpnRoute should return expectedVpn prefix") + }) + } +} diff --git a/client/internal/routemanager/systemops/systemops_linux.go b/client/internal/routemanager/systemops/systemops_linux.go index 39a9fd978..7d608d886 100644 --- a/client/internal/routemanager/systemops/systemops_linux.go +++ b/client/internal/routemanager/systemops/systemops_linux.go @@ -53,6 +53,8 @@ const ( // ipv4ForwardingPath is the path to the file containing the IP forwarding setting. ipv4ForwardingPath = "net.ipv4.ip_forward" + // ipv6ForwardingPath is the path to the file containing the IPv6 forwarding setting. + ipv6ForwardingPath = "net.ipv6.conf.all.forwarding" ) var ErrTableIDExists = errors.New("ID exists with different name") @@ -185,10 +187,11 @@ func (r *SysOps) AddVPNRoute(prefix netip.Prefix, intf *net.Interface) error { // No need to check if routes exist as main table takes precedence over the VPN table via Rule 1 - // TODO remove this once we have ipv6 support - if prefix == vars.Defaultv4 { + // When the peer has no IPv6, blackhole v6 to prevent leaking. + // When IPv6 is enabled, management sends ::/0 as a separate route. + if prefix == vars.Defaultv4 && (r.wgInterface == nil || !r.wgInterface.Address().HasIPv6()) { if err := addUnreachableRoute(vars.Defaultv6, NetbirdVPNTableID); err != nil { - return fmt.Errorf("add blackhole: %w", err) + return fmt.Errorf("add v6 blackhole: %w", err) } } if err := addRoute(prefix, Nexthop{netip.Addr{}, intf}, NetbirdVPNTableID); err != nil { @@ -206,10 +209,9 @@ func (r *SysOps) RemoveVPNRoute(prefix netip.Prefix, intf *net.Interface) error return r.genericRemoveVPNRoute(prefix, intf) } - // TODO remove this once we have ipv6 support - if prefix == vars.Defaultv4 { + if prefix == vars.Defaultv4 && (r.wgInterface == nil || !r.wgInterface.Address().HasIPv6()) { if err := removeUnreachableRoute(vars.Defaultv6, NetbirdVPNTableID); err != nil { - return fmt.Errorf("remove unreachable route: %w", err) + log.Debugf("remove v6 blackhole: %v", err) } } if err := removeRoute(prefix, Nexthop{netip.Addr{}, intf}, NetbirdVPNTableID); err != nil { @@ -761,9 +763,11 @@ func flushRoutes(tableID, family int) error { return nberrors.FormatErrorOrNil(result) } -func EnableIPForwarding() error { - _, err := sysctl.Set(ipv4ForwardingPath, 1, false) - return err +func EnableV4IPForwarding() error { + if _, err := sysctl.Set(ipv4ForwardingPath, 1, false); err != nil { + return err + } + return nil } // entryExists checks if the specified ID or name already exists in the rt_tables file diff --git a/client/internal/routemanager/systemops/systemops_linux_test.go b/client/internal/routemanager/systemops/systemops_linux_test.go index 880296d91..06c528ce5 100644 --- a/client/internal/routemanager/systemops/systemops_linux_test.go +++ b/client/internal/routemanager/systemops/systemops_linux_test.go @@ -1,13 +1,10 @@ -//go:build !android +//go:build linux && !android && privileged package systemops import ( "errors" - "fmt" "net" - "os" - "strings" "syscall" "testing" @@ -18,10 +15,6 @@ import ( "github.com/netbirdio/netbird/client/internal/routemanager/vars" ) -var expectedVPNint = "wgtest0" -var expectedExternalInt = "dummyext0" -var expectedInternalInt = "dummyint0" - func init() { testCases = append(testCases, []testCase{ { @@ -33,62 +26,6 @@ func init() { }...) } -func TestEntryExists(t *testing.T) { - tempDir := t.TempDir() - tempFilePath := fmt.Sprintf("%s/rt_tables", tempDir) - - content := []string{ - "1000 reserved", - fmt.Sprintf("%d %s", NetbirdVPNTableID, NetbirdVPNTableName), - "9999 other_table", - } - require.NoError(t, os.WriteFile(tempFilePath, []byte(strings.Join(content, "\n")), 0644)) - - file, err := os.Open(tempFilePath) - require.NoError(t, err) - defer func() { - assert.NoError(t, file.Close()) - }() - - tests := []struct { - name string - id int - shouldExist bool - err error - }{ - { - name: "ExistsWithNetbirdPrefix", - id: 7120, - shouldExist: true, - err: nil, - }, - { - name: "ExistsWithDifferentName", - id: 1000, - shouldExist: true, - err: ErrTableIDExists, - }, - { - name: "DoesNotExist", - id: 1234, - shouldExist: false, - err: nil, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - exists, err := entryExists(file, tc.id) - if tc.err != nil { - assert.ErrorIs(t, err, tc.err) - } else { - assert.NoError(t, err) - } - assert.Equal(t, tc.shouldExist, exists) - }) - } -} - func createAndSetupDummyInterface(t *testing.T, interfaceName, ipAddressCIDR string) string { t.Helper() diff --git a/client/internal/routemanager/systemops/systemops_nonlinux.go b/client/internal/routemanager/systemops/systemops_nonlinux.go index 016a62ebd..837ac0cd2 100644 --- a/client/internal/routemanager/systemops/systemops_nonlinux.go +++ b/client/internal/routemanager/systemops/systemops_nonlinux.go @@ -43,8 +43,17 @@ func (r *SysOps) RemoveVPNRoute(prefix netip.Prefix, intf *net.Interface) error return r.genericRemoveVPNRoute(prefix, intf) } -func EnableIPForwarding() error { - log.Infof("Enable IP forwarding is not implemented on %s", runtime.GOOS) +func EnableV4IPForwarding() error { + log.Infof("Enable IPv4 forwarding is not implemented on %s", runtime.GOOS) + return nil +} + +func EnableV6IPForwarding(string) (map[string]int, error) { + log.Infof("Enable IPv6 forwarding is not implemented on %s", runtime.GOOS) + return map[string]int{}, nil +} + +func DisableV6IPForwarding(map[string]int) error { return nil } diff --git a/client/internal/routemanager/systemops/systemops_routing_data_linux_test.go b/client/internal/routemanager/systemops/systemops_routing_data_linux_test.go new file mode 100644 index 000000000..9be267980 --- /dev/null +++ b/client/internal/routemanager/systemops/systemops_routing_data_linux_test.go @@ -0,0 +1,15 @@ +//go:build linux && !android + +package systemops + +// Interface names used by the shared routing test fixtures. Kept untagged (no +// privileged build tag) so the non-privileged test files in this package compile. +// +//nolint:unused // consumed by the privileged-tagged routing tests +var expectedVPNint = "wgtest0" + +//nolint:unused // consumed by the privileged-tagged routing tests +var expectedExternalInt = "dummyext0" + +//nolint:unused // consumed by the privileged-tagged routing tests +var expectedInternalInt = "dummyint0" diff --git a/client/internal/routemanager/systemops/systemops_routing_data_test.go b/client/internal/routemanager/systemops/systemops_routing_data_test.go new file mode 100644 index 000000000..16f17f5b9 --- /dev/null +++ b/client/internal/routemanager/systemops/systemops_routing_data_test.go @@ -0,0 +1,83 @@ +//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd || netbsd || dragonfly + +package systemops + +import ( + "net" + + nbnet "github.com/netbirdio/netbird/client/net" +) + +// Shared, non-privileged routing test fixtures. The privileged TestRouting (and its +// per-platform init() appenders) consume these; they live here so the unprivileged +// BSD/darwin test files compile without the privileged build tag. + +type PacketExpectation struct { + SrcIP net.IP + DstIP net.IP + SrcPort int + DstPort int + UDP bool + TCP bool +} + +//nolint:unused // consumed by the privileged-tagged routing tests +type testCase struct { + name string + expectedInterface string + dialer dialer + expectedPacket PacketExpectation +} + +//nolint:unused // consumed by the privileged-tagged routing tests +var testCases = []testCase{ + { + name: "To external host without custom dialer via vpn", + expectedInterface: expectedVPNint, + dialer: &net.Dialer{}, + expectedPacket: createPacketExpectation("100.64.0.1", 12345, "192.0.2.1", 53), + }, + { + name: "To external host with custom dialer via physical interface", + expectedInterface: expectedExternalInt, + dialer: nbnet.NewDialer(), + expectedPacket: createPacketExpectation("192.168.0.1", 12345, "192.0.2.1", 53), + }, + + { + name: "To duplicate internal route with custom dialer via physical interface", + expectedInterface: expectedInternalInt, + dialer: nbnet.NewDialer(), + expectedPacket: createPacketExpectation("192.168.1.1", 12345, "10.0.0.2", 53), + }, + { + name: "To duplicate internal route without custom dialer via physical interface", // local route takes precedence + expectedInterface: expectedInternalInt, + dialer: &net.Dialer{}, + expectedPacket: createPacketExpectation("192.168.1.1", 12345, "10.0.0.2", 53), + }, + + { + name: "To unique vpn route with custom dialer via physical interface", + expectedInterface: expectedExternalInt, + dialer: nbnet.NewDialer(), + expectedPacket: createPacketExpectation("192.168.0.1", 12345, "172.16.0.2", 53), + }, + { + name: "To unique vpn route without custom dialer via vpn", + expectedInterface: expectedVPNint, + dialer: &net.Dialer{}, + expectedPacket: createPacketExpectation("100.64.0.1", 12345, "172.16.0.2", 53), + }, +} + +//nolint:unused // consumed by the privileged-tagged routing tests +func createPacketExpectation(srcIP string, srcPort int, dstIP string, dstPort int) PacketExpectation { + return PacketExpectation{ + SrcIP: net.ParseIP(srcIP), + DstIP: net.ParseIP(dstIP), + SrcPort: srcPort, + DstPort: dstPort, + UDP: true, + } +} diff --git a/client/internal/routemanager/systemops/systemops_unix_test.go b/client/internal/routemanager/systemops/systemops_unix_test.go index 959c697e4..efb0ae4e4 100644 --- a/client/internal/routemanager/systemops/systemops_unix_test.go +++ b/client/internal/routemanager/systemops/systemops_unix_test.go @@ -1,4 +1,4 @@ -//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd || netbsd || dragonfly +//go:build ((linux && !android) || (darwin && !ios) || freebsd || openbsd || netbsd || dragonfly) && privileged package systemops @@ -20,63 +20,6 @@ import ( nbnet "github.com/netbirdio/netbird/client/net" ) -type PacketExpectation struct { - SrcIP net.IP - DstIP net.IP - SrcPort int - DstPort int - UDP bool - TCP bool -} - -type testCase struct { - name string - expectedInterface string - dialer dialer - expectedPacket PacketExpectation -} - -var testCases = []testCase{ - { - name: "To external host without custom dialer via vpn", - expectedInterface: expectedVPNint, - dialer: &net.Dialer{}, - expectedPacket: createPacketExpectation("100.64.0.1", 12345, "192.0.2.1", 53), - }, - { - name: "To external host with custom dialer via physical interface", - expectedInterface: expectedExternalInt, - dialer: nbnet.NewDialer(), - expectedPacket: createPacketExpectation("192.168.0.1", 12345, "192.0.2.1", 53), - }, - - { - name: "To duplicate internal route with custom dialer via physical interface", - expectedInterface: expectedInternalInt, - dialer: nbnet.NewDialer(), - expectedPacket: createPacketExpectation("192.168.1.1", 12345, "10.0.0.2", 53), - }, - { - name: "To duplicate internal route without custom dialer via physical interface", // local route takes precedence - expectedInterface: expectedInternalInt, - dialer: &net.Dialer{}, - expectedPacket: createPacketExpectation("192.168.1.1", 12345, "10.0.0.2", 53), - }, - - { - name: "To unique vpn route with custom dialer via physical interface", - expectedInterface: expectedExternalInt, - dialer: nbnet.NewDialer(), - expectedPacket: createPacketExpectation("192.168.0.1", 12345, "172.16.0.2", 53), - }, - { - name: "To unique vpn route without custom dialer via vpn", - expectedInterface: expectedVPNint, - dialer: &net.Dialer{}, - expectedPacket: createPacketExpectation("100.64.0.1", 12345, "172.16.0.2", 53), - }, -} - func TestRouting(t *testing.T) { nbnet.Init() for _, tc := range testCases { @@ -102,16 +45,6 @@ func TestRouting(t *testing.T) { } } -func createPacketExpectation(srcIP string, srcPort int, dstIP string, dstPort int) PacketExpectation { - return PacketExpectation{ - SrcIP: net.ParseIP(srcIP), - DstIP: net.ParseIP(dstIP), - SrcPort: srcPort, - DstPort: dstPort, - UDP: true, - } -} - func startPacketCapture(t *testing.T, intf, filter string) *pcap.Handle { t.Helper() diff --git a/client/internal/routemanager/systemops/systemops_windows.go b/client/internal/routemanager/systemops/systemops_windows.go index 7bce6af80..47d556cf6 100644 --- a/client/internal/routemanager/systemops/systemops_windows.go +++ b/client/internal/routemanager/systemops/systemops_windows.go @@ -882,26 +882,40 @@ func getInterfaceMetric(interfaceIndex uint32, family int16) int { return int(ipInterfaceRow.Metric) } -// sortRouteCandidates sorts route candidates by priority: prefix length -> route metric -> interface metric +// sortRouteCandidates sorts route candidates by priority: prefix length -> combined metric -> route metric. +// Windows prefers the longest matching prefix and, among prefixes of the same length, the lowest metric, see +// https://learn.microsoft.com/en-us/windows-hardware/customize/desktop/unattend/microsoft-windows-tcpip-interfaces-interface-routes-route-metric func sortRouteCandidates(candidates []candidateRoute) { sort.Slice(candidates, func(i, j int) bool { if candidates[i].prefixLength != candidates[j].prefixLength { return candidates[i].prefixLength > candidates[j].prefixLength } - if candidates[i].routeMetric != candidates[j].routeMetric { - return candidates[i].routeMetric < candidates[j].routeMetric + mi, mj := combinedMetric(candidates[i]), combinedMetric(candidates[j]) + if mi != mj { + return mi < mj } - return candidates[i].interfaceMetric < candidates[j].interfaceMetric + return candidates[i].routeMetric < candidates[j].routeMetric }) } +// combinedMetric returns the effective metric Windows uses to rank routes with an equal prefix length: +// the sum of the route metric and the metric of the interface the route is on, see +// https://learn.microsoft.com/en-us/windows-server/networking/technologies/network-subsystem/net-sub-interface-metric +// An unknown interface metric contributes nothing. +func combinedMetric(candidate candidateRoute) uint64 { + if candidate.interfaceMetric < 0 { + return uint64(candidate.routeMetric) + } + return uint64(candidate.routeMetric) + uint64(candidate.interfaceMetric) +} + // GetBestInterface finds the best interface for reaching a destination, // excluding the VPN interface to avoid routing loops. // // Route selection priority: // 1. Longest prefix match (most specific route) -// 2. Lowest route metric -// 3. Lowest interface metric +// 2. Lowest combined metric (route metric + interface metric) +// 3. Lowest route metric. func GetBestInterface(dest netip.Addr, vpnIntf string) (*net.Interface, error) { var skipInterfaceIndex int if vpnIntf != "" { @@ -925,7 +939,6 @@ func GetBestInterface(dest netip.Addr, vpnIntf string) (*net.Interface, error) { return nil, fmt.Errorf("no route to %s", dest) } - // Sort routes: prefix length -> route metric -> interface metric sortRouteCandidates(candidates) for _, candidate := range candidates { diff --git a/client/internal/routemanager/systemops/systemops_windows_test.go b/client/internal/routemanager/systemops/systemops_windows_test.go index 3561adec4..77e349bd6 100644 --- a/client/internal/routemanager/systemops/systemops_windows_test.go +++ b/client/internal/routemanager/systemops/systemops_windows_test.go @@ -1,3 +1,5 @@ +//go:build windows && privileged + package systemops import ( diff --git a/client/internal/routemanager/systemops/v6forwarding_linux.go b/client/internal/routemanager/systemops/v6forwarding_linux.go new file mode 100644 index 000000000..c1e0d4588 --- /dev/null +++ b/client/internal/routemanager/systemops/v6forwarding_linux.go @@ -0,0 +1,92 @@ +//go:build !android + +package systemops + +import ( + "fmt" + "net" + "os" + + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + + nberrors "github.com/netbirdio/netbird/client/errors" + "github.com/netbirdio/netbird/client/internal/routemanager/sysctl" +) + +const ( + // 1 (default) accepts RAs only while forwarding is off; 2 keeps RA + // acceptance on regardless, so RA-installed host defaults survive our + // v6 forwarding flip. + acceptRAInterfacePath = "net.ipv6.conf.%s.accept_ra" + acceptRADefaultPath = "net.ipv6.conf.default.accept_ra" + acceptRAProcPathFormat = "/proc/sys/net/ipv6/conf/%s/accept_ra" +) + +// EnableV6IPForwarding bumps accept_ra=2 on host v6 interfaces before flipping +// forwarding=1, so RA-installed host defaults survive. Returns the prior values +// of sysctls we actually changed; entries already at the target are omitted. +func EnableV6IPForwarding(wgIfaceName string) (map[string]int, error) { + saved := map[string]int{} + bumpAcceptRA(saved, wgIfaceName) + + oldVal, err := sysctl.Set(ipv6ForwardingPath, 1, false) + if err != nil { + return saved, err + } + if oldVal != 1 { + saved[ipv6ForwardingPath] = oldVal + } + return saved, nil +} + +// DisableV6IPForwarding restores what EnableV6IPForwarding captured. +func DisableV6IPForwarding(saved map[string]int) error { + var result *multierror.Error + for key, value := range saved { + if _, err := sysctl.Set(key, value, false); err != nil { + result = multierror.Append(result, fmt.Errorf("restore %s: %w", key, err)) + } + } + return nberrors.FormatErrorOrNil(result) +} + +func bumpAcceptRA(saved map[string]int, wgIfaceName string) { + // Also bump conf.default so interfaces created while forwarding is on + // (hotplug, new Wi-Fi/dock) inherit accept_ra=2 and keep accepting RAs. + bumpAcceptRAKey(saved, acceptRADefaultPath) + + interfaces, err := net.Interfaces() + if err != nil { + log.Warnf("list interfaces for accept_ra: %v", err) + return + } + for _, intf := range interfaces { + if intf.Name == "lo" || intf.Name == wgIfaceName { + continue + } + bumpAcceptRAForInterface(saved, intf.Name) + } +} + +func bumpAcceptRAForInterface(saved map[string]int, name string) { + // Build procfs path from name, not the dotted key: VLAN names like eth0.100. + if _, err := os.Stat(fmt.Sprintf(acceptRAProcPathFormat, name)); err != nil { + return + } + bumpAcceptRAKey(saved, fmt.Sprintf(acceptRAInterfacePath, sysctl.EscapeInterfaceName(name))) +} + +func bumpAcceptRAKey(saved map[string]int, key string) { + // onlyIfOne=true: leave admin overrides (0, 2) alone. + oldVal, err := sysctl.Set(key, 2, true) + if err != nil { + log.Warnf("bump %s: %v", key, err) + return + } + // With onlyIfOne, a write only happened when the old value was 1; values + // left untouched (0, 2) must not be recorded for restore. + if oldVal == 1 { + saved[key] = oldVal + } +} diff --git a/client/internal/routemanager/systemops/v6route_bsd_test.go b/client/internal/routemanager/systemops/v6route_bsd_test.go new file mode 100644 index 000000000..90e49f54e --- /dev/null +++ b/client/internal/routemanager/systemops/v6route_bsd_test.go @@ -0,0 +1,32 @@ +//go:build darwin || dragonfly || freebsd || netbsd || openbsd + +package systemops + +import ( + "bytes" + "os/exec" + "testing" +) + +// ensureIPv6DefaultRoute installs an IPv6 default route via the loopback +// interface so route lookups for global IPv6 prefixes resolve in environments +// without v6 connectivity. If a default already exists it is left alone. +// +//nolint:unused // consumed by the privileged-tagged routing tests +func ensureIPv6DefaultRoute(t *testing.T) { + t.Helper() + + out, err := exec.Command("route", "-6", "add", "default", "-iface", "lo0").CombinedOutput() + if err != nil { + // Existing default; nothing to install or clean up. + if bytes.Contains(out, []byte("route already in table")) { + return + } + t.Skipf("install IPv6 fallback default route: %v: %s", err, out) + } + t.Cleanup(func() { + if out, err := exec.Command("route", "-6", "delete", "default").CombinedOutput(); err != nil { + t.Logf("delete IPv6 fallback default route: %v: %s", err, out) + } + }) +} diff --git a/client/internal/routemanager/systemops/v6route_linux_test.go b/client/internal/routemanager/systemops/v6route_linux_test.go new file mode 100644 index 000000000..d8c0012d1 --- /dev/null +++ b/client/internal/routemanager/systemops/v6route_linux_test.go @@ -0,0 +1,75 @@ +//go:build linux && !android && privileged + +package systemops + +import ( + "errors" + "net" + "net/netip" + "syscall" + "testing" + + "github.com/stretchr/testify/require" + "github.com/vishvananda/netlink" +) + +// ensureIPv6DefaultRoute installs a low-preference IPv6 default route via the +// loopback interface so route lookups for global IPv6 prefixes resolve in +// environments without v6 connectivity. Any pre-existing default route wins +// because of its lower metric. +func ensureIPv6DefaultRoute(t *testing.T) { + t.Helper() + + lo, err := netlink.LinkByName("lo") + require.NoError(t, err, "find loopback interface") + + route := &netlink.Route{ + Dst: &net.IPNet{IP: net.IPv6zero, Mask: net.CIDRMask(0, 128)}, + LinkIndex: lo.Attrs().Index, + Priority: 1 << 20, + } + if err := netlink.RouteAdd(route); err != nil { + if errors.Is(err, syscall.EEXIST) { + requireUsableIPv6Nexthop(t) + return + } + t.Skipf("install IPv6 fallback default route: %v", err) + } + t.Cleanup(func() { + if err := netlink.RouteDel(route); err != nil && !errors.Is(err, syscall.ESRCH) { + t.Logf("delete IPv6 fallback default route: %v", err) + } + }) + + requireUsableIPv6Nexthop(t) +} + +// requireUsableIPv6Nexthop skips the test unless the resolved IPv6 default +// nexthop can actually carry a route. Installing the default route succeeding +// does not imply the kernel accepts it as a nexthop for a concrete prefix. +func requireUsableIPv6Nexthop(t *testing.T) { + t.Helper() + + nexthop, err := GetNextHop(netip.IPv6Unspecified()) + if err != nil { + t.Skipf("resolve IPv6 default nexthop: %v", err) + } + + probe := &netlink.Route{ + Scope: netlink.SCOPE_UNIVERSE, + Table: syscall.RT_TABLE_MAIN, + Family: netlink.FAMILY_V6, + Dst: &net.IPNet{IP: net.ParseIP("100::64"), Mask: net.CIDRMask(128, 128)}, + } + require.NoError(t, addNextHop(nexthop, probe), "build IPv6 probe route") + + switch err := netlink.RouteAdd(probe); { + case err == nil: + if err := netlink.RouteDel(probe); err != nil && !errors.Is(err, syscall.ESRCH) { + t.Logf("delete IPv6 probe route: %v", err) + } + case errors.Is(err, syscall.EEXIST): + default: + t.Skipf("IPv6 nexthop %s unusable for route installation: %v", nexthop, err) + } +} diff --git a/client/internal/routemanager/systemops/v6route_windows_test.go b/client/internal/routemanager/systemops/v6route_windows_test.go new file mode 100644 index 000000000..2c813a790 --- /dev/null +++ b/client/internal/routemanager/systemops/v6route_windows_test.go @@ -0,0 +1,37 @@ +//go:build windows + +package systemops + +import ( + "bytes" + "os/exec" + "testing" +) + +//nolint:unused // consumed by the privileged-tagged routing tests +const loopbackIfaceWindows = "Loopback Pseudo-Interface 1" + +// ensureIPv6DefaultRoute installs an IPv6 default route via the loopback +// interface so route lookups for global IPv6 prefixes resolve in environments +// without v6 connectivity. If a default already exists it is left alone. +// +//nolint:unused // consumed by the privileged-tagged routing tests +func ensureIPv6DefaultRoute(t *testing.T) { + t.Helper() + + script := `New-NetRoute -DestinationPrefix "::/0" -InterfaceAlias "` + loopbackIfaceWindows + `" -RouteMetric 9999 -PolicyStore ActiveStore -ErrorAction Stop` + out, err := exec.Command("powershell", "-Command", script).CombinedOutput() + if err != nil { + // Existing default; nothing to install or clean up. + if bytes.Contains(out, []byte("already exists")) { + return + } + t.Skipf("install IPv6 fallback default route: %v: %s", err, out) + } + t.Cleanup(func() { + script := `Remove-NetRoute -DestinationPrefix "::/0" -InterfaceAlias "` + loopbackIfaceWindows + `" -Confirm:$false -ErrorAction Stop` + if out, err := exec.Command("powershell", "-Command", script).CombinedOutput(); err != nil { + t.Logf("delete IPv6 fallback default route: %v: %s", err, out) + } + }) +} diff --git a/client/internal/routeselector/routeselector.go b/client/internal/routeselector/routeselector.go index 61c8bbc79..8a64ad316 100644 --- a/client/internal/routeselector/routeselector.go +++ b/client/internal/routeselector/routeselector.go @@ -7,16 +7,11 @@ import ( "sync" "github.com/hashicorp/go-multierror" - "golang.org/x/exp/maps" "github.com/netbirdio/netbird/client/errors" "github.com/netbirdio/netbird/route" ) -const ( - exitNodeCIDR = "0.0.0.0/0" -) - type RouteSelector struct { mu sync.RWMutex deselectedRoutes map[route.NetID]struct{} @@ -37,6 +32,22 @@ func (rs *RouteSelector) SelectRoutes(routes []route.NetID, appendRoute bool, al rs.mu.Lock() defer rs.mu.Unlock() + // Validate before mutating: a non-append selection wipes the current selection + // first, so a request of only unavailable routes would deselect everything and + // put nothing back. An empty request means deselect all, so it still goes through. + var err *multierror.Error + available := make([]route.NetID, 0, len(routes)) + for _, r := range routes { + if !slices.Contains(allRoutes, r) { + err = multierror.Append(err, fmt.Errorf("route '%s' is not available", r)) + continue + } + available = append(available, r) + } + if len(available) == 0 && err != nil { + return errors.FormatErrorOrNil(err) + } + if !appendRoute || rs.deselectAll { if rs.deselectedRoutes == nil { rs.deselectedRoutes = map[route.NetID]struct{}{} @@ -44,21 +55,16 @@ func (rs *RouteSelector) SelectRoutes(routes []route.NetID, appendRoute bool, al if rs.selectedRoutes == nil { rs.selectedRoutes = map[route.NetID]struct{}{} } - maps.Clear(rs.deselectedRoutes) - maps.Clear(rs.selectedRoutes) + clear(rs.deselectedRoutes) + clear(rs.selectedRoutes) for _, r := range allRoutes { rs.deselectedRoutes[r] = struct{}{} } } - var err *multierror.Error - for _, route := range routes { - if !slices.Contains(allRoutes, route) { - err = multierror.Append(err, fmt.Errorf("route '%s' is not available", route)) - continue - } - delete(rs.deselectedRoutes, route) - rs.selectedRoutes[route] = struct{}{} + for _, r := range available { + delete(rs.deselectedRoutes, r) + rs.selectedRoutes[r] = struct{}{} } rs.deselectAll = false @@ -78,8 +84,8 @@ func (rs *RouteSelector) SelectAllRoutes() { if rs.selectedRoutes == nil { rs.selectedRoutes = map[route.NetID]struct{}{} } - maps.Clear(rs.deselectedRoutes) - maps.Clear(rs.selectedRoutes) + clear(rs.deselectedRoutes) + clear(rs.selectedRoutes) } // DeselectRoutes removes specific routes from the selection. @@ -116,8 +122,47 @@ func (rs *RouteSelector) DeselectAllRoutes() { if rs.selectedRoutes == nil { rs.selectedRoutes = map[route.NetID]struct{}{} } - maps.Clear(rs.deselectedRoutes) - maps.Clear(rs.selectedRoutes) + clear(rs.deselectedRoutes) + clear(rs.selectedRoutes) +} + +// SetExclusiveExitNode atomically makes preferred the only selected exit node +// among exitIDs: every other ID in exitIDs is deselected and preferred (when +// non-empty) is selected, all under a single lock. Holding the lock across the +// whole reconciliation prevents a concurrent DeselectAllRoutes from interleaving +// between the deselect and select steps and being silently undone. A global +// deselect-all is left untouched so the user's "all off" stays in effect; +// non-exit routes are never referenced, so their selection is preserved. +func (rs *RouteSelector) SetExclusiveExitNode(preferred route.NetID, exitIDs []route.NetID) { + rs.mu.Lock() + defer rs.mu.Unlock() + + if rs.deselectAll { + return + } + + for _, id := range exitIDs { + if id == preferred { + continue + } + rs.deselectedRoutes[id] = struct{}{} + delete(rs.selectedRoutes, id) + } + + if preferred != "" { + delete(rs.deselectedRoutes, preferred) + rs.selectedRoutes[preferred] = struct{}{} + } +} + +// IsDeselectAll reports whether the global "deselect all" flag is set, i.e. the +// user explicitly disabled every route. Callers enforcing per-route invariants +// (e.g. single exit node) should leave the selection untouched when it is. +func (rs *RouteSelector) IsDeselectAll() bool { + rs.mu.RLock() + defer rs.mu.RUnlock() + + return rs.deselectAll } // IsSelected checks if a specific route is selected. @@ -125,13 +170,34 @@ func (rs *RouteSelector) IsSelected(routeID route.NetID) bool { rs.mu.RLock() defer rs.mu.RUnlock() + return rs.isSelectedLocked(routeID) +} + +// SyncPairedSelection forces pairedID's explicit selection state to match baseID's, +// so a synthesized "-v6" exit route always follows its v4 base: selecting or +// deselecting the v4 exit node governs the ::/0 pair, and any stale (orphaned) +// explicit state on the v6 entry is reset. The v4/v6 exit pair is treated as a single +// toggle, so the v6 entry carries no independent selection of its own. +func (rs *RouteSelector) SyncPairedSelection(baseID, pairedID route.NetID) { + rs.mu.Lock() + defer rs.mu.Unlock() + if rs.deselectAll { - return false + return } - _, deselected := rs.deselectedRoutes[routeID] - isSelected := !deselected - return isSelected + _, baseSelected := rs.selectedRoutes[baseID] + _, baseDeselected := rs.deselectedRoutes[baseID] + + delete(rs.selectedRoutes, pairedID) + delete(rs.deselectedRoutes, pairedID) + + switch { + case baseSelected: + rs.selectedRoutes[pairedID] = struct{}{} + case baseDeselected: + rs.deselectedRoutes[pairedID] = struct{}{} + } } // FilterSelected removes unselected routes from the provided map. @@ -145,23 +211,21 @@ func (rs *RouteSelector) FilterSelected(routes route.HAMap) route.HAMap { filtered := route.HAMap{} for id, rt := range routes { - netID := id.NetID() - _, deselected := rs.deselectedRoutes[netID] - if !deselected { + if !rs.isDeselectedLocked(id.NetID()) { filtered[id] = rt } } return filtered } -// HasUserSelectionForRoute returns true if the user has explicitly selected or deselected this specific route +// HasUserSelectionForRoute returns true if the user has explicitly selected or deselected this route. +// The lookup is literal; v4/v6 exit pairs are kept consistent at write time via SyncPairedSelection, +// so a synthesized "-v6" entry carries the same explicit state as its v4 base. func (rs *RouteSelector) HasUserSelectionForRoute(routeID route.NetID) bool { rs.mu.RLock() defer rs.mu.RUnlock() - _, selected := rs.selectedRoutes[routeID] - _, deselected := rs.deselectedRoutes[routeID] - return selected || deselected + return rs.hasUserSelectionForRouteLocked(routeID) } func (rs *RouteSelector) FilterSelectedExitNodes(routes route.HAMap) route.HAMap { @@ -175,7 +239,7 @@ func (rs *RouteSelector) FilterSelectedExitNodes(routes route.HAMap) route.HAMap filtered := make(route.HAMap, len(routes)) for id, rt := range routes { netID := id.NetID() - if rs.isDeselected(netID) { + if rs.isDeselectedLocked(netID) { continue } @@ -190,51 +254,6 @@ func (rs *RouteSelector) FilterSelectedExitNodes(routes route.HAMap) route.HAMap return filtered } -func (rs *RouteSelector) isDeselected(netID route.NetID) bool { - _, deselected := rs.deselectedRoutes[netID] - return deselected || rs.deselectAll -} - -func isExitNode(rt []*route.Route) bool { - return len(rt) > 0 && rt[0].Network.String() == exitNodeCIDR -} - -func (rs *RouteSelector) applyExitNodeFilter( - id route.HAUniqueID, - netID route.NetID, - rt []*route.Route, - out route.HAMap, -) { - - if rs.hasUserSelections() { - // user made explicit selects/deselects - if rs.IsSelected(netID) { - out[id] = rt - } - return - } - - // no explicit selections: only include routes marked !SkipAutoApply (=AutoApply) - sel := collectSelected(rt) - if len(sel) > 0 { - out[id] = sel - } -} - -func (rs *RouteSelector) hasUserSelections() bool { - return len(rs.selectedRoutes) > 0 || len(rs.deselectedRoutes) > 0 -} - -func collectSelected(rt []*route.Route) []*route.Route { - var sel []*route.Route - for _, r := range rt { - if !r.SkipAutoApply { - sel = append(sel, r) - } - } - return sel -} - // MarshalJSON implements the json.Marshaler interface func (rs *RouteSelector) MarshalJSON() ([]byte, error) { rs.mu.RLock() @@ -288,3 +307,59 @@ func (rs *RouteSelector) UnmarshalJSON(data []byte) error { return nil } + +func (rs *RouteSelector) isSelectedLocked(routeID route.NetID) bool { + if rs.deselectAll { + return false + } + _, deselected := rs.deselectedRoutes[routeID] + return !deselected +} + +func (rs *RouteSelector) isDeselectedLocked(netID route.NetID) bool { + if rs.deselectAll { + return true + } + _, deselected := rs.deselectedRoutes[netID] + return deselected +} + +func (rs *RouteSelector) hasUserSelectionForRouteLocked(routeID route.NetID) bool { + _, selected := rs.selectedRoutes[routeID] + _, deselected := rs.deselectedRoutes[routeID] + return selected || deselected +} + +func (rs *RouteSelector) applyExitNodeFilter( + id route.HAUniqueID, + netID route.NetID, + rt []*route.Route, + out route.HAMap, +) { + if rs.hasUserSelectionForRouteLocked(netID) { + if rs.isSelectedLocked(netID) { + out[id] = rt + } + return + } + + // no explicit selection for this route: defer to management's SkipAutoApply flag + sel := collectSelected(rt) + if len(sel) > 0 { + out[id] = sel + } +} + +func isExitNode(rt []*route.Route) bool { + return len(rt) > 0 && (route.IsV4DefaultRoute(rt[0].Network) || route.IsV6DefaultRoute(rt[0].Network)) +} + +func collectSelected(rt []*route.Route) []*route.Route { + var sel []*route.Route + for _, r := range rt { + if !r.SkipAutoApply { + sel = append(sel, r) + } + } + return sel +} diff --git a/client/internal/routeselector/routeselector_test.go b/client/internal/routeselector/routeselector_test.go index 5faea2456..f26d022e9 100644 --- a/client/internal/routeselector/routeselector_test.go +++ b/client/internal/routeselector/routeselector_test.go @@ -330,6 +330,171 @@ func TestRouteSelector_FilterSelectedExitNodes(t *testing.T) { assert.Len(t, filtered, 0) // No routes should be selected } +// TestRouteSelector_V6ExitPairSync covers SyncPairedSelection, which keeps a v4 +// exit node and its synthesized "-v6" counterpart consistent. The selector itself +// is literal and never infers a v6 entry's state from its v4 base; callers that know +// the pairing (exit-node code paths) call SyncPairedSelection to force the v6 entry +// to follow the base, treating the pair as a single toggle. +func TestRouteSelector_V6ExitPairSync(t *testing.T) { + all := []route.NetID{"exit1", "exit1-v6", "exit2", "exit2-v6", "corp", "corp-v6"} + + t.Run("selector lookups stay literal without sync", func(t *testing.T) { + rs := routeselector.NewRouteSelector() + require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all)) + + // The selector does not pair-resolve: the v6 entry is independent until synced. + assert.False(t, rs.HasUserSelectionForRoute("exit1-v6"), "v6 entry has no state of its own") + assert.True(t, rs.IsSelected("exit1-v6"), "unsynced v6 entry stays selected by default") + + // A route literally named "exit1-something" must never pair-resolve either. + assert.False(t, rs.HasUserSelectionForRoute("exit1-something")) + }) + + t.Run("sync mirrors deselected v4 base onto v6", func(t *testing.T) { + rs := routeselector.NewRouteSelector() + require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all)) + + rs.SyncPairedSelection("exit1", "exit1-v6") + + assert.False(t, rs.IsSelected("exit1")) + assert.False(t, rs.IsSelected("exit1-v6"), "v6 pair follows v4 base deselect") + assert.True(t, rs.HasUserSelectionForRoute("exit1-v6"), "v6 carries explicit deselect after sync") + }) + + t.Run("sync mirrors selected v4 base onto v6", func(t *testing.T) { + rs := routeselector.NewRouteSelector() + require.NoError(t, rs.SelectRoutes([]route.NetID{"exit1"}, false, all)) + + rs.SyncPairedSelection("exit1", "exit1-v6") + + assert.True(t, rs.IsSelected("exit1")) + assert.True(t, rs.IsSelected("exit1-v6"), "v6 pair follows v4 base select") + }) + + t.Run("sync clears v6 state when base has no explicit selection", func(t *testing.T) { + rs := routeselector.NewRouteSelector() + require.NoError(t, rs.SelectRoutes([]route.NetID{"exit1-v6"}, true, all)) + require.True(t, rs.HasUserSelectionForRoute("exit1-v6")) + + rs.SyncPairedSelection("exit1", "exit1-v6") + + assert.False(t, rs.HasUserSelectionForRoute("exit1-v6"), + "v6 explicit state is cleared so it follows management like its base") + }) + + // Regression for the observed bug (see netbird-engine.log): persisted state has + // the v4 base deselected but the v6 sibling explicitly selected (orphaned). The + // sync must reset the orphan so the ::/0 route does not leak onto the tunnel. + t.Run("sync clears orphaned explicit v6 selection on deselected base", func(t *testing.T) { + rs := routeselector.NewRouteSelector() + + // Prior state: both explicitly selected, then only the v4 base deselected, + // leaving the v6 entry as a stale explicit selection. + require.NoError(t, rs.SelectRoutes([]route.NetID{"exit1", "exit1-v6"}, true, all)) + require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all)) + require.True(t, rs.IsSelected("exit1-v6"), "precondition: orphaned v6 selection") + + rs.SyncPairedSelection("exit1", "exit1-v6") + + assert.False(t, rs.IsSelected("exit1-v6"), "orphaned v6 selection reset to follow v4 deselect") + + v4Route := &route.Route{NetID: "exit1", Network: netip.MustParsePrefix("0.0.0.0/0")} + v6Route := &route.Route{NetID: "exit1-v6", Network: netip.MustParsePrefix("::/0")} + routes := route.HAMap{ + "exit1|0.0.0.0/0": {v4Route}, + "exit1-v6|::/0": {v6Route}, + } + filtered := rs.FilterSelectedExitNodes(routes) + assert.Empty(t, filtered, "deselecting v4 base must drop the v6 pair even if it was explicitly selected before") + }) + + t.Run("filter drops synced v6 pair of deselected v4 base", func(t *testing.T) { + rs := routeselector.NewRouteSelector() + require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all)) + rs.SyncPairedSelection("exit1", "exit1-v6") + + v4Route := &route.Route{NetID: "exit1", Network: netip.MustParsePrefix("0.0.0.0/0")} + v6Route := &route.Route{NetID: "exit1-v6", Network: netip.MustParsePrefix("::/0")} + routes := route.HAMap{ + "exit1|0.0.0.0/0": {v4Route}, + "exit1-v6|::/0": {v6Route}, + } + + filtered := rs.FilterSelectedExitNodes(routes) + assert.Empty(t, filtered, "deselecting v4 base must also drop the v6 pair") + }) + + t.Run("deselectAll makes sync a no-op", func(t *testing.T) { + rs := routeselector.NewRouteSelector() + rs.DeselectAllRoutes() + + rs.SyncPairedSelection("exit1", "exit1-v6") + + assert.False(t, rs.HasUserSelectionForRoute("exit1-v6"), "sync must not write explicit state under deselectAll") + }) + + t.Run("non-exit *-v6 routes pass through FilterSelectedExitNodes", func(t *testing.T) { + rs := routeselector.NewRouteSelector() + require.NoError(t, rs.DeselectRoutes([]route.NetID{"corp"}, all)) + + // A non-default-route entry named "corp-v6" is not an exit node and + // must not be skipped because its v4 base "corp" is deselected. + corpV6 := &route.Route{NetID: "corp-v6", Network: netip.MustParsePrefix("10.0.0.0/8")} + routes := route.HAMap{ + "corp-v6|10.0.0.0/8": {corpV6}, + } + + filtered := rs.FilterSelectedExitNodes(routes) + assert.Contains(t, filtered, route.HAUniqueID("corp-v6|10.0.0.0/8"), + "non-exit *-v6 routes must not inherit unrelated v4 state in FilterSelectedExitNodes") + }) +} + +// TestRouteSelector_SkipAutoApplyPerRoute verifies that management's +// SkipAutoApply flag governs each untouched route independently, even when +// the user has explicit selections on other routes. +func TestRouteSelector_SkipAutoApplyPerRoute(t *testing.T) { + autoApplied := &route.Route{ + NetID: "Auto", + Network: netip.MustParsePrefix("0.0.0.0/0"), + SkipAutoApply: false, + } + skipApply := &route.Route{ + NetID: "Skip", + Network: netip.MustParsePrefix("0.0.0.0/0"), + SkipAutoApply: true, + } + routes := route.HAMap{ + "Auto|0.0.0.0/0": {autoApplied}, + "Skip|0.0.0.0/0": {skipApply}, + } + + rs := routeselector.NewRouteSelector() + // User makes an unrelated explicit selection elsewhere. + require.NoError(t, rs.DeselectRoutes([]route.NetID{"Unrelated"}, []route.NetID{"Auto", "Skip", "Unrelated"})) + + filtered := rs.FilterSelectedExitNodes(routes) + assert.Contains(t, filtered, route.HAUniqueID("Auto|0.0.0.0/0"), "AutoApply route should be included") + assert.NotContains(t, filtered, route.HAUniqueID("Skip|0.0.0.0/0"), "SkipAutoApply route should be excluded without explicit user selection") +} + +// TestRouteSelector_V6ExitIsExitNode verifies that ::/0 routes are recognized +// as exit nodes by the selector's filter path. +func TestRouteSelector_V6ExitIsExitNode(t *testing.T) { + v6Exit := &route.Route{ + NetID: "V6Only", + Network: netip.MustParsePrefix("::/0"), + SkipAutoApply: true, + } + routes := route.HAMap{ + "V6Only|::/0": {v6Exit}, + } + + rs := routeselector.NewRouteSelector() + filtered := rs.FilterSelectedExitNodes(routes) + assert.Empty(t, filtered, "::/0 should be treated as an exit node and respect SkipAutoApply") +} + func TestRouteSelector_NewRoutesBehavior(t *testing.T) { initialRoutes := []route.NetID{"route1", "route2", "route3"} newRoutes := []route.NetID{"route1", "route2", "route3", "route4", "route5"} @@ -694,3 +859,98 @@ func TestRouteSelector_ComplexScenarios(t *testing.T) { }) } } + +// TestRouteSelector_EnableExitNodeKeepsOtherRoutes is a regression test for the +// tray exit-node toggle disabling every non-exit routed network. The tray used +// to Select an exit node with append=false, which the RouteSelector treats as +// "drop the whole current selection" (default-on semantics) — so enabling an +// exit node also turned off every LAN/route the user had on. The fix sends +// append=true and lets the daemon's SelectNetworks handler deselect only the +// sibling exit nodes. This test models that handler sequence against the +// selector: SelectRoutes(exit, append=true) followed by DeselectRoutes(other +// exit nodes) must leave non-exit routes untouched. +func TestRouteSelector_EnableExitNodeKeepsOtherRoutes(t *testing.T) { + rs := routeselector.NewRouteSelector() + all := []route.NetID{"exitA", "exitB", "lan1", "lan2"} + + // User has two LAN routes on (default-on: nothing deselected => all selected). + require.True(t, rs.IsSelected("lan1")) + require.True(t, rs.IsSelected("lan2")) + + // Tray enables exitA: SelectNetworks handler does SelectRoutes(append=true) + // then deselects sibling exit nodes (exitB), never the LAN routes. + require.NoError(t, rs.SelectRoutes([]route.NetID{"exitA"}, true, all)) + require.NoError(t, rs.DeselectRoutes([]route.NetID{"exitB"}, all)) + + assert.True(t, rs.IsSelected("exitA"), "selected exit node stays on") + assert.False(t, rs.IsSelected("exitB"), "sibling exit node is deselected") + assert.True(t, rs.IsSelected("lan1"), "non-exit route must stay selected") + assert.True(t, rs.IsSelected("lan2"), "non-exit route must stay selected") +} + +// A non-append selection clears the current selection before applying the requested +// one, so an all-unavailable request used to leave nothing selected while returning +// an error. Requests with at least one available route are unaffected. +func TestRouteSelector_SelectRoutes_AllUnavailableKeepsSelection(t *testing.T) { + allRoutes := []route.NetID{"route1", "route2", "route3"} + + rs := routeselector.NewRouteSelector() + require.NoError(t, rs.SelectRoutes([]route.NetID{"route1"}, false, allRoutes)) + + err := rs.SelectRoutes([]route.NetID{"Route1", "route4"}, false, allRoutes) + + assert.Error(t, err, "an unavailable route ID must still be reported") + assert.True(t, rs.IsSelected("route1"), "the previous selection must survive a fully invalid request") + for _, id := range []route.NetID{"route2", "route3"} { + assert.False(t, rs.IsSelected(id), "no other route may become selected") + } +} + +// Boundary of the check above: an empty request is the caller deselecting everything, +// not a failed lookup, so it must keep working. +func TestRouteSelector_SelectRoutes_EmptyRequestStillDeselectsAll(t *testing.T) { + allRoutes := []route.NetID{"route1", "route2", "route3"} + + rs := routeselector.NewRouteSelector() + require.NoError(t, rs.SelectRoutes([]route.NetID{"route1"}, false, allRoutes)) + + require.NoError(t, rs.SelectRoutes(nil, false, allRoutes)) + + for _, id := range allRoutes { + assert.False(t, rs.IsSelected(id), "an empty selection request must deselect everything") + } +} + +// Mobile clients always call SelectRoutes with append=true. On that path an +// all-unavailable request was never destructive to begin with (append skips the +// wipe regardless of the guard above), but the behavior has no coverage yet. +func TestRouteSelector_SelectRoutes_AppendAllUnavailableKeepsSelection(t *testing.T) { + allRoutes := []route.NetID{"route1", "route2", "route3"} + + rs := routeselector.NewRouteSelector() + require.NoError(t, rs.SelectRoutes([]route.NetID{"route1"}, false, allRoutes)) + + err := rs.SelectRoutes([]route.NetID{"missing"}, true, allRoutes) + + assert.Error(t, err, "an unavailable route ID must still be reported") + assert.True(t, rs.IsSelected("route1"), "the previous selection must survive a fully invalid request") + for _, id := range []route.NetID{"route2", "route3"} { + assert.False(t, rs.IsSelected(id), "no other route may become selected") + } +} + +// The early return for an all-unavailable request must not clear deselectAll, +// or a typo'd network ID would silently drop the "nothing selected, including +// future networks" policy. +func TestRouteSelector_SelectRoutes_AllUnavailableAfterDeselectAllKeepsPolicy(t *testing.T) { + allRoutes := []route.NetID{"route1", "route2"} + + rs := routeselector.NewRouteSelector() + rs.DeselectAllRoutes() + + err := rs.SelectRoutes([]route.NetID{"missing"}, false, allRoutes) + + assert.Error(t, err, "an unavailable route ID must still be reported") + assert.True(t, rs.IsDeselectAll(), "deselect-all policy must survive a fully invalid request") + assert.False(t, rs.IsSelected("route3"), "deselect-all must still cover networks not present in allRoutes yet") +} diff --git a/client/internal/sleep/detector_darwin.go b/client/internal/sleep/detector_darwin.go index 3d6747ed1..fc4713b21 100644 --- a/client/internal/sleep/detector_darwin.go +++ b/client/internal/sleep/detector_darwin.go @@ -2,217 +2,360 @@ package sleep -/* -#cgo LDFLAGS: -framework IOKit -framework CoreFoundation -#include -#include -#include - -extern void sleepCallbackBridge(); -extern void poweredOnCallbackBridge(); -extern void suspendedCallbackBridge(); -extern void resumedCallbackBridge(); - - -// C global variables for IOKit state -static IONotificationPortRef g_notifyPortRef = NULL; -static io_object_t g_notifierObject = 0; -static io_object_t g_generalInterestNotifier = 0; -static io_connect_t g_rootPort = 0; -static CFRunLoopRef g_runLoop = NULL; - -static void sleepCallback(void* refCon, io_service_t service, natural_t messageType, void* messageArgument) { - switch (messageType) { - case kIOMessageSystemWillSleep: - sleepCallbackBridge(); - IOAllowPowerChange(g_rootPort, (long)messageArgument); - break; - case kIOMessageSystemHasPoweredOn: - poweredOnCallbackBridge(); - break; - case kIOMessageServiceIsSuspended: - suspendedCallbackBridge(); - break; - case kIOMessageServiceIsResumed: - resumedCallbackBridge(); - break; - default: - break; - } -} - -static void registerNotifications() { - g_rootPort = IORegisterForSystemPower( - NULL, - &g_notifyPortRef, - (IOServiceInterestCallback)sleepCallback, - &g_notifierObject - ); - - if (g_rootPort == 0) { - return; - } - - CFRunLoopAddSource(CFRunLoopGetCurrent(), - IONotificationPortGetRunLoopSource(g_notifyPortRef), - kCFRunLoopCommonModes); - - g_runLoop = CFRunLoopGetCurrent(); - CFRunLoopRun(); -} - -static void unregisterNotifications() { - CFRunLoopRemoveSource(g_runLoop, - IONotificationPortGetRunLoopSource(g_notifyPortRef), - kCFRunLoopCommonModes); - - IODeregisterForSystemPower(&g_notifierObject); - IOServiceClose(g_rootPort); - IONotificationPortDestroy(g_notifyPortRef); - CFRunLoopStop(g_runLoop); - - g_notifyPortRef = NULL; - g_notifierObject = 0; - g_rootPort = 0; - g_runLoop = NULL; -} - -*/ -import "C" - import ( - "context" "fmt" "runtime" "sync" "time" + "unsafe" + "github.com/ebitengine/purego" log "github.com/sirupsen/logrus" ) -var ( - serviceRegistry = make(map[*Detector]struct{}) - serviceRegistryMu sync.Mutex +// IOKit message types from IOKit/IOMessage.h. +const ( + kIOMessageCanSystemSleep uintptr = 0xe0000270 + kIOMessageSystemWillSleep uintptr = 0xe0000280 + kIOMessageSystemHasPoweredOn uintptr = 0xe0000300 ) -//export sleepCallbackBridge -func sleepCallbackBridge() { - log.Info("sleepCallbackBridge event triggered") +var ( + ioKit iokitFuncs + cf cfFuncs + cfCommonModes uintptr - serviceRegistryMu.Lock() - defer serviceRegistryMu.Unlock() + libInitOnce sync.Once + libInitErr error - for svc := range serviceRegistry { - svc.triggerCallback(EventTypeSleep) - } + // callbackThunk is the single C-callable trampoline registered with IOKit. + callbackThunk uintptr + + serviceRegistry = make(map[*Detector]struct{}) + serviceRegistryMu sync.Mutex + session *runLoopSession + + // lifecycleMu serializes Register/Deregister so a new registration can't + // start a second runloop while a previous teardown is still pending. + lifecycleMu sync.Mutex +) + +// iokitFuncs holds IOKit symbols resolved once at init. +type iokitFuncs struct { + IORegisterForSystemPower func(refcon uintptr, portRef *uintptr, callback uintptr, notifier *uintptr) uintptr + IODeregisterForSystemPower func(notifier *uintptr) int32 + IOAllowPowerChange func(kernelPort uintptr, notificationID uintptr) int32 + IOServiceClose func(connect uintptr) int32 + IONotificationPortGetRunLoopSource func(port uintptr) uintptr + IONotificationPortDestroy func(port uintptr) } -//export resumedCallbackBridge -func resumedCallbackBridge() { - log.Info("resumedCallbackBridge event triggered") +// cfFuncs holds CoreFoundation symbols resolved once at init. +type cfFuncs struct { + CFRunLoopGetCurrent func() uintptr + CFRunLoopRun func() + CFRunLoopStop func(rl uintptr) + CFRunLoopAddSource func(rl, source, mode uintptr) + CFRunLoopRemoveSource func(rl, source, mode uintptr) } -//export suspendedCallbackBridge -func suspendedCallbackBridge() { - log.Info("suspendedCallbackBridge event triggered") +// runLoopSession bundles the handles owned by one CFRunLoop lifetime. A nil +// session means no runloop is active and the next Register must start one. +type runLoopSession struct { + rl uintptr + port uintptr + notifier uintptr + rp uintptr } -//export poweredOnCallbackBridge -func poweredOnCallbackBridge() { - log.Info("poweredOnCallbackBridge event triggered") - serviceRegistryMu.Lock() - defer serviceRegistryMu.Unlock() - - for svc := range serviceRegistry { - svc.triggerCallback(EventTypeWakeUp) - } +// detectorSnapshot pins a detector's callback and done channel so dispatch +// runs with values valid at snapshot time, even if a concurrent +// Deregister/Register rewrites the detector's fields. +type detectorSnapshot struct { + detector *Detector + callback func(event EventType) + done <-chan struct{} } +// Detector delivers sleep and wake events to a registered callback. type Detector struct { callback func(event EventType) - ctx context.Context - cancel context.CancelFunc -} - -func NewDetector() (*Detector, error) { - return &Detector{}, nil + done chan struct{} } +// Register installs callback for power events. The first registration starts +// the CFRunLoop on a dedicated OS-locked thread and blocks until IOKit +// registration succeeds or fails; subsequent registrations just add to the +// dispatch set. func (d *Detector) Register(callback func(event EventType)) error { - serviceRegistryMu.Lock() - defer serviceRegistryMu.Unlock() + lifecycleMu.Lock() + defer lifecycleMu.Unlock() + serviceRegistryMu.Lock() if _, exists := serviceRegistry[d]; exists { + serviceRegistryMu.Unlock() return fmt.Errorf("detector service already registered") } - d.callback = callback + d.done = make(chan struct{}) + serviceRegistry[d] = struct{}{} + needSetup := session == nil + serviceRegistryMu.Unlock() - d.ctx, d.cancel = context.WithCancel(context.Background()) - - if len(serviceRegistry) > 0 { - serviceRegistry[d] = struct{}{} + if !needSetup { return nil } - serviceRegistry[d] = struct{}{} - - // CFRunLoop must run on a single fixed OS thread - go func() { - runtime.LockOSThread() - defer runtime.UnlockOSThread() - - C.registerNotifications() - }() + errCh := make(chan error, 1) + go runRunLoop(errCh) + if err := <-errCh; err != nil { + serviceRegistryMu.Lock() + delete(serviceRegistry, d) + close(d.done) + d.done = nil + serviceRegistryMu.Unlock() + return err + } log.Info("sleep detection service started on macOS") return nil } -// Deregister removes the detector. When the last detector is removed, IOKit registration is torn down -// and the runloop is stopped and cleaned up. +// Deregister removes the detector. When the last detector leaves, IOKit +// notifications are torn down and the runloop is stopped. func (d *Detector) Deregister() error { + lifecycleMu.Lock() + defer lifecycleMu.Unlock() + serviceRegistryMu.Lock() - defer serviceRegistryMu.Unlock() - _, exists := serviceRegistry[d] - if !exists { + if _, exists := serviceRegistry[d]; !exists { + serviceRegistryMu.Unlock() return nil } - - // cancel and remove this detector - d.cancel() + close(d.done) delete(serviceRegistry, d) - // If other Detectors still exist, leave IOKit running if len(serviceRegistry) > 0 { + serviceRegistryMu.Unlock() return nil } + sess := session + serviceRegistryMu.Unlock() log.Info("sleep detection service stopping (deregister)") - // Deregister IOKit notifications, stop runloop, and free resources - C.unregisterNotifications() + if sess == nil { + return nil + } + + if sess.rl != 0 && sess.port != 0 { + source := ioKit.IONotificationPortGetRunLoopSource(sess.port) + cf.CFRunLoopRemoveSource(sess.rl, source, cfCommonModes) + } + if sess.notifier != 0 { + n := sess.notifier + ioKit.IODeregisterForSystemPower(&n) + } + + // Clear session only after IODeregisterForSystemPower returns so any + // in-flight powerCallback can still look up session.rp to ack sleep. + serviceRegistryMu.Lock() + session = nil + serviceRegistryMu.Unlock() + + if sess.rp != 0 { + ioKit.IOServiceClose(sess.rp) + } + if sess.port != 0 { + ioKit.IONotificationPortDestroy(sess.port) + } + if sess.rl != 0 { + cf.CFRunLoopStop(sess.rl) + } return nil } -func (d *Detector) triggerCallback(event EventType) { - doneChan := make(chan struct{}) +func (d *Detector) triggerCallback(event EventType, cb func(event EventType), done <-chan struct{}) { + if cb == nil || done == nil { + return + } - timeout := time.NewTimer(500 * time.Millisecond) + select { + case <-done: + return + default: + } + + doneChan := make(chan struct{}) + // macOS forces sleep ~30s after kIOMessageSystemWillSleep, so block long + // enough for teardown to finish while staying under that deadline. + timeout := time.NewTimer(20 * time.Second) defer timeout.Stop() - cb := d.callback - go func(callback func(event EventType)) { + go func() { + defer close(doneChan) + defer func() { + if r := recover(); r != nil { + log.Errorf("panic in sleep callback: %v", r) + } + }() log.Info("sleep detection event fired") - callback(event) - close(doneChan) - }(cb) + cb(event) + }() select { case <-doneChan: - case <-d.ctx.Done(): + case <-done: case <-timeout.C: - log.Warnf("sleep callback timed out") + log.Warn("sleep callback timed out") } } + +// NewDetector initializes IOKit/CoreFoundation bindings and returns a Detector. +func NewDetector() (*Detector, error) { + if err := initLibs(); err != nil { + return nil, err + } + return &Detector{}, nil +} + +func initLibs() error { + libInitOnce.Do(func() { + iokit, err := purego.Dlopen("/System/Library/Frameworks/IOKit.framework/IOKit", purego.RTLD_NOW|purego.RTLD_GLOBAL) + if err != nil { + libInitErr = fmt.Errorf("dlopen IOKit: %w", err) + return + } + cfLib, err := purego.Dlopen("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", purego.RTLD_NOW|purego.RTLD_GLOBAL) + if err != nil { + libInitErr = fmt.Errorf("dlopen CoreFoundation: %w", err) + return + } + + purego.RegisterLibFunc(&ioKit.IORegisterForSystemPower, iokit, "IORegisterForSystemPower") + purego.RegisterLibFunc(&ioKit.IODeregisterForSystemPower, iokit, "IODeregisterForSystemPower") + purego.RegisterLibFunc(&ioKit.IOAllowPowerChange, iokit, "IOAllowPowerChange") + purego.RegisterLibFunc(&ioKit.IOServiceClose, iokit, "IOServiceClose") + purego.RegisterLibFunc(&ioKit.IONotificationPortGetRunLoopSource, iokit, "IONotificationPortGetRunLoopSource") + purego.RegisterLibFunc(&ioKit.IONotificationPortDestroy, iokit, "IONotificationPortDestroy") + + purego.RegisterLibFunc(&cf.CFRunLoopGetCurrent, cfLib, "CFRunLoopGetCurrent") + purego.RegisterLibFunc(&cf.CFRunLoopRun, cfLib, "CFRunLoopRun") + purego.RegisterLibFunc(&cf.CFRunLoopStop, cfLib, "CFRunLoopStop") + purego.RegisterLibFunc(&cf.CFRunLoopAddSource, cfLib, "CFRunLoopAddSource") + purego.RegisterLibFunc(&cf.CFRunLoopRemoveSource, cfLib, "CFRunLoopRemoveSource") + + modeAddr, err := purego.Dlsym(cfLib, "kCFRunLoopCommonModes") + if err != nil { + libInitErr = fmt.Errorf("dlsym kCFRunLoopCommonModes: %w", err) + return + } + // Launder the uintptr-to-pointer conversion through a Go variable so + // go vet's unsafeptr analyzer doesn't flag a system-library global. + cfCommonModes = **(**uintptr)(unsafe.Pointer(&modeAddr)) + + // NewCallback slots are a finite, non-reclaimable resource, so register + // a single thunk that dispatches to the current Detector set. + callbackThunk = purego.NewCallback(powerCallback) + }) + return libInitErr +} + +// powerCallback is the IOServiceInterestCallback trampoline, invoked on the +// runloop thread. A Go panic crossing the purego boundary has undefined +// behavior, so contain it here. +func powerCallback(refcon, service, messageType, messageArgument uintptr) uintptr { + defer func() { + if r := recover(); r != nil { + log.Errorf("panic in sleep powerCallback: %v", r) + } + }() + switch messageType { + case kIOMessageCanSystemSleep: + // Not acknowledging forces a 30s IOKit timeout before idle sleep. + allowPowerChange(messageArgument) + case kIOMessageSystemWillSleep: + dispatchEvent(EventTypeSleep) + allowPowerChange(messageArgument) + case kIOMessageSystemHasPoweredOn: + dispatchEvent(EventTypeWakeUp) + } + return 0 +} + +func allowPowerChange(messageArgument uintptr) { + serviceRegistryMu.Lock() + var port uintptr + if session != nil { + port = session.rp + } + serviceRegistryMu.Unlock() + if port != 0 { + ioKit.IOAllowPowerChange(port, messageArgument) + } +} + +func dispatchEvent(event EventType) { + serviceRegistryMu.Lock() + snaps := make([]detectorSnapshot, 0, len(serviceRegistry)) + for d := range serviceRegistry { + snaps = append(snaps, detectorSnapshot{ + detector: d, + callback: d.callback, + done: d.done, + }) + } + serviceRegistryMu.Unlock() + + for _, s := range snaps { + s.detector.triggerCallback(event, s.callback, s.done) + } +} + +// runRunLoop owns the OS-locked thread that CFRunLoop is pinned to. Setup +// result is reported on errCh so Register can surface failures synchronously. +func runRunLoop(errCh chan<- error) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + sess, err := setupSession() + if err == nil { + serviceRegistryMu.Lock() + session = sess + serviceRegistryMu.Unlock() + } + errCh <- err + if err != nil { + return + } + + defer func() { + if r := recover(); r != nil { + log.Errorf("panic in sleep runloop: %v", r) + } + }() + cf.CFRunLoopRun() +} + +// setupSession performs the IOKit registration on the current thread. Panics +// are converted to errors so runRunLoop never leaves errCh unsent. +func setupSession() (s *runLoopSession, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("panic during runloop setup: %v", r) + } + }() + + var portRef, notifier uintptr + rp := ioKit.IORegisterForSystemPower(0, &portRef, callbackThunk, ¬ifier) + if rp == 0 { + return nil, fmt.Errorf("IORegisterForSystemPower returned zero") + } + + rl := cf.CFRunLoopGetCurrent() + source := ioKit.IONotificationPortGetRunLoopSource(portRef) + cf.CFRunLoopAddSource(rl, source, cfCommonModes) + + return &runLoopSession{rl: rl, port: portRef, notifier: notifier, rp: rp}, nil +} diff --git a/client/internal/sleep/service.go b/client/internal/sleep/service.go index 196a33f52..93691c4c7 100644 --- a/client/internal/sleep/service.go +++ b/client/internal/sleep/service.go @@ -18,8 +18,8 @@ type Service struct { } func New() (*Service, error) { - d, err := NewDetector() - if err != nil { + d, err := NewDetector() //nolint:staticcheck + if err != nil { //nolint:staticcheck // always errors on platforms without a sleep detector return nil, err } diff --git a/client/internal/state.go b/client/internal/state.go index 041cb73f8..0adfa26e4 100644 --- a/client/internal/state.go +++ b/client/internal/state.go @@ -33,17 +33,34 @@ func CtxGetState(ctx context.Context) *contextState { } type contextState struct { - err error - status StatusType - mutex sync.Mutex + err error + status StatusType + mutex sync.Mutex + onChange func() +} + +// SetOnChange installs a callback fired after every successful Set. Used by +// the daemon to wire the status recorder's notifyStateChange so any +// state.Set in the connect/login paths pushes a fresh snapshot to +// SubscribeStatus subscribers without each callsite having to opt in. +// The callback runs outside the contextState mutex to avoid a lock-order +// dependency with the recorder's stateChangeMux. +func (c *contextState) SetOnChange(fn func()) { + c.mutex.Lock() + c.onChange = fn + c.mutex.Unlock() } func (c *contextState) Set(update StatusType) { c.mutex.Lock() - defer c.mutex.Unlock() - c.status = update c.err = nil + cb := c.onChange + c.mutex.Unlock() + + if cb != nil { + cb() + } } func (c *contextState) Status() (StatusType, error) { @@ -57,6 +74,17 @@ func (c *contextState) Status() (StatusType, error) { return c.status, nil } +// CurrentStatus returns the last status set via Set, ignoring any wrapped +// error. Use when the status is needed for reporting purposes (e.g. the +// status snapshot stream) and a transient wrapped error from a retry loop +// shouldn't blank out the underlying status. +func (c *contextState) CurrentStatus() StatusType { + c.mutex.Lock() + defer c.mutex.Unlock() + + return c.status +} + func (c *contextState) Wrap(err error) error { c.mutex.Lock() defer c.mutex.Unlock() diff --git a/client/internal/statemanager/manager.go b/client/internal/statemanager/manager.go index 2c9e46290..ca4194690 100644 --- a/client/internal/statemanager/manager.go +++ b/client/internal/statemanager/manager.go @@ -1,6 +1,7 @@ package statemanager import ( + "bytes" "context" "encoding/json" "errors" @@ -96,17 +97,19 @@ func (m *Manager) Stop(ctx context.Context) error { } m.mu.Lock() - defer m.mu.Unlock() + cancel := m.cancel + done := m.done + m.mu.Unlock() - if m.cancel == nil { + if cancel == nil { return nil } - m.cancel() + cancel() select { case <-ctx.Done(): return ctx.Err() - case <-m.done: + case <-done: } return nil @@ -303,6 +306,11 @@ func (m *Manager) loadStateFile(deleteCorrupt bool) (map[string]json.RawMessage, var rawStates map[string]json.RawMessage if err := json.Unmarshal(data, &rawStates); err != nil { + if len(bytes.TrimSpace(data)) == 0 { + log.Warnf("state file %s is empty (%d bytes)", m.filePath, len(data)) + } else { + log.Warnf("state file %s has malformed content (%d bytes)", m.filePath, len(data)) + } m.handleCorruptedState(deleteCorrupt) return nil, fmt.Errorf("unmarshal states: %w", err) } diff --git a/client/internal/syncstore/disk.go b/client/internal/syncstore/disk.go new file mode 100644 index 000000000..eb24e87a7 --- /dev/null +++ b/client/internal/syncstore/disk.go @@ -0,0 +1,99 @@ +package syncstore + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + + log "github.com/sirupsen/logrus" + "google.golang.org/protobuf/proto" + + mgmProto "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/util" +) + +// syncResponseFileName is the name of the file the sync response is serialized +// to, placed inside the configured directory (the state directory). +const syncResponseFileName = "networkmap.pb" + +// diskStore serializes the latest sync response to a file on disk instead of +// keeping it in memory. This trades disk I/O for a much smaller memory +// footprint, which matters on memory-constrained platforms (iOS). +type diskStore struct { + mu sync.Mutex + path string +} + +// NewDiskStore returns a Store that serializes the sync response to a file in +// the given directory. If dir is empty it falls back to the OS temp directory. +// +// Any file left over from a previous run is removed on construction so a fresh +// store never reads stale data (e.g. another profile's network map). +func NewDiskStore(dir string) Store { + if dir == "" { + dir = os.TempDir() + } + s := &diskStore{ + path: filepath.Join(dir, syncResponseFileName), + } + if err := s.Clear(); err != nil { + log.Warnf("failed to clear stale sync response file: %v", err) + } + return s +} + +func (s *diskStore) Set(resp *mgmProto.SyncResponse) error { + if resp == nil { + return s.Clear() + } + + bs, err := proto.Marshal(resp) + if err != nil { + return fmt.Errorf("marshal sync response: %w", err) + } + + s.mu.Lock() + defer s.mu.Unlock() + + if err := util.WriteBytesWithRestrictedPermission(context.Background(), s.path, bs); err != nil { + return fmt.Errorf("write sync response to %s: %w", s.path, err) + } + + log.Debugf("sync response persisted to %s (%d bytes)", s.path, len(bs)) + return nil +} + +func (s *diskStore) Get() (*mgmProto.SyncResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + + bs, err := os.ReadFile(s.path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + //nolint:nilnil // nil,nil means "nothing stored", per the Store contract; preserve the original behaviour + return nil, nil + } + return nil, fmt.Errorf("read sync response from %s: %w", s.path, err) + } + + resp := &mgmProto.SyncResponse{} + if err := proto.Unmarshal(bs, resp); err != nil { + return nil, fmt.Errorf("unmarshal sync response: %w", err) + } + + log.Debugf("retrieving latest sync response from %s (%d bytes)", s.path, len(bs)) + return resp, nil +} + +func (s *diskStore) Clear() error { + s.mu.Lock() + defer s.mu.Unlock() + + if err := os.Remove(s.path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove sync response file %s: %w", s.path, err) + } + return nil +} diff --git a/client/internal/syncstore/factory_ios.go b/client/internal/syncstore/factory_ios.go new file mode 100644 index 000000000..f19ab5e5c --- /dev/null +++ b/client/internal/syncstore/factory_ios.go @@ -0,0 +1,9 @@ +//go:build ios + +package syncstore + +// New returns the platform default store. On iOS the sync response is +// serialized to disk (in dir) to keep it out of the constrained process memory. +func New(dir string) Store { + return NewDiskStore(dir) +} diff --git a/client/internal/syncstore/factory_other.go b/client/internal/syncstore/factory_other.go new file mode 100644 index 000000000..79ea46116 --- /dev/null +++ b/client/internal/syncstore/factory_other.go @@ -0,0 +1,9 @@ +//go:build !ios + +package syncstore + +// New returns the platform default store. On all non-iOS platforms the sync +// response is kept in memory; dir is unused. +func New(_ string) Store { + return NewMemoryStore() +} diff --git a/client/internal/syncstore/memory.go b/client/internal/syncstore/memory.go new file mode 100644 index 000000000..8fc069069 --- /dev/null +++ b/client/internal/syncstore/memory.go @@ -0,0 +1,56 @@ +package syncstore + +import ( + "fmt" + "sync" + + log "github.com/sirupsen/logrus" + "google.golang.org/protobuf/proto" + + mgmProto "github.com/netbirdio/netbird/shared/management/proto" +) + +// memoryStore keeps the latest sync response in memory. +type memoryStore struct { + mu sync.RWMutex + latest *mgmProto.SyncResponse +} + +// NewMemoryStore returns a Store that keeps the sync response in memory. +func NewMemoryStore() Store { + return &memoryStore{} +} + +func (s *memoryStore) Set(resp *mgmProto.SyncResponse) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.latest = resp + return nil +} + +func (s *memoryStore) Get() (*mgmProto.SyncResponse, error) { + s.mu.RLock() + latest := s.latest + s.mu.RUnlock() + + if latest == nil { + //nolint:nilnil // nil,nil means "nothing stored", per the Store contract; preserve the original behaviour + return nil, nil + } + + log.Debugf("retrieving latest sync response with size %d bytes", proto.Size(latest)) + sr, ok := proto.Clone(latest).(*mgmProto.SyncResponse) + if !ok { + return nil, fmt.Errorf("clone sync response") + } + return sr, nil +} + +func (s *memoryStore) Clear() error { + s.mu.Lock() + defer s.mu.Unlock() + + s.latest = nil + return nil +} diff --git a/client/internal/syncstore/syncstore.go b/client/internal/syncstore/syncstore.go new file mode 100644 index 000000000..ba24b9c57 --- /dev/null +++ b/client/internal/syncstore/syncstore.go @@ -0,0 +1,29 @@ +// Package syncstore stores the latest Management sync response (which carries +// the network map) for debug bundle generation. +// +// The storage backend is selected at build time per operating system: on iOS +// the response is serialized to disk to keep it out of the (tightly +// constrained) process memory, while on all other platforms it is kept in +// memory. The backend is chosen by the New constructor; see factory_ios.go and +// factory_other.go. +package syncstore + +import ( + mgmProto "github.com/netbirdio/netbird/shared/management/proto" +) + +// Store persists the latest sync response and returns it on demand. +// +// Implementations must be safe for concurrent use. +type Store interface { + // Set stores the given sync response, replacing any previously stored one. + Set(resp *mgmProto.SyncResponse) error + + // Get returns the stored sync response, or nil if none is stored. + // The returned value is an independent copy that the caller may retain. + Get() (*mgmProto.SyncResponse, error) + + // Clear removes any stored sync response. It is safe to call when nothing + // is stored. + Clear() error +} diff --git a/client/internal/tunnelnotifier/notifier.go b/client/internal/tunnelnotifier/notifier.go new file mode 100644 index 000000000..b62923a6e --- /dev/null +++ b/client/internal/tunnelnotifier/notifier.go @@ -0,0 +1,124 @@ +package tunnelnotifier + +import ( + "container/list" + "sync" + + "github.com/netbirdio/netbird/client/internal/dns" + "github.com/netbirdio/netbird/client/internal/listener" +) + +type eventKind int + +const ( + eventRoutes eventKind = iota + eventIfaceIP + eventIfaceIPv6 + eventDNS +) + +var ( + _ listener.NetworkChangeListener = (*Notifier)(nil) + _ dns.IosDnsManager = (*Notifier)(nil) +) + +type event struct { + kind eventKind + payload string +} + +type Notifier struct { + mu sync.Mutex + cond *sync.Cond + queue *list.List + closed bool + done chan struct{} + + listener listener.NetworkChangeListener + dnsManager dns.IosDnsManager +} + +func New(l listener.NetworkChangeListener, dm dns.IosDnsManager) *Notifier { + n := &Notifier{ + queue: list.New(), + done: make(chan struct{}), + listener: l, + dnsManager: dm, + } + n.cond = sync.NewCond(&n.mu) + go n.deliverLoop() + return n +} + +func (n *Notifier) OnNetworkChanged(routes string) { + n.enqueue(event{kind: eventRoutes, payload: routes}) +} + +func (n *Notifier) SetInterfaceIP(ip string) { + n.enqueue(event{kind: eventIfaceIP, payload: ip}) +} + +func (n *Notifier) SetInterfaceIPv6(ip string) { + n.enqueue(event{kind: eventIfaceIPv6, payload: ip}) +} + +func (n *Notifier) ApplyDns(config string) { + n.enqueue(event{kind: eventDNS, payload: config}) +} + +// Close stops accepting new events and blocks until the delivery loop has +// drained all queued events and exited. +func (n *Notifier) Close() { + n.mu.Lock() + n.closed = true + n.cond.Signal() + n.mu.Unlock() + <-n.done +} + +func (n *Notifier) enqueue(ev event) { + n.mu.Lock() + defer n.mu.Unlock() + if n.closed { + return + } + n.queue.PushBack(ev) + n.cond.Signal() +} + +func (n *Notifier) deliverLoop() { + defer close(n.done) + for { + n.mu.Lock() + for n.queue.Len() == 0 && !n.closed { + n.cond.Wait() + } + if n.closed && n.queue.Len() == 0 { + n.mu.Unlock() + return + } + ev := n.queue.Remove(n.queue.Front()).(event) + l := n.listener + dm := n.dnsManager + n.mu.Unlock() + + switch ev.kind { + case eventRoutes: + if l != nil { + l.OnNetworkChanged(ev.payload) + } + case eventIfaceIP: + if l != nil { + l.SetInterfaceIP(ev.payload) + } + case eventIfaceIPv6: + if l != nil { + l.SetInterfaceIPv6(ev.payload) + } + case eventDNS: + if dm != nil { + dm.ApplyDns(ev.payload) + } + } + } +} diff --git a/client/internal/tunnelnotifier/notifier_test.go b/client/internal/tunnelnotifier/notifier_test.go new file mode 100644 index 000000000..ffbcdc15c --- /dev/null +++ b/client/internal/tunnelnotifier/notifier_test.go @@ -0,0 +1,192 @@ +package tunnelnotifier + +import ( + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type call struct { + kind string + payload string +} + +type recorder struct { + mu sync.Mutex + calls []call + inFlight atomic.Int32 + overlap atomic.Bool + delay time.Duration +} + +func (r *recorder) record(kind, payload string) { + if r.inFlight.Add(1) != 1 { + r.overlap.Store(true) + } + if r.delay > 0 { + time.Sleep(r.delay) + } + r.mu.Lock() + r.calls = append(r.calls, call{kind: kind, payload: payload}) + r.mu.Unlock() + r.inFlight.Add(-1) +} + +func (r *recorder) count() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.calls) +} + +func (r *recorder) snapshot() []call { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]call, len(r.calls)) + copy(out, r.calls) + return out +} + +type fakeListener struct { + rec *recorder +} + +func (f *fakeListener) OnNetworkChanged(routes string) { + f.rec.record("routes", routes) +} + +func (f *fakeListener) SetInterfaceIP(ip string) { + f.rec.record("ip", ip) +} + +func (f *fakeListener) SetInterfaceIPv6(ip string) { + f.rec.record("ipv6", ip) +} + +type fakeDNSManager struct { + rec *recorder +} + +func (f *fakeDNSManager) ApplyDns(config string) { + f.rec.record("dns", config) +} + +func TestFIFOOrder(t *testing.T) { + rec := &recorder{} + n := New(&fakeListener{rec: rec}, &fakeDNSManager{rec: rec}) + defer n.Close() + + n.SetInterfaceIP("10.0.0.1") + n.SetInterfaceIPv6("fd00::1") + n.ApplyDns(`{"domains":[]}`) + n.OnNetworkChanged("10.0.0.0/8,192.168.0.0/16") + n.ApplyDns(`{"domains":["example.com"]}`) + + require.Eventually(t, func() bool { return rec.count() == 5 }, time.Second, time.Millisecond) + + expected := []call{ + {kind: "ip", payload: "10.0.0.1"}, + {kind: "ipv6", payload: "fd00::1"}, + {kind: "dns", payload: `{"domains":[]}`}, + {kind: "routes", payload: "10.0.0.0/8,192.168.0.0/16"}, + {kind: "dns", payload: `{"domains":["example.com"]}`}, + } + assert.Equal(t, expected, rec.snapshot()) +} + +func TestNoOverlappingCalls(t *testing.T) { + rec := &recorder{delay: 100 * time.Microsecond} + n := New(&fakeListener{rec: rec}, &fakeDNSManager{rec: rec}) + defer n.Close() + + const producers = 8 + const perProducer = 25 + + var wg sync.WaitGroup + for i := 0; i < producers; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := 0; j < perProducer; j++ { + payload := fmt.Sprintf("%d-%d", id, j) + switch j % 4 { + case 0: + n.OnNetworkChanged(payload) + case 1: + n.SetInterfaceIP(payload) + case 2: + n.SetInterfaceIPv6(payload) + case 3: + n.ApplyDns(payload) + } + } + }(i) + } + wg.Wait() + + require.Eventually(t, func() bool { return rec.count() == producers*perProducer }, 5*time.Second, time.Millisecond) + assert.False(t, rec.overlap.Load()) +} + +func TestDNSAndRoutesInterleaved(t *testing.T) { + rec := &recorder{delay: 100 * time.Microsecond} + n := New(&fakeListener{rec: rec}, &fakeDNSManager{rec: rec}) + defer n.Close() + + const events = 50 + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + for i := 0; i < events; i++ { + n.ApplyDns(fmt.Sprintf("dns-%d", i)) + } + }() + go func() { + defer wg.Done() + for i := 0; i < events; i++ { + n.OnNetworkChanged(fmt.Sprintf("routes-%d", i)) + } + }() + wg.Wait() + + require.Eventually(t, func() bool { return rec.count() == 2*events }, 5*time.Second, time.Millisecond) + assert.False(t, rec.overlap.Load()) + + var dnsSeen, routesSeen int + for _, c := range rec.snapshot() { + switch c.kind { + case "dns": + assert.Equal(t, fmt.Sprintf("dns-%d", dnsSeen), c.payload) + dnsSeen++ + case "routes": + assert.Equal(t, fmt.Sprintf("routes-%d", routesSeen), c.payload) + routesSeen++ + } + } + assert.Equal(t, events, dnsSeen) + assert.Equal(t, events, routesSeen) +} + +func TestCloseDrainsQueue(t *testing.T) { + rec := &recorder{delay: time.Millisecond} + n := New(&fakeListener{rec: rec}, &fakeDNSManager{rec: rec}) + + const events = 20 + for i := 0; i < events; i++ { + n.OnNetworkChanged(fmt.Sprintf("routes-%d", i)) + } + n.Close() + + require.Equal(t, events, rec.count(), "Close must not return before all queued events are delivered") + + n.OnNetworkChanged("after-close") + n.ApplyDns("after-close") + time.Sleep(50 * time.Millisecond) + assert.Equal(t, events, rec.count()) +} diff --git a/client/internal/updater/installer/doc.go b/client/internal/updater/installer/doc.go index 0a60454bb..aff0f24f7 100644 --- a/client/internal/updater/installer/doc.go +++ b/client/internal/updater/installer/doc.go @@ -37,23 +37,32 @@ // Updater Process (Setup): // // 1. Receives parameters from service via command-line arguments -// 2. Runs installer with appropriate silent/quiet flags: +// 2. Terminates the UI so the installer does not have to replace a locked image +// file, which would otherwise leave the install needing a reboot +// 3. Runs installer with appropriate silent/quiet flags: // - Windows EXE: installer.exe /S -// - Windows MSI: msiexec.exe /i installer.msi /quiet /qn /l*v msi.log +// - Windows MSI: msiexec.exe /i installer.msi /qn /norestart REBOOT=ReallySuppress /l*v msi.log // - macOS PKG: installer -pkg installer.pkg -target / // - macOS Homebrew: brew upgrade netbirdio/tap/netbird -// 3. Installer terminates daemon and UI processes -// 4. Installer replaces binaries with new version -// 5. Updater waits for installer to complete -// 6. Updater restarts daemon: +// 4. Installer terminates the daemon +// 5. Installer replaces binaries with new version +// 6. Updater waits for installer to complete. On Windows, MSI exit codes 3010 +// (ERROR_SUCCESS_REBOOT_REQUIRED) and 1641 (ERROR_SUCCESS_REBOOT_INITIATED) +// are a pending-reboot outcome, not a failure: the install succeeded, but +// some files are only replaced on the next restart (the reboot itself is +// suppressed via /norestart and REBOOT=ReallySuppress), and the flow +// continues as on success +// 7. Updater restarts daemon: // - Windows: netbird.exe service start // - macOS/Linux: netbird service start -// 7. Updater restarts UI: -// - Windows: Launches netbird-ui.exe as active console user using CreateProcessAsUser +// 8. Updater restarts UI: +// - Windows: Launches netbird-ui.exe using CreateProcessAsUser in every +// session it was terminated in, falling back to the active console session // - macOS: Uses launchctl asuser to launch NetBird.app for console user // - Linux: Not implemented (UI typically auto-starts) -// 8. Updater writes result.json with success/error status -// 9. Updater process exits +// 9. Updater writes result.json with success/error status (a pending reboot is +// recorded as success) +// 10. Updater process exits // // # Result Communication // @@ -100,6 +109,10 @@ // - Does NOT remove result.json (cleaned by ResultHandler after read) // - Does NOT remove msi.log (kept for debugging) // +// On Windows the updater copy is often still locked when the daemon it restarted +// runs cleanup, so removing it is retried briefly and otherwise left in place for +// the next update to overwrite rather than reported as a failure. +// // # Dry-Run Mode // // Dry-run mode allows testing the update process without actually installing: diff --git a/client/internal/updater/installer/installer_cleanup_windows_test.go b/client/internal/updater/installer/installer_cleanup_windows_test.go new file mode 100644 index 000000000..aab16dc93 --- /dev/null +++ b/client/internal/updater/installer/installer_cleanup_windows_test.go @@ -0,0 +1,67 @@ +package installer + +import ( + "os" + "path/filepath" + "testing" + "time" + + "golang.org/x/sys/windows" +) + +// lockFile opens path without FILE_SHARE_DELETE, so os.Remove fails the way it does +// while the updater process still holds its own image. +func lockFile(t *testing.T, path string) windows.Handle { + t.Helper() + + p, err := windows.UTF16PtrFromString(path) + if err != nil { + t.Fatalf("convert path: %v", err) + } + + handle, err := windows.CreateFile(p, windows.GENERIC_READ, windows.FILE_SHARE_READ, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + t.Fatalf("lock %s: %v", path, err) + } + return handle +} + +// releaseAfter closes the handle once the delay has passed, standing in for the +// updater process finally exiting. +func releaseAfter(t *testing.T, handle windows.Handle, delay time.Duration) { + t.Helper() + + released := make(chan struct{}) + t.Cleanup(func() { <-released }) + + go func() { + defer close(released) + time.Sleep(delay) + if err := windows.CloseHandle(handle); err != nil { + t.Errorf("close handle: %v", err) + } + }() +} + +// TestCleanUpInstallerFilesLockedUpdater covers the post-update cleanup race: the +// daemon cleans up at startup while the updater that restarted it is still exiting, +// so the updater image is locked and Windows refuses the delete. Cleanup must wait +// the lock out instead of reporting a failure and leaving the binary behind. +func TestCleanUpInstallerFilesLockedUpdater(t *testing.T) { + tempDir := t.TempDir() + path := filepath.Join(tempDir, updaterBinary) + if err := os.WriteFile(path, []byte("x"), 0o600); err != nil { + t.Fatalf("write updater: %v", err) + } + + releaseAfter(t, lockFile(t, path), 300*time.Millisecond) + + u := NewWithDir(tempDir) + if err := u.CleanUpInstallerFiles(); err != nil { + t.Fatalf("cleanup must tolerate a still-locked updater: %v", err) + } + + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("updater binary still present (stat err: %v)", err) + } +} diff --git a/client/internal/updater/installer/installer_common.go b/client/internal/updater/installer/installer_common.go index 8e44bee82..f917424b8 100644 --- a/client/internal/updater/installer/installer_common.go +++ b/client/internal/updater/installer/installer_common.go @@ -42,6 +42,9 @@ func NewWithDir(tempDir string) *Installer { // This will run by the original service process func (u *Installer) RunInstallation(ctx context.Context, targetVersion string) (err error) { resultHandler := NewResultHandler(u.tempDir) + if err := resultHandler.ClearStaleResult(); err != nil { + log.Warnf("clear stale installer result: %v", err) + } defer func() { if err != nil { @@ -149,8 +152,8 @@ func (u *Installer) CleanUpInstallerFiles() error { var merr *multierror.Error - if err := os.Remove(filepath.Join(u.tempDir, updaterBinary)); err != nil && !os.IsNotExist(err) { - merr = multierror.Append(merr, fmt.Errorf("failed to remove updater binary: %w", err)) + if err := removeUpdaterBinary(filepath.Join(u.tempDir, updaterBinary)); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove updater binary: %w", err)) } entries, err := os.ReadDir(u.tempDir) @@ -164,10 +167,16 @@ func (u *Installer) CleanUpInstallerFiles() error { } name := entry.Name() + // The updater copy is handled above; on Windows its name also matches the + // extension sweep, which would report the same file twice. + if strings.EqualFold(name, updaterBinary) { + continue + } + for _, ext := range binaryExtensions { if strings.HasSuffix(strings.ToLower(name), strings.ToLower(ext)) { if err := os.Remove(filepath.Join(u.tempDir, name)); err != nil { - merr = multierror.Append(merr, fmt.Errorf("failed to remove %s: %w", name, err)) + merr = multierror.Append(merr, fmt.Errorf("remove %s: %w", name, err)) } break } diff --git a/client/internal/updater/installer/installer_common_test.go b/client/internal/updater/installer/installer_common_test.go new file mode 100644 index 000000000..c1556c828 --- /dev/null +++ b/client/internal/updater/installer/installer_common_test.go @@ -0,0 +1,52 @@ +//go:build windows || darwin + +package installer + +import ( + "os" + "path/filepath" + "testing" +) + +// TestCleanUpInstallerFiles checks that cleanup removes the updater copy and the +// downloaded installer while leaving the logs and the result file for the daemon. +func TestCleanUpInstallerFiles(t *testing.T) { + tempDir := t.TempDir() + + installers := make([]string, 0, len(binaryExtensions)) + for _, ext := range binaryExtensions { + installers = append(installers, "netbird_installer."+ext) + } + + kept := []string{"installer.log", "result.json"} + + for _, name := range append(append([]string{updaterBinary}, installers...), kept...) { + if err := os.WriteFile(filepath.Join(tempDir, name), []byte("x"), 0o600); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + + u := NewWithDir(tempDir) + if err := u.CleanUpInstallerFiles(); err != nil { + t.Fatalf("CleanUpInstallerFiles: %v", err) + } + + for _, name := range append([]string{updaterBinary}, installers...) { + if _, err := os.Stat(filepath.Join(tempDir, name)); !os.IsNotExist(err) { + t.Errorf("%s was not removed (stat err: %v)", name, err) + } + } + + for _, name := range kept { + if _, err := os.Stat(filepath.Join(tempDir, name)); err != nil { + t.Errorf("%s should have been kept: %v", name, err) + } + } +} + +func TestCleanUpInstallerFilesMissingTempDir(t *testing.T) { + u := NewWithDir(filepath.Join(t.TempDir(), "does-not-exist")) + if err := u.CleanUpInstallerFiles(); err != nil { + t.Errorf("a missing temp dir is not a cleanup failure, got: %v", err) + } +} diff --git a/client/internal/updater/installer/installer_run_darwin.go b/client/internal/updater/installer/installer_run_darwin.go index 248a404aa..5650bc769 100644 --- a/client/internal/updater/installer/installer_run_darwin.go +++ b/client/internal/updater/installer/installer_run_darwin.go @@ -98,47 +98,44 @@ func (u *Installer) startDaemon(daemonFolder string) error { func (u *Installer) startUIAsUser() error { log.Infof("starting netbird-ui: %s", uiBinary) - // Get the current console user - cmd := exec.Command("stat", "-f", "%Su", "/dev/console") - output, err := cmd.Output() + username, err := consoleUser() if err != nil { - return fmt.Errorf("failed to get console user: %w", err) + return err } - username := strings.TrimSpace(string(output)) - if username == "" || username == "root" { - return fmt.Errorf("no active user session found") - } - - log.Infof("starting UI for user: %s", username) - - // Get user's UID userInfo, err := user.Lookup(username) if err != nil { - return fmt.Errorf("failed to lookup user %s: %w", username, err) + return fmt.Errorf("lookup user %s: %w", username, err) } - // Start the UI process as the console user using launchctl - // This ensures the app runs in the user's context with proper GUI access - launchCmd := exec.Command("launchctl", "asuser", userInfo.Uid, "open", "-a", uiBinary) + log.Infof("starting UI for user: %s (uid %s)", username, userInfo.Uid) + + launchCmd := exec.Command("launchctl", "asuser", userInfo.Uid, "sudo", "-u", username, "-H", "open", "-a", uiBinary) log.Infof("launchCmd: %s", launchCmd.String()) - // Set the user's home directory for proper macOS app behavior - launchCmd.Env = append(os.Environ(), "HOME="+userInfo.HomeDir) - log.Infof("set HOME environment variable: %s", userInfo.HomeDir) - if err := launchCmd.Start(); err != nil { - return fmt.Errorf("failed to start UI process: %w", err) - } - - // Release the process so it can run independently - if err := launchCmd.Process.Release(); err != nil { - log.Warnf("failed to release UI process: %v", err) + if err := launchCmd.Run(); err != nil { + return fmt.Errorf("run UI launch: %w", err) } log.Infof("netbird-ui started successfully for user %s", username) return nil } +func consoleUser() (string, error) { + output, err := exec.Command("stat", "-f", "%Su", "/dev/console").Output() + if err != nil { + return "", fmt.Errorf("get console user: %w", err) + } + + username := strings.TrimSpace(string(output)) + switch username { + case "", "root", "loginwindow", "_mbsetupuser": + return "", fmt.Errorf("no active GUI user session, console user: %q", username) + } + + return username, nil +} + func (u *Installer) installPkgFile(ctx context.Context, path string) error { log.Infof("installing pkg file: %s", path) diff --git a/client/internal/updater/installer/installer_run_windows.go b/client/internal/updater/installer/installer_run_windows.go index 70c7e32cf..b2ecf3299 100644 --- a/client/internal/updater/installer/installer_run_windows.go +++ b/client/internal/updater/installer/installer_run_windows.go @@ -2,6 +2,7 @@ package installer import ( "context" + "errors" "fmt" "os" "os/exec" @@ -22,6 +23,12 @@ const ( msiLogFile = "msi.log" + // ERROR_SUCCESS_REBOOT_REQUIRED and ERROR_SUCCESS_REBOOT_INITIATED + msiRebootRequired = 3010 + msiRebootInitiated = 1641 + + processExitWait = 10 * time.Second + msiDownloadURL = "https://github.com/netbirdio/netbird/releases/download/v%version/netbird_installer_%version_windows_%arch.msi" exeDownloadURL = "https://github.com/netbirdio/netbird/releases/download/v%version/netbird_installer_%version_windows_%arch.exe" ) @@ -38,6 +45,8 @@ var ( func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string, daemonFolder string) (resultErr error) { resultHandler := NewResultHandler(u.tempDir) + var uiSessions []uint32 + // Always ensure daemon and UI are restarted after setup defer func() { log.Infof("starting daemon back") @@ -46,7 +55,7 @@ func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string } log.Infof("starting UI back") - if err := u.startUIAsUser(daemonFolder); err != nil { + if err := u.startUI(daemonFolder, uiSessions); err != nil { log.Errorf("failed to start UI: %v", err) } @@ -75,6 +84,14 @@ func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string return } + // The UI holds an open handle on its own image. Left running, Restart Manager + // cannot shut it down (msiexec runs as LocalSystem here, the UI as the + // interactive user), so the MSI falls back to replacing the file on reboot and + // marks the install as restart-required. The deferred close-application action + // in the package runs too late to prevent that, it happens after + // InstallValidate has already registered the file as in use. + uiSessions = killUI() + var cmd *exec.Cmd switch installerType { case TypeExe: @@ -84,7 +101,9 @@ func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string installerDir := filepath.Dir(installerFile) logPath := filepath.Join(installerDir, msiLogFile) log.Infof("run msi installer: %s", installerFile) - cmd = exec.CommandContext(ctx, "msiexec.exe", "/i", filepath.Base(installerFile), "/quiet", "/qn", "/l*v", logPath) + // REBOOT=ReallySuppress: a silent install has no way to ask, so without it + // msiexec reboots the machine on its own if it decides one is needed. + cmd = exec.CommandContext(ctx, "msiexec.exe", "/i", filepath.Base(installerFile), "/qn", "/norestart", "REBOOT=ReallySuppress", "/l*v", logPath) } cmd.Dir = filepath.Dir(installerFile) @@ -95,9 +114,13 @@ func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string } log.Infof("installer started with PID %d", cmd.Process.Pid) - if resultErr = cmd.Wait(); resultErr != nil { - log.Errorf("installer process finished with error: %v", resultErr) - return + if err := cmd.Wait(); err != nil { + if !isRebootPending(err) { + resultErr = err + log.Errorf("installer process finished with error: %v", err) + return + } + log.Warnf("installer completed but reported a pending reboot, some files will be replaced on the next restart") } return nil @@ -117,16 +140,142 @@ func (u *Installer) startDaemon(daemonFolder string) error { return nil } -func (u *Installer) startUIAsUser(daemonFolder string) error { +func (u *Installer) startUI(daemonFolder string, sessionIDs []uint32) error { uiPath := filepath.Join(daemonFolder, uiName) log.Infof("starting netbird-ui: %s", uiPath) - // Get the active console session ID - sessionID := windows.WTSGetActiveConsoleSessionId() - if sessionID == 0xFFFFFFFF { - return fmt.Errorf("no active user session found") + if len(sessionIDs) == 0 { + sessionID := windows.WTSGetActiveConsoleSessionId() + if sessionID == 0xFFFFFFFF { + return fmt.Errorf("no active user session found") + } + sessionIDs = []uint32{sessionID} } + var errs []error + for _, sessionID := range sessionIDs { + if err := startUIInSession(uiPath, sessionID); err != nil { + errs = append(errs, fmt.Errorf("session %d: %w", sessionID, err)) + continue + } + log.Infof("netbird-ui started successfully in session %d", sessionID) + } + return errors.Join(errs...) +} + +// isRebootPending reports whether the installer exit code means it succeeded but +// left work for the next restart. The reboot itself is suppressed, so this is not +// a failure. +func isRebootPending(err error) bool { + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + return false + } + + switch exitErr.ExitCode() { + case msiRebootRequired, msiRebootInitiated: + return true + default: + return false + } +} + +// killUI terminates any running netbird-ui process and returns the IDs of the +// interactive sessions the terminated processes belonged to. Setup starts the +// UI again in those sessions once the installer is done. +func killUI() []uint32 { + pids, err := processIDsByName(uiName) + if err != nil { + log.Warnf("failed to look up %s processes: %v", uiName, err) + return nil + } + + sessions := make(map[uint32]struct{}) + for _, pid := range pids { + var sessionID uint32 + if err := windows.ProcessIdToSessionId(pid, &sessionID); err != nil { + log.Warnf("failed to look up session of %s (PID %d): %v", uiName, pid, err) + } + + if err := terminateProcess(pid); err != nil { + log.Warnf("failed to terminate %s (PID %d): %v", uiName, pid, err) + continue + } + log.Infof("terminated %s (PID %d) in session %d", uiName, pid, sessionID) + + if sessionID != 0 { + sessions[sessionID] = struct{}{} + } + } + + sessionIDs := make([]uint32, 0, len(sessions)) + for sessionID := range sessions { + sessionIDs = append(sessionIDs, sessionID) + } + return sessionIDs +} + +func processIDsByName(name string) ([]uint32, error) { + snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return nil, fmt.Errorf("create process snapshot: %w", err) + } + defer func() { + if err := windows.CloseHandle(snapshot); err != nil { + log.Warnf("failed to close process snapshot: %v", err) + } + }() + + var entry windows.ProcessEntry32 + entry.Size = uint32(unsafe.Sizeof(entry)) + + var pids []uint32 + for err = windows.Process32First(snapshot, &entry); err == nil; err = windows.Process32Next(snapshot, &entry) { + if strings.EqualFold(windows.UTF16ToString(entry.ExeFile[:]), name) { + pids = append(pids, entry.ProcessID) + } + } + if !errors.Is(err, windows.ERROR_NO_MORE_FILES) { + return nil, fmt.Errorf("enumerate processes: %w", err) + } + + return pids, nil +} + +func terminateProcess(pid uint32) error { + handle, err := windows.OpenProcess(windows.PROCESS_TERMINATE|windows.SYNCHRONIZE, false, pid) + if err != nil { + // The process may have exited between enumeration and now. + if errors.Is(err, windows.ERROR_INVALID_PARAMETER) { + return nil + } + return fmt.Errorf("open process: %w", err) + } + defer func() { + if err := windows.CloseHandle(handle); err != nil { + log.Warnf("failed to close process handle: %v", err) + } + }() + + if err := windows.TerminateProcess(handle, 0); err != nil { + return fmt.Errorf("terminate process: %w", err) + } + + // Wait for the handle to signal so the image file is released before the + // installer tries to overwrite it. A timeout is reported through the returned + // event, not through err, which stays nil unless the wait itself failed. + event, err := windows.WaitForSingleObject(handle, uint32(processExitWait.Milliseconds())) + if err != nil { + return fmt.Errorf("wait for process exit: %w", err) + } + if event != windows.WAIT_OBJECT_0 { + return fmt.Errorf("wait for process exit: unexpected wait result %#x", event) + } + + return nil +} + +func startUIInSession(uiPath string, sessionID uint32) error { // Get the user token for that session var userToken windows.Token err := windows.WTSQueryUserToken(sessionID, &userToken) @@ -158,6 +307,16 @@ func (u *Installer) startUIAsUser(daemonFolder string) error { } }() + var env *uint16 + if err := windows.CreateEnvironmentBlock(&env, primaryToken, false); err != nil { + return fmt.Errorf("create environment block: %w", err) + } + defer func() { + if err := windows.DestroyEnvironmentBlock(env); err != nil { + log.Warnf("failed to destroy environment block: %v", err) + } + }() + // Prepare startup info var si windows.StartupInfo si.Cb = uint32(unsafe.Sizeof(si)) @@ -180,7 +339,7 @@ func (u *Installer) startUIAsUser(daemonFolder string) error { nil, false, creationFlags, - nil, + env, nil, &si, &pi, @@ -197,7 +356,6 @@ func (u *Installer) startUIAsUser(daemonFolder string) error { log.Warnf("failed to close thread handle: %v", err) } - log.Infof("netbird-ui started successfully in session %d", sessionID) return nil } diff --git a/client/internal/updater/installer/installer_run_windows_test.go b/client/internal/updater/installer/installer_run_windows_test.go new file mode 100644 index 000000000..6a4540610 --- /dev/null +++ b/client/internal/updater/installer/installer_run_windows_test.go @@ -0,0 +1,108 @@ +package installer + +import ( + "errors" + "os/exec" + "slices" + "strconv" + "testing" +) + +// exitErrorWithCode returns a real *exec.ExitError carrying the given exit code. +func exitErrorWithCode(t *testing.T, code int) error { + t.Helper() + + err := exec.Command("cmd.exe", "/c", "exit "+strconv.Itoa(code)).Run() + if err == nil { + t.Fatalf("expected a non-zero exit for code %d", code) + } + return err +} + +func TestIsRebootPending(t *testing.T) { + tests := []struct { + name string + code int + want bool + }{ + {name: "reboot required", code: msiRebootRequired, want: true}, + {name: "reboot initiated", code: msiRebootInitiated, want: true}, + {name: "generic failure", code: 1603, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isRebootPending(exitErrorWithCode(t, tt.code)); got != tt.want { + t.Errorf("isRebootPending(exit %d) = %v, want %v", tt.code, got, tt.want) + } + }) + } +} + +// TestProcessIDsByNameAndTerminate spawns a long-running system process, finds it +// by name and terminates it, covering the path the updater uses to release the UI +// image file before the installer replaces it. +func TestProcessIDsByNameAndTerminate(t *testing.T) { + cmd := exec.Command("ping.exe", "-n", "60", "127.0.0.1") + if err := cmd.Start(); err != nil { + t.Fatalf("start ping: %v", err) + } + + pid := uint32(cmd.Process.Pid) + killed := false + t.Cleanup(func() { + if !killed { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + }) + + // Name matching must be case-insensitive: the snapshot reports PING.EXE. + pids, err := processIDsByName("ping.exe") + if err != nil { + t.Fatalf("processIDsByName: %v", err) + } + + if !slices.Contains(pids, pid) { + t.Fatalf("PID %d not among the ping.exe processes found: %v", pid, pids) + } + + if err := terminateProcess(pid); err != nil { + t.Fatalf("terminateProcess: %v", err) + } + killed = true + + // terminateProcess only returns once the handle has signalled, so the process + // is already gone and Wait must not block. It exits with the code passed to + // TerminateProcess, which is 0, so Wait reports no error. + if err := cmd.Wait(); err != nil { + t.Fatalf("wait for terminated ping: %v", err) + } + if !cmd.ProcessState.Exited() { + t.Error("process did not exit after terminateProcess") + } + + remaining, err := processIDsByName("ping.exe") + if err != nil { + t.Fatalf("processIDsByName after terminate: %v", err) + } + if slices.Contains(remaining, pid) { + t.Errorf("PID %d still listed after terminateProcess", pid) + } +} + +func TestProcessIDsByNameNoMatch(t *testing.T) { + pids, err := processIDsByName("netbird-nonexistent-process.exe") + if err != nil { + t.Fatalf("processIDsByName: %v", err) + } + if len(pids) != 0 { + t.Errorf("expected no matches, got %v", pids) + } +} + +func TestIsRebootPendingNonExitError(t *testing.T) { + if isRebootPending(errors.New("start installer: file not found")) { + t.Error("a non-exit error must not be treated as a pending reboot") + } +} diff --git a/client/internal/updater/installer/remove_updater_darwin.go b/client/internal/updater/installer/remove_updater_darwin.go new file mode 100644 index 000000000..4d4a0be60 --- /dev/null +++ b/client/internal/updater/installer/remove_updater_darwin.go @@ -0,0 +1,12 @@ +package installer + +import "os" + +// removeUpdaterBinary deletes the updater copy left in the temp dir. On darwin a +// running binary can be unlinked, so no retry is needed. +func removeUpdaterBinary(path string) error { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} diff --git a/client/internal/updater/installer/remove_updater_windows.go b/client/internal/updater/installer/remove_updater_windows.go new file mode 100644 index 000000000..0e23b1644 --- /dev/null +++ b/client/internal/updater/installer/remove_updater_windows.go @@ -0,0 +1,45 @@ +package installer + +import ( + "errors" + "os" + "time" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" +) + +const ( + // The updater is the process that restarted the daemon, so when the daemon + // cleans up at startup the updater is often still exiting and Windows refuses + // to delete its locked image. These bound how long cleanup waits for it. + updaterRemoveAttempts = 5 + updaterRemoveDelay = 200 * time.Millisecond +) + +// removeUpdaterBinary deletes the updater copy left in the temp dir, retrying +// while the still-exiting updater process holds its image. A binary that stays +// locked for the whole window is left in place and reported at info level: the +// next update overwrites it, so it is not worth failing cleanup over. +func removeUpdaterBinary(path string) error { + for attempt := 0; attempt < updaterRemoveAttempts; attempt++ { + if attempt > 0 { + time.Sleep(updaterRemoveDelay) + } + + err := os.Remove(path) + if err == nil || os.IsNotExist(err) { + return nil + } + if !isFileLocked(err) { + return err + } + } + + log.Infof("updater binary %s is still locked, leaving it for the next update to overwrite", path) + return nil +} + +func isFileLocked(err error) bool { + return errors.Is(err, windows.ERROR_ACCESS_DENIED) || errors.Is(err, windows.ERROR_SHARING_VIOLATION) +} diff --git a/client/internal/updater/installer/remove_updater_windows_test.go b/client/internal/updater/installer/remove_updater_windows_test.go new file mode 100644 index 000000000..09910d034 --- /dev/null +++ b/client/internal/updater/installer/remove_updater_windows_test.go @@ -0,0 +1,59 @@ +package installer + +import ( + "os" + "path/filepath" + "testing" + "time" + + "golang.org/x/sys/windows" +) + +func TestRemoveUpdaterBinaryRetriesWhileLocked(t *testing.T) { + path := filepath.Join(t.TempDir(), updaterBinary) + if err := os.WriteFile(path, []byte("x"), 0o600); err != nil { + t.Fatalf("write updater: %v", err) + } + + releaseAfter(t, lockFile(t, path), updaterRemoveDelay+50*time.Millisecond) + + if err := removeUpdaterBinary(path); err != nil { + t.Fatalf("removeUpdaterBinary: %v", err) + } + + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("updater binary still present (stat err: %v)", err) + } +} + +// TestRemoveUpdaterBinaryStaysLocked covers an updater that never releases its +// image within the retry window. Cleanup gives up quietly and leaves the file +// behind rather than reporting a failure. +func TestRemoveUpdaterBinaryStaysLocked(t *testing.T) { + path := filepath.Join(t.TempDir(), updaterBinary) + if err := os.WriteFile(path, []byte("x"), 0o600); err != nil { + t.Fatalf("write updater: %v", err) + } + + handle := lockFile(t, path) + t.Cleanup(func() { + if err := windows.CloseHandle(handle); err != nil { + t.Errorf("close handle: %v", err) + } + }) + + if err := removeUpdaterBinary(path); err != nil { + t.Fatalf("a permanently locked updater is not a cleanup failure, got: %v", err) + } + + if _, err := os.Stat(path); err != nil { + t.Errorf("locked updater binary should be left in place, stat: %v", err) + } +} + +func TestRemoveUpdaterBinaryMissingFile(t *testing.T) { + path := filepath.Join(t.TempDir(), updaterBinary) + if err := removeUpdaterBinary(path); err != nil { + t.Errorf("a missing updater binary is not a failure, got: %v", err) + } +} diff --git a/client/internal/updater/installer/result.go b/client/internal/updater/installer/result.go index 526c3eb53..55a0d8ac8 100644 --- a/client/internal/updater/installer/result.go +++ b/client/internal/updater/installer/result.go @@ -54,6 +54,12 @@ func (rh *ResultHandler) GetErrorResultReason() string { return "" } +// ClearStaleResult removes a result file left over from a previous installation +// attempt so result watchers cannot read an outdated outcome for the current attempt. +func (rh *ResultHandler) ClearStaleResult() error { + return rh.cleanup() +} + func (rh *ResultHandler) WriteSuccess() error { result := Result{ Success: true, diff --git a/client/internal/updater/manager.go b/client/internal/updater/manager.go index dfcb93177..1b69368d0 100644 --- a/client/internal/updater/manager.go +++ b/client/internal/updater/manager.go @@ -19,8 +19,6 @@ import ( const ( latestVersion = "latest" - // this version will be ignored - developmentVersion = "development" ) var errNoUpdateState = errors.New("no update state found") @@ -437,7 +435,7 @@ func (m *Manager) install(ctx context.Context, pendingVersion *v.Version) error } inst := installer.New() - if err := inst.RunInstallation(ctx, pendingVersion.String()); err != nil { + if err := inst.RunInstallation(ctx, pendingVersion.String()); err != nil { //nolint:staticcheck // always errors on platforms without an installer log.Errorf("error triggering update: %v", err) m.statusRecorder.PublishEvent( cProto.SystemEvent_ERROR, @@ -483,7 +481,7 @@ func (m *Manager) loadAndDeleteUpdateState(ctx context.Context) (*UpdateState, e } func (m *Manager) shouldUpdate(updateVersion *v.Version, forceUpdate bool) bool { - if m.currentVersion == developmentVersion { + if version.IsDevelopmentVersion(m.currentVersion) { log.Debugf("skipping auto-update, running development version") return false } diff --git a/client/internal/wg_iface_monitor.go b/client/internal/wg_iface_monitor.go index a870c1145..2a2fa2366 100644 --- a/client/internal/wg_iface_monitor.go +++ b/client/internal/wg_iface_monitor.go @@ -6,7 +6,6 @@ import ( "fmt" "net" "runtime" - "time" log "github.com/sirupsen/logrus" @@ -28,6 +27,10 @@ func NewWGIfaceMonitor() *WGIfaceMonitor { // Start begins monitoring the WireGuard interface. // It relies on the provided context cancellation to stop. +// +// On Linux the watcher is event-driven (RTNLGRP_LINK netlink subscription) +// to avoid the allocation churn of repeatedly dumping the kernel link +// table; on other platforms it falls back to a low-frequency poll. func (m *WGIfaceMonitor) Start(ctx context.Context, ifaceName string) (shouldRestart bool, err error) { defer close(m.done) @@ -56,31 +59,7 @@ func (m *WGIfaceMonitor) Start(ctx context.Context, ifaceName string) (shouldRes log.Infof("Interface monitor: watching %s (index: %d)", ifaceName, expectedIndex) - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - log.Infof("Interface monitor: stopped for %s", ifaceName) - return false, fmt.Errorf("wg interface monitor stopped: %v", ctx.Err()) - case <-ticker.C: - currentIndex, err := getInterfaceIndex(ifaceName) - if err != nil { - // Interface was deleted - log.Infof("Interface monitor: %s deleted", ifaceName) - return true, fmt.Errorf("interface %s deleted: %w", ifaceName, err) - } - - // Check if interface index changed (interface was recreated) - if currentIndex != expectedIndex { - log.Infof("Interface monitor: %s recreated (index changed from %d to %d), restarting engine", - ifaceName, expectedIndex, currentIndex) - return true, nil - } - } - } - + return watchInterface(ctx, ifaceName, expectedIndex) } // getInterfaceIndex returns the index of a network interface by name. diff --git a/client/internal/wg_iface_monitor_linux.go b/client/internal/wg_iface_monitor_linux.go new file mode 100644 index 000000000..2662b99d6 --- /dev/null +++ b/client/internal/wg_iface_monitor_linux.go @@ -0,0 +1,134 @@ +//go:build linux + +package internal + +import ( + "context" + "fmt" + "syscall" + + log "github.com/sirupsen/logrus" + "github.com/vishvananda/netlink" +) + +// watchInterface uses an RTNLGRP_LINK netlink subscription to detect +// deletion or recreation of the WireGuard interface. +// +// The previous implementation polled net.InterfaceByName every 2 s, which +// on Linux issues syscall.NetlinkRIB(RTM_GETLINK, ...) and dumps the +// entire kernel link table on every call. On hosts with many veth +// interfaces (containers, bridges) the resulting allocation churn was on +// the order of ~1 GB/day from this single ticker, which on small ARM +// hosts manifested as a slow RSS climb (see netbirdio/netbird#3678). +// +// The event-driven version below allocates only when the kernel actually +// publishes a link event for the tracked interface — typically zero +// allocations between events. +func watchInterface(ctx context.Context, ifaceName string, expectedIndex int) (bool, error) { + done := make(chan struct{}) + defer close(done) + + // Buffer the channel to absorb event bursts (e.g. when many veth + // pairs are created/destroyed at once by container runtimes). + linkChan := make(chan netlink.LinkUpdate, 32) + if err := netlink.LinkSubscribe(linkChan, done); err != nil { + // Return shouldRestart=true so the engine recovers monitoring + // via triggerClientRestart instead of silently losing it for + // the rest of the process lifetime. + return true, fmt.Errorf("subscribe to link updates: %w", err) + } + + // Race window: the interface could have been deleted (or recreated) + // between the initial getInterfaceIndex() in Start and LinkSubscribe + // completing its handshake with the kernel. Re-check explicitly so we + // do not block forever waiting for an event that already fired. + if currentIndex, err := getInterfaceIndex(ifaceName); err != nil { + log.Infof("Interface monitor: %s deleted before subscription completed", ifaceName) + return true, fmt.Errorf("interface %s deleted: %w", ifaceName, err) + } else if currentIndex != expectedIndex { + log.Infof("Interface monitor: %s recreated (index changed from %d to %d) before subscription completed", + ifaceName, expectedIndex, currentIndex) + return true, nil + } + + for { + select { + case <-ctx.Done(): + log.Infof("Interface monitor: stopped for %s", ifaceName) + return false, fmt.Errorf("wg interface monitor stopped: %w", ctx.Err()) + + case update, ok := <-linkChan: + if !ok { + // The vishvananda/netlink subscription goroutine closes + // the channel on receive errors. Signal the engine to + // restart so monitoring is re-established instead of + // silently ending. + log.Warnf("Interface monitor: link subscription channel closed unexpectedly for %s", ifaceName) + return true, fmt.Errorf("link subscription channel closed unexpectedly") + } + if restart, err := inspectLinkEvent(update, ifaceName, expectedIndex); restart { + return true, err + } + } + } +} + +// inspectLinkEvent classifies a single netlink link update against the +// tracked WireGuard interface. It returns (true, err) when the engine +// should restart monitoring; (false, nil) means the event is unrelated +// and the caller should keep waiting. +// +// The error component, when non-nil, describes the kernel-side reason +// (deletion or rename); the recreation case returns (true, nil) since +// no error condition is reported. +func inspectLinkEvent(update netlink.LinkUpdate, ifaceName string, expectedIndex int) (bool, error) { + eventIndex := int(update.Index) + eventName := "" + if attrs := update.Attrs(); attrs != nil { + eventName = attrs.Name + } + + switch update.Header.Type { + case syscall.RTM_DELLINK: + return inspectDelLink(eventIndex, ifaceName, expectedIndex) + case syscall.RTM_NEWLINK: + return inspectNewLink(eventIndex, eventName, ifaceName, expectedIndex) + } + return false, nil +} + +// inspectDelLink reports a restart when an RTM_DELLINK arrives for the +// tracked interface index. +func inspectDelLink(eventIndex int, ifaceName string, expectedIndex int) (bool, error) { + if eventIndex != expectedIndex { + return false, nil + } + log.Infof("Interface monitor: %s deleted", ifaceName) + return true, fmt.Errorf("interface %s deleted", ifaceName) +} + +// inspectNewLink reports a restart when an RTM_NEWLINK either: +// +// 1. Introduces a link with our name at a different index (recreation +// after a delete), or +// +// 2. Reports a link still at our index but with a different name +// (in-place rename). The previous polling implementation caught +// this implicitly because net.InterfaceByName(ifaceName) would +// start failing; the event-driven version has to test it. +// +// Same name + same index is just a flag/state change on the existing +// interface and is ignored. +func inspectNewLink(eventIndex int, eventName, ifaceName string, expectedIndex int) (bool, error) { + if eventName == ifaceName && eventIndex != expectedIndex { + log.Infof("Interface monitor: %s recreated (index changed from %d to %d), restarting engine", + ifaceName, expectedIndex, eventIndex) + return true, nil + } + if eventIndex == expectedIndex && eventName != "" && eventName != ifaceName { + log.Infof("Interface monitor: %s renamed to %s (index %d), restarting engine", + ifaceName, eventName, expectedIndex) + return true, fmt.Errorf("interface %s renamed to %s", ifaceName, eventName) + } + return false, nil +} diff --git a/client/internal/wg_iface_monitor_other.go b/client/internal/wg_iface_monitor_other.go new file mode 100644 index 000000000..afebbf4df --- /dev/null +++ b/client/internal/wg_iface_monitor_other.go @@ -0,0 +1,56 @@ +//go:build !linux + +package internal + +import ( + "context" + "fmt" + "time" + + log "github.com/sirupsen/logrus" +) + +// watchInterface polls net.InterfaceByName at a fixed interval to detect +// deletion or recreation of the WireGuard interface. +// +// This is the fallback used on non-Linux desktop and server platforms +// (darwin, windows, freebsd). It is also compiled on android and ios so +// the package builds on every supported GOOS, but it is never reached +// at runtime there because Start() in wg_iface_monitor.go exits early +// on mobile platforms. +// +// The Linux build (see wg_iface_monitor_linux.go) uses an event-driven +// RTNLGRP_LINK netlink subscription instead, because on Linux +// net.InterfaceByName issues syscall.NetlinkRIB(RTM_GETLINK, ...) which +// dumps the entire kernel link table on every call and produces +// significant allocation churn (netbirdio/netbird#3678). +// +// Windows is also reported in #3678 as affected by RSS climb. A future +// follow-up could implement an event-driven watcher there using +// NotifyIpInterfaceChange from iphlpapi. +func watchInterface(ctx context.Context, ifaceName string, expectedIndex int) (bool, error) { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + log.Infof("Interface monitor: stopped for %s", ifaceName) + return false, fmt.Errorf("wg interface monitor stopped: %w", ctx.Err()) + case <-ticker.C: + currentIndex, err := getInterfaceIndex(ifaceName) + if err != nil { + // Interface was deleted + log.Infof("Interface monitor: %s deleted", ifaceName) + return true, fmt.Errorf("interface %s deleted: %w", ifaceName, err) + } + + // Check if interface index changed (interface was recreated) + if currentIndex != expectedIndex { + log.Infof("Interface monitor: %s recreated (index changed from %d to %d), restarting engine", + ifaceName, expectedIndex, currentIndex) + return true, nil + } + } + } +} diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index 043673904..96c747ae4 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -4,33 +4,42 @@ package NetBirdSDK import ( "context" + "errors" "fmt" "net/netip" "os" "sort" "strings" "sync" + "sync/atomic" "time" log "github.com/sirupsen/logrus" - "golang.org/x/exp/maps" + nbAnonymize "github.com/netbirdio/netbird/client/anonymize" "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/auth" + "github.com/netbirdio/netbird/client/internal/debug" "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/netevents" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/formatter" "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" + types "github.com/netbirdio/netbird/upload-server/types" ) -// ConnectionListener export internal Listener for mobile -type ConnectionListener interface { - peer.Listener -} +// AnonymizeLevelDefault and AnonymizeLevelStrict are the accepted +// anonymizeLevel values for DebugBundle. +const ( + AnonymizeLevelDefault = nbAnonymize.LevelDefaultString + AnonymizeLevelStrict = nbAnonymize.LevelStrictString +) + +var errClientAlreadyRunning = errors.New("client is already running") // RouteListener export internal RouteListener for mobile type NetworkChangeListener interface { @@ -50,10 +59,12 @@ type CustomLogger interface { } type selectRoute struct { - NetID string - Network netip.Prefix - Domains domain.List - Selected bool + NetID string + Network netip.Prefix + Domains domain.List + Selected bool + Status string + extraNetworks []netip.Prefix } func init() { @@ -64,119 +75,275 @@ func init() { type Client struct { cfgFile string stateFile string + cacheDir string + logFilePath string recorder *peer.Status - ctxCancel context.CancelFunc - ctxCancelLock *sync.Mutex deviceName string osName string osVersion string networkChangeListener listener.NetworkChangeListener onHostDnsFn func([]string) dnsManager dns.IosDnsManager - loginComplete bool - connectClient *internal.ConnectClient - // preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked) - preloadedConfig *profilemanager.Config + loginComplete atomic.Bool + // netMgr outlives engine restarts: it mirrors the OS connectivity, not + // the engine lifecycle. Run injects its state and sweeper into each new + // ConnectClient. + netMgr *netevents.Manager + preloadedConfigJSON atomic.Pointer[string] + + // mdmSource holds the per-Client MDM policy source and its change + // detector as one unit. Set by SetMDMPolicyFetcher (called from the + // Swift side at extension init). Each Run passes the loader to the + // resolved Config so applyMDMPolicy picks up the active overlay. Nil + // means "MDM enforcement off for this Client". + mdmSource atomic.Pointer[mdmSource] + + // stateMu guards the run lifecycle as one unit: the cancel installed by + // the current run, the channel it closes on exit, and the state it + // published. One run at a time: startRun refuses a second Run while the + // previous one has not exited, and the platform serializes Stop before + // Start, so no generation tracking is needed. + stateMu sync.RWMutex + connectClient *internal.ConnectClient + config *profilemanager.Config + runDone chan struct{} + ctxCancel context.CancelFunc } // NewClient instantiate a new Client -func NewClient(cfgFile, stateFile, deviceName string, osVersion string, osName string, networkChangeListener NetworkChangeListener, dnsManager DnsManager) *Client { +func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osVersion string, osName string, networkChangeListener NetworkChangeListener, dnsManager DnsManager) *Client { + recorder := peer.NewRecorder("") return &Client{ cfgFile: cfgFile, stateFile: stateFile, + cacheDir: cacheDir, + logFilePath: logFilePath, deviceName: deviceName, osName: osName, osVersion: osVersion, - recorder: peer.NewRecorder(""), - ctxCancelLock: &sync.Mutex{}, + recorder: recorder, networkChangeListener: networkChangeListener, dnsManager: dnsManager, + netMgr: netevents.NewManager(recorder), } } -// SetConfigFromJSON loads config from a JSON string into memory. -// This is used on tvOS where file writes to App Group containers are blocked. -// When set, IsLoginRequired() and Run() will use this preloaded config instead of reading from file. +// SetConfigFromJSON stores the JSON config that later loads resolve instead of the config file (tvOS). func (c *Client) SetConfigFromJSON(jsonStr string) error { - cfg, err := profilemanager.ConfigFromJSON(jsonStr) - if err != nil { + if _, err := profilemanager.ConfigFromJSON(jsonStr); err != nil { log.Errorf("SetConfigFromJSON: failed to parse config JSON: %v", err) return err } - c.preloadedConfig = cfg + c.preloadedConfigJSON.Store(&jsonStr) log.Infof("SetConfigFromJSON: config loaded successfully from JSON") return nil } +func (c *Client) loadConfig(input profilemanager.ConfigInput) (*profilemanager.Config, error) { + var cfg *profilemanager.Config + var err error + if preloaded := c.preloadedConfigJSON.Load(); preloaded != nil { + cfg, err = profilemanager.ConfigFromJSON(*preloaded) + } else { + cfg, err = profilemanager.DirectUpdateOrCreateConfig(input) + } + if err != nil { + return nil, err + } + c.applyMDMOverlay(cfg) + return cfg, nil +} + // Run start the internal client. It is a blocker function func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error { exportEnvList(envList) log.Infof("Starting NetBird client") log.Debugf("Tunnel uses interface: %s", interfaceName) - var cfg *profilemanager.Config - var err error - - // Use preloaded config if available (tvOS where file writes are blocked) - if c.preloadedConfig != nil { - log.Infof("Run: using preloaded config from memory") - cfg = c.preloadedConfig - } else { - log.Infof("Run: loading config from file") - // Use DirectUpdateOrCreateConfig to avoid atomic file operations (temp file + rename) - // which are blocked by the tvOS sandbox in App Group containers - cfg, err = profilemanager.DirectUpdateOrCreateConfig(profilemanager.ConfigInput{ - ConfigPath: c.cfgFile, - StateFilePath: c.stateFile, - }) - if err != nil { - return err - } + cfg, err := c.loadConfig(profilemanager.ConfigInput{ + ConfigPath: c.cfgFile, + StateFilePath: c.stateFile, + }) + if err != nil { + return err } c.recorder.UpdateManagementAddress(cfg.ManagementURL.String()) c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive) - var ctx context.Context //nolint ctxWithValues := context.WithValue(context.Background(), system.DeviceNameCtxKey, c.deviceName) //nolint ctxWithValues = context.WithValue(ctxWithValues, system.OsNameCtxKey, c.osName) //nolint ctxWithValues = context.WithValue(ctxWithValues, system.OsVersionCtxKey, c.osVersion) - c.ctxCancelLock.Lock() - ctx, c.ctxCancel = context.WithCancel(ctxWithValues) - defer c.ctxCancel() - c.ctxCancelLock.Unlock() + runCtx, runCancel := context.WithCancel(ctxWithValues) + defer runCancel() - auth := NewAuthWithConfig(ctx, cfg) - err = auth.LoginSync() + done, err := c.startRun(runCancel) if err != nil { return err } + defer c.finishRun(done) + ctx := runCtx - log.Infof("Auth successful") + // No login pre-flight here. The engine's own loginToManagement (connect.go) performs + // the authoritative Login immediately before the first Sync, so a LoginSync() call at + // this point only duplicated it — costing two extra Login RPCs (IsLoginRequired + + // Login) on every engine start, since IsLoginRequired is itself a full Login RPC. + // + // Auth failures still reach the caller through the engine path: loginToManagement + // returns PermissionDenied, which marks the shared status recorder + // (MarkManagementDisconnected) and fires ClientStop → onDisconnected, where + // IsLoginRequiredCached() reports login-required. The error is also returned out of Run(). + // + // A pre-flight was also actively harmful when the server is unreachable: its 2-minute + // backoff blocked the start and then reported "login required" for what was really a + // timeout. The engine instead keeps retrying and recovers when the server returns. // todo do not throw error in case of cancelled context ctx = internal.CtxInitState(ctx) c.onHostDnsFn = func([]string) {} cfg.WgIface = interfaceName - c.connectClient = internal.NewConnectClient(ctx, cfg, c.recorder) - hostDNS := []netip.AddrPort{ - netip.MustParseAddrPort("9.9.9.9:53"), - netip.MustParseAddrPort("149.112.112.112:53"), - } - return c.connectClient.RunOniOS(fd, c.networkChangeListener, c.dnsManager, hostDNS, c.stateFile) + connectClient := internal.NewConnectClient(ctx, cfg, c.recorder, + internal.WithNetEvents(c.netMgr)) + c.setState(cfg, connectClient) + // Persist the latest sync response so DebugBundle can include the network + // map. On iOS this is backed by disk to keep it out of the constrained + // process memory (see the syncstore package). + connectClient.SetSyncResponsePersistence(true) + return connectClient.RunOniOS(fd, c.networkChangeListener, c.dnsManager, c.stateFile, c.cacheDir, c.logFilePath) } -// Stop the internal client and free the resources +// SetNetworkAvailable feeds OS-reported network availability into the client +// (e.g. from NWPathMonitor). While unavailable, the internal reconnect loops +// suspend their attempts and the connection listener reports NoNetwork +// instead of Connecting; when availability returns, the loops resume +// immediately with a fresh backoff. Losing the last network also sweeps the +// registered connections, so the client does not keep reporting Connected +// over stale sockets with no network at all. +func (c *Client) SetNetworkAvailable(available bool) { + c.netMgr.SetNetworkAvailable(available) +} + +// NotifyNetworkChange marks the management, signal and relay connections +// stale after the OS switched networks and schedules a sweep that cuts +// whatever has not redialed on the new network by then. The engine and the +// TUN device stay untouched. +func (c *Client) NotifyNetworkChange() { + c.netMgr.NotifyNetworkChange() +} + +// Stop cancels the running client and waits for the run loop to exit, so a +// caller that restarts immediately cannot race the outgoing teardown. func (c *Client) Stop() { - c.ctxCancelLock.Lock() - defer c.ctxCancelLock.Unlock() - if c.ctxCancel == nil { + done := c.cancelRun() + if done == nil { return } - c.ctxCancel() + select { + case <-done: + case <-time.After(stopRunWaitTimeout): + log.Warnf("Stop: timed out waiting for the run loop to exit") + } +} + +// StopWithoutWait cancels the running client without waiting for the run loop. +// Use it where the caller is on a deadline the wait could overrun, such as +// NEPacketTunnelProvider.stopTunnel, which iOS gives only a few seconds +// before it kills the extension. +func (c *Client) StopWithoutWait() { + c.cancelRun() +} + +func (c *Client) cancelRun() chan struct{} { + c.stateMu.RLock() + done := c.runDone + cancel := c.ctxCancel + c.stateMu.RUnlock() + + if cancel != nil { + cancel() + } + + return done +} + +// DebugBundle generates a debug bundle, uploads it and returns the upload key. +// It works with or without a running engine: when the engine is up it reuses +// the live config, sync response and client metrics; otherwise it loads the +// config from disk (or the preloaded tvOS config). anonymizeLevel is "default" +// or "strict"; strict also anonymizes internal IP ranges, peer names, and +// WireGuard public keys, and implies anonymize. +func (c *Client) DebugBundle(anonymize bool, anonymizeLevel string) (string, error) { + cfg, cc := c.stateSnapshot() + + // If the engine hasn't been started, load config so we can reach management. + if cfg == nil { + var err error + cfg, err = c.loadConfig(profilemanager.ConfigInput{ + ConfigPath: c.cfgFile, + StateFilePath: c.stateFile, + }) + if err != nil { + return "", fmt.Errorf("load config: %w", err) + } + } + + deps := debug.GeneratorDependencies{ + InternalConfig: cfg, + StatusRecorder: c.recorder, + TempDir: c.cacheDir, + StatePath: c.stateFile, + LogPath: c.logFilePath, + } + + if cc != nil { + resp, err := cc.GetLatestSyncResponse() + if err != nil { + log.Warnf("get latest sync response: %v", err) + } + deps.SyncResponse = resp + + if e := cc.Engine(); e != nil { + deps.RefreshStatus = func() { + e.RunHealthProbes(context.Background(), true) + } + if cm := e.GetClientMetrics(); cm != nil { + deps.ClientMetrics = cm + } + } + } + + bundleGenerator := debug.NewBundleGenerator( + deps, + debug.BundleConfig{ + Anonymize: anonymize, + AnonymizeLevel: nbAnonymize.ParseLevel(anonymizeLevel), + IncludeSystemInfo: true, + }, + ) + + path, err := bundleGenerator.Generate() + if err != nil { + return "", fmt.Errorf("generate debug bundle: %w", err) + } + defer func() { + if err := os.Remove(path); err != nil { + log.Errorf("failed to remove debug bundle file: %v", err) + } + }() + + uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path, false) + if err != nil { + return "", fmt.Errorf("upload debug bundle: %w", err) + } + + log.Infof("debug bundle uploaded with key %s", key) + return key, nil } // SetTraceLogLevel configure the logger to trace level @@ -198,6 +365,7 @@ func (c *Client) GetStatusDetails() *StatusDetails { } pi := PeerInfo{ IP: p.IP, + IPv6: p.IPv6, FQDN: p.FQDN, LocalIceCandidateEndpoint: p.LocalIceCandidateEndpoint, RemoteIceCandidateEndpoint: p.RemoteIceCandidateEndpoint, @@ -216,12 +384,16 @@ func (c *Client) GetStatusDetails() *StatusDetails { } peerInfos[n] = pi } - return &StatusDetails{items: peerInfos, fqdn: fullStatus.LocalPeerState.FQDN, ip: fullStatus.LocalPeerState.IP} + return &StatusDetails{items: peerInfos, fqdn: fullStatus.LocalPeerState.FQDN, ip: fullStatus.LocalPeerState.IP, ipv6: fullStatus.LocalPeerState.IPv6} } // SetConnectionListener set the network connection listener func (c *Client) SetConnectionListener(listener ConnectionListener) { - c.recorder.SetConnectionListener(listener) + if listener == nil { + c.recorder.RemoveConnectionListener() + return + } + c.recorder.SetConnectionListener(connectionListenerAdapter{listener}) } // RemoveConnectionListener remove connection listener @@ -229,41 +401,29 @@ func (c *Client) RemoveConnectionListener() { c.recorder.RemoveConnectionListener() } +// IsLoginRequiredCached reports whether the LAST observed management error was an +// auth failure (PermissionDenied/InvalidArgument), using the in-memory status +// recorder. Unlike IsLoginRequired() it performs NO network call, so it is safe to +// call from the connection listener during teardown (e.g. onDisconnected) without +// blocking on a slow or unavailable network. Returns false while connected to +// management or when the last error was not auth-related. +func (c *Client) IsLoginRequiredCached() bool { + return c.recorder.IsLoginRequired() +} + func (c *Client) IsLoginRequired() bool { - var ctx context.Context //nolint ctxWithValues := context.WithValue(context.Background(), system.DeviceNameCtxKey, c.deviceName) //nolint ctxWithValues = context.WithValue(ctxWithValues, system.OsNameCtxKey, c.osName) //nolint ctxWithValues = context.WithValue(ctxWithValues, system.OsVersionCtxKey, c.osVersion) - c.ctxCancelLock.Lock() - defer c.ctxCancelLock.Unlock() - ctx, c.ctxCancel = context.WithCancel(ctxWithValues) + ctx, cancel := context.WithCancel(ctxWithValues) + defer cancel() - var cfg *profilemanager.Config - var err error - - // Use preloaded config if available (tvOS where file writes are blocked) - if c.preloadedConfig != nil { - log.Infof("IsLoginRequired: using preloaded config from memory") - cfg = c.preloadedConfig - } else { - log.Infof("IsLoginRequired: loading config from file") - // Use DirectUpdateOrCreateConfig to avoid atomic file operations (temp file + rename) - // which are blocked by the tvOS sandbox in App Group containers - cfg, err = profilemanager.DirectUpdateOrCreateConfig(profilemanager.ConfigInput{ - ConfigPath: c.cfgFile, - }) - if err != nil { - log.Errorf("IsLoginRequired: failed to load config: %v", err) - // If we can't load config, assume login is required - return true - } - } - - if cfg == nil { - log.Errorf("IsLoginRequired: config is nil") + cfg, err := c.loadConfig(profilemanager.ConfigInput{ConfigPath: c.cfgFile}) + if err != nil { + log.Errorf("IsLoginRequired: failed to load config: %v", err) return true } @@ -287,17 +447,22 @@ func (c *Client) IsLoginRequired() bool { // loginForMobileAuthTimeout is the timeout for requesting auth info from the server const loginForMobileAuthTimeout = 30 * time.Second +const stopRunWaitTimeout = 20 * time.Second + func (c *Client) LoginForMobile() string { - var ctx context.Context //nolint ctxWithValues := context.WithValue(context.Background(), system.DeviceNameCtxKey, c.deviceName) //nolint ctxWithValues = context.WithValue(ctxWithValues, system.OsNameCtxKey, c.osName) //nolint ctxWithValues = context.WithValue(ctxWithValues, system.OsVersionCtxKey, c.osVersion) - c.ctxCancelLock.Lock() - defer c.ctxCancelLock.Unlock() - ctx, c.ctxCancel = context.WithCancel(ctxWithValues) + ctx, cancel := context.WithCancel(ctxWithValues) + loginDone := false + defer func() { + if !loginDone { + cancel() + } + }() // Use DirectUpdateOrCreateConfig to avoid atomic file operations (temp file + rename) // which are blocked by the tvOS sandbox in App Group containers @@ -308,6 +473,7 @@ func (c *Client) LoginForMobile() string { log.Errorf("LoginForMobile: failed to load config: %v", err) return fmt.Sprintf("failed to load config: %v", err) } + c.applyMDMOverlay(cfg) oAuthFlow, err := auth.NewOAuthFlow(ctx, cfg, false, false, "") if err != nil { @@ -324,7 +490,9 @@ func (c *Client) LoginForMobile() string { } // This could cause a potential race condition with loading the extension which need to be handled on swift side + loginDone = true go func() { + defer cancel() tokenInfo, err := oAuthFlow.WaitToken(ctx, flowInfo) if err != nil { log.Errorf("LoginForMobile: WaitToken failed: %v", err) @@ -341,99 +509,179 @@ func (c *Client) LoginForMobile() string { log.Errorf("LoginForMobile: Login failed: %v", err) return } - c.loginComplete = true + c.loginComplete.Store(true) }() return flowInfo.VerificationURIComplete } func (c *Client) IsLoginComplete() bool { - return c.loginComplete + return c.loginComplete.Load() } func (c *Client) ClearLoginComplete() { - c.loginComplete = false + c.loginComplete.Store(false) } func (c *Client) GetRoutesSelectionDetails() (*RoutesSelectionDetails, error) { - if c.connectClient == nil { + _, connectClient := c.stateSnapshot() + if connectClient == nil { return nil, fmt.Errorf("not connected") } - engine := c.connectClient.Engine() + engine := connectClient.Engine() if engine == nil { return nil, fmt.Errorf("not connected") } routeManager := engine.GetRouteManager() - routesMap := routeManager.GetClientRoutesWithNetID() if routeManager == nil { return nil, fmt.Errorf("could not get route manager") } + routesMap := routeManager.GetClientRoutesWithNetID() routeSelector := routeManager.GetRouteSelector() if routeSelector == nil { return nil, fmt.Errorf("could not get route selector") } + v6ExitMerged := route.V6ExitMergeSet(routesMap) + routes := buildSelectRoutes(routesMap, routeSelector.IsSelected, v6ExitMerged) + resolvedDomains := c.recorder.GetResolvedDomainsStates() + + // Compute each route's connection status in the core (mirroring the Android + // bridge), so the UI doesn't have to infer it by string-matching the joined + // Network value against peer routes. For a merged exit node the status reflects + // whichever of the v4/v6 prefixes is served by a connected peer; for dynamic + // (DNS) routes the peer route key is the domain pattern (see dynamic.Route.String). + connectedRoutes := c.connectedRouteSet() + for _, r := range routes { + r.Status = routeStatus(r, connectedRoutes) + } + + return prepareRouteSelectionDetails(routes, resolvedDomains), nil +} + +// connectedRouteSet returns the set of route keys (as strings) currently served by a +// connected peer, gathered across all connected peers' route tables. The keys match +// what the route manager records: a prefix string for static routes (e.g. "0.0.0.0/0") +// and the domain pattern for dynamic routes (e.g. "*.example.com"). +func (c *Client) connectedRouteSet() map[string]struct{} { + connected := map[string]struct{}{} + for _, p := range c.recorder.GetFullStatus().Peers { + if p.ConnStatus != peer.StatusConnected { + continue + } + for r := range p.GetRoutes() { + connected[r] = struct{}{} + } + } + return connected +} + +// routeStatus reports "Connected" if any of the route's keys is served by a connected +// peer: the primary Network prefix, an extra v6 network of a merged exit node, or the +// domain pattern for a dynamic DNS route. Otherwise "Idle". +func routeStatus(r *selectRoute, connectedRoutes map[string]struct{}) string { + keys := make([]string, 0, 1+len(r.extraNetworks)) + if len(r.Domains) > 0 { + keys = append(keys, r.Domains.SafeString()) + } else { + keys = append(keys, r.Network.String()) + } + for _, extra := range r.extraNetworks { + keys = append(keys, extra.String()) + } + for _, k := range keys { + if _, ok := connectedRoutes[k]; ok { + return peer.StatusConnected.String() + } + } + return peer.StatusIdle.String() +} + +func buildSelectRoutes(routesMap map[route.NetID][]*route.Route, isSelected func(route.NetID) bool, v6Merged map[route.NetID]struct{}) []*selectRoute { var routes []*selectRoute for id, rt := range routesMap { if len(rt) == 0 { continue } - route := &selectRoute{ + if _, ok := v6Merged[id]; ok { + continue + } + + r := &selectRoute{ NetID: string(id), Network: rt[0].Network, Domains: rt[0].Domains, - Selected: routeSelector.IsSelected(id), + Selected: isSelected(id), } - routes = append(routes, route) + + v6ID := route.NetID(string(id) + route.V6ExitSuffix) + if _, ok := v6Merged[v6ID]; ok { + r.extraNetworks = []netip.Prefix{routesMap[v6ID][0].Network} + } + + routes = append(routes, r) } sort.Slice(routes, func(i, j int) bool { - iPrefix := routes[i].Network.Bits() - jPrefix := routes[j].Network.Bits() - - if iPrefix == jPrefix { - iAddr := routes[i].Network.Addr() - jAddr := routes[j].Network.Addr() - if iAddr == jAddr { - return routes[i].NetID < routes[j].NetID - } - return iAddr.String() < jAddr.String() + iBits, jBits := routes[i].Network.Bits(), routes[j].Network.Bits() + if iBits != jBits { + return iBits < jBits } - return iPrefix < jPrefix + iAddr, jAddr := routes[i].Network.Addr(), routes[j].Network.Addr() + if iAddr != jAddr { + return iAddr.Less(jAddr) + } + return routes[i].NetID < routes[j].NetID }) - resolvedDomains := c.recorder.GetResolvedDomainsStates() - - return prepareRouteSelectionDetails(routes, resolvedDomains), nil - + return routes } func prepareRouteSelectionDetails(routes []*selectRoute, resolvedDomains map[domain.Domain]peer.ResolvedDomainInfo) *RoutesSelectionDetails { var routeSelection []RoutesSelectionInfo for _, r := range routes { - domainList := make([]DomainInfo, 0) + // resolvedDomains is keyed by the resolved domain (e.g. api.ipify.org), + // not the configured pattern (e.g. *.ipify.org). Group entries whose + // ParentDomain belongs to this route, mirroring the daemon logic in + // client/server/network.go. + domainList := make([]DomainInfo, 0, len(r.Domains)) + domainIndex := make(map[domain.Domain]int, len(r.Domains)) for _, d := range r.Domains { - domainResp := DomainInfo{ - Domain: d.SafeString(), - } - - if info, exists := resolvedDomains[d]; exists { - var ipStrings []string - for _, prefix := range info.Prefixes { - ipStrings = append(ipStrings, prefix.Addr().String()) - } - domainResp.ResolvedIPs = strings.Join(ipStrings, ", ") - } - domainList = append(domainList, domainResp) + domainIndex[d] = len(domainList) + domainList = append(domainList, DomainInfo{Domain: d.SafeString()}) } + + for _, info := range resolvedDomains { + idx, ok := domainIndex[info.ParentDomain] + if !ok { + continue + } + for _, prefix := range info.Prefixes { + domainList[idx].AddResolvedIP(prefix.Addr().String()) + } + } + domainDetails := DomainDetails{items: domainList} + + // For dynamic (DNS) routes, expose the joined domain pattern as the + // Network value so it matches the peer.routes entries on the Swift + // side (mirroring the Android bridge in client/android/client.go). + netStr := r.Network.String() + if len(r.Domains) > 0 { + netStr = r.Domains.SafeString() + } + for _, extra := range r.extraNetworks { + netStr += ", " + extra.String() + } + routeSelection = append(routeSelection, RoutesSelectionInfo{ ID: r.NetID, - Network: r.Network.String(), + Network: netStr, Domains: &domainDetails, Selected: r.Selected, + Status: r.Status, }) } @@ -442,59 +690,95 @@ func prepareRouteSelectionDetails(routes []*selectRoute, resolvedDomains map[dom } func (c *Client) SelectRoute(id string) error { - if c.connectClient == nil { + _, connectClient := c.stateSnapshot() + if connectClient == nil { return fmt.Errorf("not connected") } - engine := c.connectClient.Engine() + engine := connectClient.Engine() if engine == nil { return fmt.Errorf("not connected") } routeManager := engine.GetRouteManager() - routeSelector := routeManager.GetRouteSelector() if id == "All" { log.Debugf("select all routes") - routeSelector.SelectAllRoutes() - } else { - log.Debugf("select route with id: %s", id) - routes := toNetIDs([]string{id}) - if err := routeSelector.SelectRoutes(routes, true, maps.Keys(routeManager.GetClientRoutesWithNetID())); err != nil { - log.Debugf("error when selecting routes: %s", err) - return fmt.Errorf("select routes: %w", err) - } + routeManager.SelectAllRoutes() + return nil } - routeManager.TriggerSelection(routeManager.GetClientRoutes()) - return nil + log.Debugf("select route with id: %s", id) + if err := routeManager.SelectRoutes(toNetIDs([]string{id}), true); err != nil { + log.Debugf("error when selecting routes: %s", err) + return err + } + return nil } func (c *Client) DeselectRoute(id string) error { - if c.connectClient == nil { + _, connectClient := c.stateSnapshot() + if connectClient == nil { return fmt.Errorf("not connected") } - engine := c.connectClient.Engine() + engine := connectClient.Engine() if engine == nil { return fmt.Errorf("not connected") } routeManager := engine.GetRouteManager() - routeSelector := routeManager.GetRouteSelector() if id == "All" { log.Debugf("deselect all routes") - routeSelector.DeselectAllRoutes() - } else { - log.Debugf("deselect route with id: %s", id) - routes := toNetIDs([]string{id}) - if err := routeSelector.DeselectRoutes(routes, maps.Keys(routeManager.GetClientRoutesWithNetID())); err != nil { - log.Debugf("error when deselecting routes: %s", err) - return fmt.Errorf("deselect routes: %w", err) - } + routeManager.DeselectAllRoutes() + return nil + } + + log.Debugf("deselect route with id: %s", id) + if err := routeManager.DeselectRoutes(toNetIDs([]string{id})); err != nil { + log.Debugf("error when deselecting routes: %s", err) + return err } - routeManager.TriggerSelection(routeManager.GetClientRoutes()) return nil } +func (c *Client) startRun(cancel context.CancelFunc) (chan struct{}, error) { + c.stateMu.Lock() + defer c.stateMu.Unlock() + + if c.runDone != nil { + return nil, errClientAlreadyRunning + } + + done := make(chan struct{}) + c.runDone = done + c.ctxCancel = cancel + return done, nil +} + +func (c *Client) finishRun(done chan struct{}) { + c.stateMu.Lock() + c.connectClient = nil + c.config = nil + c.runDone = nil + c.ctxCancel = nil + c.stateMu.Unlock() + + close(done) +} + +func (c *Client) setState(cfg *profilemanager.Config, cc *internal.ConnectClient) { + c.stateMu.Lock() + c.config = cfg + c.connectClient = cc + c.stateMu.Unlock() +} + +// stateSnapshot returns the current config and ConnectClient under the lock. +func (c *Client) stateSnapshot() (*profilemanager.Config, *internal.ConnectClient) { + c.stateMu.RLock() + defer c.stateMu.RUnlock() + return c.config, c.connectClient +} + func formatDuration(d time.Duration) string { ds := d.String() dotIndex := strings.Index(ds, ".") diff --git a/client/ios/NetBirdSDK/connection_listener.go b/client/ios/NetBirdSDK/connection_listener.go new file mode 100644 index 000000000..d792537ba --- /dev/null +++ b/client/ios/NetBirdSDK/connection_listener.go @@ -0,0 +1,43 @@ +//go:build ios + +package NetBirdSDK + +import ( + "github.com/netbirdio/netbird/client/internal/peer" +) + +// Client state values, re-exported as basic constants so gomobile emits them +// into the generated bindings. They mirror peer.ClientState*: append-only, +// never reorder. +const ( + ClientStateDisconnected = int(peer.ClientStateDisconnected) + ClientStateConnected = int(peer.ClientStateConnected) + ClientStateConnecting = int(peer.ClientStateConnecting) + ClientStateDisconnecting = int(peer.ClientStateDisconnecting) + ClientStateNoNetwork = int(peer.ClientStateNoNetwork) +) + +// ConnectionListener export internal Listener for mobile. +// +// It intentionally lacks OnStateChanged for now: adding a method to a gomobile +// interface breaks every Swift implementation, so the iOS app keeps building +// against the legacy per-state callbacks. A follow-up will extend it together +// with the app. +type ConnectionListener interface { + OnConnected() + OnDisconnected() + OnConnecting() + OnDisconnecting() + OnAddressChanged(string, string) + OnPeersListChanged(int) +} + +// connectionListenerAdapter adapts the gomobile-facing ConnectionListener to +// peer.Listener. +type connectionListenerAdapter struct { + ConnectionListener +} + +// OnStateChanged is dropped on iOS until the app adopts the state callback; +// the legacy per-state callbacks continue to fire. +func (a connectionListenerAdapter) OnStateChanged(peer.ClientState) {} diff --git a/client/ios/NetBirdSDK/env_list.go b/client/ios/NetBirdSDK/env_list.go index 88ac97957..a3ffa0ebe 100644 --- a/client/ios/NetBirdSDK/env_list.go +++ b/client/ios/NetBirdSDK/env_list.go @@ -38,7 +38,7 @@ func GetEnvKeyNBForceRelay() string { // GetEnvKeyNBLazyConn Exports the environment variable for the iOS client func GetEnvKeyNBLazyConn() string { - return lazyconn.EnvEnableLazyConn + return lazyconn.EnvLazyConn } // GetEnvKeyNBInactivityThreshold Exports the environment variable for the iOS client diff --git a/client/ios/NetBirdSDK/login.go b/client/ios/NetBirdSDK/login.go index 9d447ef3f..0dfff620e 100644 --- a/client/ios/NetBirdSDK/login.go +++ b/client/ios/NetBirdSDK/login.go @@ -11,6 +11,8 @@ import ( "github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" + "github.com/netbirdio/netbird/client/mobile" "github.com/netbirdio/netbird/client/system" ) @@ -36,39 +38,82 @@ type URLOpener interface { // Auth can register or login new client type Auth struct { ctx context.Context + cancel context.CancelFunc config *profilemanager.Config + base *profilemanager.Config + policy *mdm.Policy cfgPath string } -// NewAuth instantiate Auth struct and validate the management URL -func NewAuth(cfgPath string, mgmURL string) (*Auth, error) { - inputCfg := profilemanager.ConfigInput{ - ManagementURL: mgmURL, +// NewAuth instantiate Auth struct and validate the management URL. +// Auth is constructed under the active MDM policy: the policy is overlaid on +// the resolved config so the login runs against the enforced values, while +// the persisted config keeps the caller-supplied ones; a caller-supplied +// management URL is ignored while MDM manages that key. A nil fetcher +// disables MDM enforcement. +func NewAuth(cfgPath string, mgmURL string, fetcher PolicyFetcher) (*Auth, error) { + policy := loaderFor(fetcher).Load() + inputCfg := profilemanager.ConfigInput{ConfigPath: cfgPath} + if _, managed := policy.GetString(mdm.KeyManagementURL); !managed { + inputCfg.ManagementURL = mgmURL } - cfg, err := profilemanager.CreateInMemoryConfig(inputCfg) + // Load the existing config when a config file is already present so an + // interactive re-login reuses the peer's persisted WireGuard private key + // (and thus its identity) instead of generating a fresh one. Generating a + // new key registers a brand-new peer on the management server on every + // re-auth (named after the fallback hostname). Only fall back to a fresh + // in-memory config for the first-time login when no config file exists yet. + // DirectUpdateOrCreateConfig uses non-atomic writes so it also works inside + // the tvOS App Group sandbox where atomic temp-file+rename is blocked. + var cfg *profilemanager.Config + var err error + if cfgPath != "" { + cfg, err = profilemanager.DirectUpdateOrCreateConfig(inputCfg) + } else { + cfg, err = profilemanager.CreateInMemoryConfig(inputCfg) + } if err != nil { return nil, err } + a := &Auth{policy: policy, cfgPath: cfgPath} + if err := a.setBaseConfig(cfg); err != nil { + return nil, err + } - return &Auth{ - ctx: context.Background(), - config: cfg, - cfgPath: cfgPath, - }, nil + // Use a cancellable context so Stop() can abort an in-progress interactive + // login. The PKCE flow's WaitToken blocks (and keeps its loopback HTTP server + // bound to a port) until the OAuth callback arrives or the flow expires; + // cancelling the context unblocks WaitToken, which then shuts that server down + // and frees the port for the next login attempt. iOS runs login in the main-app + // process (decoupled from the network extension), so without this the server + // lingers after the user dismisses the browser and the next connect stalls + // trying to bind the same port. + a.ctx, a.cancel = context.WithCancel(context.Background()) + return a, nil } // NewAuthWithConfig instantiate Auth based on existing config func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config) *Auth { + ctx, cancel := context.WithCancel(ctx) return &Auth{ ctx: ctx, + cancel: cancel, config: config, } } -// SaveConfigIfSSOSupported test the connectivity with the management server by retrieving the server device flow info. -// If it returns a flow info than save the configuration and return true. If it gets a codes.NotFound, it means that SSO -// is not supported and returns false without saving the configuration. For other errors return false. +// Stop aborts an in-progress interactive login started via Login/LoginWithDeviceName. +// It cancels the auth context, which unblocks the PKCE WaitToken and shuts down its +// loopback HTTP server, freeing the redirect port. Safe to call multiple times and +// safe to call when no login is running. +func (a *Auth) Stop() { + if a.cancel != nil { + a.cancel() + } +} + +// SaveConfigIfSSOSupported reports whether the management server supports SSO; the config is already persisted by NewAuth. func (a *Auth) SaveConfigIfSSOSupported(listener SSOListener) { if listener == nil { log.Errorf("SaveConfigIfSSOSupported: listener is nil") @@ -96,17 +141,10 @@ func (a *Auth) saveConfigIfSSOSupported() (bool, error) { return false, fmt.Errorf("failed to check SSO support: %v", err) } - if !supportsSSO { - return false, nil - } - - // Use DirectWriteOutConfig to avoid atomic file operations (temp file + rename) - // which are blocked by the tvOS sandbox in App Group containers - err = profilemanager.DirectWriteOutConfig(a.cfgPath, a.config) - return true, err + return supportsSSO, nil } -// LoginWithSetupKeyAndSaveConfig test the connectivity with the management server with the setup key. +// LoginWithSetupKeyAndSaveConfig registers the peer with the setup key; the config is already persisted by NewAuth. func (a *Auth) LoginWithSetupKeyAndSaveConfig(resultListener ErrListener, setupKey string, deviceName string) { if resultListener == nil { log.Errorf("LoginWithSetupKeyAndSaveConfig: resultListener is nil") @@ -135,10 +173,7 @@ func (a *Auth) loginWithSetupKeyAndSaveConfig(setupKey string, deviceName string if err != nil { return fmt.Errorf("login failed: %v", err) } - - // Use DirectWriteOutConfig to avoid atomic file operations (temp file + rename) - // which are blocked by the tvOS sandbox in App Group containers - return profilemanager.DirectWriteOutConfig(a.cfgPath, a.config) + return nil } // LoginSync performs a synchronous login check without UI interaction @@ -183,17 +218,36 @@ func (a *Auth) Login(resultListener ErrListener, urlOpener URLOpener, forceDevic // LoginWithDeviceName performs interactive login with device authentication support // The deviceName parameter allows specifying a custom device name (required for tvOS) func (a *Auth) LoginWithDeviceName(resultListener ErrListener, urlOpener URLOpener, forceDeviceAuth bool, deviceName string) { + a.startLogin(resultListener, urlOpener, forceDeviceAuth, deviceName, false) +} + +// LoginInteractive performs the same interactive login as LoginWithDeviceName but skips the +// IsLoginRequired() pre-flight and goes straight to the browser / device-code flow. +// +// IsLoginRequired() is itself a full Login RPC against the management server, so when the +// caller has ALREADY established that login is required it is a pure duplicate. On iOS the +// main app decides to show the browser based on its own isLoginRequired() check and then +// calls straight into this method, so re-asking the server would add another Login RPC to +// every interactive login. +// +// Use LoginWithDeviceName when the auth state is unknown and a silent (browser-less) login +// must still be possible; use this when the browser is going to be shown regardless. +func (a *Auth) LoginInteractive(resultListener ErrListener, urlOpener URLOpener, forceDeviceAuth bool, deviceName string) { + a.startLogin(resultListener, urlOpener, forceDeviceAuth, deviceName, true) +} + +func (a *Auth) startLogin(resultListener ErrListener, urlOpener URLOpener, forceDeviceAuth bool, deviceName string, skipLoginCheck bool) { if resultListener == nil { - log.Errorf("LoginWithDeviceName: resultListener is nil") + log.Errorf("startLogin: resultListener is nil") return } if urlOpener == nil { - log.Errorf("LoginWithDeviceName: urlOpener is nil") + log.Errorf("startLogin: urlOpener is nil") resultListener.OnError(fmt.Errorf("urlOpener is nil")) return } go func() { - err := a.login(urlOpener, forceDeviceAuth, deviceName) + err := a.login(urlOpener, forceDeviceAuth, deviceName, skipLoginCheck) if err != nil { resultListener.OnError(err) } else { @@ -202,7 +256,7 @@ func (a *Auth) LoginWithDeviceName(resultListener ErrListener, urlOpener URLOpen }() } -func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName string) error { +func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName string, skipLoginCheck bool) error { // Create context with device name if provided ctx := a.ctx if deviceName != "" { @@ -216,19 +270,24 @@ func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName strin } defer authClient.Close() - // check if we need to generate JWT token - needsLogin, err := authClient.IsLoginRequired(ctx) - if err != nil { - return fmt.Errorf("failed to check login requirement: %v", err) + // check if we need to generate JWT token (skipped when the caller already knows) + needsLogin := true + if !skipLoginCheck { + needsLogin, err = authClient.IsLoginRequired(ctx) + if err != nil { + return fmt.Errorf("failed to check login requirement: %v", err) + } } jwtToken := "" + email := "" if needsLogin { tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, forceDeviceAuth) if err != nil { return fmt.Errorf("interactive sso login failed: %v", err) } jwtToken = tokenInfo.GetTokenToUse() + email = tokenInfo.Email } err, isAuthError := authClient.Login(ctx, "", jwtToken) @@ -240,16 +299,11 @@ func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName strin return fmt.Errorf("login failed: %v", err) } - // Save the config before notifying success to ensure persistence completes - // before the callback potentially triggers teardown on the Swift side. - // Note: This differs from Android which doesn't save config after login. - // On iOS/tvOS, we save here because: - // 1. The config may have been modified during login (e.g., new tokens) - // 2. On tvOS, the Network Extension context may be the only place with - // write permissions to the App Group container - if a.cfgPath != "" { - if err := profilemanager.DirectWriteOutConfig(a.cfgPath, a.config); err != nil { - log.Warnf("failed to save config after login: %v", err) + // Stored after Login, not before: a rejected token must not leave a hint + // pointing at an account that cannot be used. + if email != "" && a.cfgPath != "" { + if err := mobile.WriteProfileEmail(a.cfgPath, email); err != nil { + log.Warnf("failed to store profile account email: %v", err) } } @@ -259,10 +313,24 @@ func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName strin return nil } +// profileLoginHint returns the stored account email for the profile at cfgPath, +// so a re-login targets the account the profile already belongs to instead of +// whatever session the shared browser cookie jar happens to hold. +// +// An empty hint is deliberate, not a fallback: a fresh profile leaves the +// choice to the IdP. Switching accounts is done by switching or removing +// profiles, not by logging out — logout keeps the email. +func profileLoginHint(cfgPath string) string { + if cfgPath == "" { + return "" + } + return mobile.ReadProfileEmail(cfgPath) +} + const authInfoRequestTimeout = 30 * time.Second func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, forceDeviceAuth bool) (*auth.TokenInfo, error) { - oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, forceDeviceAuth) + oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, forceDeviceAuth, profileLoginHint(a.cfgPath)) if err != nil { return nil, fmt.Errorf("failed to get OAuth flow: %v", err) } @@ -289,23 +357,44 @@ func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener return &tokenInfo, nil } -// GetConfigJSON returns the current config as a JSON string. -// This can be used by the caller to persist the config via alternative storage -// mechanisms (e.g., UserDefaults on tvOS where file writes are blocked). +// GetConfigJSON returns the config without the MDM overlay as JSON, for persisting it outside the config file (tvOS). func (a *Auth) GetConfigJSON() (string, error) { - if a.config == nil { + cfg := a.base + if cfg == nil { + cfg = a.config + } + if cfg == nil { return "", fmt.Errorf("no config available") } - return profilemanager.ConfigToJSON(a.config) + return profilemanager.ConfigToJSON(cfg) } -// SetConfigFromJSON loads config from a JSON string. -// This can be used to restore config from alternative storage mechanisms. +// SetConfigFromJSON replaces the config from JSON; the MDM overlay is applied on top for the login. func (a *Auth) SetConfigFromJSON(jsonStr string) error { cfg, err := profilemanager.ConfigFromJSON(jsonStr) if err != nil { return err } - a.config = cfg + return a.setBaseConfig(cfg) +} + +func (a *Auth) setBaseConfig(base *profilemanager.Config) error { + overlaid, err := copyConfig(base) + if err != nil { + return err + } + if a.policy != nil { + overlaid.ApplyMDMPolicy(a.policy) + } + a.base = base + a.config = overlaid return nil } + +func copyConfig(cfg *profilemanager.Config) (*profilemanager.Config, error) { + raw, err := profilemanager.ConfigToJSON(cfg) + if err != nil { + return nil, err + } + return profilemanager.ConfigFromJSON(raw) +} diff --git a/client/ios/NetBirdSDK/mdm.go b/client/ios/NetBirdSDK/mdm.go new file mode 100644 index 000000000..93a31916c --- /dev/null +++ b/client/ios/NetBirdSDK/mdm.go @@ -0,0 +1,66 @@ +//go:build ios + +package NetBirdSDK + +import ( + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" +) + +// PolicyFetcher is implemented by the native layer to return the current +// managed configuration as a JSON-encoded object string; "" means no MDM +// source is present. +type PolicyFetcher interface { + FetchJSON() string +} + +type mdmSource struct { + loader *mdm.Loader + detector *mdm.ChangeDetector +} + +// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on +// this Client; passing nil disables MDM enforcement. +func (c *Client) SetMDMPolicyFetcher(p PolicyFetcher) { + loader := loaderFor(p) + c.mdmSource.Store(&mdmSource{loader: loader, detector: mdm.NewChangeDetector(loader)}) +} + +// HasMDMPolicyChanged re-reads the managed configuration and reports whether +// it changed since the last observation; call it from the native OS-change +// notification and restart the engine only on true. +func (c *Client) HasMDMPolicyChanged() bool { + src := c.mdmSource.Load() + if src == nil { + return false + } + return src.detector.Changed() +} + +// GetRestrictionsJSON returns the UI enforcement snapshot derived from the +// active MDM policy, in the JSON shape shared with the desktop frontend. +func (c *Client) GetRestrictionsJSON() (string, error) { + return mdm.BuildRestrictions(c.mdmLoader().Load()).JSON() +} + +func (c *Client) applyMDMOverlay(cfg *profilemanager.Config) { + loader := c.mdmLoader() + if cfg == nil || loader == nil { + return + } + cfg.ApplyMDMPolicy(loader.Load()) +} + +func (c *Client) mdmLoader() *mdm.Loader { + if src := c.mdmSource.Load(); src != nil { + return src.loader + } + return nil +} + +func loaderFor(p PolicyFetcher) *mdm.Loader { + if p == nil { + return mdm.NewJSONLoader(nil) + } + return mdm.NewJSONLoader(p.FetchJSON) +} diff --git a/client/ios/NetBirdSDK/peer_notifier.go b/client/ios/NetBirdSDK/peer_notifier.go index 9b00568be..025cd94cd 100644 --- a/client/ios/NetBirdSDK/peer_notifier.go +++ b/client/ios/NetBirdSDK/peer_notifier.go @@ -5,6 +5,7 @@ package NetBirdSDK // PeerInfo describe information about the peers. It designed for the UI usage type PeerInfo struct { IP string + IPv6 string FQDN string LocalIceCandidateEndpoint string RemoteIceCandidateEndpoint string @@ -23,6 +24,11 @@ type PeerInfo struct { Routes RoutesDetails } +// GetIPv6 returns the IPv6 address of the peer +func (p PeerInfo) GetIPv6() string { + return p.IPv6 +} + // GetRoutes return with RouteDetails func (p PeerInfo) GetRouteDetails() *RoutesDetails { return &p.Routes @@ -57,6 +63,7 @@ type StatusDetails struct { items []PeerInfo fqdn string ip string + ipv6 string } // Add new PeerInfo to the collection @@ -100,3 +107,8 @@ func (array StatusDetails) GetFQDN() string { func (array StatusDetails) GetIP() string { return array.ip } + +// GetIPv6 return with the IPv6 of the local peer +func (array StatusDetails) GetIPv6() string { + return array.ipv6 +} diff --git a/client/ios/NetBirdSDK/preferences.go b/client/ios/NetBirdSDK/preferences.go index c26a6decd..5297920a3 100644 --- a/client/ios/NetBirdSDK/preferences.go +++ b/client/ios/NetBirdSDK/preferences.go @@ -3,12 +3,16 @@ package NetBirdSDK import ( + "sync/atomic" + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" ) // Preferences export a subset of the internal config for gomobile type Preferences struct { configInput profilemanager.ConfigInput + mdmLoader atomic.Pointer[mdm.Loader] } // NewPreferences create new Preferences instance @@ -17,11 +21,30 @@ func NewPreferences(configPath string, stateFilePath string) *Preferences { ConfigPath: configPath, StateFilePath: stateFilePath, } - return &Preferences{ci} + return &Preferences{configInput: ci} +} + +// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on +// this Preferences instance; passing nil disables MDM enforcement. +func (p *Preferences) SetMDMPolicyFetcher(f PolicyFetcher) { + p.mdmLoader.Store(loaderFor(f)) +} + +// GetRestrictionsJSON returns the UI enforcement snapshot derived from the +// active MDM policy, in the JSON shape shared with the desktop frontend. +func (p *Preferences) GetRestrictionsJSON() (string, error) { + return mdm.BuildRestrictions(p.policy()).JSON() +} + +func (p *Preferences) policy() *mdm.Policy { + return p.mdmLoader.Load().Load() } // GetManagementURL read url from config file func (p *Preferences) GetManagementURL() (string, error) { + if v, ok := p.policy().GetString(mdm.KeyManagementURL); ok { + return mdm.CanonicalURL(v), nil + } if p.configInput.ManagementURL != "" { return p.configInput.ManagementURL, nil } @@ -30,7 +53,7 @@ func (p *Preferences) GetManagementURL() (string, error) { if err != nil { return "", err } - return cfg.ManagementURL.String(), err + return cfg.ManagementURL.String(), nil } // SetManagementURL store the given url and wait for commit @@ -56,17 +79,21 @@ func (p *Preferences) SetAdminURL(url string) { p.configInput.AdminURL = url } -// GetPreSharedKey read preshared key from config file -func (p *Preferences) GetPreSharedKey() (string, error) { +// HasPreSharedKey reports whether a pre-shared key is staged, persisted, or +// enforced by MDM; the key itself is never handed to the native layer. +func (p *Preferences) HasPreSharedKey() (bool, error) { + if _, ok := p.policy().GetString(mdm.KeyPreSharedKey); ok { + return true, nil + } if p.configInput.PreSharedKey != nil { - return *p.configInput.PreSharedKey, nil + return *p.configInput.PreSharedKey != "", nil } cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) if err != nil { - return "", err + return false, err } - return cfg.PreSharedKey, err + return cfg.PreSharedKey != "", nil } // SetPreSharedKey store the given key and wait for commit @@ -81,6 +108,9 @@ func (p *Preferences) SetRosenpassEnabled(enabled bool) { // GetRosenpassEnabled read rosenpass enabled from config file func (p *Preferences) GetRosenpassEnabled() (bool, error) { + if v, ok := p.policy().GetBool(mdm.KeyRosenpassEnabled); ok { + return v, nil + } if p.configInput.RosenpassEnabled != nil { return *p.configInput.RosenpassEnabled, nil } @@ -99,6 +129,9 @@ func (p *Preferences) SetRosenpassPermissive(permissive bool) { // GetRosenpassPermissive read rosenpass permissive from config file func (p *Preferences) GetRosenpassPermissive() (bool, error) { + if v, ok := p.policy().GetBool(mdm.KeyRosenpassPermissive); ok { + return v, nil + } if p.configInput.RosenpassPermissive != nil { return *p.configInput.RosenpassPermissive, nil } @@ -110,8 +143,52 @@ func (p *Preferences) GetRosenpassPermissive() (bool, error) { return cfg.RosenpassPermissive, err } +// GetDisableIPv6 reads disable IPv6 setting from config file +func (p *Preferences) GetDisableIPv6() (bool, error) { + if p.configInput.DisableIPv6 != nil { + return *p.configInput.DisableIPv6, nil + } + + cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + if err != nil { + return false, err + } + return cfg.DisableIPv6, err +} + +// SetDisableIPv6 stores the given value and waits for commit +func (p *Preferences) SetDisableIPv6(disable bool) { + p.configInput.DisableIPv6 = &disable +} + +// GetRemoteJobsAllowed reads the remote jobs opt-in from config file +func (p *Preferences) GetRemoteJobsAllowed() (bool, error) { + policy := p.policy() + if !policy.HasKey(mdm.KeyRemoteJobsAllowed) && p.configInput.RemoteJobsAllowed != nil { + return *p.configInput.RemoteJobsAllowed, nil + } + + cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + if err != nil { + return false, err + } + cfg.ApplyMDMPolicy(policy) + if cfg.RemoteJobsAllowed == nil { + return false, nil + } + return *cfg.RemoteJobsAllowed, nil +} + +// SetRemoteJobsAllowed stores the given value and waits for commit +func (p *Preferences) SetRemoteJobsAllowed(allowed bool) { + p.configInput.RemoteJobsAllowed = &allowed +} + // Commit write out the changes into config file func (p *Preferences) Commit() error { + if err := profilemanager.CheckMDMConflicts(p.configInput, p.policy()); err != nil { + return err + } // Use DirectUpdateOrCreateConfig to avoid atomic file operations (temp file + rename) // which are blocked by the tvOS sandbox in App Group containers _, err := profilemanager.DirectUpdateOrCreateConfig(p.configInput) diff --git a/client/ios/NetBirdSDK/preferences_test.go b/client/ios/NetBirdSDK/preferences_test.go index 5f75e7c9a..2382e123c 100644 --- a/client/ios/NetBirdSDK/preferences_test.go +++ b/client/ios/NetBirdSDK/preferences_test.go @@ -31,14 +31,13 @@ func TestPreferences_DefaultValues(t *testing.T) { t.Errorf("invalid default management url: %s", defaultVar) } - var preSharedKey string - preSharedKey, err = p.GetPreSharedKey() + hasPSK, err := p.HasPreSharedKey() if err != nil { - t.Fatalf("failed to read default preshared key: %s", err) + t.Fatalf("failed to read default preshared key presence: %s", err) } - if preSharedKey != "" { - t.Errorf("invalid preshared key: %s", preSharedKey) + if hasPSK { + t.Errorf("unexpected preshared key presence on fresh config") } } @@ -69,13 +68,13 @@ func TestPreferences_ReadUncommitedValues(t *testing.T) { } p.SetPreSharedKey(exampleString) - resp, err = p.GetPreSharedKey() + hasPSK, err := p.HasPreSharedKey() if err != nil { - t.Fatalf("failed to read preshared key: %s", err) + t.Fatalf("failed to read preshared key presence: %s", err) } - if resp != exampleString { - t.Errorf("unexpected preshared key: %s", resp) + if !hasPSK { + t.Errorf("expected preshared key presence after staging one") } } @@ -114,12 +113,12 @@ func TestPreferences_Commit(t *testing.T) { t.Errorf("unexpected management url: %s", resp) } - resp, err = p.GetPreSharedKey() + hasPSK, err := p.HasPreSharedKey() if err != nil { - t.Fatalf("failed to read preshared key: %s", err) + t.Fatalf("failed to read preshared key presence: %s", err) } - if resp != examplePresharedKey { - t.Errorf("unexpected preshared key: %s", resp) + if !hasPSK { + t.Errorf("expected preshared key presence after commit") } } diff --git a/client/ios/NetBirdSDK/profile_manager.go b/client/ios/NetBirdSDK/profile_manager.go new file mode 100644 index 000000000..df962e227 --- /dev/null +++ b/client/ios/NetBirdSDK/profile_manager.go @@ -0,0 +1,144 @@ +//go:build ios + +package NetBirdSDK + +import ( + "github.com/netbirdio/netbird/client/mobile" +) + +const ( + // iOS uses a single user context per app. + iosUsername = "ios" +) + +// Profile represents a profile for gomobile. +type Profile struct { + ID string + Name string + Email string + IsActive bool +} + +// ProfileArray wraps profiles for gomobile compatibility (gomobile cannot +// bind Go slices directly). +type ProfileArray struct { + items []*Profile +} + +// Length returns the number of profiles. +func (p *ProfileArray) Length() int { + return len(p.items) +} + +// Get returns the profile at index i, or nil if out of range. +func (p *ProfileArray) Get(i int) *Profile { + if i < 0 || i >= len(p.items) { + return nil + } + return p.items[i] +} + +// ProfileManager adapts the shared mobile profile manager (client/mobile) to +// gomobile-friendly types. See that package for the on-disk layout and +// semantics. +type ProfileManager struct { + impl *mobile.ProfileManager +} + +// NewProfileManager creates a new profile manager for iOS. configDir is the +// App Group shared container path that both the app and the network extension +// can reach. +func NewProfileManager(configDir string) *ProfileManager { + return &ProfileManager{impl: mobile.NewProfileManager(configDir, iosUsername)} +} + +// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on +// this ProfileManager; passing nil disables MDM enforcement. +func (pm *ProfileManager) SetMDMPolicyFetcher(f PolicyFetcher) { + pm.impl.SetMDMLoader(loaderFor(f)) +} + +// ListProfiles returns all available profiles, including the default profile, +// with their active status set. +func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) { + profiles, err := pm.impl.ListProfiles() + if err != nil { + return nil, err + } + + items := make([]*Profile, 0, len(profiles)) + for i := range profiles { + items = append(items, fromMobileProfile(&profiles[i])) + } + return &ProfileArray{items: items}, nil +} + +// GetActiveProfile returns the currently active profile. +func (pm *ProfileManager) GetActiveProfile() (*Profile, error) { + p, err := pm.impl.GetActiveProfile() + if err != nil { + return nil, err + } + return fromMobileProfile(p), nil +} + +// SwitchProfile records the given profile ID as the active profile. The caller +// must stop the VPN tunnel before switching. +func (pm *ProfileManager) SwitchProfile(id string) error { + return pm.impl.SwitchProfile(id) +} + +// AddProfile creates a new profile with the given display name and a +// generated ID. It returns the created profile so the caller learns the ID. +func (pm *ProfileManager) AddProfile(displayName string) (*Profile, error) { + p, err := pm.impl.AddProfile(displayName) + if err != nil { + return nil, err + } + return fromMobileProfile(p), nil +} + +// RenameProfile changes the display name of the profile identified by id. The +// on-disk filename (the ID) is left unchanged. +func (pm *ProfileManager) RenameProfile(id string, newName string) error { + return pm.impl.RenameProfile(id, newName) +} + +// LogoutProfile clears authentication data for a profile, forcing a re-login. +// The management URL and other settings are preserved. +func (pm *ProfileManager) LogoutProfile(id string) error { + return pm.impl.LogoutProfile(id) +} + +// RemoveProfile deletes a profile. The default profile and the active profile +// cannot be removed. +func (pm *ProfileManager) RemoveProfile(id string) error { + return pm.impl.RemoveProfile(id) +} + +// GetConfigPath returns the config file path for the given profile ID. Swift +// should call this instead of constructing paths itself. +func (pm *ProfileManager) GetConfigPath(id string) (string, error) { + return pm.impl.GetConfigPath(id) +} + +// GetStateFilePath returns the state file path for the given profile ID. +func (pm *ProfileManager) GetStateFilePath(id string) (string, error) { + return pm.impl.GetStateFilePath(id) +} + +// GetActiveConfigPath returns the config file path for the currently active +// profile. +func (pm *ProfileManager) GetActiveConfigPath() (string, error) { + return pm.impl.GetActiveConfigPath() +} + +// GetActiveStateFilePath returns the state file path for the currently active +// profile. +func (pm *ProfileManager) GetActiveStateFilePath() (string, error) { + return pm.impl.GetActiveStateFilePath() +} + +func fromMobileProfile(p *mobile.Profile) *Profile { + return &Profile{ID: p.ID, Name: p.Name, Email: p.Email, IsActive: p.IsActive} +} diff --git a/client/ios/NetBirdSDK/routes.go b/client/ios/NetBirdSDK/routes.go index 7b84d6e1c..56af2a1ad 100644 --- a/client/ios/NetBirdSDK/routes.go +++ b/client/ios/NetBirdSDK/routes.go @@ -20,6 +20,7 @@ type RoutesSelectionInfo struct { Network string Domains *DomainDetails Selected bool + Status string } type DomainCollection interface { @@ -34,7 +35,34 @@ type DomainDetails struct { type DomainInfo struct { Domain string - ResolvedIPs string + resolvedIPs ResolvedIPs +} + +func (d *DomainInfo) AddResolvedIP(ipAddress string) { + d.resolvedIPs.Add(ipAddress) +} + +func (d *DomainInfo) GetResolvedIPs() *ResolvedIPs { + return &d.resolvedIPs +} + +type ResolvedIPs struct { + items []string +} + +func (r *ResolvedIPs) Add(ipAddress string) { + r.items = append(r.items, ipAddress) +} + +func (r *ResolvedIPs) Get(i int) string { + if i < 0 || i >= len(r.items) { + return "" + } + return r.items[i] +} + +func (r *ResolvedIPs) Size() int { + return len(r.items) } // Add new PeerInfo to the collection diff --git a/client/ios/NetBirdSDK/version.go b/client/ios/NetBirdSDK/version.go new file mode 100644 index 000000000..606ad18e2 --- /dev/null +++ b/client/ios/NetBirdSDK/version.go @@ -0,0 +1,12 @@ +//go:build ios + +package NetBirdSDK + +import "github.com/netbirdio/netbird/version" + +// GoClientVersion returns the NetBird Go client version that was baked into +// the framework at compile time via +// -ldflags "-X github.com/netbirdio/netbird/version.version=". +func GoClientVersion() string { + return version.NetbirdVersion() +} diff --git a/client/jobexec/executor.go b/client/jobexec/executor.go index e29cc8840..7c730f757 100644 --- a/client/jobexec/executor.go +++ b/client/jobexec/executor.go @@ -28,7 +28,11 @@ func NewExecutor() *Executor { return &Executor{} } -func (e *Executor) BundleJob(ctx context.Context, debugBundleDependencies debug.GeneratorDependencies, params debug.BundleConfig, waitForDuration time.Duration, mgmURL string) (string, error) { +func (e *Executor) BundleJob(ctx context.Context, debugBundleDependencies debug.GeneratorDependencies, params debug.BundleConfig, waitForDuration time.Duration, mgmURL, uploadURL string) (string, error) { + if uploadURL == "" { + uploadURL = types.DefaultBundleURL + } + if waitForDuration > MaxBundleWaitTime { log.Warnf("bundle wait time %v exceeds maximum %v, capping to maximum", waitForDuration, MaxBundleWaitTime) waitForDuration = MaxBundleWaitTime @@ -54,7 +58,7 @@ func (e *Executor) BundleJob(ctx context.Context, debugBundleDependencies debug. } }() - key, err := debug.UploadDebugBundle(ctx, types.DefaultBundleURL, mgmURL, path) + key, err := debug.UploadDebugBundle(ctx, uploadURL, mgmURL, path, false) if err != nil { log.Errorf("failed to upload debug bundle: %v", err) return "", fmt.Errorf("upload debug bundle: %w", err) diff --git a/client/mdm/canonical_loaders.go b/client/mdm/canonical_loaders.go new file mode 100644 index 000000000..64a8093c3 --- /dev/null +++ b/client/mdm/canonical_loaders.go @@ -0,0 +1,57 @@ +//go:build windows || darwin + +package mdm + +import "strings" + +// allKeys is the set of recognised MDM keys. Unknown keys in a managed +// configuration are ignored but logged. Lives in this build-tagged file +// (windows || darwin) because only desktop loaders need the +// canonicalisation table that consumes it; including it unconditionally +// would trigger the `unused` golangci-lint check on platforms that +// don't import canonical_loaders.go. +var allKeys = []string{ + KeyManagementURL, + KeyDisableUpdateSettings, + KeyDisableProfiles, + KeyDisableNetworks, + KeyDisableAdvancedView, + KeyDisableClientRoutes, + KeyDisableServerRoutes, + KeyBlockInbound, + KeyDisableMetricsCollection, + KeyAllowServerSSH, + KeyDisableAutoConnect, + KeyDisableAutostart, + KeyPreSharedKey, + KeyRosenpassEnabled, + KeyRosenpassPermissive, + KeyWireguardPort, + KeyEnableLocalMetrics, + KeyLocalMetricsAddress, + KeySplitTunnelMode, + KeySplitTunnelApps, + KeyLazyConnection, + KeyRemoteJobsAllowed, + KeyBundleUploadURL, +} + +// canonicalKey maps the lowercase form of a managed-config value name to +// its canonical mdm.Key* form. Admins commonly write PascalCase value +// names in ADMX / Group Policy ("ManagementURL"); the iOS/AppConfig and +// macOS plist conventions are camelCase ("managementURL"); both must +// resolve to the same Policy lookup. +// +// Lives in a desktop-loader-only file (build tag `windows || darwin`) +// because no other build path consumes it. Linux / FreeBSD / mobile +// builds don't ship a platform loader that reads arbitrary-case key +// names, so they don't need the canonicalisation table — and including +// the var unconditionally would trigger the `unused` golangci-lint +// check on those platforms. +var canonicalKey = func() map[string]string { + m := make(map[string]string, len(allKeys)) + for _, k := range allKeys { + m[strings.ToLower(k)] = k + } + return m +}() diff --git a/client/mdm/canonical_loaders_test.go b/client/mdm/canonical_loaders_test.go new file mode 100644 index 000000000..330a15c47 --- /dev/null +++ b/client/mdm/canonical_loaders_test.go @@ -0,0 +1,52 @@ +//go:build windows || darwin + +package mdm + +import ( + "go/ast" + "go/parser" + "go/token" + "slices" + "strconv" + "testing" +) + +// TestAllKeysCoversEveryPolicyKey guards against the drift that adding a Key* +// constant without listing it in allKeys causes: the desktop loaders resolve +// value names through canonicalKey, so an unlisted key is silently discarded as +// unknown. policy.go is parsed rather than hand-mirrored so the test cannot go +// stale in the same way. +func TestAllKeysCoversEveryPolicyKey(t *testing.T) { + file, err := parser.ParseFile(token.NewFileSet(), "policy.go", nil, 0) + if err != nil { + t.Fatalf("parse policy.go: %v", err) + } + + for _, decl := range file.Decls { + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != token.CONST { + continue + } + for _, spec := range gen.Specs { + value, ok := spec.(*ast.ValueSpec) + if !ok || len(value.Names) != 1 || len(value.Values) != 1 { + continue + } + name := value.Names[0].Name + if len(name) < 4 || name[:3] != "Key" { + continue + } + lit, ok := value.Values[0].(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + continue + } + key, err := strconv.Unquote(lit.Value) + if err != nil { + t.Fatalf("unquote %s: %v", name, err) + } + if !slices.Contains(allKeys, key) { + t.Errorf("%s (%q) is missing from allKeys, so the desktop loaders discard it as unknown", name, key) + } + } + } +} diff --git a/client/mdm/changedetector.go b/client/mdm/changedetector.go new file mode 100644 index 000000000..5c21ae355 --- /dev/null +++ b/client/mdm/changedetector.go @@ -0,0 +1,34 @@ +package mdm + +import "sync" + +// ChangeDetector tracks the last observed policy of a Loader so an +// OS-notification-driven caller can ask whether the managed configuration +// actually changed before restarting anything. +type ChangeDetector struct { + mu sync.Mutex + loader *Loader + prev *Policy +} + +// NewChangeDetector constructs a ChangeDetector seeded with the loader's +// current policy, so only a later change reports as changed. +func NewChangeDetector(loader *Loader) *ChangeDetector { + return &ChangeDetector{ + loader: loader, + prev: loader.Load(), + } +} + +// Changed re-reads the policy, logs the per-key diff, and reports whether it +// diverged from the last observation; the new snapshot becomes the baseline. +func (d *ChangeDetector) Changed() bool { + d.mu.Lock() + defer d.mu.Unlock() + curr := d.loader.Load() + if !policyChanged(d.prev, curr) { + return false + } + d.prev = curr + return true +} diff --git a/client/mdm/conflicts.go b/client/mdm/conflicts.go new file mode 100644 index 000000000..160212afb --- /dev/null +++ b/client/mdm/conflicts.go @@ -0,0 +1,116 @@ +package mdm + +import ( + "net/url" + + "github.com/netbirdio/netbird/util" +) + +// PreSharedKeyRedactedSentinel is the redaction mask returned in place of a +// real pre-shared key; an incoming value equal to it is a round-trip echo, +// never an override. +const PreSharedKeyRedactedSentinel = "**********" + +// ConflictCheck is a value-aware comparison between a single requested field +// and the corresponding MDM-enforced value. +type ConflictCheck struct { + Key string + Check func(*Policy) bool +} + +// ConflictBool builds a ConflictCheck for a boolean MDM key. +func ConflictBool(key string, p *bool) ConflictCheck { + return ConflictCheck{ + Key: key, + Check: func(pol *Policy) bool { + if p == nil { + return true + } + want, ok := pol.GetBool(key) + return ok && want == *p + }, + } +} + +// ConflictStringPtr builds a ConflictCheck for an optional string MDM key, +// where an explicit empty value is still a request to change the setting. A +// nil p means "field not set" (no override requested). +func ConflictStringPtr(key string, p *string) ConflictCheck { + return ConflictCheck{ + Key: key, + Check: func(pol *Policy) bool { + if p == nil { + return true + } + want, ok := pol.GetString(key) + return ok && want == *p + }, + } +} + +// ConflictURL builds a ConflictCheck for a URL-typed MDM key. The two sides are +// compared as the endpoints they address, not as strings: see +// util.SameServiceURL. +func ConflictURL(key, got string) ConflictCheck { + return ConflictCheck{ + Key: key, + Check: func(pol *Policy) bool { + if got == "" { + return true + } + want, ok := pol.GetString(key) + return ok && util.SameServiceURLStrings(want, got) + }, + } +} + +// ConflictInt64 builds a ConflictCheck for an integer MDM key. +func ConflictInt64(key string, p *int64) ConflictCheck { + return ConflictCheck{ + Key: key, + Check: func(pol *Policy) bool { + if p == nil { + return true + } + want, ok := pol.GetInt(key) + return ok && want == *p + }, + } +} + +// ResolveConflicts returns the names of keys whose requested value diverges +// from the policy-enforced value; keys the policy does not manage are skipped, +// a managed key without a Check counts as a conflict. +func ResolveConflicts(policy *Policy, checks []ConflictCheck) []string { + if policy.IsEmpty() { + return nil + } + var conflicts []string + for _, c := range checks { + if !policy.HasKey(c.Key) { + continue + } + if c.Check == nil || !c.Check(policy) { + conflicts = append(conflicts, c.Key) + } + } + return conflicts +} + +// CanonicalURL normalizes a service URL by appending the scheme default port +// when none is present; unparseable input is returned unchanged. +func CanonicalURL(s string) string { + u, err := url.ParseRequestURI(s) + if err != nil { + return s + } + if u.Port() == "" { + switch u.Scheme { + case "https": + u.Host += ":443" + case "http": + u.Host += ":80" + } + } + return u.String() +} diff --git a/client/mdm/conflicts_test.go b/client/mdm/conflicts_test.go new file mode 100644 index 000000000..d145ec103 --- /dev/null +++ b/client/mdm/conflicts_test.go @@ -0,0 +1,40 @@ +package mdm + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The same spellings, through the conflict check that decides whether a request +// is refused. An enforced URL restated in another spelling addresses the very +// server the policy names, so it must not be reported as a conflict. +func TestConflictURLComparesEndpoints(t *testing.T) { + policy := NewPolicy(map[string]any{KeyManagementURL: "https://mgmt.example.com"}) + require.True(t, policy.HasKey(KeyManagementURL)) + + for _, restated := range []string{ + "https://mgmt.example.com", + "https://mgmt.example.com:443", + "https://mgmt.example.com/", + "https://MGMT.example.com", + "https://mgmt.example.com:0443", + } { + conflicts := ResolveConflicts(policy, []ConflictCheck{ConflictURL(KeyManagementURL, restated)}) + assert.Empty(t, conflicts, "%q is the enforced endpoint written differently", restated) + } + + for _, diverging := range []string{ + "https://other.example.com", + "http://mgmt.example.com", + "https://mgmt.example.com:8443", + "https://mgmt.example.com/other", + } { + conflicts := ResolveConflicts(policy, []ConflictCheck{ConflictURL(KeyManagementURL, diverging)}) + assert.Equal(t, []string{KeyManagementURL}, conflicts, "%q addresses another endpoint", diverging) + } + + // An unset field is not a request to change anything. + assert.Empty(t, ResolveConflicts(policy, []ConflictCheck{ConflictURL(KeyManagementURL, "")})) +} diff --git a/client/mdm/jsonloader.go b/client/mdm/jsonloader.go new file mode 100644 index 000000000..7139b0e4f --- /dev/null +++ b/client/mdm/jsonloader.go @@ -0,0 +1,34 @@ +package mdm + +import ( + "encoding/json" + + log "github.com/sirupsen/logrus" +) + +type jsonPolicyFetcher struct { + fetch func() string +} + +// NewJSONLoader constructs a Loader whose policy source is a JSON-encoded +// object string, as produced by the mobile native layers; a nil fetch +// disables MDM enforcement. +func NewJSONLoader(fetch func() string) *Loader { + if fetch == nil { + return NewLoader(nil) + } + return NewLoader(&jsonPolicyFetcher{fetch: fetch}) +} + +func (f *jsonPolicyFetcher) Fetch() map[string]any { + raw := f.fetch() + if raw == "" { + return nil + } + var out map[string]any + if err := json.Unmarshal([]byte(raw), &out); err != nil { + log.Warnf("MDM mobile fetcher: invalid JSON payload from native: %v", err) + return nil + } + return out +} diff --git a/client/mdm/policy.go b/client/mdm/policy.go new file mode 100644 index 000000000..638fa0d80 --- /dev/null +++ b/client/mdm/policy.go @@ -0,0 +1,315 @@ +// Package mdm reads MDM-managed configuration from platform-native sources +// (plist on macOS, registry on Windows, UserDefaults on iOS, +// RestrictionsManager on Android). The returned Policy is consumed by +// profilemanager.Config.apply() as the highest-priority override layer. +// +// An empty Policy (no source present, or source present with zero keys) +// means no MDM enforcement is active and the client behaves as if the +// feature did not exist. +package mdm + +import ( + "sort" + "strconv" + "strings" + + log "github.com/sirupsen/logrus" +) + +// Well-known policy keys. Names mirror the corresponding ConfigInput Go field +// names (lowerCamelCase) so the daemon can map a Policy key directly to a +// configuration field. +const ( + KeyManagementURL = "managementURL" + KeyDisableUpdateSettings = "disableUpdateSettings" + KeyDisableProfiles = "disableProfiles" + KeyDisableNetworks = "disableNetworks" + // KeyDisableAdvancedView gates the advanced-view section in the + // upcoming UI revision. UI-only: NOT stored on Config, not + // applied by applyMDMPolicy, not rejectable via SetConfig. The + // daemon surfaces it through GetFeatures (tristate: present + // true / present false / absent) and the same key appears in + // GetConfigResponse.mDMManagedFields when set. + KeyDisableAdvancedView = "disableAdvancedView" + KeyDisableClientRoutes = "disableClientRoutes" + KeyDisableServerRoutes = "disableServerRoutes" + KeyBlockInbound = "blockInbound" + KeyDisableMetricsCollection = "disableMetricsCollection" + KeyAllowServerSSH = "allowServerSSH" + KeyDisableAutoConnect = "disableAutoConnect" + // KeyDisableAutostart suppresses the GUI's fresh-install + // launch-on-login default and marks the Settings toggle as + // MDM-managed. UI-only: NOT stored on Config and not applied by + // applyMDMPolicy; the GUI reads it directly and it appears in + // GetConfigResponse.mDMManagedFields when set. + KeyDisableAutostart = "disableAutostart" + KeyPreSharedKey = "preSharedKey" + KeyRosenpassEnabled = "rosenpassEnabled" + KeyRosenpassPermissive = "rosenpassPermissive" + KeyWireguardPort = "wireguardPort" + KeyEnableLocalMetrics = "enableLocalMetrics" + KeyLocalMetricsAddress = "localMetricsAddress" + + // Split tunnel is modeled as a single conceptual policy with two + // registry/plist values. KeySplitTunnelMode is the discriminator + // ("allow" or "disallow"); KeySplitTunnelApps is a comma-separated + // list of package names. The values are mutually exclusive by + // construction — only one mode can be set at a time. + KeySplitTunnelMode = "splitTunnelMode" + KeySplitTunnelApps = "splitTunnelApps" + + // KeyLazyConnection forces the lazy-connection feature on or off, overriding + // the management feature flag. Read as a bool (native bool, or on/off, + // true/false, 1/0, yes/no); absent = defer to management. + KeyLazyConnection = "lazyConnection" + + // KeyRemoteJobsAllowed opts the peer into management-requested remote jobs + // (e.g. debug bundles). Read as a bool; absent = defer to the local config + // (which defaults to disabled). Stored on Config as RemoteJobsAllowed. + KeyRemoteJobsAllowed = "allowRemoteJobs" + + // KeyBundleUploadURL overrides the debug-bundle upload service URL for + // remote jobs, taking precedence over the management-supplied value. Read + // as a string; must be an https URL with a host. Absent = defer to the + // management-supplied URL (or the default upload server). + KeyBundleUploadURL = "debugBundleUploadURL" +) + +// Split-tunnel mode literals (KeySplitTunnelMode values). +const ( + SplitTunnelModeAllow = "allow" + SplitTunnelModeDisallow = "disallow" +) + +// SecretKeys lists keys whose values must be redacted in logs. +var SecretKeys = map[string]struct{}{ + KeyPreSharedKey: {}, + // The upload URL can embed credentials or signed query tokens. + KeyBundleUploadURL: {}, +} + +// boolStringLiterals enumerates the textual boolean encodings the +// platform loaders may produce (Windows REG_SZ "true", iOS / Android +// managed-config booleans-as-strings, etc.). Lookup keeps GetBool flat +// (no nested switch on the string case). +var boolStringLiterals = map[string]bool{ + "true": true, + "1": true, + "yes": true, + "on": true, + "false": false, + "0": false, + "no": false, + "off": false, +} + +// Policy holds MDM-managed settings read from the platform source. A nil or +// empty Policy means no enforcement is active. +type Policy struct { + values map[string]any +} + +// NewPolicy constructs a Policy from a key→value map. Pass nil or an +// empty map to construct an empty (no-enforcement) Policy. The returned +// *Policy is always non-nil. +func NewPolicy(values map[string]any) *Policy { + if values == nil { + values = map[string]any{} + } + return &Policy{values: values} +} + +// PolicyFetcher supplies the managed configuration to a Loader. Mobile +// platforms (Android / iOS) implement it to push the OS-managed values +// into the Go runtime. On every platform a non-nil fetcher takes +// precedence over the native source, which is the test seam for the +// registry / plist loaders; a nil fetcher leaves the native source in +// charge, or disables MDM enforcement where there is none. +type PolicyFetcher interface { + Fetch() map[string]any +} + +// Loader is the DI-friendly entry point for reading the active MDM +// policy. Construct one at the daemon's lifecycle owner (Server on +// desktop, gomobile-exposed bridge on mobile) and pass it to anything +// that needs to read MDM state (the reload ticker, profilemanager's +// Config). Each callsite has the Loader handed in instead of looking +// up package-level state. +type Loader struct { + fetcher PolicyFetcher +} + +// NewLoader constructs a Loader. A non-nil fetcher takes precedence over +// the platform-native source; production desktop callers pass nil so the +// registry / plist stays authoritative. +func NewLoader(f PolicyFetcher) *Loader { + return &Loader{fetcher: f} +} + +// Load reads the platform-native MDM configuration and returns a +// Policy. Returns an empty (but non-nil) Policy when no source is +// present, the source is empty, or the platform is unsupported. +// +// Diagnostic logging differentiates the three states: +// - source absent / unsupported platform: trace log only +// - source present, zero keys: info "MDM enrolled (no managed keys)" +// - source present, N keys: info "MDM enrolled with N managed keys: [...]" +func (l *Loader) Load() *Policy { + if l == nil { + return &Policy{values: map[string]any{}} + } + values, err := l.loadPlatform() + if err != nil { + log.Tracef("MDM policy load: %v", err) + return &Policy{values: map[string]any{}} + } + if values == nil { + return &Policy{values: map[string]any{}} + } + if len(values) == 0 { + log.Info("MDM enrolled (no managed keys)") + } else { + log.Infof("MDM enrolled with %d managed key(s): %v", len(values), sortedKeys(values)) + } + return &Policy{values: values} +} + +// IsEmpty reports whether the Policy has no managed keys. +func (p *Policy) IsEmpty() bool { + return p == nil || len(p.values) == 0 +} + +// HasKey reports whether the given key is MDM-managed. +func (p *Policy) HasKey(key string) bool { + if p == nil { + return false + } + _, ok := p.values[key] + return ok +} + +// ManagedKeys returns the sorted list of managed key names. Returns an empty +// slice (not nil) on an empty Policy. +func (p *Policy) ManagedKeys() []string { + if p == nil { + return []string{} + } + return sortedKeys(p.values) +} + +// GetString returns the managed value for key coerced to string, and whether +// the key was set. A non-string value returns ("", false). +func (p *Policy) GetString(key string) (string, bool) { + if p == nil { + return "", false + } + v, ok := p.values[key] + if !ok { + return "", false + } + s, ok := v.(string) + if !ok || s == "" { + return "", false + } + return s, true +} + +// GetBool returns the managed value for key coerced to bool, and whether the +// key was set. Accepts native bool and string literals (true/false, 1/0, +// yes/no, on/off), case-insensitively and trimmed of surrounding whitespace. +func (p *Policy) GetBool(key string) (bool, bool) { + if p == nil { + return false, false + } + v, ok := p.values[key] + if !ok { + return false, false + } + switch t := v.(type) { + case bool: + return t, true + case string: + b, known := boolStringLiterals[strings.ToLower(strings.TrimSpace(t))] + return b, known + case int: + return t != 0, true + case int64: + return t != 0, true + case float64: + return t != 0, true + } + return false, false +} + +// GetInt returns the managed value for key as int64, and whether the key +// was set. Accepts native int / int64 (as produced by the Windows registry +// loader for REG_DWORD/REG_QWORD) and numeric strings (decimal). +func (p *Policy) GetInt(key string) (int64, bool) { + if p == nil { + return 0, false + } + v, ok := p.values[key] + if !ok { + return 0, false + } + switch t := v.(type) { + case int64: + return t, true + case int: + return int64(t), true + case int32: + return int64(t), true + case uint64: + return int64(t), true + case float64: + return int64(t), true + case string: + if n, err := strconv.ParseInt(t, 10, 64); err == nil { + return n, true + } + } + return 0, false +} + +// GetStringSlice returns the managed value for key as []string, and whether +// the key was set. Accepts []string, []any (of strings), and a single string +// (treated as a one-element list). +func (p *Policy) GetStringSlice(key string) ([]string, bool) { + if p == nil { + return nil, false + } + v, ok := p.values[key] + if !ok { + return nil, false + } + switch t := v.(type) { + case []string: + return append([]string(nil), t...), true + case []any: + out := make([]string, 0, len(t)) + for _, item := range t { + s, ok := item.(string) + if !ok { + return nil, false + } + out = append(out, s) + } + return out, true + case string: + return []string{t}, true + } + return nil, false +} + +// sortedKeys returns the keys of m as a deterministic, lexicographically +// sorted slice. Used internally by Policy.ManagedKeys and Loader.Load's +// diagnostic log line so callers see a stable key order across runs +// regardless of Go's randomised map iteration. +func sortedKeys(m map[string]any) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/client/mdm/policy_darwin.go b/client/mdm/policy_darwin.go new file mode 100644 index 000000000..4159f5b7e --- /dev/null +++ b/client/mdm/policy_darwin.go @@ -0,0 +1,97 @@ +//go:build darwin && !ios + +package mdm + +import ( + "errors" + "fmt" + "io/fs" + "os" + "strings" + + log "github.com/sirupsen/logrus" + "howett.net/plist" +) + +// policyPlistPath is the well-known location where macOS writes the +// device-level mandatory MDM payload for NetBird. The path is fixed by +// Apple convention: when an MDM provider (Jamf / Kandji / Mosyle / +// Intune for Mac / Workspace ONE) pushes a Configuration Profile that +// contains a com.apple.ManagedClient.preferences payload targeting the +// bundle id io.netbird.client, the OS materializes the payload here. +// +// Read-only — only the OS (root) is supposed to write this file. The +// loader sanity-checks the file mode and refuses to honour a world- +// writable plist, as a defense against tampered installs. +const policyPlistPath = "/Library/Managed Preferences/io.netbird.client.plist" + +// loadPlatform reads the MDM-managed configuration from the macOS +// managed-preferences plist at policyPlistPath, unless a fetcher was +// injected, in which case its values are returned instead. Returns: +// - (nil, nil) when the plist is absent (device not MDM-enrolled for +// NetBird, or admin has not yet pushed a payload) +// - (map, nil) with N entries when N managed values are present +// (N may be 0 — empty plist still signals enrollment to the caller) +// - (nil, err) on permission / parse / safety errors (including +// refusal to read a world-writable plist) +// +// Top-level plist keys are canonicalised case-insensitively to the +// package's internal mdm.Key* names; unknown keys are logged and +// skipped so a stray entry in the payload does not block startup. +// Native plist value types map naturally onto the Policy accessor +// expectations (GetString / GetBool / GetInt / GetStringSlice). +func (l *Loader) loadPlatform() (map[string]any, error) { + // Honour the injected fetcher when present so tests (and any + // future non-macOS MDM channel) can short-circuit the plist read + // with a scripted policy. + if l != nil && l.fetcher != nil { + return l.fetcher.Fetch(), nil + } + f, err := os.Open(policyPlistPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + // Not enrolled for NetBird. Caller treats nil as + // "no MDM source present". + //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see Loader.Load. + return nil, nil + } + return nil, fmt.Errorf("open %s: %w", policyPlistPath, err) + } + defer func() { + if closeErr := f.Close(); closeErr != nil { + log.Warnf("MDM close plist %s: %v", policyPlistPath, closeErr) + } + }() + + info, err := f.Stat() + if err != nil { + return nil, fmt.Errorf("stat %s: %w", policyPlistPath, err) + } + // World-writable plist => tampered install. Refuse rather than + // honour potentially attacker-controlled policy values. + if info.Mode().Perm()&0o002 != 0 { + return nil, fmt.Errorf("refusing to read world-writable MDM source %s (mode %o)", + policyPlistPath, info.Mode().Perm()) + } + + raw := make(map[string]any) + if err := plist.NewDecoder(f).Decode(&raw); err != nil { + return nil, fmt.Errorf("decode plist %s: %w", policyPlistPath, err) + } + + out := make(map[string]any, len(raw)) + for name, val := range raw { + // macOS / AppConfig conventions both use camelCase for managed + // preferences keys; canonicalize to the mdm.Key* form so a key + // written as "ManagementURL" (PascalCase, rare on macOS but + // possible if the admin reused an ADMX-style name) still + // resolves. + canonical, known := canonicalKey[strings.ToLower(name)] + if !known { + log.Warnf("MDM ignoring unknown plist key %s: %s", policyPlistPath, name) + continue + } + out[canonical] = val + } + return out, nil +} diff --git a/client/mdm/policy_mobile.go b/client/mdm/policy_mobile.go new file mode 100644 index 000000000..2e25a2bb5 --- /dev/null +++ b/client/mdm/policy_mobile.go @@ -0,0 +1,15 @@ +//go:build ios || android + +package mdm + +// loadPlatform reads the OS-managed configuration via the native +// PolicyFetcher injected at Loader construction. Returns +// (nil, nil) — the platform-absent sentinel that Loader.Load treats as +// "no MDM source present" — when no fetcher was provided. +func (l *Loader) loadPlatform() (map[string]any, error) { + if l == nil || l.fetcher == nil { + //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see Loader.Load. + return nil, nil + } + return l.fetcher.Fetch(), nil +} diff --git a/client/mdm/policy_other.go b/client/mdm/policy_other.go new file mode 100644 index 000000000..5d0b17cfd --- /dev/null +++ b/client/mdm/policy_other.go @@ -0,0 +1,18 @@ +//go:build !windows && !darwin && !ios && !android + +package mdm + +// loadPlatform reads the MDM policy on platforms without a native MDM +// channel (Linux, FreeBSD). When no fetcher was injected the policy is +// (nil, nil) — the platform-absent sentinel that Loader.Load treats as +// "MDM enforcement disabled". A non-nil fetcher takes precedence: it +// is the test-seam used by unit tests to inject a scripted policy +// without touching the OS, and the same hook supports any future +// non-mobile OS that grows an out-of-band MDM channel. +func (l *Loader) loadPlatform() (map[string]any, error) { + if l != nil && l.fetcher != nil { + return l.fetcher.Fetch(), nil + } + //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see Loader.Load. + return nil, nil +} diff --git a/client/mdm/policy_test.go b/client/mdm/policy_test.go new file mode 100644 index 000000000..ea467f861 --- /dev/null +++ b/client/mdm/policy_test.go @@ -0,0 +1,186 @@ +package mdm + +import ( + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPolicy_NilSafe(t *testing.T) { + var p *Policy + assert.True(t, p.IsEmpty()) + assert.False(t, p.HasKey(KeyManagementURL)) + assert.Empty(t, p.ManagedKeys()) + + _, ok := p.GetString(KeyManagementURL) + assert.False(t, ok) + _, ok = p.GetBool(KeyDisableProfiles) + assert.False(t, ok) + _, ok = p.GetStringSlice(KeySplitTunnelApps) + assert.False(t, ok) +} + +func TestPolicy_Empty(t *testing.T) { + p := NewPolicy(nil) + require.NotNil(t, p) + assert.True(t, p.IsEmpty()) + assert.False(t, p.HasKey(KeyManagementURL)) + assert.Empty(t, p.ManagedKeys()) +} + +func TestPolicy_HasKey(t *testing.T) { + p := NewPolicy(map[string]any{ + KeyManagementURL: "https://corp.example.com", + KeyDisableProfiles: true, + }) + assert.False(t, p.IsEmpty()) + assert.True(t, p.HasKey(KeyManagementURL)) + assert.True(t, p.HasKey(KeyDisableProfiles)) + assert.False(t, p.HasKey(KeyPreSharedKey)) +} + +func TestPolicy_ManagedKeysSorted(t *testing.T) { + p := NewPolicy(map[string]any{ + KeyDisableProfiles: true, + KeyManagementURL: "https://x", + KeyAllowServerSSH: false, + }) + got := p.ManagedKeys() + assert.Equal(t, []string{KeyAllowServerSSH, KeyDisableProfiles, KeyManagementURL}, got) +} + +func TestPolicy_GetString(t *testing.T) { + p := NewPolicy(map[string]any{ + KeyManagementURL: "https://corp.example.com", + KeyDisableProfiles: true, // wrong type for GetString + KeyPreSharedKey: "", // empty rejected + }) + v, ok := p.GetString(KeyManagementURL) + assert.True(t, ok) + assert.Equal(t, "https://corp.example.com", v) + + _, ok = p.GetString(KeyDisableProfiles) + assert.False(t, ok, "non-string value must not be reported as string") + + _, ok = p.GetString(KeyPreSharedKey) + assert.False(t, ok, "empty string treated as unset") + + _, ok = p.GetString("nonexistent") + assert.False(t, ok) +} + +func TestPolicy_GetBool(t *testing.T) { + cases := []struct { + name string + raw any + want bool + ok bool + }{ + {"native true", true, true, true}, + {"native false", false, false, true}, + {"string true", "true", true, true}, + {"string false", "false", false, true}, + {"string 1", "1", true, true}, + {"string 0", "0", false, true}, + {"string yes", "yes", true, true}, + {"string no", "no", false, true}, + {"string on", "on", true, true}, + {"string off", "off", false, true}, + {"mixed case On", "On", true, true}, + {"upper TRUE", "TRUE", true, true}, + {"padded yes", " yes ", true, true}, + {"int nonzero", 1, true, true}, + {"int zero", 0, false, true}, + {"int64 nonzero", int64(2), true, true}, + {"int64 zero", int64(0), false, true}, + {"string garbage", "maybe", false, false}, + {"float nonzero", 1.0, true, true}, + {"float zero", 0.0, false, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + p := NewPolicy(map[string]any{KeyDisableProfiles: c.raw}) + got, ok := p.GetBool(KeyDisableProfiles) + assert.Equal(t, c.ok, ok) + if c.ok { + assert.Equal(t, c.want, got) + } + }) + } + + _, ok := NewPolicy(nil).GetBool(KeyDisableProfiles) + assert.False(t, ok) +} + +func TestPolicy_GetStringSlice(t *testing.T) { + t.Run("native string slice", func(t *testing.T) { + p := NewPolicy(map[string]any{ + KeySplitTunnelApps: []string{"com.a", "com.b"}, + }) + got, ok := p.GetStringSlice(KeySplitTunnelApps) + assert.True(t, ok) + assert.Equal(t, []string{"com.a", "com.b"}, got) + }) + + t.Run("any slice of strings", func(t *testing.T) { + p := NewPolicy(map[string]any{ + KeySplitTunnelApps: []any{"com.a", "com.b"}, + }) + got, ok := p.GetStringSlice(KeySplitTunnelApps) + assert.True(t, ok) + assert.Equal(t, []string{"com.a", "com.b"}, got) + }) + + t.Run("single string lifts to one-element slice", func(t *testing.T) { + p := NewPolicy(map[string]any{ + KeySplitTunnelApps: "com.a", + }) + got, ok := p.GetStringSlice(KeySplitTunnelApps) + assert.True(t, ok) + assert.Equal(t, []string{"com.a"}, got) + }) + + t.Run("mixed any slice rejected", func(t *testing.T) { + p := NewPolicy(map[string]any{ + KeySplitTunnelApps: []any{"com.a", 1}, + }) + _, ok := p.GetStringSlice(KeySplitTunnelApps) + assert.False(t, ok) + }) + + t.Run("missing key", func(t *testing.T) { + p := NewPolicy(nil) + _, ok := p.GetStringSlice(KeySplitTunnelApps) + assert.False(t, ok) + }) +} + +// encoding/json decodes every JSON number into float64, so the mobile +// loaders never see int. +func TestJSONLoader_BoolFromNumber(t *testing.T) { + p := NewJSONLoader(func() string { return `{"blockInbound":1,"disableProfiles":0}` }).Load() + + got, ok := p.GetBool(KeyBlockInbound) + assert.True(t, ok) + assert.True(t, got) + + got, ok = p.GetBool(KeyDisableProfiles) + assert.True(t, ok) + assert.False(t, got) +} + +func TestLoader_NilFetcherReturnsEmpty(t *testing.T) { + // Loader.Load with no fetcher (desktop construction) must degrade + // gracefully and never return nil; on linux loadPlatform is a stub + // returning (nil, nil), and Load is expected to translate that + // into a non-nil empty Policy. + if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { + t.Skip("a nil fetcher reads the OS-managed policy on this platform") + } + p := NewLoader(nil).Load() + require.NotNil(t, p) + assert.True(t, p.IsEmpty()) + assert.Empty(t, p.ManagedKeys()) +} diff --git a/client/mdm/policy_windows.go b/client/mdm/policy_windows.go new file mode 100644 index 000000000..9363db436 --- /dev/null +++ b/client/mdm/policy_windows.go @@ -0,0 +1,115 @@ +//go:build windows + +package mdm + +import ( + "errors" + "fmt" + "strings" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows/registry" +) + +// policyRegistryPath is the well-known MDM policy registry key for NetBird. +// Admins push values here through Group Policy, Intune ADMX ingestion, an +// Intune custom Registry CSP profile, or `reg add` during MSI deployment. +// Listed in the project's docs/mdm/netbird.admx schema. +const policyRegistryPath = `Software\Policies\NetBird` + +// readRegistryValue reads a single value under policyRegistryPath and, +// on success, stores the type-coerced result in out[canonical]. Type +// coercion mirrors loadPlatformPolicy's documented mapping: +// - REG_SZ / REG_EXPAND_SZ -> string (REG_EXPAND_SZ is expanded by the API) +// - REG_DWORD / REG_QWORD -> int64 +// - REG_MULTI_SZ -> []string +// +// Unsupported value types and per-value read failures are logged at +// warn level and skipped — one malformed value must not block the +// surrounding loop. Extracted from loadPlatformPolicy to keep that +// function's cognitive complexity in check. +func readRegistryValue(k registry.Key, name, canonical string, out map[string]any) { + _, valType, err := k.GetValue(name, nil) + if err != nil { + log.Warnf("MDM stat %s\\%s: %v", policyRegistryPath, name, err) + return + } + switch valType { + case registry.SZ, registry.EXPAND_SZ: + if v, _, err := k.GetStringValue(name); err == nil { + out[canonical] = v + } else { + log.Warnf("MDM read string %s\\%s: %v", policyRegistryPath, name, err) + } + case registry.DWORD, registry.QWORD: + if v, _, err := k.GetIntegerValue(name); err == nil { + // uint64 from the registry API; Policy.GetBool / GetInt + // helpers consume int64, so narrow safely. + out[canonical] = int64(v) + } else { + log.Warnf("MDM read int %s\\%s: %v", policyRegistryPath, name, err) + } + case registry.MULTI_SZ: + if v, _, err := k.GetStringsValue(name); err == nil { + out[canonical] = v + } else { + log.Warnf("MDM read multi-string %s\\%s: %v", policyRegistryPath, name, err) + } + default: + log.Warnf("MDM ignoring unsupported registry value type %d at %s\\%s", + valType, policyRegistryPath, name) + } +} + +// loadPlatform reads the MDM-managed configuration from the Windows +// registry under HKLM\Software\Policies\NetBird, unless a fetcher was +// injected, in which case its values are returned instead. Returns: +// - (nil, nil) when the key is absent (device not MDM-enrolled for NetBird) +// - (map, nil) with N entries when N managed values are set (N may be 0) +// - (nil, err) on open / enumerate registry errors +// +// Per-value type coercion + skip-on-error is delegated to +// readRegistryValue. Unknown value names are logged and skipped so a +// malformed deployment does not block startup. +func (l *Loader) loadPlatform() (map[string]any, error) { + // Honour the injected fetcher when present so tests (and any + // future non-Windows MDM channel) can short-circuit the registry + // read with a scripted policy. + if l != nil && l.fetcher != nil { + return l.fetcher.Fetch(), nil + } + k, err := registry.OpenKey(registry.LOCAL_MACHINE, policyRegistryPath, registry.QUERY_VALUE) + if err != nil { + if errors.Is(err, registry.ErrNotExist) { + // Not enrolled. Caller treats nil as "no MDM source present". + //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see Loader.Load. + return nil, nil + } + return nil, fmt.Errorf("open %s: %w", policyRegistryPath, err) + } + defer func() { + if closeErr := k.Close(); closeErr != nil { + log.Warnf("MDM close registry key %s: %v", policyRegistryPath, closeErr) + } + }() + + names, err := k.ReadValueNames(-1) + if err != nil { + return nil, fmt.Errorf("enumerate values of %s: %w", policyRegistryPath, err) + } + + out := make(map[string]any, len(names)) + for _, name := range names { + // Canonicalize the registry value name against the known MDM key + // set so Policy.HasKey lookups (which use the canonical names) + // succeed regardless of the casing used by the admin's ADMX or + // `reg add` command. + canonical, known := canonicalKey[strings.ToLower(name)] + if !known { + log.Warnf("MDM ignoring unknown registry value %s\\%s", policyRegistryPath, name) + continue + } + readRegistryValue(k, name, canonical, out) + } + return out, nil +} diff --git a/client/mdm/restrictions.go b/client/mdm/restrictions.go new file mode 100644 index 000000000..200756b78 --- /dev/null +++ b/client/mdm/restrictions.go @@ -0,0 +1,91 @@ +package mdm + +import "encoding/json" + +// Fields carries the per-key MDM enforcement state for a UI: value-typed +// fields hold the enforced value (nil pointer = not managed), boolean +// fields report that the key is managed. +type Fields struct { + ManagementURL string `json:"managementURL"` + PreSharedKey bool `json:"preSharedKey"` + WireguardPort bool `json:"wireguardPort"` + RosenpassEnabled bool `json:"rosenpassEnabled"` + RosenpassPermissive bool `json:"rosenpassPermissive"` + DisableClientRoutes bool `json:"disableClientRoutes"` + DisableServerRoutes bool `json:"disableServerRoutes"` + AllowServerSSH *bool `json:"allowServerSSH"` + DisableAutoConnect bool `json:"disableAutoConnect"` + DisableAutostart bool `json:"disableAutostart"` + BlockInbound bool `json:"blockInbound"` + DisableMetricsCollection bool `json:"disableMetricsCollection"` + SplitTunnelMode bool `json:"splitTunnelMode"` + SplitTunnelApps bool `json:"splitTunnelApps"` + RemoteJobsAllowed bool `json:"allowRemoteJobs"` + DisableAdvancedView *bool `json:"disableAdvancedView"` +} + +// Features carries the feature gates a UI must honor. +type Features struct { + DisableProfiles bool `json:"disableProfiles"` + DisableNetworks bool `json:"disableNetworks"` + DisableUpdateSettings bool `json:"disableUpdateSettings"` +} + +// Restrictions is the UI-facing enforcement snapshot; the JSON shape is +// shared by the desktop frontend and the mobile bridges. +type Restrictions struct { + MDM Fields `json:"mdm"` + Features Features `json:"features"` +} + +// BuildRestrictions derives the UI enforcement snapshot from the active +// policy. +func BuildRestrictions(policy *Policy) Restrictions { + var r Restrictions + if policy.IsEmpty() { + return r + } + + if v, ok := policy.GetString(KeyManagementURL); ok { + r.MDM.ManagementURL = CanonicalURL(v) + } + r.MDM.PreSharedKey = policy.HasKey(KeyPreSharedKey) + r.MDM.WireguardPort = policy.HasKey(KeyWireguardPort) + r.MDM.RosenpassEnabled = policy.HasKey(KeyRosenpassEnabled) + r.MDM.RosenpassPermissive = policy.HasKey(KeyRosenpassPermissive) + r.MDM.DisableClientRoutes = policy.HasKey(KeyDisableClientRoutes) + r.MDM.DisableServerRoutes = policy.HasKey(KeyDisableServerRoutes) + r.MDM.DisableAutoConnect = policy.HasKey(KeyDisableAutoConnect) + r.MDM.DisableAutostart = policy.HasKey(KeyDisableAutostart) + r.MDM.BlockInbound = policy.HasKey(KeyBlockInbound) + r.MDM.DisableMetricsCollection = policy.HasKey(KeyDisableMetricsCollection) + r.MDM.SplitTunnelMode = policy.HasKey(KeySplitTunnelMode) + r.MDM.SplitTunnelApps = policy.HasKey(KeySplitTunnelApps) + r.MDM.RemoteJobsAllowed = policy.HasKey(KeyRemoteJobsAllowed) + if v, ok := policy.GetBool(KeyAllowServerSSH); ok { + r.MDM.AllowServerSSH = &v + } + if v, ok := policy.GetBool(KeyDisableAdvancedView); ok { + r.MDM.DisableAdvancedView = &v + } + + if v, ok := policy.GetBool(KeyDisableProfiles); ok { + r.Features.DisableProfiles = v + } + if v, ok := policy.GetBool(KeyDisableNetworks); ok { + r.Features.DisableNetworks = v + } + if v, ok := policy.GetBool(KeyDisableUpdateSettings); ok { + r.Features.DisableUpdateSettings = v + } + return r +} + +// JSON renders the snapshot in the shared UI JSON shape. +func (r Restrictions) JSON() (string, error) { + b, err := json.Marshal(r) + if err != nil { + return "", err + } + return string(b), nil +} diff --git a/client/mdm/ticker.go b/client/mdm/ticker.go new file mode 100644 index 000000000..be8fdcce7 --- /dev/null +++ b/client/mdm/ticker.go @@ -0,0 +1,135 @@ +package mdm + +import ( + "context" + "reflect" + "sort" + "time" + + log "github.com/sirupsen/logrus" +) + +// DefaultReloadInterval is the production cadence at which the desktop daemon +// re-reads the OS-native MDM policy. Picked to balance responsiveness against +// registry/plist I/O overhead. Mobile builds use OS-side notifications +// instead, hence anticipating the ticker mechanism entirely. +const DefaultReloadInterval = 1 * time.Minute + +// Ticker periodically re-reads the OS-native MDM policy via the +// injected Loader and invokes the onChange callback (supplied to Run) +// whenever the observed Policy diverges from the last observation +// (added / removed / changed keys). Launch with Run from a goroutine; +// cancel the supplied context to stop. +type Ticker struct { + interval time.Duration + loader *Loader + prev *Policy +} + +// NewTicker constructs a Ticker that will re-read the OS-native policy +// every reloadInterval once Run is called. The Loader is injected so +// the ticker doesn't depend on any package-level state — production +// passes the daemon-owned Loader, tests pass a fake Loader (built with +// a fake PolicyFetcher). +// +// The initial snapshot is populated by calling loader.Load() at +// construction time so the first tick only fires +// onChange when the policy actually changed since boot — without +// this baseline the first tick would report every currently-managed +// key as "added" and trigger a spurious engine restart. +func NewTicker(reloadInterval time.Duration, loader *Loader) *Ticker { + return &Ticker{ + interval: reloadInterval, + loader: loader, + prev: loader.Load(), + } +} + +// Run blocks until ctx is cancelled, polling the OS-native policy store at +// the configured cadence and emitting log lines + onChange callback on +// every observed diff. onChange must be non-nil. +func (t *Ticker) Run(ctx context.Context, onChange func(prev, curr *Policy) error) { + tk := time.NewTicker(t.interval) + defer tk.Stop() + log.Infof("MDM policy reload ticker started (interval=%s)", t.interval) + for { + select { + case <-ctx.Done(): + log.Info("MDM policy reload ticker stopped") + return + case <-tk.C: + curr := t.loader.Load() + if !policyChanged(t.prev, curr) { + continue + } + prev := t.prev + if err := onChange(prev, curr); err != nil { + log.Errorf("MDM policy change handler failed (retrying in 1 minute): %v", err) + continue + } + t.prev = curr + } + } +} + +// policiesEqual reports whether two Policy instances carry the same +// managed key set with identical values. Nil and empty policies +// compare equal; one-nil/one-non-empty compare not equal; otherwise +// the underlying values maps are compared with reflect.DeepEqual. +func policiesEqual(a, b *Policy) bool { + if a.IsEmpty() && b.IsEmpty() { + return true + } + if a == nil || b == nil { + return false + } + return reflect.DeepEqual(a.values, b.values) +} + +// diffPolicies returns the keys added in curr, removed from prev, and +// whose values changed between prev and curr. Each slice is sorted +// lexicographically for stable log output; value differences are +// determined with reflect.DeepEqual. +func diffPolicies(prev, curr *Policy) (added, removed, changed []string) { + prevKVs := mapOf(prev) + currKVs := mapOf(curr) + for k := range currKVs { + if _, ok := prevKVs[k]; !ok { + added = append(added, k) + } else if !reflect.DeepEqual(prevKVs[k], currKVs[k]) { + changed = append(changed, k) + } + } + for k := range prevKVs { + if _, ok := currKVs[k]; !ok { + removed = append(removed, k) + } + } + sort.Strings(added) + sort.Strings(removed) + sort.Strings(changed) + return added, removed, changed +} + +// mapOf returns a (possibly empty, never nil) copy of the underlying +// values map of a Policy so callers outside this package can compare +// keys/values across the type boundary. Returns an empty map on nil p. +func mapOf(p *Policy) map[string]any { + if p == nil { + return map[string]any{} + } + out := make(map[string]any, len(p.values)) + for k, v := range p.values { + out[k] = v + } + return out +} + +func policyChanged(prev, curr *Policy) bool { + if policiesEqual(prev, curr) { + return false + } + added, removed, changed := diffPolicies(prev, curr) + log.Infof("MDM policy changed: added=%v removed=%v changed=%v", added, removed, changed) + return true +} diff --git a/client/mdm/ticker_test.go b/client/mdm/ticker_test.go new file mode 100644 index 000000000..29e48e728 --- /dev/null +++ b/client/mdm/ticker_test.go @@ -0,0 +1,109 @@ +package mdm + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// testReloadInterval for speeding up the ticker cadence under `go test` +const testReloadInterval = 1 * time.Second + +// fakePolicyFetcher implements PolicyFetcher returning a scripted +// policy map. Goroutine-safe so the test can mutate the script while +// the ticker is observing it. +type fakePolicyFetcher struct { + mu sync.Mutex + values map[string]any +} + +func (f *fakePolicyFetcher) Fetch() map[string]any { + f.mu.Lock() + defer f.mu.Unlock() + if f.values == nil { + return nil + } + out := make(map[string]any, len(f.values)) + for k, v := range f.values { + out[k] = v + } + return out +} + +func (f *fakePolicyFetcher) set(values map[string]any) { + f.mu.Lock() + defer f.mu.Unlock() + f.values = values +} + +func TestTicker_FiresOnChangeWithDelta(t *testing.T) { + fetcher := &fakePolicyFetcher{} // initial observation: empty (no enforcement) + loader := NewLoader(fetcher) + + type change struct{ prev, curr *Policy } + changes := make(chan change, 1) + tk := NewTicker(testReloadInterval, loader) + require.Equal(t, testReloadInterval, tk.interval) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + tk.Run(ctx, func(prev, curr *Policy) error { + select { + case changes <- change{prev, curr}: + default: + } + return nil + }) + close(done) + }() + // Stop Run and wait for it to exit before returning, so the test + // goroutine doesn't race the still-running ticker. + defer func() { cancel(); <-done }() + + // Flip the OS-observed policy from empty to one managed key. The + // next tick must detect the diff and invoke onChange. + fetcher.set(map[string]any{KeyManagementURL: "https://mdm.example.com:443"}) + + select { + case c := <-changes: + assert.True(t, c.prev.IsEmpty(), "prev should be the initial empty policy") + assert.True(t, c.curr.HasKey(KeyManagementURL), "curr should carry the newly-pushed managed key") + case <-time.After(5 * time.Second): + t.Fatal("onChange not invoked within 5s; ticker should fire every 1s under test") + } +} + +func TestTicker_NoCallbackWhenPolicyUnchanged(t *testing.T) { + fetcher := &fakePolicyFetcher{values: map[string]any{KeyBlockInbound: true}} + loader := NewLoader(fetcher) + + fired := make(chan struct{}, 1) + tk := NewTicker(testReloadInterval, loader) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + tk.Run(ctx, func(_, _ *Policy) error { + select { + case fired <- struct{}{}: + default: + } + return nil + }) + close(done) + }() + defer func() { cancel(); <-done }() + + // Over ~2 ticks at the 1s test cadence the policy never changes, + // so the diff guard must suppress the callback entirely. + select { + case <-fired: + t.Fatal("onChange fired despite an unchanged policy") + case <-time.After(2500 * time.Millisecond): + } +} diff --git a/client/mobile/profile_manager.go b/client/mobile/profile_manager.go new file mode 100644 index 000000000..348b7253b --- /dev/null +++ b/client/mobile/profile_manager.go @@ -0,0 +1,336 @@ +// Package mobile holds the profile manager implementation shared by the +// Android and iOS gomobile bindings. The platform packages (client/android, +// client/ios/NetBirdSDK) only adapt this API to gomobile-friendly types. +package mobile + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" +) + +const ( + // Config filename of the default profile, stored at the configDir root. + // Both platforms use netbird.cfg (matching the desktop netbird.cfg rather + // than default.json); the app-side path constants must match. + defaultConfigFilename = "netbird.cfg" + // Subdirectory of configDir holding non-default profiles. + profilesSubdir = "profiles" +) + +// ErrProfilesDisabled marks a profile mutation rejected by MDM policy. +var ErrProfilesDisabled = errors.New("profile management is disabled by MDM policy") + +/* + +/ ← app-writable config root +├── netbird.cfg ← Default profile config +├── netbird.account.json ← Default profile account email (see profile_state.go) +├── state.json ← Default profile state +├── active_profile.json ← Active profile tracker (JSON with ID + Username) +└── profiles/ ← Subdirectory for non-default profiles + ├── 4c5f5c8198c3989cffb5b5394f5a7ae0.json ← Profile config (filename = ID) + ├── 4c5f5c8198c3989cffb5b5394f5a7ae0.state.json ← Profile state + ├── 4c5f5c8198c3989cffb5b5394f5a7ae0.account.json ← Profile account email + └── 4c5f5c8198c3989cffb5b5394f5a7ae0.prefs.json ← Profile preferences +*/ + +// Profile is the platform-independent profile view handed to the bindings. +type Profile struct { + ID string + Name string + // Email is the account this profile last logged in with, "" if it never + // completed an SSO login. Kept across logouts; cleared when the profile is + // removed. See profile_state.go. + Email string + IsActive bool +} + +// ProfileManager manages profiles for the mobile platforms. It wraps the +// internal profilemanager.ServiceManager with mobile-specific path handling. +// All profile identity is ID-based; the human-readable name lives inside the +// profile config's Name field. +type ProfileManager struct { + configDir string + username string + serviceMgr *profilemanager.ServiceManager + mdmLoader *mdm.Loader +} + +// NewProfileManager creates a profile manager rooted at configDir, the +// app-writable directory that every process of the app can reach. username is +// the platform's fixed single-user context (a non-empty username is required +// by ServiceManager for non-default profiles). +func NewProfileManager(configDir, username string) *ProfileManager { + // The default profile is stored in the root configDir, not under profiles/. + defaultConfigPath := filepath.Join(configDir, defaultConfigFilename) + + // Point the package globals at the app-provided directory, overriding the + // desktop defaults set in profilemanager's init(). + profilemanager.DefaultConfigPathDir = configDir + profilemanager.DefaultConfigPath = defaultConfigPath + profilemanager.ActiveProfileStatePath = filepath.Join(configDir, "active_profile.json") + + // Non-default profiles live in the profiles/ subdirectory. Passing it + // explicitly avoids touching the global config-dir override. + profilesDir := filepath.Join(configDir, profilesSubdir) + serviceMgr := profilemanager.NewServiceManagerWithProfilesDir(defaultConfigPath, profilesDir) + + return &ProfileManager{ + configDir: configDir, + username: username, + serviceMgr: serviceMgr, + } +} + +// ListProfiles returns all available profiles, including the default profile, +// with their active status set. +func (pm *ProfileManager) ListProfiles() ([]Profile, error) { + internalProfiles, err := pm.serviceMgr.ListProfiles(pm.username) + if err != nil { + return nil, fmt.Errorf("list profiles: %w", err) + } + + profiles := make([]Profile, 0, len(internalProfiles)) + for _, p := range internalProfiles { + profiles = append(profiles, Profile{ + ID: p.ID.String(), + Name: p.Name, + Email: pm.profileEmail(p.ID.String()), + IsActive: p.IsActive, + }) + } + + return profiles, nil +} + +// GetActiveProfile returns the currently active profile, resolving its ID to +// the full profile so callers get the real display name. +func (pm *ProfileManager) GetActiveProfile() (*Profile, error) { + activeState, err := pm.serviceMgr.GetActiveProfileState() + if err != nil { + return nil, fmt.Errorf("get active profile: %w", err) + } + + prof, err := pm.serviceMgr.ResolveProfile(activeState.ID.String(), pm.username) + if err != nil { + return nil, fmt.Errorf("resolve active profile %q: %w", activeState.ID, err) + } + return &Profile{ + ID: prof.ID.String(), + Name: prof.Name, + Email: pm.profileEmail(prof.ID.String()), + IsActive: true, + }, nil +} + +// SwitchProfile records the given profile ID as the active profile. The caller +// must stop the VPN tunnel before switching. +func (pm *ProfileManager) SwitchProfile(id string) error { + if err := pm.checkProfilesAllowed(); err != nil { + return err + } + if err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{ + ID: profilemanager.ID(id), + Username: pm.username, + }); err != nil { + return fmt.Errorf("switch profile: %w", err) + } + + log.Infof("switched to profile: %s", id) + return nil +} + +// AddProfile creates a new profile with the given display name and a +// generated ID. It returns the created profile so the caller learns the ID. +func (pm *ProfileManager) AddProfile(displayName string) (*Profile, error) { + if err := pm.checkProfilesAllowed(); err != nil { + return nil, err + } + profile, err := pm.serviceMgr.AddProfile(displayName, pm.username) + if err != nil { + return nil, fmt.Errorf("add profile: %w", err) + } + + log.Infof("created new profile: %s", profile.ID) + return &Profile{ID: profile.ID.String(), Name: profile.Name, IsActive: false}, nil +} + +// RenameProfile changes the display name of the profile identified by id. The +// on-disk filename (the ID) is left unchanged. +func (pm *ProfileManager) RenameProfile(id string, newName string) error { + if err := pm.checkProfilesAllowed(); err != nil { + return err + } + if err := pm.serviceMgr.RenameProfile(profilemanager.ID(id), pm.username, newName); err != nil { + return fmt.Errorf("rename profile: %w", err) + } + + log.Infof("renamed profile %s to %q", id, newName) + return nil +} + +// LogoutProfile clears authentication data for a profile by removing its +// private key and SSH key from the config, forcing a re-login. The management +// URL and other settings are preserved. +func (pm *ProfileManager) LogoutProfile(id string) error { + if err := pm.checkProfileLogoutAllowed(id); err != nil { + return err + } + configPath, err := pm.getProfileConfigPath(id) + if err != nil { + return err + } + + if _, err := os.Stat(configPath); os.IsNotExist(err) { + return fmt.Errorf("profile %q does not exist", id) + } + + config, err := profilemanager.ReadConfig(configPath) + if err != nil { + return fmt.Errorf("read profile config: %w", err) + } + + config.PrivateKey = "" + config.SSHKey = "" + + if err := profilemanager.WriteOutConfig(configPath, config); err != nil { + return fmt.Errorf("save config: %w", err) + } + + // The stored account email is kept on purpose, matching the desktop and CLI + // logout semantics: the next login passes it as the login_hint so the IdP + // preselects the account. Removing the profile is what deletes it. + log.Infof("logged out from profile: %s", id) + return nil +} + +// RemoveProfile deletes a profile. The default profile and the active profile +// cannot be removed. +func (pm *ProfileManager) RemoveProfile(id string) error { + if err := pm.checkProfilesAllowed(); err != nil { + return err + } + configPath, err := pm.getProfileConfigPath(id) + if err != nil { + return err + } + + if err := pm.serviceMgr.RemoveProfile(profilemanager.ID(id), pm.username); err != nil { + return fmt.Errorf("remove profile: %w", err) + } + + // The account file is this package's, not the ServiceManager's, so it must + // go here. The default profile has a fixed filename, so a recreated one + // would otherwise inherit the deleted profile's email as its login_hint. + // Not fatal: the profile itself is gone. + if err := removeProfileEmail(configPath); err != nil { + log.Warnf("failed to remove stored account email for profile %s: %v", id, err) + } + + log.Infof("removed profile: %s", id) + return nil +} + +// ProfilePrefs returns the namespaced per-profile preference store of the +// profile identified by id. +func (pm *ProfileManager) ProfilePrefs(id string) (*profilemanager.Prefs, error) { + prefs, err := pm.serviceMgr.ProfilePrefs(profilemanager.ID(id), pm.username) + if err != nil { + return nil, fmt.Errorf("resolve profile prefs: %w", err) + } + return prefs, nil +} + +// GetConfigPath returns the config file path for the given profile ID. The +// platform code should call this instead of constructing paths itself. +func (pm *ProfileManager) GetConfigPath(id string) (string, error) { + return pm.getProfileConfigPath(id) +} + +// GetStateFilePath returns the state file path for the given profile ID. +func (pm *ProfileManager) GetStateFilePath(id string) (string, error) { + if id == "" || id == profilemanager.DefaultProfileName { + return filepath.Join(pm.configDir, "state.json"), nil + } + + if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) { + return "", fmt.Errorf("id %q is not valid", id) + } + + profilesDir := filepath.Join(pm.configDir, profilesSubdir) + return filepath.Join(profilesDir, id+".state.json"), nil +} + +// GetActiveConfigPath returns the config file path for the currently active +// profile. +func (pm *ProfileManager) GetActiveConfigPath() (string, error) { + activeProfile, err := pm.GetActiveProfile() + if err != nil { + return "", fmt.Errorf("get active profile: %w", err) + } + return pm.GetConfigPath(activeProfile.ID) +} + +// GetActiveStateFilePath returns the state file path for the currently active +// profile. +func (pm *ProfileManager) GetActiveStateFilePath() (string, error) { + activeProfile, err := pm.GetActiveProfile() + if err != nil { + return "", fmt.Errorf("get active profile: %w", err) + } + return pm.GetStateFilePath(activeProfile.ID) +} + +// SetMDMLoader registers the MDM policy source consulted before profile +// mutations; a nil loader disables enforcement. +func (pm *ProfileManager) SetMDMLoader(loader *mdm.Loader) { + pm.mdmLoader = loader +} + +func (pm *ProfileManager) checkProfilesAllowed() error { + if v, ok := pm.mdmLoader.Load().GetBool(mdm.KeyDisableProfiles); ok && v { + return ErrProfilesDisabled + } + return nil +} + +func (pm *ProfileManager) checkProfileLogoutAllowed(id string) error { + active, err := pm.serviceMgr.GetActiveProfileState() + if err == nil && active.ID.String() == id { + return nil + } + return pm.checkProfilesAllowed() +} + +// profileEmail returns the account email recorded for a profile. Display-only, +// so an unresolvable path degrades to "" rather than an error. +func (pm *ProfileManager) profileEmail(id string) string { + configPath, err := pm.getProfileConfigPath(id) + if err != nil { + return "" + } + return ReadProfileEmail(configPath) +} + +// getProfileConfigPath returns the config file path for a profile ID. The +// default profile uses netbird.cfg in the root configDir; other profiles use +// .json in the profiles/ subdirectory. +func (pm *ProfileManager) getProfileConfigPath(id string) (string, error) { + if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) { + return "", fmt.Errorf("id %q is not valid", id) + } + + if id == profilemanager.DefaultProfileName { + return filepath.Join(pm.configDir, defaultConfigFilename), nil + } + + profilesDir := filepath.Join(pm.configDir, profilesSubdir) + return filepath.Join(profilesDir, id+".json"), nil +} diff --git a/client/mobile/profile_manager_mdm_test.go b/client/mobile/profile_manager_mdm_test.go new file mode 100644 index 000000000..305becac3 --- /dev/null +++ b/client/mobile/profile_manager_mdm_test.go @@ -0,0 +1,83 @@ +package mobile + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" +) + +type fakeFetcher struct{ values map[string]any } + +func (f *fakeFetcher) Fetch() map[string]any { return f.values } + +func newTestProfileManager(t *testing.T) *ProfileManager { + t.Helper() + origDir := profilemanager.DefaultConfigPathDir + origPath := profilemanager.DefaultConfigPath + origActive := profilemanager.ActiveProfileStatePath + t.Cleanup(func() { + profilemanager.DefaultConfigPathDir = origDir + profilemanager.DefaultConfigPath = origPath + profilemanager.ActiveProfileStatePath = origActive + }) + + configDir := t.TempDir() + pm := NewProfileManager(configDir, "mobile") + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: filepath.Join(configDir, defaultConfigFilename), + }) + require.NoError(t, err) + return pm +} + +func privateKeyOf(t *testing.T, pm *ProfileManager, id string) string { + t.Helper() + path, err := pm.getProfileConfigPath(id) + require.NoError(t, err) + raw, err := os.ReadFile(path) + require.NoError(t, err) + var cfg struct{ PrivateKey string } + require.NoError(t, json.Unmarshal(raw, &cfg)) + return cfg.PrivateKey +} + +func TestLogoutProfile_DisableProfiles(t *testing.T) { + pm := newTestProfileManager(t) + other, err := pm.AddProfile("work") + require.NoError(t, err) + require.NoError(t, pm.SwitchProfile(profilemanager.DefaultProfileName)) + require.NotEmpty(t, privateKeyOf(t, pm, profilemanager.DefaultProfileName)) + require.NotEmpty(t, privateKeyOf(t, pm, other.ID)) + + pm.SetMDMLoader(mdm.NewLoader(&fakeFetcher{values: map[string]any{ + mdm.KeyDisableProfiles: true, + }})) + + err = pm.LogoutProfile(other.ID) + assert.ErrorIs(t, err, ErrProfilesDisabled) + assert.NotEmpty(t, privateKeyOf(t, pm, other.ID)) + + require.NoError(t, pm.LogoutProfile(profilemanager.DefaultProfileName)) + assert.Empty(t, privateKeyOf(t, pm, profilemanager.DefaultProfileName)) +} + +func TestLogoutProfile_ProfilesAllowed(t *testing.T) { + pm := newTestProfileManager(t) + other, err := pm.AddProfile("work") + require.NoError(t, err) + require.NoError(t, pm.SwitchProfile(profilemanager.DefaultProfileName)) + + pm.SetMDMLoader(mdm.NewLoader(&fakeFetcher{values: map[string]any{ + mdm.KeyDisableProfiles: false, + }})) + + require.NoError(t, pm.LogoutProfile(other.ID)) + assert.Empty(t, privateKeyOf(t, pm, other.ID)) +} diff --git a/client/mobile/profile_state.go b/client/mobile/profile_state.go new file mode 100644 index 000000000..ad05801f8 --- /dev/null +++ b/client/mobile/profile_state.go @@ -0,0 +1,110 @@ +package mobile + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/util" +) + +const ( + // profileAccountSuffix names the file holding the profile's account email. + // Deliberately not ".state.json", which desktop uses for the same data: + // there the email and the engine's state manager live in different + // directories, but on mobile both resolve under configDir, so sharing the + // name would have the two overwrite each other — the state manager rewrites + // the whole file from its own keys (see statemanager.Manager.PersistState), + // and this package's writer does the same in reverse. + profileAccountSuffix = ".account.json" +) + +// profileAccountPathFor derives the account file path from a profile's config +// path: netbird.cfg -> netbird.account.json, .json -> .account.json. +// +// Deriving from the config path rather than resolving the active profile keeps +// the write on the profile the login actually ran for: login flows run in a +// goroutine, so the active profile can change under a flow already in flight. +func profileAccountPathFor(configPath string) (string, error) { + if configPath == "" { + return "", fmt.Errorf("empty config path") + } + + base := filepath.Base(configPath) + stem := strings.TrimSuffix(base, filepath.Ext(base)) + if stem == "" || stem == "." { + return "", fmt.Errorf("config path %q has no filename stem", configPath) + } + + return filepath.Join(filepath.Dir(configPath), stem+profileAccountSuffix), nil +} + +// ReadProfileEmail returns the account email stored for the profile whose +// config lives at configPath. A missing or unreadable file yields "", which +// leaves the account choice to the IdP. +func ReadProfileEmail(configPath string) string { + accountPath, err := profileAccountPathFor(configPath) + if err != nil { + log.Debugf("no profile account path for login hint: %v", err) + return "" + } + + var state profilemanager.ProfileState + if _, err := util.ReadJson(accountPath, &state); err != nil { + if !os.IsNotExist(err) { + log.Debugf("failed to read profile account for login hint: %v", err) + } + return "" + } + + return state.Email +} + +// WriteProfileEmail records the account email for the profile whose config +// lives at configPath, so later logins can pass it as an OIDC login_hint. An +// empty email is ignored rather than blanking what is already stored. +func WriteProfileEmail(configPath string, email string) error { + if email == "" { + return nil + } + + accountPath, err := profileAccountPathFor(configPath) + if err != nil { + return fmt.Errorf("resolve profile account path: %w", err) + } + + // DirectWriteJson, not the atomic writers: those create a temp file and + // rename it over the target, which the tvOS App Group sandbox blocks. It is + // the same reason the config next to this file goes through + // DirectWriteOutConfig. The file is rewritten whole from one key, so losing + // atomicity costs nothing beyond a torn write on a crash mid-write, which + // reads back as "no email" and is recovered by the next login. + state := profilemanager.ProfileState{Email: email} + if err := util.DirectWriteJson(context.Background(), accountPath, state); err != nil { + return fmt.Errorf("write profile account: %w", err) + } + + return nil +} + +// removeProfileEmail drops the stored account email. Called on profile removal, +// not on logout: a logged-out profile keeps its email so the next login passes +// it as the login_hint, matching the desktop and CLI semantics. Mirrors the +// desktop UI's RemoveProfileState call. +func removeProfileEmail(configPath string) error { + accountPath, err := profileAccountPathFor(configPath) + if err != nil { + return fmt.Errorf("resolve profile account path: %w", err) + } + + if err := os.Remove(accountPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove profile account: %w", err) + } + + return nil +} diff --git a/client/mobile/profile_state_test.go b/client/mobile/profile_state_test.go new file mode 100644 index 000000000..99cba15de --- /dev/null +++ b/client/mobile/profile_state_test.go @@ -0,0 +1,161 @@ +package mobile + +import ( + "os" + "path/filepath" + "testing" +) + +func TestProfileAccountPathFor(t *testing.T) { + tests := []struct { + name string + configPath string + want string + wantErr bool + }{ + { + name: "default profile", + configPath: "/data/netbird/files/netbird.cfg", + want: filepath.FromSlash("/data/netbird/files/netbird.account.json"), + }, + { + name: "id profile", + configPath: "/data/netbird/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.json", + want: filepath.FromSlash("/data/netbird/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.account.json"), + }, + { + name: "legacy name-keyed profile is handled the same way", + configPath: "/data/netbird/files/profiles/work.json", + want: filepath.FromSlash("/data/netbird/files/profiles/work.account.json"), + }, + { + name: "empty path is rejected", + configPath: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := profileAccountPathFor(tt.configPath) + if tt.wantErr { + if err == nil { + t.Fatalf("expected an error, got path %q", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestProfileAccountPathForDefaultDoesNotCollide(t *testing.T) { + root := "/data/netbird/files" + + defaultAccount, err := profileAccountPathFor(filepath.Join(root, defaultConfigFilename)) + if err != nil { + t.Fatalf("default profile: %v", err) + } + + idAccount, err := profileAccountPathFor(filepath.Join(root, profilesSubdir, "abc123.json")) + if err != nil { + t.Fatalf("id profile: %v", err) + } + + if defaultAccount == idAccount { + t.Fatalf("default and id profile share an account file: %q", defaultAccount) + } +} + +// The account file must never land on the engine state file: on mobile both +// resolve under configDir, and the state manager rewrites the whole file from +// its own keys, so sharing a path would have the two overwrite each other. The +// expected names here mirror ProfileManager.GetStateFilePath. +func TestProfileAccountPathAvoidsEngineStateFile(t *testing.T) { + root := "/data/netbird/files" + + cases := []struct { + configPath string + engineState string + }{ + { + configPath: filepath.Join(root, defaultConfigFilename), + engineState: filepath.Join(root, "state.json"), + }, + { + configPath: filepath.Join(root, profilesSubdir, "abc123.json"), + engineState: filepath.Join(root, profilesSubdir, "abc123.state.json"), + }, + } + + for _, c := range cases { + account, err := profileAccountPathFor(c.configPath) + if err != nil { + t.Fatalf("%s: %v", c.configPath, err) + } + if account == c.engineState { + t.Errorf("account file collides with the engine state file: %q", account) + } + } +} + +func TestWriteThenReadProfileEmail(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "profiles", "abc123.json") + if err := ensureDirFor(t, configPath); err != nil { + t.Fatalf("prepare dir: %v", err) + } + + if got := ReadProfileEmail(configPath); got != "" { + t.Errorf("expected no email before a login, got %q", got) + } + + const email = "user@example.com" + if err := WriteProfileEmail(configPath, email); err != nil { + t.Fatalf("write: %v", err) + } + + if got := ReadProfileEmail(configPath); got != email { + t.Errorf("got %q, want %q", got, email) + } + + if err := removeProfileEmail(configPath); err != nil { + t.Fatalf("remove: %v", err) + } + if got := ReadProfileEmail(configPath); got != "" { + t.Errorf("expected no email after removal, got %q", got) + } + + // Removal may run on a never-logged-in profile, so a second remove must pass. + if err := removeProfileEmail(configPath); err != nil { + t.Fatalf("second remove should be a no-op: %v", err) + } +} + +func TestWriteProfileEmailIgnoresEmpty(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "profiles", "abc123.json") + if err := ensureDirFor(t, configPath); err != nil { + t.Fatalf("prepare dir: %v", err) + } + + const email = "user@example.com" + if err := WriteProfileEmail(configPath, email); err != nil { + t.Fatalf("write: %v", err) + } + if err := WriteProfileEmail(configPath, ""); err != nil { + t.Fatalf("write empty: %v", err) + } + + if got := ReadProfileEmail(configPath); got != email { + t.Errorf("empty write clobbered the stored email: got %q, want %q", got, email) + } +} + +func ensureDirFor(t *testing.T, path string) error { + t.Helper() + return os.MkdirAll(filepath.Dir(path), 0o700) +} diff --git a/client/net/fwmark.go b/client/net/fwmark.go new file mode 100644 index 000000000..b526feee4 --- /dev/null +++ b/client/net/fwmark.go @@ -0,0 +1,110 @@ +package net + +import ( + "fmt" + "os" + "strconv" + "strings" + + log "github.com/sirupsen/logrus" +) + +const ( + // envFwmarkBase overrides the base of the fwmark range. Container network + // plugins, CNIs and other VPNs claim bits of the mark space for themselves, + // and a rule of theirs matching one of our bits acts on our traffic, so + // hosts running such software may need to move the range out of the way. + envFwmarkBase = "NB_FWMARK_BASE" + + // defaultFwmarkBase is the base of the fwmark range used when the + // environment does not override it. + defaultFwmarkBase uint32 = 0x1BD00 + + // fwmarkOffsetMask is the part of a mark that identifies the individual mark + // within the range, so the base occupies everything above it. + fwmarkOffsetMask uint32 = 0xFF +) + +// Offsets of the individual marks within the range. +const ( + offsetControlPlane uint32 = 0x00 + offsetDataPlaneIn uint32 = 0x10 + offsetDataPlaneOut uint32 = 0x11 + offsetRedirected uint32 = 0x20 + offsetMasquerade uint32 = 0x21 + offsetMasqueradeReturn uint32 = 0x22 + offsetDataPlaneLower uint32 = 0x10 + offsetDataPlaneUpper uint32 = fwmarkOffsetMask +) + +var ( + fwmarkBase = loadFwmarkBase() + + // ControlPlaneMark is the fwmark value used to mark packets that should not be routed through the NetBird interface to + // avoid routing loops. + // This includes all control plane traffic (mgmt, signal, flows), relay, ICE/stun/turn and everything that is emitted by the wireguard socket. + // It doesn't collide with the other marks, as the others are used for data plane traffic only. + ControlPlaneMark = fwmarkBase | offsetControlPlane + + // DataPlaneMarkLower is the lowest value for the data plane range + DataPlaneMarkLower = fwmarkBase | offsetDataPlaneLower + // DataPlaneMarkUpper is the highest value for the data plane range + DataPlaneMarkUpper = fwmarkBase | offsetDataPlaneUpper + + // DataPlaneMarkIn is the mark for inbound data plane traffic. + DataPlaneMarkIn = fwmarkBase | offsetDataPlaneIn + + // DataPlaneMarkOut is the mark for outbound data plane traffic. + DataPlaneMarkOut = fwmarkBase | offsetDataPlaneOut + + // PreroutingFwmarkRedirected is applied to packets that were redirected (input -> forward, e.g. by Docker or Podman) for special handling. + PreroutingFwmarkRedirected = fwmarkBase | offsetRedirected + + // PreroutingFwmarkMasquerade is applied to packets that arrive from the NetBird interface and should be masqueraded. + PreroutingFwmarkMasquerade = fwmarkBase | offsetMasquerade + + // PreroutingFwmarkMasqueradeReturn is applied to packets that will leave through the NetBird interface and should be masqueraded. + PreroutingFwmarkMasqueradeReturn = fwmarkBase | offsetMasqueradeReturn +) + +// IsDataPlaneMark determines if a fwmark is in the data plane range. +func IsDataPlaneMark(fwmark uint32) bool { + return fwmark >= DataPlaneMarkLower && fwmark <= DataPlaneMarkUpper +} + +func loadFwmarkBase() uint32 { + val := os.Getenv(envFwmarkBase) + if val == "" { + return defaultFwmarkBase + } + + base, err := parseFwmarkBase(val) + if err != nil { + log.Warnf("failed to parse %s=%q, using the default range: %v", envFwmarkBase, val, err) + return defaultFwmarkBase + } + + log.Infof("using fwmark range %#x-%#x from %s", base, base|fwmarkOffsetMask, envFwmarkBase) + return base +} + +// parseFwmarkBase reads a mark range base. The low byte of a mark identifies the +// individual mark within the range, so a base has to leave it free. +func parseFwmarkBase(val string) (uint32, error) { + val = strings.TrimSpace(val) + + base, err := strconv.ParseUint(val, 0, 32) + if err != nil { + return 0, fmt.Errorf("not a 32 bit number: %w", err) + } + + if base == 0 { + return 0, fmt.Errorf("base must not be zero") + } + + if uint32(base)&fwmarkOffsetMask != 0 { + return 0, fmt.Errorf("base %#x must leave the low byte free", base) + } + + return uint32(base), nil +} diff --git a/client/net/fwmark_test.go b/client/net/fwmark_test.go new file mode 100644 index 000000000..2dbebec2a --- /dev/null +++ b/client/net/fwmark_test.go @@ -0,0 +1,111 @@ +package net + +import ( + "testing" +) + +func TestParseFwmarkBase(t *testing.T) { + tests := []struct { + name string + val string + want uint32 + wantErr bool + }{ + {name: "hex", val: "0x5A000", want: 0x5A000}, + {name: "hex upper case", val: "0X5A000", want: 0x5A000}, + {name: "decimal", val: "65536", want: 65536}, + {name: "octal", val: "0o400", want: 0o400}, + {name: "surrounding space", val: " 0x5A000 ", want: 0x5A000}, + {name: "highest usable base", val: "0xFFFFFF00", want: 0xFFFFFF00}, + {name: "low byte in use", val: "0x1BD01", wantErr: true}, + {name: "zero", val: "0", wantErr: true}, + {name: "not a number", val: "wireguard", wantErr: true}, + {name: "wider than 32 bit", val: "0x1FFFFFFFF", wantErr: true}, + {name: "negative", val: "-0x100", wantErr: true}, + {name: "empty", val: "", wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := parseFwmarkBase(tc.val) + if tc.wantErr { + if err == nil { + t.Fatalf("parseFwmarkBase(%q) = %#x, want an error", tc.val, got) + } + return + } + if err != nil { + t.Fatalf("parseFwmarkBase(%q): %v", tc.val, err) + } + if got != tc.want { + t.Errorf("parseFwmarkBase(%q) = %#x, want %#x", tc.val, got, tc.want) + } + }) + } +} + +// The marks have to stay inside the range the base defines, otherwise a host +// that moved the range to dodge a collision would still emit the old values. +func TestMarksStayWithinTheRange(t *testing.T) { + lower, upper := fwmarkBase, fwmarkBase|fwmarkOffsetMask + + marks := map[string]uint32{ + "ControlPlaneMark": ControlPlaneMark, + "DataPlaneMarkLower": DataPlaneMarkLower, + "DataPlaneMarkUpper": DataPlaneMarkUpper, + "DataPlaneMarkIn": DataPlaneMarkIn, + "DataPlaneMarkOut": DataPlaneMarkOut, + "PreroutingFwmarkRedirected": PreroutingFwmarkRedirected, + "PreroutingFwmarkMasquerade": PreroutingFwmarkMasquerade, + "PreroutingFwmarkMasqueradeReturn": PreroutingFwmarkMasqueradeReturn, + } + + for name, mark := range marks { + if mark < lower || mark > upper { + t.Errorf("%s = %#x, outside the range %#x-%#x", name, mark, lower, upper) + } + } + + // the control plane mark must stay out of the data plane range, the netflow + // conntrack path tells them apart by it + if IsDataPlaneMark(ControlPlaneMark) { + t.Errorf("ControlPlaneMark %#x is inside the data plane range", ControlPlaneMark) + } + for name, mark := range map[string]uint32{ + "DataPlaneMarkIn": DataPlaneMarkIn, + "DataPlaneMarkOut": DataPlaneMarkOut, + "PreroutingFwmarkRedirected": PreroutingFwmarkRedirected, + "PreroutingFwmarkMasquerade": PreroutingFwmarkMasquerade, + "PreroutingFwmarkMasqueradeReturn": PreroutingFwmarkMasqueradeReturn, + } { + if !IsDataPlaneMark(mark) { + t.Errorf("%s = %#x is outside the data plane range %#x-%#x", name, mark, DataPlaneMarkLower, DataPlaneMarkUpper) + } + } +} + +func TestDefaultMarksAreUnchanged(t *testing.T) { + tests := map[string]struct { + got uint32 + want uint32 + }{ + "ControlPlaneMark": {ControlPlaneMark, 0x1BD00}, + "DataPlaneMarkLower": {DataPlaneMarkLower, 0x1BD10}, + "DataPlaneMarkUpper": {DataPlaneMarkUpper, 0x1BDFF}, + "DataPlaneMarkIn": {DataPlaneMarkIn, 0x1BD10}, + "DataPlaneMarkOut": {DataPlaneMarkOut, 0x1BD11}, + "PreroutingFwmarkRedirected": {PreroutingFwmarkRedirected, 0x1BD20}, + "PreroutingFwmarkMasquerade": {PreroutingFwmarkMasquerade, 0x1BD21}, + "PreroutingFwmarkMasqueradeReturn": {PreroutingFwmarkMasqueradeReturn, 0x1BD22}, + } + + if fwmarkBase != defaultFwmarkBase { + t.Skipf("%s is set, the defaults do not apply", envFwmarkBase) + } + + for name, tc := range tests { + if tc.got != tc.want { + t.Errorf("%s = %#x, want %#x", name, tc.got, tc.want) + } + } +} diff --git a/client/net/net.go b/client/net/net.go index a97de9d59..77fba36d1 100644 --- a/client/net/net.go +++ b/client/net/net.go @@ -7,41 +7,6 @@ import ( "net/netip" ) -const ( - // ControlPlaneMark is the fwmark value used to mark packets that should not be routed through the NetBird interface to - // avoid routing loops. - // This includes all control plane traffic (mgmt, signal, flows), relay, ICE/stun/turn and everything that is emitted by the wireguard socket. - // It doesn't collide with the other marks, as the others are used for data plane traffic only. - ControlPlaneMark = 0x1BD00 - - // Data plane marks (0x1BD10 - 0x1BDFF) - - // DataPlaneMarkLower is the lowest value for the data plane range - DataPlaneMarkLower = 0x1BD10 - // DataPlaneMarkUpper is the highest value for the data plane range - DataPlaneMarkUpper = 0x1BDFF - - // DataPlaneMarkIn is the mark for inbound data plane traffic. - DataPlaneMarkIn = 0x1BD10 - - // DataPlaneMarkOut is the mark for outbound data plane traffic. - DataPlaneMarkOut = 0x1BD11 - - // PreroutingFwmarkRedirected is applied to packets that are were redirected (input -> forward, e.g. by Docker or Podman) for special handling. - PreroutingFwmarkRedirected = 0x1BD20 - - // PreroutingFwmarkMasquerade is applied to packets that arrive from the NetBird interface and should be masqueraded. - PreroutingFwmarkMasquerade = 0x1BD21 - - // PreroutingFwmarkMasqueradeReturn is applied to packets that will leave through the NetBird interface and should be masqueraded. - PreroutingFwmarkMasqueradeReturn = 0x1BD22 -) - -// IsDataPlaneMark determines if a fwmark is in the data plane range (0x1BD10-0x1BDFF) -func IsDataPlaneMark(fwmark uint32) bool { - return fwmark >= DataPlaneMarkLower && fwmark <= DataPlaneMarkUpper -} - func GetLastIPFromNetwork(network netip.Prefix, fromEnd int) (netip.Addr, error) { var endIP net.IP addr := network.Addr().AsSlice() diff --git a/client/net/net_linux.go b/client/net/net_linux.go index 9e7d13702..8ed8a1944 100644 --- a/client/net/net_linux.go +++ b/client/net/net_linux.go @@ -21,15 +21,6 @@ func SetSocketMark(conn syscall.Conn) error { return setRawSocketMark(sysconn) } -// SetSocketOpt sets the SO_MARK option on the given file descriptor -func SetSocketOpt(fd int) error { - if !AdvancedRouting() { - return nil - } - - return setSocketOptInt(fd) -} - func setRawSocketMark(conn syscall.RawConn) error { var setErr error @@ -51,5 +42,5 @@ func setRawSocketMark(conn syscall.RawConn) error { } func setSocketOptInt(fd int) error { - return syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_MARK, ControlPlaneMark) + return syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_MARK, int(ControlPlaneMark)) } diff --git a/client/netbird.wxs b/client/netbird.wxs index 03221dd91..f30a7aa7e 100644 --- a/client/netbird.wxs +++ b/client/netbird.wxs @@ -18,13 +18,17 @@ - - + + + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + diff --git a/client/netevents/netevents.go b/client/netevents/netevents.go new file mode 100644 index 000000000..474cbfa22 --- /dev/null +++ b/client/netevents/netevents.go @@ -0,0 +1,173 @@ +// Package netevents owns the OS network event handling shared by the mobile +// bindings: availability changes park or wake the reconnection loops and drive +// the NoNetwork listener state, and both losing the last network and switching +// networks sweep the stale connections so their owners redial immediately. +package netevents + +import ( + "context" + "sync" + "time" + + "github.com/cenkalti/backoff/v4" + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/netevents/netstate" + "github.com/netbirdio/netbird/client/netevents/sweep" +) + +// Recorder receives the availability changes for listener state reporting. +type Recorder interface { + SetNetworkAvailable(available bool) +} + +// Manager ties the network availability state, the connection sweeper and the +// status recorder together; it outlives engine restarts. A nil *Manager is +// the valid no-events value for consumers: the read methods report +// always-online and never sweep. Only the event sources hold a real Manager, +// so the write methods do not tolerate a nil receiver. +type Manager struct { + // mu serializes availability transitions: the IsOnline check and the + // state update must be atomic, or a racing offline flip can skip the sweep + // and leave netState and the recorder disagreeing. + mu sync.Mutex + netState *netstate.State + sweeper *sweep.Sweeper + recorder Recorder +} + +// NewManager creates a Manager reporting into recorder, starting online. +func NewManager(recorder Recorder) *Manager { + return &Manager{ + netState: netstate.New(), + sweeper: sweep.New(), + recorder: recorder, + } +} + +// SetNetworkAvailable records OS-reported network availability. While +// unavailable, the reconnection loops suspend their attempts and the +// connection listener reports NoNetwork instead of Connecting; when +// availability returns, the loops resume immediately with a fresh backoff. +// Losing the last network also sweeps the registered connections: nothing can +// redial while offline, so the stale sockets would otherwise stay silently +// "connected" until their own timeouts and the client would keep reporting +// Connected with no network at all. +// +// Panics on a nil receiver: only the mobile bindings that own a Manager +// report availability. +func (m *Manager) SetNetworkAvailable(available bool) { + m.mu.Lock() + defer m.mu.Unlock() + + if !available && m.netState.IsOnline() { + m.sweeper.MarkNetworkChange() + } + m.netState.Set(available) + m.recorder.SetNetworkAvailable(available) +} + +// NotifyNetworkChange marks the management, signal and relay connections +// stale after the OS switched networks and schedules a sweep that cuts +// whatever has not redialed on the new network by then. The engine and the +// TUN device stay untouched. +// +// Panics on a nil receiver: only the mobile bindings that own a Manager +// report network changes. +func (m *Manager) NotifyNetworkChange() { + m.sweeper.MarkNetworkChange() + log.Infof("network change: connections marked stale") +} + +// IsOnline reports whether the OS reports at least one usable network. +func (m *Manager) IsOnline() bool { + if m == nil { + return true + } + return m.netState.IsOnline() +} + +// Changed returns a channel closed on the next availability transition. +func (m *Manager) Changed() <-chan struct{} { + if m == nil { + return nil + } + return m.netState.Changed() +} + +// Wait blocks while the network is offline; see netstate.State.Wait. +func (m *Manager) Wait(ctx context.Context) (bool, error) { + if m == nil { + return false, nil + } + return m.netState.Wait(ctx) +} + +// WaitSettled waits until an online verdict holds for a full settleWindow, or +// while offline until the budget runs out. Returns false when ctx is +// cancelled. The settle window exists because a disconnect often precedes the +// OS offline flag by a few milliseconds, so a fresh online verdict cannot be +// trusted immediately. A nil Manager has no events to watch: it degrades to a +// fixed budget-long sleep. +func (m *Manager) WaitSettled(ctx context.Context, budget, settleWindow time.Duration) bool { + if m == nil { + select { + case <-time.After(budget): + return true + case <-ctx.Done(): + return false + } + } + + budgetTimer := time.NewTimer(budget) + defer budgetTimer.Stop() + + settle := time.NewTimer(settleWindow) + defer settle.Stop() + + for { + // Channel first, flag second: a flip in between still fires the channel. + changedCh := m.netState.Changed() + if m.netState.IsOnline() { + select { + case <-settle.C: + return true + case <-changedCh: + case <-ctx.Done(): + return false + } + } else { + select { + case <-budgetTimer.C: + return true + case <-changedCh: + case <-ctx.Done(): + return false + } + } + if !settle.Stop() { + select { + case <-settle.C: + default: + } + } + settle.Reset(settleWindow) + } +} + +// StartDial registers an in-flight dial with the sweeper; see sweep.Sweeper.StartDial. +func (m *Manager) StartDial(ctx context.Context) *sweep.Dial { + if m == nil { + return (*sweep.Sweeper)(nil).StartDial(ctx) + } + return m.sweeper.StartDial(ctx) +} + +// QuickRetryBackoff wraps bo for a quick retry after a network change; see +// sweep.Sweeper.QuickRetryBackoff. +func (m *Manager) QuickRetryBackoff(ctx context.Context, bo backoff.BackOff) backoff.BackOff { + if m == nil { + return bo + } + return m.sweeper.QuickRetryBackoff(ctx, bo, m.netState) +} diff --git a/client/netevents/netevents_test.go b/client/netevents/netevents_test.go new file mode 100644 index 000000000..a62ddc270 --- /dev/null +++ b/client/netevents/netevents_test.go @@ -0,0 +1,34 @@ +package netevents + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +type recorderStub struct{} + +func (recorderStub) SetNetworkAvailable(bool) {} + +func TestWaitSettledAfterOutage(t *testing.T) { + const budget = 1500 * time.Millisecond + const settleWindow = 200 * time.Millisecond + const outage = 2 * settleWindow + + m := NewManager(recorderStub{}) + m.SetNetworkAvailable(false) + + start := time.Now() + go func() { + time.Sleep(outage) + m.SetNetworkAvailable(true) + }() + + ok := m.WaitSettled(context.Background(), budget, settleWindow) + elapsed := time.Since(start) + + assert.True(t, ok, "recovered network must let the caller proceed") + assert.GreaterOrEqual(t, elapsed, outage+settleWindow, "an online verdict must hold a full settle window before it is trusted") +} diff --git a/client/netevents/netstate/netstate.go b/client/netevents/netstate/netstate.go new file mode 100644 index 000000000..0d7a1268b --- /dev/null +++ b/client/netevents/netstate/netstate.go @@ -0,0 +1,110 @@ +// Package netstate tracks OS-reported network availability for the client. +// +// A State instance is owned by the platform integration (e.g. the Android or +// iOS bindings, fed from ConnectivityManager callbacks or NWPathMonitor) and +// is injected into the connection retry loops (management, signal, relay, +// peer guards and the top-level connect loop), which consult it to avoid +// burning CPU and battery on reconnect attempts while the device has no +// network at all (e.g. airplane mode), and to reset their backoff as soon as +// the network returns. +// +// Consumers hold a *State that may be nil — every non-mobile platform leaves +// it unset. The read methods are safe on a nil receiver: they report online +// and never block, so consumers behave as if this package did not exist. +package netstate + +import ( + "context" + "sync" + + log "github.com/sirupsen/logrus" +) + +// State holds the OS-reported network availability. The zero value is not +// usable; create instances with New. +type State struct { + mu sync.Mutex + online bool + changed chan struct{} +} + +// New creates a State that starts online. Platforms without network tracking +// pass a nil *State instead: the read methods treat nil as always online and +// never block, so consumers need no nil guards. +func New() *State { + return &State{ + online: true, + changed: make(chan struct{}), + } +} + +// Set records whether the OS reports any usable network. Transitions wake up +// all Wait callers immediately. Unlike the read methods, Set is not nil-safe: +// it is only for the platform owner that created the State with New. +func (s *State) Set(online bool) { + s.mu.Lock() + defer s.mu.Unlock() + if s.online == online { + return + } + s.online = online + close(s.changed) + s.changed = make(chan struct{}) + log.Infof("OS network availability changed: online=%t", online) +} + +// IsOnline reports whether the OS reports at least one usable network. On a +// nil receiver — no State injected — it reports online. +func (s *State) IsOnline() bool { + if s == nil { + return true + } + s.mu.Lock() + defer s.mu.Unlock() + return s.online +} + +// Changed returns a channel closed on the next availability transition, for +// callers that already own a select loop and cannot block in Wait. Re-read it +// after every fire: each transition installs a fresh channel. On a nil +// receiver — no State injected — it returns nil, which blocks forever in a +// select, so the caller simply never observes a transition. +func (s *State) Changed() <-chan struct{} { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + return s.changed +} + +// Wait blocks while the network is offline. It reports whether it had to +// wait, so callers can reset their backoff after an outage. It returns early +// with the context error when ctx is done. On a nil receiver — no State +// injected — it returns immediately. +func (s *State) Wait(ctx context.Context) (bool, error) { + if s == nil { + return false, nil + } + waited := false + for { + s.mu.Lock() + if s.online { + s.mu.Unlock() + return waited, nil + } + ch := s.changed + s.mu.Unlock() + + if !waited { + waited = true + log.Debugf("network is offline, pausing connection attempts") + } + + select { + case <-ctx.Done(): + return waited, ctx.Err() + case <-ch: + } + } +} diff --git a/client/netevents/netstate/netstate_test.go b/client/netevents/netstate/netstate_test.go new file mode 100644 index 000000000..ea7015761 --- /dev/null +++ b/client/netevents/netstate/netstate_test.go @@ -0,0 +1,170 @@ +package netstate + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewStateIsOnline(t *testing.T) { + assert.True(t, New().IsOnline(), "a fresh State should start online") +} + +func TestSetTogglesOnlineState(t *testing.T) { + s := New() + + s.Set(false) + assert.False(t, s.IsOnline(), "state should be offline after Set(false)") + + s.Set(true) + assert.True(t, s.IsOnline(), "state should be online after Set(true)") +} + +func TestWaitReturnsImmediatelyWhenOnline(t *testing.T) { + s := New() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + waited, err := s.Wait(ctx) + require.NoError(t, err) + assert.False(t, waited, "Wait should not block when the network is online") +} + +func TestWaitBlocksUntilOnline(t *testing.T) { + s := New() + s.Set(false) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + result := make(chan bool, 1) + go func() { + waited, err := s.Wait(ctx) + if err != nil { + result <- false + return + } + result <- waited + }() + + // Verify Wait is actually blocking while offline + select { + case <-result: + t.Fatal("Wait should block while the network is offline") + case <-time.After(100 * time.Millisecond): + } + + s.Set(true) + + select { + case waited := <-result: + assert.True(t, waited, "Wait should report that it had to wait for the network") + case <-time.After(2 * time.Second): + t.Fatal("Wait should return promptly after the network becomes available") + } +} + +func TestWaitReturnsOnContextCancel(t *testing.T) { + s := New() + s.Set(false) + + ctx, cancel := context.WithCancel(context.Background()) + + result := make(chan error, 1) + go func() { + _, err := s.Wait(ctx) + result <- err + }() + + cancel() + + select { + case err := <-result: + assert.ErrorIs(t, err, context.Canceled) + case <-time.After(2 * time.Second): + t.Fatal("Wait should return promptly after context cancellation") + } +} + +func TestWaitWakesAllWaiters(t *testing.T) { + s := New() + s.Set(false) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + const waiters = 10 + var wg sync.WaitGroup + results := make(chan bool, waiters) + for i := 0; i < waiters; i++ { + wg.Add(1) + go func() { + defer wg.Done() + waited, err := s.Wait(ctx) + if err != nil { + results <- false + return + } + results <- waited + }() + } + + time.Sleep(100 * time.Millisecond) + s.Set(true) + wg.Wait() + + close(results) + count := 0 + for waited := range results { + assert.True(t, waited, "every waiter should report that it waited") + count++ + } + assert.Equal(t, waiters, count, "all waiters should have returned") +} + +func TestNilStateReadsAreNoops(t *testing.T) { + var s *State + + assert.True(t, s.IsOnline(), "nil State should report online") + + waited, err := s.Wait(context.Background()) + require.NoError(t, err) + assert.False(t, waited, "nil State's Wait should not block") +} + +func TestConcurrentSetAndWait(t *testing.T) { + s := New() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var wg sync.WaitGroup + for i := 0; i < 4; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 100; j++ { + s.Set(j%2 == 0) + s.IsOnline() + } + }() + } + for i := 0; i < 4; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 100; j++ { + if _, err := s.Wait(ctx); err != nil { + return + } + } + }() + } + + wg.Wait() +} diff --git a/client/netevents/sweep/quick_retry.go b/client/netevents/sweep/quick_retry.go new file mode 100644 index 000000000..1e174b20a --- /dev/null +++ b/client/netevents/sweep/quick_retry.go @@ -0,0 +1,39 @@ +package sweep + +import ( + "time" + + "github.com/cenkalti/backoff/v4" + + "github.com/netbirdio/netbird/client/netevents/netstate" +) + +const quickRetryDelay = 200 * time.Millisecond + +type quickRetryBackoff struct { + backoff.BackOff + sweeper *Sweeper + netState *netstate.State + used bool +} + +func newQuickRetryBackoff(bo backoff.BackOff, sweeper *Sweeper, netState *netstate.State) *quickRetryBackoff { + return &quickRetryBackoff{ + BackOff: bo, + sweeper: sweeper, + netState: netState, + } +} + +func (b *quickRetryBackoff) NextBackOff() time.Duration { + if !b.used && b.sweeper.markedRecently() && b.netState.IsOnline() { + b.used = true + return quickRetryDelay + } + return b.BackOff.NextBackOff() +} + +func (b *quickRetryBackoff) Reset() { + b.used = false + b.BackOff.Reset() +} diff --git a/client/netevents/sweep/quick_retry_test.go b/client/netevents/sweep/quick_retry_test.go new file mode 100644 index 000000000..3dadd951c --- /dev/null +++ b/client/netevents/sweep/quick_retry_test.go @@ -0,0 +1,58 @@ +package sweep + +import ( + "context" + "testing" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/stretchr/testify/assert" +) + +func TestQuickRetryAfterRecentMark(t *testing.T) { + sweeper := New() + sweeper.MarkNetworkChange() + + slow := backoff.NewConstantBackOff(5 * time.Second) + bo := sweeper.QuickRetryBackoff(context.Background(), slow, nil) + + assert.Equal(t, quickRetryDelay, bo.NextBackOff(), "first retry after a mark must be quick") + assert.Equal(t, 5*time.Second, bo.NextBackOff(), "second retry must fall back to the wrapped backoff") + + bo.Reset() + assert.Equal(t, quickRetryDelay, bo.NextBackOff(), "reset must re-arm the quick retry") +} + +func TestQuickRetryWithoutMarkKeepsSpread(t *testing.T) { + sweeper := New() + + slow := backoff.NewConstantBackOff(5 * time.Second) + bo := sweeper.QuickRetryBackoff(context.Background(), slow, nil) + + assert.Equal(t, 5*time.Second, bo.NextBackOff(), "without a mark the wrapped backoff decides") + + sweeper.mu.Lock() + sweeper.lastMark = time.Now().Add(-recentMarkWindow) + sweeper.mu.Unlock() + assert.Equal(t, 5*time.Second, bo.NextBackOff(), "a stale mark must not trigger the quick retry") +} + +func TestQuickRetryNilSweeperPassthrough(t *testing.T) { + var sweeper *Sweeper + + slow := backoff.NewConstantBackOff(5 * time.Second) + bo := sweeper.QuickRetryBackoff(context.Background(), slow, nil) + + assert.Equal(t, backoff.BackOff(slow), bo, "nil sweeper must return the backoff unchanged") +} + +func TestQuickRetryHonorsContext(t *testing.T) { + sweeper := New() + sweeper.MarkNetworkChange() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + bo := sweeper.QuickRetryBackoff(ctx, backoff.NewConstantBackOff(time.Millisecond), nil) + + assert.Equal(t, backoff.Stop, bo.NextBackOff(), "cancelled context must stop the retry loop") +} diff --git a/client/netevents/sweep/sweep.go b/client/netevents/sweep/sweep.go new file mode 100644 index 000000000..52dce92be --- /dev/null +++ b/client/netevents/sweep/sweep.go @@ -0,0 +1,267 @@ +// Package sweep cuts network-bound activity when the OS switches networks: +// a sweep closes the registered connections and aborts the in-flight dials, so +// their owners redial immediately instead of waiting for the old sockets to +// time out. +// +// A nil *Sweeper disables everything: all methods are nil-safe no-ops. +package sweep + +import ( + "context" + "errors" + "net" + "sync" + "time" + + "github.com/cenkalti/backoff/v4" + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/netevents/netstate" +) + +// DefaultSweepDelay absorbs network flapping while the OS settles on a +// default network before the stale registrations are cut. +const DefaultSweepDelay = 500 * time.Millisecond + +const recentMarkWindow = 3 * time.Second + +// Config customizes a Sweeper. The zero value applies the defaults. +type Config struct { + // SweepDelay overrides DefaultSweepDelay when positive. + SweepDelay time.Duration +} + +// ErrSwept reports that a dial finished after a network change swept its +// registration. The connection is already closed; the caller must treat it +// as a failed dial and redial on the new network. +var ErrSwept = errors.New("sweep: connection swept by network change") + +// sweepID identifies one registration in a sweeper. Connections and dials +// draw from the same counter, so an id is unique across both registries. +type sweepID uint64 + +type connEntry struct { + conn net.Conn + gen uint64 +} + +// Dial tracks one dial from start to connection registration. It hands the +// dialed connection to the sweeper atomically, so a sweep can never fall +// between the dial finishing and the connection being registered. +type Dial struct { + sweeper *Sweeper + ctx context.Context + cancel context.CancelFunc + id sweepID + done bool // set by a sweep, WrapConn or Release; guarded by sweeper.mu + gen uint64 +} + +// Ctx returns the dial's context. A sweep cancels it, so a dial started on the +// old network aborts instead of waiting out its handshake timeout. +func (d *Dial) Ctx() context.Context { + return d.ctx +} + +// Release ends the dial's registration and cancels its context. It is +// idempotent and safe after WrapConn, so callers can defer it. +func (d *Dial) Release() { + s := d.sweeper + if s == nil { + return + } + + s.mu.Lock() + d.done = true + delete(s.dials, d.id) + s.mu.Unlock() + + d.cancel() +} + +// sweptConn deregisters itself from the sweeper when closed. +type sweptConn struct { + net.Conn + sweeper *Sweeper + id sweepID +} + +func (c *sweptConn) Close() error { + c.sweeper.deregister(c.id) + return c.Conn.Close() +} + +// Sweeper registers live connections and in-flight dials so the +// network-change sweep can cut everything registered before the change. +type Sweeper struct { + mu sync.Mutex + conns map[sweepID]connEntry + dials map[sweepID]*Dial + nextID sweepID + gen uint64 + timer *time.Timer + sweepDelay time.Duration + lastMark time.Time +} + +// New creates an empty sweeper with the default configuration. +func New() *Sweeper { + return NewWithConfig(Config{}) +} + +// NewWithConfig creates an empty sweeper customized by cfg. +func NewWithConfig(cfg Config) *Sweeper { + delay := cfg.SweepDelay + if delay <= 0 { + delay = DefaultSweepDelay + } + return &Sweeper{ + conns: make(map[sweepID]connEntry), + dials: make(map[sweepID]*Dial), + sweepDelay: delay, + } +} + +// StartDial registers an in-flight dial. Dial with Ctx, hand the result to +// WrapConn, and Release the dial when the attempt is over, typically deferred. +func (s *Sweeper) StartDial(ctx context.Context) *Dial { + if s == nil { + return &Dial{ctx: ctx} + } + + ctx, cancel := context.WithCancel(ctx) + d := &Dial{sweeper: s, ctx: ctx, cancel: cancel} + + s.mu.Lock() + d.id = s.nextID + s.nextID++ + d.gen = s.gen + s.dials[d.id] = d + s.mu.Unlock() + + return d +} + +// WrapConn hands conn over to the sweeper. If a sweep ran since StartDial, +// the connection belongs to the old network: it is closed and ErrSwept is +// returned. Otherwise conn is registered against the next sweep and returned +// wrapped, deregistering itself on Close. Call it once, before Release. +func (d *Dial) WrapConn(conn net.Conn) (net.Conn, error) { + s := d.sweeper + if s == nil { + return conn, nil + } + + s.mu.Lock() + if d.done { + s.mu.Unlock() + if err := conn.Close(); err != nil { + log.Debugf("swept dial close error: %v", err) + } + return nil, ErrSwept + } + d.done = true + delete(s.dials, d.id) + id := s.nextID + s.nextID++ + // The conn inherits the dial's generation: the socket was bound to the + // network that was default when the dial started, not when it finished. + s.conns[id] = connEntry{conn: conn, gen: d.gen} + s.mu.Unlock() + + return &sweptConn{Conn: conn, sweeper: s, id: id}, nil +} + +// MarkNetworkChange records that the OS switched networks: everything +// registered so far becomes stale, and a sweep is (re)scheduled after the +// configured delay to cut whatever is still stale by then. Owners that +// redialed in the meantime hold fresh-generation registrations and survive, +// so no cancellation is needed around the sweep. +func (s *Sweeper) MarkNetworkChange() { + if s == nil { + return + } + + s.mu.Lock() + s.gen++ + cutoff := s.gen + s.lastMark = time.Now() + if s.timer != nil { + s.timer.Stop() + } + s.timer = time.AfterFunc(s.sweepDelay, func() { + n := s.sweep(cutoff) + log.Infof("network change sweep: closed %d stale connections", n) + }) + s.mu.Unlock() +} + +// QuickRetryBackoff wraps bo so that after each Reset the first retry comes +// quickly when the disconnect followed a recent network change and the +// network is online. Any other failure keeps bo's spread, so the clients of +// a restarted server still scatter their reconnects. A nil sweeper returns +// bo unchanged. +func (s *Sweeper) QuickRetryBackoff(ctx context.Context, bo backoff.BackOff, netState *netstate.State) backoff.BackOff { + if s == nil { + return bo + } + return backoff.WithContext(newQuickRetryBackoff(bo, s, netState), ctx) +} + +func (s *Sweeper) markedRecently() bool { + if s == nil { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + return !s.lastMark.IsZero() && time.Since(s.lastMark) < recentMarkWindow +} + +// sweep closes the registered connections and aborts the in-flight dials +// older than cutoff, and returns how many connections it closed. A dial +// whose connection was not yet handed to WrapConn is marked, so the late +// WrapConn closes it instead of registering it. +func (s *Sweeper) sweep(cutoff uint64) int { + if s == nil { + return 0 + } + + s.mu.Lock() + var conns []net.Conn + for id, e := range s.conns { + if e.gen < cutoff { + delete(s.conns, id) + conns = append(conns, e.conn) + } + } + var dials []*Dial + for id, d := range s.dials { + if d.gen < cutoff { + d.done = true + delete(s.dials, id) + dials = append(dials, d) + } + } + s.mu.Unlock() + + if len(dials) > 0 { + log.Debugf("aborting %d in-flight dials", len(dials)) + for _, d := range dials { + d.cancel() + } + } + + for _, conn := range conns { + log.Debugf("sweeping connection %s -> %s", conn.LocalAddr(), conn.RemoteAddr()) + if err := conn.Close(); err != nil { + log.Debugf("swept connection close error: %v", err) + } + } + return len(conns) +} + +func (s *Sweeper) deregister(id sweepID) { + s.mu.Lock() + delete(s.conns, id) + s.mu.Unlock() +} diff --git a/client/netevents/sweep/sweep_test.go b/client/netevents/sweep/sweep_test.go new file mode 100644 index 000000000..c162d4c0f --- /dev/null +++ b/client/netevents/sweep/sweep_test.go @@ -0,0 +1,241 @@ +package sweep + +import ( + "context" + "math" + "net" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSweepClosesRegisteredConns(t *testing.T) { + sweeper := New() + + c1 := wrap(t, sweeper, connPair(t)) + c2 := wrap(t, sweeper, connPair(t)) + + assert.Equal(t, 2, sweeper.sweepAll(), "both live connections should be closed") + + // The wrappers must report closed now. + buf := make([]byte, 1) + _, err := c1.Read(buf) + assert.Error(t, err, "first connection should be unusable after the sweep") + _, err = c2.Read(buf) + assert.Error(t, err, "second connection should be unusable after the sweep") + + assert.Equal(t, 0, sweeper.sweepAll(), "second sweep should find nothing") +} + +func TestCloseDeregisters(t *testing.T) { + sweeper := New() + + conn := wrap(t, sweeper, connPair(t)) + require.NoError(t, conn.Close()) + + assert.Equal(t, 0, sweeper.sweepAll(), "closed connection must leave the registry") +} + +func TestCloseIsIdempotent(t *testing.T) { + sweeper := New() + + conn := wrap(t, sweeper, connPair(t)) + require.NoError(t, conn.Close()) + assert.Error(t, conn.Close(), "double close surfaces the underlying error but must not panic") +} + +func TestSweepOnlyAffectsOlderConns(t *testing.T) { + sweeper := New() + + _ = wrap(t, sweeper, connPair(t)) + assert.Equal(t, 1, sweeper.sweepAll()) + + // A connection dialed after the sweep must survive until the next one. + _ = wrap(t, sweeper, connPair(t)) + assert.Equal(t, 1, sweeper.sweepAll(), "post-sweep connection belongs to the next sweep") +} + +func TestSweepAbortsInFlightDials(t *testing.T) { + sweeper := New() + + dial := sweeper.StartDial(context.Background()) + defer dial.Release() + + sweeper.sweepAll() + + assert.ErrorIs(t, dial.Ctx().Err(), context.Canceled, "sweep must cancel the in-flight dial context") +} + +func TestReleasedDialIsNotAborted(t *testing.T) { + sweeper := New() + + // Simulate a dial that finished before the sweep. + released := sweeper.StartDial(context.Background()) + released.Release() + + // A dial still in flight during the sweep. + pending := sweeper.StartDial(context.Background()) + defer pending.Release() + + sweeper.sweepAll() + assert.ErrorIs(t, pending.Ctx().Err(), context.Canceled, "pending dial must be aborted") +} + +func TestSweepBetweenDialAndHandoffClosesConn(t *testing.T) { + sweeper := New() + + dial := sweeper.StartDial(context.Background()) + defer dial.Release() + + // The dial succeeds on the old network, then the sweep lands before the + // connection is handed over. + conn := connPair(t) + assert.Equal(t, 0, sweeper.sweepAll(), "the connection is not registered yet") + + wrapped, err := dial.WrapConn(conn) + require.ErrorIs(t, err, ErrSwept) + require.Nil(t, wrapped) + + buf := make([]byte, 1) + _, err = conn.Read(buf) + assert.Error(t, err, "the old-network connection must be closed, not leaked") + + assert.Equal(t, 0, sweeper.sweepAll(), "nothing may leak into the next sweep") +} + +func TestMarkNetworkChangeSparesFreshConns(t *testing.T) { + sweeper := NewWithConfig(Config{SweepDelay: 10 * time.Millisecond}) + + stale := wrap(t, sweeper, connPair(t)) + sweeper.MarkNetworkChange() + _ = wrap(t, sweeper, connPair(t)) + + _ = stale.SetReadDeadline(time.Now().Add(time.Second)) + buf := make([]byte, 1) + _, err := stale.Read(buf) + require.ErrorIs(t, err, net.ErrClosed, "stale connection must be closed by the delayed sweep") + + assert.Equal(t, 1, sweeper.sweepAll(), "the fresh connection must survive the stale sweep") +} + +func TestMarkNetworkChangeAbortsStaleDials(t *testing.T) { + sweeper := NewWithConfig(Config{SweepDelay: 10 * time.Millisecond}) + + stale := sweeper.StartDial(context.Background()) + defer stale.Release() + sweeper.MarkNetworkChange() + fresh := sweeper.StartDial(context.Background()) + defer fresh.Release() + + assert.Eventually(t, func() bool { + return stale.Ctx().Err() != nil + }, time.Second, 5*time.Millisecond, "stale dial must be aborted by the delayed sweep") + assert.NoError(t, fresh.Ctx().Err(), "post-mark dial must not be aborted") +} + +func TestConnInheritsDialGeneration(t *testing.T) { + sweeper := NewWithConfig(Config{SweepDelay: 20 * time.Millisecond}) + + // The dial starts before the network change but completes after it: the + // socket is bound to the old network, so the sweep must still cut it. + dial := sweeper.StartDial(context.Background()) + defer dial.Release() + sweeper.MarkNetworkChange() + + wrapped, err := dial.WrapConn(connPair(t)) + require.NoError(t, err) + + _ = wrapped.SetReadDeadline(time.Now().Add(time.Second)) + buf := make([]byte, 1) + _, err = wrapped.Read(buf) + require.ErrorIs(t, err, net.ErrClosed, "old-generation connection must be swept") +} + +func TestRepeatedMarksCoalesce(t *testing.T) { + sweeper := NewWithConfig(Config{SweepDelay: 20 * time.Millisecond}) + + first := wrap(t, sweeper, connPair(t)) + sweeper.MarkNetworkChange() + second := wrap(t, sweeper, connPair(t)) + sweeper.MarkNetworkChange() + _ = wrap(t, sweeper, connPair(t)) + + buf := make([]byte, 1) + for _, conn := range []net.Conn{first, second} { + _ = conn.SetReadDeadline(time.Now().Add(time.Second)) + _, err := conn.Read(buf) + require.ErrorIs(t, err, net.ErrClosed, "every pre-mark connection must be swept by the rescheduled sweep") + } + assert.Equal(t, 1, sweeper.sweepAll(), "only the newest-generation connection may remain") +} + +func TestNilSweeperIsNoop(t *testing.T) { + var sweeper *Sweeper + + conn := connPair(t) + dial := sweeper.StartDial(context.Background()) + defer dial.Release() + + wrapped, err := dial.WrapConn(conn) + require.NoError(t, err) + assert.Equal(t, conn, wrapped, "nil sweeper must return the conn unchanged") + assert.NoError(t, dial.Ctx().Err(), "nil sweeper must not cancel the dial context") + assert.Equal(t, 0, sweeper.sweepAll(), "nil sweeper closes nothing") +} + +// wrap registers conn with the sweeper through a completed dial. +func wrap(t *testing.T, sweeper *Sweeper, conn net.Conn) net.Conn { + t.Helper() + + dial := sweeper.StartDial(context.Background()) + defer dial.Release() + + wrapped, err := dial.WrapConn(conn) + require.NoError(t, err) + return wrapped +} + +// connPair dials a loopback TCP connection and keeps the accepted peer open +// until the test ends: a peer that closed early would make the connection +// unreadable on its own, so a read error after the sweep would prove nothing. +func connPair(t *testing.T) net.Conn { + t.Helper() + + l, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { + if err := l.Close(); err != nil { + t.Logf("listener close error: %v", err) + } + }) + + accepted := make(chan net.Conn, 1) + go func() { + conn, err := l.Accept() + if err != nil { + close(accepted) + return + } + accepted <- conn + }() + + conn, err := net.Dial("tcp", l.Addr().String()) + require.NoError(t, err) + + peer, ok := <-accepted + require.True(t, ok, "listener must accept the dialed connection") + t.Cleanup(func() { + if err := peer.Close(); err != nil { + t.Logf("peer close error: %v", err) + } + }) + + return conn +} + +// sweepAll cuts every registration regardless of generation. +func (s *Sweeper) sweepAll() int { + return s.sweep(math.MaxUint64) +} diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index 6506307d3..7f3ce1bbf 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -143,56 +143,6 @@ func (ExposeProtocol) EnumDescriptor() ([]byte, []int) { return file_daemon_proto_rawDescGZIP(), []int{1} } -// avoid collision with loglevel enum -type OSLifecycleRequest_CycleType int32 - -const ( - OSLifecycleRequest_UNKNOWN OSLifecycleRequest_CycleType = 0 - OSLifecycleRequest_SLEEP OSLifecycleRequest_CycleType = 1 - OSLifecycleRequest_WAKEUP OSLifecycleRequest_CycleType = 2 -) - -// Enum value maps for OSLifecycleRequest_CycleType. -var ( - OSLifecycleRequest_CycleType_name = map[int32]string{ - 0: "UNKNOWN", - 1: "SLEEP", - 2: "WAKEUP", - } - OSLifecycleRequest_CycleType_value = map[string]int32{ - "UNKNOWN": 0, - "SLEEP": 1, - "WAKEUP": 2, - } -) - -func (x OSLifecycleRequest_CycleType) Enum() *OSLifecycleRequest_CycleType { - p := new(OSLifecycleRequest_CycleType) - *p = x - return p -} - -func (x OSLifecycleRequest_CycleType) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (OSLifecycleRequest_CycleType) Descriptor() protoreflect.EnumDescriptor { - return file_daemon_proto_enumTypes[2].Descriptor() -} - -func (OSLifecycleRequest_CycleType) Type() protoreflect.EnumType { - return &file_daemon_proto_enumTypes[2] -} - -func (x OSLifecycleRequest_CycleType) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use OSLifecycleRequest_CycleType.Descriptor instead. -func (OSLifecycleRequest_CycleType) EnumDescriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{1, 0} -} - type SystemEvent_Severity int32 const ( @@ -229,11 +179,11 @@ func (x SystemEvent_Severity) String() string { } func (SystemEvent_Severity) Descriptor() protoreflect.EnumDescriptor { - return file_daemon_proto_enumTypes[3].Descriptor() + return file_daemon_proto_enumTypes[2].Descriptor() } func (SystemEvent_Severity) Type() protoreflect.EnumType { - return &file_daemon_proto_enumTypes[3] + return &file_daemon_proto_enumTypes[2] } func (x SystemEvent_Severity) Number() protoreflect.EnumNumber { @@ -284,11 +234,11 @@ func (x SystemEvent_Category) String() string { } func (SystemEvent_Category) Descriptor() protoreflect.EnumDescriptor { - return file_daemon_proto_enumTypes[4].Descriptor() + return file_daemon_proto_enumTypes[3].Descriptor() } func (SystemEvent_Category) Type() protoreflect.EnumType { - return &file_daemon_proto_enumTypes[4] + return &file_daemon_proto_enumTypes[3] } func (x SystemEvent_Category) Number() protoreflect.EnumNumber { @@ -336,86 +286,6 @@ func (*EmptyRequest) Descriptor() ([]byte, []int) { return file_daemon_proto_rawDescGZIP(), []int{0} } -type OSLifecycleRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Type OSLifecycleRequest_CycleType `protobuf:"varint,1,opt,name=type,proto3,enum=daemon.OSLifecycleRequest_CycleType" json:"type,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *OSLifecycleRequest) Reset() { - *x = OSLifecycleRequest{} - mi := &file_daemon_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *OSLifecycleRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*OSLifecycleRequest) ProtoMessage() {} - -func (x *OSLifecycleRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[1] - 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 OSLifecycleRequest.ProtoReflect.Descriptor instead. -func (*OSLifecycleRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{1} -} - -func (x *OSLifecycleRequest) GetType() OSLifecycleRequest_CycleType { - if x != nil { - return x.Type - } - return OSLifecycleRequest_UNKNOWN -} - -type OSLifecycleResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *OSLifecycleResponse) Reset() { - *x = OSLifecycleResponse{} - mi := &file_daemon_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *OSLifecycleResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*OSLifecycleResponse) ProtoMessage() {} - -func (x *OSLifecycleResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[2] - 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 OSLifecycleResponse.ProtoReflect.Descriptor instead. -func (*OSLifecycleResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{2} -} - type LoginRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // setupKey netbird setup key. @@ -472,13 +342,19 @@ type LoginRequest struct { EnableSSHRemotePortForwarding *bool `protobuf:"varint,37,opt,name=enableSSHRemotePortForwarding,proto3,oneof" json:"enableSSHRemotePortForwarding,omitempty"` DisableSSHAuth *bool `protobuf:"varint,38,opt,name=disableSSHAuth,proto3,oneof" json:"disableSSHAuth,omitempty"` SshJWTCacheTTL *int32 `protobuf:"varint,39,opt,name=sshJWTCacheTTL,proto3,oneof" json:"sshJWTCacheTTL,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + DisableIpv6 *bool `protobuf:"varint,40,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"` + EnableLocalMetrics *bool `protobuf:"varint,41,opt,name=enable_local_metrics,json=enableLocalMetrics,proto3,oneof" json:"enable_local_metrics,omitempty"` + LocalMetricsAddress *string `protobuf:"bytes,42,opt,name=local_metrics_address,json=localMetricsAddress,proto3,oneof" json:"local_metrics_address,omitempty"` + // remoteJobsAllowed opts the peer into management-requested remote jobs + // (e.g. debug bundles). Absent leaves the stored value unchanged. + RemoteJobsAllowed *bool `protobuf:"varint,43,opt,name=remoteJobsAllowed,proto3,oneof" json:"remoteJobsAllowed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *LoginRequest) Reset() { *x = LoginRequest{} - mi := &file_daemon_proto_msgTypes[3] + mi := &file_daemon_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -490,7 +366,7 @@ func (x *LoginRequest) String() string { func (*LoginRequest) ProtoMessage() {} func (x *LoginRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[3] + mi := &file_daemon_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -503,7 +379,7 @@ func (x *LoginRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use LoginRequest.ProtoReflect.Descriptor instead. func (*LoginRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{3} + return file_daemon_proto_rawDescGZIP(), []int{1} } func (x *LoginRequest) GetSetupKey() string { @@ -780,6 +656,34 @@ func (x *LoginRequest) GetSshJWTCacheTTL() int32 { return 0 } +func (x *LoginRequest) GetDisableIpv6() bool { + if x != nil && x.DisableIpv6 != nil { + return *x.DisableIpv6 + } + return false +} + +func (x *LoginRequest) GetEnableLocalMetrics() bool { + if x != nil && x.EnableLocalMetrics != nil { + return *x.EnableLocalMetrics + } + return false +} + +func (x *LoginRequest) GetLocalMetricsAddress() string { + if x != nil && x.LocalMetricsAddress != nil { + return *x.LocalMetricsAddress + } + return "" +} + +func (x *LoginRequest) GetRemoteJobsAllowed() bool { + if x != nil && x.RemoteJobsAllowed != nil { + return *x.RemoteJobsAllowed + } + return false +} + type LoginResponse struct { state protoimpl.MessageState `protogen:"open.v1"` NeedsSSOLogin bool `protobuf:"varint,1,opt,name=needsSSOLogin,proto3" json:"needsSSOLogin,omitempty"` @@ -792,7 +696,7 @@ type LoginResponse struct { func (x *LoginResponse) Reset() { *x = LoginResponse{} - mi := &file_daemon_proto_msgTypes[4] + mi := &file_daemon_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -804,7 +708,7 @@ func (x *LoginResponse) String() string { func (*LoginResponse) ProtoMessage() {} func (x *LoginResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[4] + mi := &file_daemon_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -817,7 +721,7 @@ func (x *LoginResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use LoginResponse.ProtoReflect.Descriptor instead. func (*LoginResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{4} + return file_daemon_proto_rawDescGZIP(), []int{2} } func (x *LoginResponse) GetNeedsSSOLogin() bool { @@ -858,7 +762,7 @@ type WaitSSOLoginRequest struct { func (x *WaitSSOLoginRequest) Reset() { *x = WaitSSOLoginRequest{} - mi := &file_daemon_proto_msgTypes[5] + mi := &file_daemon_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -870,7 +774,7 @@ func (x *WaitSSOLoginRequest) String() string { func (*WaitSSOLoginRequest) ProtoMessage() {} func (x *WaitSSOLoginRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[5] + mi := &file_daemon_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -883,7 +787,7 @@ func (x *WaitSSOLoginRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WaitSSOLoginRequest.ProtoReflect.Descriptor instead. func (*WaitSSOLoginRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{5} + return file_daemon_proto_rawDescGZIP(), []int{3} } func (x *WaitSSOLoginRequest) GetUserCode() string { @@ -909,7 +813,7 @@ type WaitSSOLoginResponse struct { func (x *WaitSSOLoginResponse) Reset() { *x = WaitSSOLoginResponse{} - mi := &file_daemon_proto_msgTypes[6] + mi := &file_daemon_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -921,7 +825,7 @@ func (x *WaitSSOLoginResponse) String() string { func (*WaitSSOLoginResponse) ProtoMessage() {} func (x *WaitSSOLoginResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[6] + mi := &file_daemon_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -934,7 +838,7 @@ func (x *WaitSSOLoginResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WaitSSOLoginResponse.ProtoReflect.Descriptor instead. func (*WaitSSOLoginResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{6} + return file_daemon_proto_rawDescGZIP(), []int{4} } func (x *WaitSSOLoginResponse) GetEmail() string { @@ -945,16 +849,22 @@ func (x *WaitSSOLoginResponse) GetEmail() string { } 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"` + 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"` + // async instructs the daemon to start the connection attempt and return + // immediately without waiting for the engine to become ready. Status updates + // are delivered via the SubscribeStatus stream. When false (the default) the + // RPC blocks until the engine is running or gives up, which is the behaviour + // needed by the CLI. + Async bool `protobuf:"varint,4,opt,name=async,proto3" json:"async,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *UpRequest) Reset() { *x = UpRequest{} - mi := &file_daemon_proto_msgTypes[7] + mi := &file_daemon_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -966,7 +876,7 @@ func (x *UpRequest) String() string { func (*UpRequest) ProtoMessage() {} func (x *UpRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[7] + mi := &file_daemon_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -979,7 +889,7 @@ func (x *UpRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpRequest.ProtoReflect.Descriptor instead. func (*UpRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{7} + return file_daemon_proto_rawDescGZIP(), []int{5} } func (x *UpRequest) GetProfileName() string { @@ -996,6 +906,13 @@ func (x *UpRequest) GetUsername() string { return "" } +func (x *UpRequest) GetAsync() bool { + if x != nil { + return x.Async + } + return false +} + type UpResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -1004,7 +921,7 @@ type UpResponse struct { func (x *UpResponse) Reset() { *x = UpResponse{} - mi := &file_daemon_proto_msgTypes[8] + mi := &file_daemon_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1016,7 +933,7 @@ func (x *UpResponse) String() string { func (*UpResponse) ProtoMessage() {} func (x *UpResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[8] + mi := &file_daemon_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1029,7 +946,7 @@ func (x *UpResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpResponse.ProtoReflect.Descriptor instead. func (*UpResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{8} + return file_daemon_proto_rawDescGZIP(), []int{6} } type StatusRequest struct { @@ -1044,7 +961,7 @@ type StatusRequest struct { func (x *StatusRequest) Reset() { *x = StatusRequest{} - mi := &file_daemon_proto_msgTypes[9] + mi := &file_daemon_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1056,7 +973,7 @@ func (x *StatusRequest) String() string { func (*StatusRequest) ProtoMessage() {} func (x *StatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[9] + mi := &file_daemon_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1069,7 +986,7 @@ func (x *StatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StatusRequest.ProtoReflect.Descriptor instead. func (*StatusRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{9} + return file_daemon_proto_rawDescGZIP(), []int{7} } func (x *StatusRequest) GetGetFullPeerStatus() bool { @@ -1100,13 +1017,17 @@ type StatusResponse struct { FullStatus *FullStatus `protobuf:"bytes,2,opt,name=fullStatus,proto3" json:"fullStatus,omitempty"` // NetBird daemon version DaemonVersion string `protobuf:"bytes,3,opt,name=daemonVersion,proto3" json:"daemonVersion,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Absolute UTC instant at which the peer's SSO session expires. + // Unset when the peer is not SSO-registered or login expiration is disabled. + // The UI derives "warning active" from this value and its own clock. + SessionExpiresAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=sessionExpiresAt,proto3" json:"sessionExpiresAt,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *StatusResponse) Reset() { *x = StatusResponse{} - mi := &file_daemon_proto_msgTypes[10] + mi := &file_daemon_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1118,7 +1039,7 @@ func (x *StatusResponse) String() string { func (*StatusResponse) ProtoMessage() {} func (x *StatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[10] + mi := &file_daemon_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1131,7 +1052,7 @@ func (x *StatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StatusResponse.ProtoReflect.Descriptor instead. func (*StatusResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{10} + return file_daemon_proto_rawDescGZIP(), []int{8} } func (x *StatusResponse) GetStatus() string { @@ -1155,6 +1076,13 @@ func (x *StatusResponse) GetDaemonVersion() string { return "" } +func (x *StatusResponse) GetSessionExpiresAt() *timestamppb.Timestamp { + if x != nil { + return x.SessionExpiresAt + } + return nil +} + type DownRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -1163,7 +1091,7 @@ type DownRequest struct { func (x *DownRequest) Reset() { *x = DownRequest{} - mi := &file_daemon_proto_msgTypes[11] + mi := &file_daemon_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1175,7 +1103,7 @@ func (x *DownRequest) String() string { func (*DownRequest) ProtoMessage() {} func (x *DownRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[11] + mi := &file_daemon_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1188,7 +1116,7 @@ func (x *DownRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DownRequest.ProtoReflect.Descriptor instead. func (*DownRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{11} + return file_daemon_proto_rawDescGZIP(), []int{9} } type DownResponse struct { @@ -1199,7 +1127,7 @@ type DownResponse struct { func (x *DownResponse) Reset() { *x = DownResponse{} - mi := &file_daemon_proto_msgTypes[12] + mi := &file_daemon_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1211,7 +1139,7 @@ func (x *DownResponse) String() string { func (*DownResponse) ProtoMessage() {} func (x *DownResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[12] + mi := &file_daemon_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1224,7 +1152,7 @@ func (x *DownResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DownResponse.ProtoReflect.Descriptor instead. func (*DownResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{12} + return file_daemon_proto_rawDescGZIP(), []int{10} } type GetConfigRequest struct { @@ -1237,7 +1165,7 @@ type GetConfigRequest struct { func (x *GetConfigRequest) Reset() { *x = GetConfigRequest{} - mi := &file_daemon_proto_msgTypes[13] + mi := &file_daemon_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1249,7 +1177,7 @@ func (x *GetConfigRequest) String() string { func (*GetConfigRequest) ProtoMessage() {} func (x *GetConfigRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[13] + mi := &file_daemon_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1262,7 +1190,7 @@ func (x *GetConfigRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetConfigRequest.ProtoReflect.Descriptor instead. func (*GetConfigRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{13} + return file_daemon_proto_rawDescGZIP(), []int{11} } func (x *GetConfigRequest) GetProfileName() string { @@ -1312,13 +1240,21 @@ type GetConfigResponse struct { EnableSSHRemotePortForwarding bool `protobuf:"varint,23,opt,name=enableSSHRemotePortForwarding,proto3" json:"enableSSHRemotePortForwarding,omitempty"` DisableSSHAuth bool `protobuf:"varint,25,opt,name=disableSSHAuth,proto3" json:"disableSSHAuth,omitempty"` SshJWTCacheTTL int32 `protobuf:"varint,26,opt,name=sshJWTCacheTTL,proto3" json:"sshJWTCacheTTL,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + DisableIpv6 bool `protobuf:"varint,27,opt,name=disable_ipv6,json=disableIpv6,proto3" json:"disable_ipv6,omitempty"` + RemoteJobsAllowed bool `protobuf:"varint,29,opt,name=remoteJobsAllowed,proto3" json:"remoteJobsAllowed,omitempty"` + // mDMManagedFields lists the names of configuration keys whose value is + // currently enforced by an MDM policy. Names match mdm.Key* constants + // (e.g. "managementURL", "disableClientRoutes"). UI/CLI clients should + // render the corresponding inputs as read-only and display a "managed + // by MDM" indicator. + MDMManagedFields []string `protobuf:"bytes,28,rep,name=mDMManagedFields,proto3" json:"mDMManagedFields,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetConfigResponse) Reset() { *x = GetConfigResponse{} - mi := &file_daemon_proto_msgTypes[14] + mi := &file_daemon_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1330,7 +1266,7 @@ func (x *GetConfigResponse) String() string { func (*GetConfigResponse) ProtoMessage() {} func (x *GetConfigResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[14] + mi := &file_daemon_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1343,7 +1279,7 @@ func (x *GetConfigResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetConfigResponse.ProtoReflect.Descriptor instead. func (*GetConfigResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{14} + return file_daemon_proto_rawDescGZIP(), []int{12} } func (x *GetConfigResponse) GetManagementUrl() string { @@ -1528,6 +1464,27 @@ func (x *GetConfigResponse) GetSshJWTCacheTTL() int32 { return 0 } +func (x *GetConfigResponse) GetDisableIpv6() bool { + if x != nil { + return x.DisableIpv6 + } + return false +} + +func (x *GetConfigResponse) GetRemoteJobsAllowed() bool { + if x != nil { + return x.RemoteJobsAllowed + } + return false +} + +func (x *GetConfigResponse) GetMDMManagedFields() []string { + if x != nil { + return x.MDMManagedFields + } + return nil +} + // PeerState contains the latest state of a peer type PeerState struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1549,13 +1506,14 @@ type PeerState struct { Latency *durationpb.Duration `protobuf:"bytes,17,opt,name=latency,proto3" json:"latency,omitempty"` RelayAddress string `protobuf:"bytes,18,opt,name=relayAddress,proto3" json:"relayAddress,omitempty"` SshHostKey []byte `protobuf:"bytes,19,opt,name=sshHostKey,proto3" json:"sshHostKey,omitempty"` + Ipv6 string `protobuf:"bytes,20,opt,name=ipv6,proto3" json:"ipv6,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *PeerState) Reset() { *x = PeerState{} - mi := &file_daemon_proto_msgTypes[15] + mi := &file_daemon_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1567,7 +1525,7 @@ func (x *PeerState) String() string { func (*PeerState) ProtoMessage() {} func (x *PeerState) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[15] + mi := &file_daemon_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1580,7 +1538,7 @@ func (x *PeerState) ProtoReflect() protoreflect.Message { // Deprecated: Use PeerState.ProtoReflect.Descriptor instead. func (*PeerState) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{15} + return file_daemon_proto_rawDescGZIP(), []int{13} } func (x *PeerState) GetIP() string { @@ -1709,6 +1667,13 @@ func (x *PeerState) GetSshHostKey() []byte { return nil } +func (x *PeerState) GetIpv6() string { + if x != nil { + return x.Ipv6 + } + return "" +} + // LocalPeerState contains the latest state of the local peer type LocalPeerState struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1719,13 +1684,15 @@ type LocalPeerState struct { RosenpassEnabled bool `protobuf:"varint,5,opt,name=rosenpassEnabled,proto3" json:"rosenpassEnabled,omitempty"` RosenpassPermissive bool `protobuf:"varint,6,opt,name=rosenpassPermissive,proto3" json:"rosenpassPermissive,omitempty"` Networks []string `protobuf:"bytes,7,rep,name=networks,proto3" json:"networks,omitempty"` + Ipv6 string `protobuf:"bytes,8,opt,name=ipv6,proto3" json:"ipv6,omitempty"` + WgPort int32 `protobuf:"varint,9,opt,name=wgPort,proto3" json:"wgPort,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *LocalPeerState) Reset() { *x = LocalPeerState{} - mi := &file_daemon_proto_msgTypes[16] + mi := &file_daemon_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1737,7 +1704,7 @@ func (x *LocalPeerState) String() string { func (*LocalPeerState) ProtoMessage() {} func (x *LocalPeerState) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[16] + mi := &file_daemon_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1750,7 +1717,7 @@ func (x *LocalPeerState) ProtoReflect() protoreflect.Message { // Deprecated: Use LocalPeerState.ProtoReflect.Descriptor instead. func (*LocalPeerState) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{16} + return file_daemon_proto_rawDescGZIP(), []int{14} } func (x *LocalPeerState) GetIP() string { @@ -1802,6 +1769,20 @@ func (x *LocalPeerState) GetNetworks() []string { return nil } +func (x *LocalPeerState) GetIpv6() string { + if x != nil { + return x.Ipv6 + } + return "" +} + +func (x *LocalPeerState) GetWgPort() int32 { + if x != nil { + return x.WgPort + } + return 0 +} + // SignalState contains the latest state of a signal connection type SignalState struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1814,7 +1795,7 @@ type SignalState struct { func (x *SignalState) Reset() { *x = SignalState{} - mi := &file_daemon_proto_msgTypes[17] + mi := &file_daemon_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1826,7 +1807,7 @@ func (x *SignalState) String() string { func (*SignalState) ProtoMessage() {} func (x *SignalState) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[17] + mi := &file_daemon_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1839,7 +1820,7 @@ func (x *SignalState) ProtoReflect() protoreflect.Message { // Deprecated: Use SignalState.ProtoReflect.Descriptor instead. func (*SignalState) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{17} + return file_daemon_proto_rawDescGZIP(), []int{15} } func (x *SignalState) GetURL() string { @@ -1875,7 +1856,7 @@ type ManagementState struct { func (x *ManagementState) Reset() { *x = ManagementState{} - mi := &file_daemon_proto_msgTypes[18] + mi := &file_daemon_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1887,7 +1868,7 @@ func (x *ManagementState) String() string { func (*ManagementState) ProtoMessage() {} func (x *ManagementState) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[18] + mi := &file_daemon_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1900,7 +1881,7 @@ func (x *ManagementState) ProtoReflect() protoreflect.Message { // Deprecated: Use ManagementState.ProtoReflect.Descriptor instead. func (*ManagementState) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{18} + return file_daemon_proto_rawDescGZIP(), []int{16} } func (x *ManagementState) GetURL() string { @@ -1926,17 +1907,20 @@ func (x *ManagementState) GetError() string { // RelayState contains the latest state of the relay type RelayState struct { - state protoimpl.MessageState `protogen:"open.v1"` - URI string `protobuf:"bytes,1,opt,name=URI,proto3" json:"URI,omitempty"` - Available bool `protobuf:"varint,2,opt,name=available,proto3" json:"available,omitempty"` - Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + URI string `protobuf:"bytes,1,opt,name=URI,proto3" json:"URI,omitempty"` + Available bool `protobuf:"varint,2,opt,name=available,proto3" json:"available,omitempty"` + Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` + // transport is the negotiated relay transport (e.g. "ws", "quic"), + // empty for stun/turn probes or when not connected. + Transport string `protobuf:"bytes,4,opt,name=transport,proto3" json:"transport,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *RelayState) Reset() { *x = RelayState{} - mi := &file_daemon_proto_msgTypes[19] + mi := &file_daemon_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1948,7 +1932,7 @@ func (x *RelayState) String() string { func (*RelayState) ProtoMessage() {} func (x *RelayState) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[19] + mi := &file_daemon_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1961,7 +1945,7 @@ func (x *RelayState) ProtoReflect() protoreflect.Message { // Deprecated: Use RelayState.ProtoReflect.Descriptor instead. func (*RelayState) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{19} + return file_daemon_proto_rawDescGZIP(), []int{17} } func (x *RelayState) GetURI() string { @@ -1985,6 +1969,13 @@ func (x *RelayState) GetError() string { return "" } +func (x *RelayState) GetTransport() string { + if x != nil { + return x.Transport + } + return "" +} + type NSGroupState struct { state protoimpl.MessageState `protogen:"open.v1"` Servers []string `protobuf:"bytes,1,rep,name=servers,proto3" json:"servers,omitempty"` @@ -1997,7 +1988,7 @@ type NSGroupState struct { func (x *NSGroupState) Reset() { *x = NSGroupState{} - mi := &file_daemon_proto_msgTypes[20] + mi := &file_daemon_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2009,7 +2000,7 @@ func (x *NSGroupState) String() string { func (*NSGroupState) ProtoMessage() {} func (x *NSGroupState) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[20] + mi := &file_daemon_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2022,7 +2013,7 @@ func (x *NSGroupState) ProtoReflect() protoreflect.Message { // Deprecated: Use NSGroupState.ProtoReflect.Descriptor instead. func (*NSGroupState) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{20} + return file_daemon_proto_rawDescGZIP(), []int{18} } func (x *NSGroupState) GetServers() []string { @@ -2067,7 +2058,7 @@ type SSHSessionInfo struct { func (x *SSHSessionInfo) Reset() { *x = SSHSessionInfo{} - mi := &file_daemon_proto_msgTypes[21] + mi := &file_daemon_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2079,7 +2070,7 @@ func (x *SSHSessionInfo) String() string { func (*SSHSessionInfo) ProtoMessage() {} func (x *SSHSessionInfo) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[21] + mi := &file_daemon_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2092,7 +2083,7 @@ func (x *SSHSessionInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use SSHSessionInfo.ProtoReflect.Descriptor instead. func (*SSHSessionInfo) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{21} + return file_daemon_proto_rawDescGZIP(), []int{19} } func (x *SSHSessionInfo) GetUsername() string { @@ -2141,7 +2132,7 @@ type SSHServerState struct { func (x *SSHServerState) Reset() { *x = SSHServerState{} - mi := &file_daemon_proto_msgTypes[22] + mi := &file_daemon_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2153,7 +2144,7 @@ func (x *SSHServerState) String() string { func (*SSHServerState) ProtoMessage() {} func (x *SSHServerState) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[22] + mi := &file_daemon_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2166,7 +2157,7 @@ func (x *SSHServerState) ProtoReflect() protoreflect.Message { // Deprecated: Use SSHServerState.ProtoReflect.Descriptor instead. func (*SSHServerState) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{22} + return file_daemon_proto_rawDescGZIP(), []int{20} } func (x *SSHServerState) GetEnabled() bool { @@ -2196,13 +2187,18 @@ type FullStatus struct { Events []*SystemEvent `protobuf:"bytes,7,rep,name=events,proto3" json:"events,omitempty"` LazyConnectionEnabled bool `protobuf:"varint,9,opt,name=lazyConnectionEnabled,proto3" json:"lazyConnectionEnabled,omitempty"` SshServerState *SSHServerState `protobuf:"bytes,10,opt,name=sshServerState,proto3" json:"sshServerState,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // networksRevision bumps whenever the set of routed networks (route and + // exit-node candidates) or their selected state changes. The UI fingerprints + // on it to know when to re-fetch ListNetworks via the push stream, instead + // of polling on every status snapshot. + NetworksRevision uint64 `protobuf:"varint,11,opt,name=networksRevision,proto3" json:"networksRevision,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *FullStatus) Reset() { *x = FullStatus{} - mi := &file_daemon_proto_msgTypes[23] + mi := &file_daemon_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2214,7 +2210,7 @@ func (x *FullStatus) String() string { func (*FullStatus) ProtoMessage() {} func (x *FullStatus) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[23] + mi := &file_daemon_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2227,7 +2223,7 @@ func (x *FullStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use FullStatus.ProtoReflect.Descriptor instead. func (*FullStatus) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{23} + return file_daemon_proto_rawDescGZIP(), []int{21} } func (x *FullStatus) GetManagementState() *ManagementState { @@ -2300,6 +2296,13 @@ func (x *FullStatus) GetSshServerState() *SSHServerState { return nil } +func (x *FullStatus) GetNetworksRevision() uint64 { + if x != nil { + return x.NetworksRevision + } + return 0 +} + // Networks type ListNetworksRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -2309,7 +2312,7 @@ type ListNetworksRequest struct { func (x *ListNetworksRequest) Reset() { *x = ListNetworksRequest{} - mi := &file_daemon_proto_msgTypes[24] + mi := &file_daemon_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2321,7 +2324,7 @@ func (x *ListNetworksRequest) String() string { func (*ListNetworksRequest) ProtoMessage() {} func (x *ListNetworksRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[24] + mi := &file_daemon_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2334,7 +2337,7 @@ func (x *ListNetworksRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListNetworksRequest.ProtoReflect.Descriptor instead. func (*ListNetworksRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{24} + return file_daemon_proto_rawDescGZIP(), []int{22} } type ListNetworksResponse struct { @@ -2346,7 +2349,7 @@ type ListNetworksResponse struct { func (x *ListNetworksResponse) Reset() { *x = ListNetworksResponse{} - mi := &file_daemon_proto_msgTypes[25] + mi := &file_daemon_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2358,7 +2361,7 @@ func (x *ListNetworksResponse) String() string { func (*ListNetworksResponse) ProtoMessage() {} func (x *ListNetworksResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[25] + mi := &file_daemon_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2371,7 +2374,7 @@ func (x *ListNetworksResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListNetworksResponse.ProtoReflect.Descriptor instead. func (*ListNetworksResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{25} + return file_daemon_proto_rawDescGZIP(), []int{23} } func (x *ListNetworksResponse) GetRoutes() []*Network { @@ -2392,7 +2395,7 @@ type SelectNetworksRequest struct { func (x *SelectNetworksRequest) Reset() { *x = SelectNetworksRequest{} - mi := &file_daemon_proto_msgTypes[26] + mi := &file_daemon_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2404,7 +2407,7 @@ func (x *SelectNetworksRequest) String() string { func (*SelectNetworksRequest) ProtoMessage() {} func (x *SelectNetworksRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[26] + mi := &file_daemon_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2417,7 +2420,7 @@ func (x *SelectNetworksRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SelectNetworksRequest.ProtoReflect.Descriptor instead. func (*SelectNetworksRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{26} + return file_daemon_proto_rawDescGZIP(), []int{24} } func (x *SelectNetworksRequest) GetNetworkIDs() []string { @@ -2449,7 +2452,7 @@ type SelectNetworksResponse struct { func (x *SelectNetworksResponse) Reset() { *x = SelectNetworksResponse{} - mi := &file_daemon_proto_msgTypes[27] + mi := &file_daemon_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2461,7 +2464,7 @@ func (x *SelectNetworksResponse) String() string { func (*SelectNetworksResponse) ProtoMessage() {} func (x *SelectNetworksResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[27] + mi := &file_daemon_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2474,7 +2477,7 @@ func (x *SelectNetworksResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SelectNetworksResponse.ProtoReflect.Descriptor instead. func (*SelectNetworksResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{27} + return file_daemon_proto_rawDescGZIP(), []int{25} } type IPList struct { @@ -2486,7 +2489,7 @@ type IPList struct { func (x *IPList) Reset() { *x = IPList{} - mi := &file_daemon_proto_msgTypes[28] + mi := &file_daemon_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2498,7 +2501,7 @@ func (x *IPList) String() string { func (*IPList) ProtoMessage() {} func (x *IPList) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[28] + mi := &file_daemon_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2511,7 +2514,7 @@ func (x *IPList) ProtoReflect() protoreflect.Message { // Deprecated: Use IPList.ProtoReflect.Descriptor instead. func (*IPList) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{28} + return file_daemon_proto_rawDescGZIP(), []int{26} } func (x *IPList) GetIps() []string { @@ -2534,7 +2537,7 @@ type Network struct { func (x *Network) Reset() { *x = Network{} - mi := &file_daemon_proto_msgTypes[29] + mi := &file_daemon_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2546,7 +2549,7 @@ func (x *Network) String() string { func (*Network) ProtoMessage() {} func (x *Network) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[29] + mi := &file_daemon_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2559,7 +2562,7 @@ func (x *Network) ProtoReflect() protoreflect.Message { // Deprecated: Use Network.ProtoReflect.Descriptor instead. func (*Network) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{29} + return file_daemon_proto_rawDescGZIP(), []int{27} } func (x *Network) GetID() string { @@ -2611,7 +2614,7 @@ type PortInfo struct { func (x *PortInfo) Reset() { *x = PortInfo{} - mi := &file_daemon_proto_msgTypes[30] + mi := &file_daemon_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2623,7 +2626,7 @@ func (x *PortInfo) String() string { func (*PortInfo) ProtoMessage() {} func (x *PortInfo) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[30] + mi := &file_daemon_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2636,7 +2639,7 @@ func (x *PortInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use PortInfo.ProtoReflect.Descriptor instead. func (*PortInfo) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{30} + return file_daemon_proto_rawDescGZIP(), []int{28} } func (x *PortInfo) GetPortSelection() isPortInfo_PortSelection { @@ -2693,7 +2696,7 @@ type ForwardingRule struct { func (x *ForwardingRule) Reset() { *x = ForwardingRule{} - mi := &file_daemon_proto_msgTypes[31] + mi := &file_daemon_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2705,7 +2708,7 @@ func (x *ForwardingRule) String() string { func (*ForwardingRule) ProtoMessage() {} func (x *ForwardingRule) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[31] + mi := &file_daemon_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2718,7 +2721,7 @@ func (x *ForwardingRule) ProtoReflect() protoreflect.Message { // Deprecated: Use ForwardingRule.ProtoReflect.Descriptor instead. func (*ForwardingRule) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{31} + return file_daemon_proto_rawDescGZIP(), []int{29} } func (x *ForwardingRule) GetProtocol() string { @@ -2765,7 +2768,7 @@ type ForwardingRulesResponse struct { func (x *ForwardingRulesResponse) Reset() { *x = ForwardingRulesResponse{} - mi := &file_daemon_proto_msgTypes[32] + mi := &file_daemon_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2777,7 +2780,7 @@ func (x *ForwardingRulesResponse) String() string { func (*ForwardingRulesResponse) ProtoMessage() {} func (x *ForwardingRulesResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[32] + mi := &file_daemon_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2790,7 +2793,7 @@ func (x *ForwardingRulesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ForwardingRulesResponse.ProtoReflect.Descriptor instead. func (*ForwardingRulesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{32} + return file_daemon_proto_rawDescGZIP(), []int{30} } func (x *ForwardingRulesResponse) GetRules() []*ForwardingRule { @@ -2802,18 +2805,28 @@ func (x *ForwardingRulesResponse) GetRules() []*ForwardingRule { // DebugBundler type DebugBundleRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Anonymize bool `protobuf:"varint,1,opt,name=anonymize,proto3" json:"anonymize,omitempty"` - SystemInfo bool `protobuf:"varint,3,opt,name=systemInfo,proto3" json:"systemInfo,omitempty"` - UploadURL string `protobuf:"bytes,4,opt,name=uploadURL,proto3" json:"uploadURL,omitempty"` - LogFileCount uint32 `protobuf:"varint,5,opt,name=logFileCount,proto3" json:"logFileCount,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Anonymize bool `protobuf:"varint,1,opt,name=anonymize,proto3" json:"anonymize,omitempty"` + SystemInfo bool `protobuf:"varint,3,opt,name=systemInfo,proto3" json:"systemInfo,omitempty"` + UploadURL string `protobuf:"bytes,4,opt,name=uploadURL,proto3" json:"uploadURL,omitempty"` + LogFileCount uint32 `protobuf:"varint,5,opt,name=logFileCount,proto3" json:"logFileCount,omitempty"` + CliVersion string `protobuf:"bytes,6,opt,name=cliVersion,proto3" json:"cliVersion,omitempty"` + // uploadInsecure allows uploading to an http endpoint or one with an + // untrusted TLS certificate. Restricted to privileged callers; for + // self-hosted upload servers. + UploadInsecure bool `protobuf:"varint,7,opt,name=uploadInsecure,proto3" json:"uploadInsecure,omitempty"` + // anonymizeLevel selects how much the anonymizer redacts: "default" + // (or empty) keeps internal IP ranges, "strict" also anonymizes them. + // Unknown values are treated as "strict". Only meaningful with anonymize; + // "strict" implies it. + AnonymizeLevel string `protobuf:"bytes,8,opt,name=anonymizeLevel,proto3" json:"anonymizeLevel,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DebugBundleRequest) Reset() { *x = DebugBundleRequest{} - mi := &file_daemon_proto_msgTypes[33] + mi := &file_daemon_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2825,7 +2838,7 @@ func (x *DebugBundleRequest) String() string { func (*DebugBundleRequest) ProtoMessage() {} func (x *DebugBundleRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[33] + mi := &file_daemon_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2838,7 +2851,7 @@ func (x *DebugBundleRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DebugBundleRequest.ProtoReflect.Descriptor instead. func (*DebugBundleRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{33} + return file_daemon_proto_rawDescGZIP(), []int{31} } func (x *DebugBundleRequest) GetAnonymize() bool { @@ -2869,6 +2882,27 @@ func (x *DebugBundleRequest) GetLogFileCount() uint32 { return 0 } +func (x *DebugBundleRequest) GetCliVersion() string { + if x != nil { + return x.CliVersion + } + return "" +} + +func (x *DebugBundleRequest) GetUploadInsecure() bool { + if x != nil { + return x.UploadInsecure + } + return false +} + +func (x *DebugBundleRequest) GetAnonymizeLevel() string { + if x != nil { + return x.AnonymizeLevel + } + return "" +} + type DebugBundleResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` @@ -2880,7 +2914,7 @@ type DebugBundleResponse struct { func (x *DebugBundleResponse) Reset() { *x = DebugBundleResponse{} - mi := &file_daemon_proto_msgTypes[34] + mi := &file_daemon_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2892,7 +2926,7 @@ func (x *DebugBundleResponse) String() string { func (*DebugBundleResponse) ProtoMessage() {} func (x *DebugBundleResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[34] + mi := &file_daemon_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2905,7 +2939,7 @@ func (x *DebugBundleResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DebugBundleResponse.ProtoReflect.Descriptor instead. func (*DebugBundleResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{34} + return file_daemon_proto_rawDescGZIP(), []int{32} } func (x *DebugBundleResponse) GetPath() string { @@ -2937,7 +2971,7 @@ type GetLogLevelRequest struct { func (x *GetLogLevelRequest) Reset() { *x = GetLogLevelRequest{} - mi := &file_daemon_proto_msgTypes[35] + mi := &file_daemon_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2949,7 +2983,7 @@ func (x *GetLogLevelRequest) String() string { func (*GetLogLevelRequest) ProtoMessage() {} func (x *GetLogLevelRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[35] + mi := &file_daemon_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2962,7 +2996,7 @@ func (x *GetLogLevelRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetLogLevelRequest.ProtoReflect.Descriptor instead. func (*GetLogLevelRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{35} + return file_daemon_proto_rawDescGZIP(), []int{33} } type GetLogLevelResponse struct { @@ -2974,7 +3008,7 @@ type GetLogLevelResponse struct { func (x *GetLogLevelResponse) Reset() { *x = GetLogLevelResponse{} - mi := &file_daemon_proto_msgTypes[36] + mi := &file_daemon_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2986,7 +3020,7 @@ func (x *GetLogLevelResponse) String() string { func (*GetLogLevelResponse) ProtoMessage() {} func (x *GetLogLevelResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[36] + mi := &file_daemon_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2999,7 +3033,7 @@ func (x *GetLogLevelResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetLogLevelResponse.ProtoReflect.Descriptor instead. func (*GetLogLevelResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{36} + return file_daemon_proto_rawDescGZIP(), []int{34} } func (x *GetLogLevelResponse) GetLevel() LogLevel { @@ -3018,7 +3052,7 @@ type SetLogLevelRequest struct { func (x *SetLogLevelRequest) Reset() { *x = SetLogLevelRequest{} - mi := &file_daemon_proto_msgTypes[37] + mi := &file_daemon_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3030,7 +3064,7 @@ func (x *SetLogLevelRequest) String() string { func (*SetLogLevelRequest) ProtoMessage() {} func (x *SetLogLevelRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[37] + mi := &file_daemon_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3043,7 +3077,7 @@ func (x *SetLogLevelRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetLogLevelRequest.ProtoReflect.Descriptor instead. func (*SetLogLevelRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{37} + return file_daemon_proto_rawDescGZIP(), []int{35} } func (x *SetLogLevelRequest) GetLevel() LogLevel { @@ -3061,7 +3095,7 @@ type SetLogLevelResponse struct { func (x *SetLogLevelResponse) Reset() { *x = SetLogLevelResponse{} - mi := &file_daemon_proto_msgTypes[38] + mi := &file_daemon_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3073,7 +3107,7 @@ func (x *SetLogLevelResponse) String() string { func (*SetLogLevelResponse) ProtoMessage() {} func (x *SetLogLevelResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[38] + mi := &file_daemon_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3086,6 +3120,86 @@ func (x *SetLogLevelResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetLogLevelResponse.ProtoReflect.Descriptor instead. func (*SetLogLevelResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{36} +} + +type RegisterUILogRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegisterUILogRequest) Reset() { + *x = RegisterUILogRequest{} + mi := &file_daemon_proto_msgTypes[37] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegisterUILogRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterUILogRequest) ProtoMessage() {} + +func (x *RegisterUILogRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[37] + 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 RegisterUILogRequest.ProtoReflect.Descriptor instead. +func (*RegisterUILogRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{37} +} + +func (x *RegisterUILogRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type RegisterUILogResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegisterUILogResponse) Reset() { + *x = RegisterUILogResponse{} + mi := &file_daemon_proto_msgTypes[38] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegisterUILogResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterUILogResponse) ProtoMessage() {} + +func (x *RegisterUILogResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[38] + 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 RegisterUILogResponse.ProtoReflect.Descriptor instead. +func (*RegisterUILogResponse) Descriptor() ([]byte, []int) { return file_daemon_proto_rawDescGZIP(), []int{38} } @@ -4013,9 +4127,11 @@ func (x *GetEventsResponse) GetEvents() []*SystemEvent { } type SwitchProfileRequest 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"` + state protoimpl.MessageState `protogen:"open.v1"` + // profileName is treated as a handle: exact ID, unique ID prefix, or + // unique display name. The daemon resolves it server-side. + 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"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4065,7 +4181,11 @@ func (x *SwitchProfileRequest) GetUsername() string { } type SwitchProfileResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` + state protoimpl.MessageState `protogen:"open.v1"` + // id is the resolved on-disk ID of the profile that became active. + // Lets CLI clients update their local active-profile state without + // duplicating the resolution logic. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4100,6 +4220,13 @@ func (*SwitchProfileResponse) Descriptor() ([]byte, []int) { return file_daemon_proto_rawDescGZIP(), []int{57} } +func (x *SwitchProfileResponse) GetId() string { + if x != nil { + return x.Id + } + return "" +} + type SetConfigRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` @@ -4139,8 +4266,14 @@ type SetConfigRequest struct { EnableSSHRemotePortForwarding *bool `protobuf:"varint,32,opt,name=enableSSHRemotePortForwarding,proto3,oneof" json:"enableSSHRemotePortForwarding,omitempty"` DisableSSHAuth *bool `protobuf:"varint,33,opt,name=disableSSHAuth,proto3,oneof" json:"disableSSHAuth,omitempty"` SshJWTCacheTTL *int32 `protobuf:"varint,34,opt,name=sshJWTCacheTTL,proto3,oneof" json:"sshJWTCacheTTL,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + DisableIpv6 *bool `protobuf:"varint,35,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"` + EnableLocalMetrics *bool `protobuf:"varint,36,opt,name=enable_local_metrics,json=enableLocalMetrics,proto3,oneof" json:"enable_local_metrics,omitempty"` + LocalMetricsAddress *string `protobuf:"bytes,37,opt,name=local_metrics_address,json=localMetricsAddress,proto3,oneof" json:"local_metrics_address,omitempty"` + // remoteJobsAllowed opts the peer into management-requested remote jobs + // (e.g. debug bundles). Absent leaves the stored value unchanged. + RemoteJobsAllowed *bool `protobuf:"varint,38,opt,name=remoteJobsAllowed,proto3,oneof" json:"remoteJobsAllowed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SetConfigRequest) Reset() { @@ -4411,6 +4544,34 @@ func (x *SetConfigRequest) GetSshJWTCacheTTL() int32 { return 0 } +func (x *SetConfigRequest) GetDisableIpv6() bool { + if x != nil && x.DisableIpv6 != nil { + return *x.DisableIpv6 + } + return false +} + +func (x *SetConfigRequest) GetEnableLocalMetrics() bool { + if x != nil && x.EnableLocalMetrics != nil { + return *x.EnableLocalMetrics + } + return false +} + +func (x *SetConfigRequest) GetLocalMetricsAddress() string { + if x != nil && x.LocalMetricsAddress != nil { + return *x.LocalMetricsAddress + } + return "" +} + +func (x *SetConfigRequest) GetRemoteJobsAllowed() bool { + if x != nil && x.RemoteJobsAllowed != nil { + return *x.RemoteJobsAllowed + } + return false +} + type SetConfigResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -4448,9 +4609,11 @@ func (*SetConfigResponse) Descriptor() ([]byte, []int) { } type AddProfileRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` - ProfileName string `protobuf:"bytes,2,opt,name=profileName,proto3" json:"profileName,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` + // profileName carries the human-readable display name for the new + // profile. The on-disk filename is a separately-generated ID. + ProfileName string `protobuf:"bytes,2,opt,name=profileName,proto3" json:"profileName,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4500,7 +4663,10 @@ func (x *AddProfileRequest) GetProfileName() string { } type AddProfileResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` + state protoimpl.MessageState `protogen:"open.v1"` + // id is the generated on-disk ID of the new profile. CLI clients + // display a truncated form, UI clients can ignore it. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4535,17 +4701,133 @@ func (*AddProfileResponse) Descriptor() ([]byte, []int) { return file_daemon_proto_rawDescGZIP(), []int{61} } +func (x *AddProfileResponse) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type RenameProfileRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` + // handle: an exact ID, a unique ID prefix, or a unique display name. + Handle string `protobuf:"bytes,2,opt,name=handle,proto3" json:"handle,omitempty"` + // newProfileName is the new human-readable display name for the profile. + NewProfileName string `protobuf:"bytes,3,opt,name=newProfileName,proto3" json:"newProfileName,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RenameProfileRequest) Reset() { + *x = RenameProfileRequest{} + mi := &file_daemon_proto_msgTypes[62] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RenameProfileRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RenameProfileRequest) ProtoMessage() {} + +func (x *RenameProfileRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[62] + 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 RenameProfileRequest.ProtoReflect.Descriptor instead. +func (*RenameProfileRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{62} +} + +func (x *RenameProfileRequest) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *RenameProfileRequest) GetHandle() string { + if x != nil { + return x.Handle + } + return "" +} + +func (x *RenameProfileRequest) GetNewProfileName() string { + if x != nil { + return x.NewProfileName + } + return "" +} + +type RenameProfileResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // confirm the old profile name after resolving handle. + OldProfileName string `protobuf:"bytes,1,opt,name=oldProfileName,proto3" json:"oldProfileName,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RenameProfileResponse) Reset() { + *x = RenameProfileResponse{} + mi := &file_daemon_proto_msgTypes[63] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RenameProfileResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RenameProfileResponse) ProtoMessage() {} + +func (x *RenameProfileResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[63] + 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 RenameProfileResponse.ProtoReflect.Descriptor instead. +func (*RenameProfileResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{63} +} + +func (x *RenameProfileResponse) GetOldProfileName() string { + if x != nil { + return x.OldProfileName + } + return "" +} + type RemoveProfileRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` - ProfileName string `protobuf:"bytes,2,opt,name=profileName,proto3" json:"profileName,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` + // profileName is treated as a handle: an exact ID, a unique ID + // prefix, or a unique display name. Resolution happens server-side. + ProfileName string `protobuf:"bytes,2,opt,name=profileName,proto3" json:"profileName,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *RemoveProfileRequest) Reset() { *x = RemoveProfileRequest{} - mi := &file_daemon_proto_msgTypes[62] + mi := &file_daemon_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4557,7 +4839,7 @@ func (x *RemoveProfileRequest) String() string { func (*RemoveProfileRequest) ProtoMessage() {} func (x *RemoveProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[62] + mi := &file_daemon_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4570,7 +4852,7 @@ func (x *RemoveProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveProfileRequest.ProtoReflect.Descriptor instead. func (*RemoveProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{62} + return file_daemon_proto_rawDescGZIP(), []int{64} } func (x *RemoveProfileRequest) GetUsername() string { @@ -4588,14 +4870,17 @@ func (x *RemoveProfileRequest) GetProfileName() string { } type RemoveProfileResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` + state protoimpl.MessageState `protogen:"open.v1"` + // id is the full resolved ID of the removed profile, so callers can + // confirm exactly which profile a name/prefix handle resolved to. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *RemoveProfileResponse) Reset() { *x = RemoveProfileResponse{} - mi := &file_daemon_proto_msgTypes[63] + mi := &file_daemon_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4607,7 +4892,7 @@ func (x *RemoveProfileResponse) String() string { func (*RemoveProfileResponse) ProtoMessage() {} func (x *RemoveProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[63] + mi := &file_daemon_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4620,7 +4905,14 @@ func (x *RemoveProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveProfileResponse.ProtoReflect.Descriptor instead. func (*RemoveProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{63} + return file_daemon_proto_rawDescGZIP(), []int{65} +} + +func (x *RemoveProfileResponse) GetId() string { + if x != nil { + return x.Id + } + return "" } type ListProfilesRequest struct { @@ -4632,7 +4924,7 @@ type ListProfilesRequest struct { func (x *ListProfilesRequest) Reset() { *x = ListProfilesRequest{} - mi := &file_daemon_proto_msgTypes[64] + mi := &file_daemon_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4644,7 +4936,7 @@ func (x *ListProfilesRequest) String() string { func (*ListProfilesRequest) ProtoMessage() {} func (x *ListProfilesRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[64] + mi := &file_daemon_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4657,7 +4949,7 @@ func (x *ListProfilesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProfilesRequest.ProtoReflect.Descriptor instead. func (*ListProfilesRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{64} + return file_daemon_proto_rawDescGZIP(), []int{66} } func (x *ListProfilesRequest) GetUsername() string { @@ -4676,7 +4968,7 @@ type ListProfilesResponse struct { func (x *ListProfilesResponse) Reset() { *x = ListProfilesResponse{} - mi := &file_daemon_proto_msgTypes[65] + mi := &file_daemon_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4688,7 +4980,7 @@ func (x *ListProfilesResponse) String() string { func (*ListProfilesResponse) ProtoMessage() {} func (x *ListProfilesResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[65] + mi := &file_daemon_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4701,7 +4993,7 @@ func (x *ListProfilesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProfilesResponse.ProtoReflect.Descriptor instead. func (*ListProfilesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{65} + return file_daemon_proto_rawDescGZIP(), []int{67} } func (x *ListProfilesResponse) GetProfiles() []*Profile { @@ -4715,13 +5007,14 @@ type Profile struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` IsActive bool `protobuf:"varint,2,opt,name=is_active,json=isActive,proto3" json:"is_active,omitempty"` + Id string `protobuf:"bytes,3,opt,name=id,proto3" json:"id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Profile) Reset() { *x = Profile{} - mi := &file_daemon_proto_msgTypes[66] + mi := &file_daemon_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4733,7 +5026,7 @@ func (x *Profile) String() string { func (*Profile) ProtoMessage() {} func (x *Profile) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[66] + mi := &file_daemon_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4746,7 +5039,7 @@ func (x *Profile) ProtoReflect() protoreflect.Message { // Deprecated: Use Profile.ProtoReflect.Descriptor instead. func (*Profile) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{66} + return file_daemon_proto_rawDescGZIP(), []int{68} } func (x *Profile) GetName() string { @@ -4763,6 +5056,13 @@ func (x *Profile) GetIsActive() bool { return false } +func (x *Profile) GetId() string { + if x != nil { + return x.Id + } + return "" +} + type GetActiveProfileRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -4771,7 +5071,7 @@ type GetActiveProfileRequest struct { func (x *GetActiveProfileRequest) Reset() { *x = GetActiveProfileRequest{} - mi := &file_daemon_proto_msgTypes[67] + mi := &file_daemon_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4783,7 +5083,7 @@ func (x *GetActiveProfileRequest) String() string { func (*GetActiveProfileRequest) ProtoMessage() {} func (x *GetActiveProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[67] + mi := &file_daemon_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4796,20 +5096,21 @@ func (x *GetActiveProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetActiveProfileRequest.ProtoReflect.Descriptor instead. func (*GetActiveProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{67} + return file_daemon_proto_rawDescGZIP(), []int{69} } type GetActiveProfileResponse struct { state protoimpl.MessageState `protogen:"open.v1"` ProfileName string `protobuf:"bytes,1,opt,name=profileName,proto3" json:"profileName,omitempty"` Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"` + Id string `protobuf:"bytes,3,opt,name=id,proto3" json:"id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetActiveProfileResponse) Reset() { *x = GetActiveProfileResponse{} - mi := &file_daemon_proto_msgTypes[68] + mi := &file_daemon_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4821,7 +5122,7 @@ func (x *GetActiveProfileResponse) String() string { func (*GetActiveProfileResponse) ProtoMessage() {} func (x *GetActiveProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[68] + mi := &file_daemon_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4834,7 +5135,7 @@ func (x *GetActiveProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetActiveProfileResponse.ProtoReflect.Descriptor instead. func (*GetActiveProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{68} + return file_daemon_proto_rawDescGZIP(), []int{70} } func (x *GetActiveProfileResponse) GetProfileName() string { @@ -4851,6 +5152,13 @@ func (x *GetActiveProfileResponse) GetUsername() string { return "" } +func (x *GetActiveProfileResponse) GetId() string { + if x != nil { + return x.Id + } + return "" +} + type LogoutRequest struct { state protoimpl.MessageState `protogen:"open.v1"` ProfileName *string `protobuf:"bytes,1,opt,name=profileName,proto3,oneof" json:"profileName,omitempty"` @@ -4861,7 +5169,7 @@ type LogoutRequest struct { func (x *LogoutRequest) Reset() { *x = LogoutRequest{} - mi := &file_daemon_proto_msgTypes[69] + mi := &file_daemon_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4873,7 +5181,7 @@ func (x *LogoutRequest) String() string { func (*LogoutRequest) ProtoMessage() {} func (x *LogoutRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[69] + mi := &file_daemon_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4886,7 +5194,7 @@ func (x *LogoutRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use LogoutRequest.ProtoReflect.Descriptor instead. func (*LogoutRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{69} + return file_daemon_proto_rawDescGZIP(), []int{71} } func (x *LogoutRequest) GetProfileName() string { @@ -4911,7 +5219,7 @@ type LogoutResponse struct { func (x *LogoutResponse) Reset() { *x = LogoutResponse{} - mi := &file_daemon_proto_msgTypes[70] + mi := &file_daemon_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4923,7 +5231,7 @@ func (x *LogoutResponse) String() string { func (*LogoutResponse) ProtoMessage() {} func (x *LogoutResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[70] + mi := &file_daemon_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4936,7 +5244,79 @@ func (x *LogoutResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use LogoutResponse.ProtoReflect.Descriptor instead. func (*LogoutResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{70} + return file_daemon_proto_rawDescGZIP(), []int{72} +} + +type WailsUIReadyRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WailsUIReadyRequest) Reset() { + *x = WailsUIReadyRequest{} + mi := &file_daemon_proto_msgTypes[73] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WailsUIReadyRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WailsUIReadyRequest) ProtoMessage() {} + +func (x *WailsUIReadyRequest) 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 WailsUIReadyRequest.ProtoReflect.Descriptor instead. +func (*WailsUIReadyRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{73} +} + +type WailsUIReadyResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WailsUIReadyResponse) Reset() { + *x = WailsUIReadyResponse{} + mi := &file_daemon_proto_msgTypes[74] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WailsUIReadyResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WailsUIReadyResponse) ProtoMessage() {} + +func (x *WailsUIReadyResponse) 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 WailsUIReadyResponse.ProtoReflect.Descriptor instead. +func (*WailsUIReadyResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{74} } type GetFeaturesRequest struct { @@ -4947,7 +5327,7 @@ type GetFeaturesRequest struct { func (x *GetFeaturesRequest) Reset() { *x = GetFeaturesRequest{} - mi := &file_daemon_proto_msgTypes[71] + mi := &file_daemon_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4959,7 +5339,7 @@ func (x *GetFeaturesRequest) String() string { func (*GetFeaturesRequest) ProtoMessage() {} func (x *GetFeaturesRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[71] + mi := &file_daemon_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4972,7 +5352,7 @@ func (x *GetFeaturesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetFeaturesRequest.ProtoReflect.Descriptor instead. func (*GetFeaturesRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{71} + return file_daemon_proto_rawDescGZIP(), []int{75} } type GetFeaturesResponse struct { @@ -4980,13 +5360,19 @@ type GetFeaturesResponse struct { DisableProfiles bool `protobuf:"varint,1,opt,name=disable_profiles,json=disableProfiles,proto3" json:"disable_profiles,omitempty"` DisableUpdateSettings bool `protobuf:"varint,2,opt,name=disable_update_settings,json=disableUpdateSettings,proto3" json:"disable_update_settings,omitempty"` DisableNetworks bool `protobuf:"varint,3,opt,name=disable_networks,json=disableNetworks,proto3" json:"disable_networks,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // disableAdvancedView gates the upcoming UI revision's advanced + // section. Tristate: unset = no MDM directive, the UI applies its + // own default; true = MDM enforces disable; false = MDM enforces + // enable. Sourced exclusively from the MDM policy — no CLI / + // config flag backs this value. + DisableAdvancedView *bool `protobuf:"varint,4,opt,name=disable_advanced_view,json=disableAdvancedView,proto3,oneof" json:"disable_advanced_view,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetFeaturesResponse) Reset() { *x = GetFeaturesResponse{} - mi := &file_daemon_proto_msgTypes[72] + mi := &file_daemon_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4998,7 +5384,7 @@ func (x *GetFeaturesResponse) String() string { func (*GetFeaturesResponse) ProtoMessage() {} func (x *GetFeaturesResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[72] + mi := &file_daemon_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5011,7 +5397,7 @@ func (x *GetFeaturesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetFeaturesResponse.ProtoReflect.Descriptor instead. func (*GetFeaturesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{72} + return file_daemon_proto_rawDescGZIP(), []int{76} } func (x *GetFeaturesResponse) GetDisableProfiles() bool { @@ -5035,6 +5421,62 @@ func (x *GetFeaturesResponse) GetDisableNetworks() bool { return false } +func (x *GetFeaturesResponse) GetDisableAdvancedView() bool { + if x != nil && x.DisableAdvancedView != nil { + return *x.DisableAdvancedView + } + return false +} + +// MDMManagedFieldsViolation is attached as a gRPC error detail on a +// FailedPrecondition status returned from SetConfig (and similar mutating +// RPCs) when the caller tries to modify one or more MDM-enforced fields. +// The fields list contains the offending key names; the entire request is +// rejected (no partial apply). +type MDMManagedFieldsViolation struct { + state protoimpl.MessageState `protogen:"open.v1"` + Fields []string `protobuf:"bytes,1,rep,name=fields,proto3" json:"fields,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MDMManagedFieldsViolation) Reset() { + *x = MDMManagedFieldsViolation{} + mi := &file_daemon_proto_msgTypes[77] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MDMManagedFieldsViolation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MDMManagedFieldsViolation) ProtoMessage() {} + +func (x *MDMManagedFieldsViolation) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[77] + 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 MDMManagedFieldsViolation.ProtoReflect.Descriptor instead. +func (*MDMManagedFieldsViolation) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{77} +} + +func (x *MDMManagedFieldsViolation) GetFields() []string { + if x != nil { + return x.Fields + } + return nil +} + type TriggerUpdateRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -5043,7 +5485,7 @@ type TriggerUpdateRequest struct { func (x *TriggerUpdateRequest) Reset() { *x = TriggerUpdateRequest{} - mi := &file_daemon_proto_msgTypes[73] + mi := &file_daemon_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5055,7 +5497,7 @@ func (x *TriggerUpdateRequest) String() string { func (*TriggerUpdateRequest) ProtoMessage() {} func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[73] + mi := &file_daemon_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5068,7 +5510,7 @@ func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TriggerUpdateRequest.ProtoReflect.Descriptor instead. func (*TriggerUpdateRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{73} + return file_daemon_proto_rawDescGZIP(), []int{78} } type TriggerUpdateResponse struct { @@ -5081,7 +5523,7 @@ type TriggerUpdateResponse struct { func (x *TriggerUpdateResponse) Reset() { *x = TriggerUpdateResponse{} - mi := &file_daemon_proto_msgTypes[74] + mi := &file_daemon_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5093,7 +5535,7 @@ func (x *TriggerUpdateResponse) String() string { func (*TriggerUpdateResponse) ProtoMessage() {} func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[74] + mi := &file_daemon_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5106,7 +5548,7 @@ func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TriggerUpdateResponse.ProtoReflect.Descriptor instead. func (*TriggerUpdateResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{74} + return file_daemon_proto_rawDescGZIP(), []int{79} } func (x *TriggerUpdateResponse) GetSuccess() bool { @@ -5134,7 +5576,7 @@ type GetPeerSSHHostKeyRequest struct { func (x *GetPeerSSHHostKeyRequest) Reset() { *x = GetPeerSSHHostKeyRequest{} - mi := &file_daemon_proto_msgTypes[75] + mi := &file_daemon_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5146,7 +5588,7 @@ func (x *GetPeerSSHHostKeyRequest) String() string { func (*GetPeerSSHHostKeyRequest) ProtoMessage() {} func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[75] + mi := &file_daemon_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5159,7 +5601,7 @@ func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetPeerSSHHostKeyRequest.ProtoReflect.Descriptor instead. func (*GetPeerSSHHostKeyRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{75} + return file_daemon_proto_rawDescGZIP(), []int{80} } func (x *GetPeerSSHHostKeyRequest) GetPeerAddress() string { @@ -5186,7 +5628,7 @@ type GetPeerSSHHostKeyResponse struct { func (x *GetPeerSSHHostKeyResponse) Reset() { *x = GetPeerSSHHostKeyResponse{} - mi := &file_daemon_proto_msgTypes[76] + mi := &file_daemon_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5198,7 +5640,7 @@ func (x *GetPeerSSHHostKeyResponse) String() string { func (*GetPeerSSHHostKeyResponse) ProtoMessage() {} func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[76] + mi := &file_daemon_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5211,7 +5653,7 @@ func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetPeerSSHHostKeyResponse.ProtoReflect.Descriptor instead. func (*GetPeerSSHHostKeyResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{76} + return file_daemon_proto_rawDescGZIP(), []int{81} } func (x *GetPeerSSHHostKeyResponse) GetSshHostKey() []byte { @@ -5246,14 +5688,18 @@ func (x *GetPeerSSHHostKeyResponse) GetFound() bool { type RequestJWTAuthRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // hint for OIDC login_hint parameter (typically email address) - Hint *string `protobuf:"bytes,1,opt,name=hint,proto3,oneof" json:"hint,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Hint *string `protobuf:"bytes,1,opt,name=hint,proto3,oneof" json:"hint,omitempty"` + // hasGraphicalSession tells the daemon that the caller has a graphical session, + // which decides whether PKCE or the device code flow is preferred. The daemon + // cannot detect this itself: it does not inherit the session environment. + HasGraphicalSession bool `protobuf:"varint,2,opt,name=hasGraphicalSession,proto3" json:"hasGraphicalSession,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RequestJWTAuthRequest) Reset() { *x = RequestJWTAuthRequest{} - mi := &file_daemon_proto_msgTypes[77] + mi := &file_daemon_proto_msgTypes[82] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5265,7 +5711,7 @@ func (x *RequestJWTAuthRequest) String() string { func (*RequestJWTAuthRequest) ProtoMessage() {} func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[77] + mi := &file_daemon_proto_msgTypes[82] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5278,7 +5724,7 @@ func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RequestJWTAuthRequest.ProtoReflect.Descriptor instead. func (*RequestJWTAuthRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{77} + return file_daemon_proto_rawDescGZIP(), []int{82} } func (x *RequestJWTAuthRequest) GetHint() string { @@ -5288,6 +5734,13 @@ func (x *RequestJWTAuthRequest) GetHint() string { return "" } +func (x *RequestJWTAuthRequest) GetHasGraphicalSession() bool { + if x != nil { + return x.HasGraphicalSession + } + return false +} + // RequestJWTAuthResponse contains authentication flow information type RequestJWTAuthResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -5311,7 +5764,7 @@ type RequestJWTAuthResponse struct { func (x *RequestJWTAuthResponse) Reset() { *x = RequestJWTAuthResponse{} - mi := &file_daemon_proto_msgTypes[78] + mi := &file_daemon_proto_msgTypes[83] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5323,7 +5776,7 @@ func (x *RequestJWTAuthResponse) String() string { func (*RequestJWTAuthResponse) ProtoMessage() {} func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[78] + mi := &file_daemon_proto_msgTypes[83] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5336,7 +5789,7 @@ func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RequestJWTAuthResponse.ProtoReflect.Descriptor instead. func (*RequestJWTAuthResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{78} + return file_daemon_proto_rawDescGZIP(), []int{83} } func (x *RequestJWTAuthResponse) GetVerificationURI() string { @@ -5401,7 +5854,7 @@ type WaitJWTTokenRequest struct { func (x *WaitJWTTokenRequest) Reset() { *x = WaitJWTTokenRequest{} - mi := &file_daemon_proto_msgTypes[79] + mi := &file_daemon_proto_msgTypes[84] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5413,7 +5866,7 @@ func (x *WaitJWTTokenRequest) String() string { func (*WaitJWTTokenRequest) ProtoMessage() {} func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[79] + mi := &file_daemon_proto_msgTypes[84] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5426,7 +5879,7 @@ func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WaitJWTTokenRequest.ProtoReflect.Descriptor instead. func (*WaitJWTTokenRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{79} + return file_daemon_proto_rawDescGZIP(), []int{84} } func (x *WaitJWTTokenRequest) GetDeviceCode() string { @@ -5458,7 +5911,7 @@ type WaitJWTTokenResponse struct { func (x *WaitJWTTokenResponse) Reset() { *x = WaitJWTTokenResponse{} - mi := &file_daemon_proto_msgTypes[80] + mi := &file_daemon_proto_msgTypes[85] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5470,7 +5923,7 @@ func (x *WaitJWTTokenResponse) String() string { func (*WaitJWTTokenResponse) ProtoMessage() {} func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[80] + mi := &file_daemon_proto_msgTypes[85] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5483,7 +5936,7 @@ func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WaitJWTTokenResponse.ProtoReflect.Descriptor instead. func (*WaitJWTTokenResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{80} + return file_daemon_proto_rawDescGZIP(), []int{85} } func (x *WaitJWTTokenResponse) GetToken() string { @@ -5507,6 +5960,329 @@ func (x *WaitJWTTokenResponse) GetExpiresIn() int64 { return 0 } +// RequestExtendAuthSessionRequest kicks off the session-extension SSO flow. +type RequestExtendAuthSessionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Optional OIDC login_hint (typically the user's email) to pre-fill the + // IdP login form. + Hint *string `protobuf:"bytes,1,opt,name=hint,proto3,oneof" json:"hint,omitempty"` + // hasGraphicalSession tells the daemon that the caller has a graphical session, + // which decides whether PKCE or the device code flow is preferred. The daemon + // cannot detect this itself: it does not inherit the session environment. + HasGraphicalSession bool `protobuf:"varint,2,opt,name=hasGraphicalSession,proto3" json:"hasGraphicalSession,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RequestExtendAuthSessionRequest) Reset() { + *x = RequestExtendAuthSessionRequest{} + mi := &file_daemon_proto_msgTypes[86] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RequestExtendAuthSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RequestExtendAuthSessionRequest) ProtoMessage() {} + +func (x *RequestExtendAuthSessionRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[86] + 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 RequestExtendAuthSessionRequest.ProtoReflect.Descriptor instead. +func (*RequestExtendAuthSessionRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{86} +} + +func (x *RequestExtendAuthSessionRequest) GetHint() string { + if x != nil && x.Hint != nil { + return *x.Hint + } + return "" +} + +func (x *RequestExtendAuthSessionRequest) GetHasGraphicalSession() bool { + if x != nil { + return x.HasGraphicalSession + } + return false +} + +// RequestExtendAuthSessionResponse carries the verification URI the UI +// should open in a browser. The daemon retains the flow state and resolves +// it via WaitExtendAuthSession. +type RequestExtendAuthSessionResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // verification URI for the user to open in the browser + VerificationURI string `protobuf:"bytes,1,opt,name=verificationURI,proto3" json:"verificationURI,omitempty"` + // complete verification URI (with embedded user code) + VerificationURIComplete string `protobuf:"bytes,2,opt,name=verificationURIComplete,proto3" json:"verificationURIComplete,omitempty"` + // user code to enter on verification URI (for device-code flows) + UserCode string `protobuf:"bytes,3,opt,name=userCode,proto3" json:"userCode,omitempty"` + // device code for matching the WaitExtendAuthSession call to this flow + DeviceCode string `protobuf:"bytes,4,opt,name=deviceCode,proto3" json:"deviceCode,omitempty"` + // expiration time in seconds for the device code / PKCE flow + ExpiresIn int64 `protobuf:"varint,5,opt,name=expiresIn,proto3" json:"expiresIn,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RequestExtendAuthSessionResponse) Reset() { + *x = RequestExtendAuthSessionResponse{} + mi := &file_daemon_proto_msgTypes[87] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RequestExtendAuthSessionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RequestExtendAuthSessionResponse) ProtoMessage() {} + +func (x *RequestExtendAuthSessionResponse) 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 RequestExtendAuthSessionResponse.ProtoReflect.Descriptor instead. +func (*RequestExtendAuthSessionResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{87} +} + +func (x *RequestExtendAuthSessionResponse) GetVerificationURI() string { + if x != nil { + return x.VerificationURI + } + return "" +} + +func (x *RequestExtendAuthSessionResponse) GetVerificationURIComplete() string { + if x != nil { + return x.VerificationURIComplete + } + return "" +} + +func (x *RequestExtendAuthSessionResponse) GetUserCode() string { + if x != nil { + return x.UserCode + } + return "" +} + +func (x *RequestExtendAuthSessionResponse) GetDeviceCode() string { + if x != nil { + return x.DeviceCode + } + return "" +} + +func (x *RequestExtendAuthSessionResponse) GetExpiresIn() int64 { + if x != nil { + return x.ExpiresIn + } + return 0 +} + +// WaitExtendAuthSessionRequest is sent by the UI after it opens the +// verification URI. The daemon blocks on this call until the user +// completes (or aborts) the SSO step. +type WaitExtendAuthSessionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // device code returned by RequestExtendAuthSession + DeviceCode string `protobuf:"bytes,1,opt,name=deviceCode,proto3" json:"deviceCode,omitempty"` + // user code for verification + UserCode string `protobuf:"bytes,2,opt,name=userCode,proto3" json:"userCode,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WaitExtendAuthSessionRequest) Reset() { + *x = WaitExtendAuthSessionRequest{} + mi := &file_daemon_proto_msgTypes[88] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WaitExtendAuthSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WaitExtendAuthSessionRequest) ProtoMessage() {} + +func (x *WaitExtendAuthSessionRequest) 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 WaitExtendAuthSessionRequest.ProtoReflect.Descriptor instead. +func (*WaitExtendAuthSessionRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{88} +} + +func (x *WaitExtendAuthSessionRequest) GetDeviceCode() string { + if x != nil { + return x.DeviceCode + } + return "" +} + +func (x *WaitExtendAuthSessionRequest) GetUserCode() string { + if x != nil { + return x.UserCode + } + return "" +} + +// WaitExtendAuthSessionResponse carries the refreshed deadline returned +// by the management server. Unset when the management server reports the +// peer is not eligible for session extension. +type WaitExtendAuthSessionResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionExpiresAt *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=sessionExpiresAt,proto3" json:"sessionExpiresAt,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WaitExtendAuthSessionResponse) Reset() { + *x = WaitExtendAuthSessionResponse{} + mi := &file_daemon_proto_msgTypes[89] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WaitExtendAuthSessionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WaitExtendAuthSessionResponse) ProtoMessage() {} + +func (x *WaitExtendAuthSessionResponse) 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 WaitExtendAuthSessionResponse.ProtoReflect.Descriptor instead. +func (*WaitExtendAuthSessionResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{89} +} + +func (x *WaitExtendAuthSessionResponse) GetSessionExpiresAt() *timestamppb.Timestamp { + if x != nil { + return x.SessionExpiresAt + } + return nil +} + +// DismissSessionWarningRequest is sent by the UI when the user clicks +// "Dismiss" on the T-WarningLead notification. +type DismissSessionWarningRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DismissSessionWarningRequest) Reset() { + *x = DismissSessionWarningRequest{} + mi := &file_daemon_proto_msgTypes[90] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DismissSessionWarningRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DismissSessionWarningRequest) ProtoMessage() {} + +func (x *DismissSessionWarningRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[90] + 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 DismissSessionWarningRequest.ProtoReflect.Descriptor instead. +func (*DismissSessionWarningRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{90} +} + +// DismissSessionWarningResponse acknowledges the dismissal. Carries no +// payload — the daemon's only obligation is to silence the upcoming +// T-FinalWarningLead fallback for the current deadline. +type DismissSessionWarningResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DismissSessionWarningResponse) Reset() { + *x = DismissSessionWarningResponse{} + mi := &file_daemon_proto_msgTypes[91] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DismissSessionWarningResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DismissSessionWarningResponse) ProtoMessage() {} + +func (x *DismissSessionWarningResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[91] + 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 DismissSessionWarningResponse.ProtoReflect.Descriptor instead. +func (*DismissSessionWarningResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{91} +} + // StartCPUProfileRequest for starting CPU profiling type StartCPUProfileRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -5516,7 +6292,7 @@ type StartCPUProfileRequest struct { func (x *StartCPUProfileRequest) Reset() { *x = StartCPUProfileRequest{} - mi := &file_daemon_proto_msgTypes[81] + mi := &file_daemon_proto_msgTypes[92] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5528,7 +6304,7 @@ func (x *StartCPUProfileRequest) String() string { func (*StartCPUProfileRequest) ProtoMessage() {} func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[81] + mi := &file_daemon_proto_msgTypes[92] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5541,7 +6317,7 @@ func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCPUProfileRequest.ProtoReflect.Descriptor instead. func (*StartCPUProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{81} + return file_daemon_proto_rawDescGZIP(), []int{92} } // StartCPUProfileResponse confirms CPU profiling has started @@ -5553,7 +6329,7 @@ type StartCPUProfileResponse struct { func (x *StartCPUProfileResponse) Reset() { *x = StartCPUProfileResponse{} - mi := &file_daemon_proto_msgTypes[82] + mi := &file_daemon_proto_msgTypes[93] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5565,7 +6341,7 @@ func (x *StartCPUProfileResponse) String() string { func (*StartCPUProfileResponse) ProtoMessage() {} func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[82] + mi := &file_daemon_proto_msgTypes[93] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5578,7 +6354,7 @@ func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCPUProfileResponse.ProtoReflect.Descriptor instead. func (*StartCPUProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{82} + return file_daemon_proto_rawDescGZIP(), []int{93} } // StopCPUProfileRequest for stopping CPU profiling @@ -5590,7 +6366,7 @@ type StopCPUProfileRequest struct { func (x *StopCPUProfileRequest) Reset() { *x = StopCPUProfileRequest{} - mi := &file_daemon_proto_msgTypes[83] + mi := &file_daemon_proto_msgTypes[94] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5602,7 +6378,7 @@ func (x *StopCPUProfileRequest) String() string { func (*StopCPUProfileRequest) ProtoMessage() {} func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[83] + mi := &file_daemon_proto_msgTypes[94] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5615,7 +6391,7 @@ func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StopCPUProfileRequest.ProtoReflect.Descriptor instead. func (*StopCPUProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{83} + return file_daemon_proto_rawDescGZIP(), []int{94} } // StopCPUProfileResponse confirms CPU profiling has stopped @@ -5627,7 +6403,7 @@ type StopCPUProfileResponse struct { func (x *StopCPUProfileResponse) Reset() { *x = StopCPUProfileResponse{} - mi := &file_daemon_proto_msgTypes[84] + mi := &file_daemon_proto_msgTypes[95] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5639,7 +6415,7 @@ func (x *StopCPUProfileResponse) String() string { func (*StopCPUProfileResponse) ProtoMessage() {} func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[84] + mi := &file_daemon_proto_msgTypes[95] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5652,7 +6428,7 @@ func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StopCPUProfileResponse.ProtoReflect.Descriptor instead. func (*StopCPUProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{84} + return file_daemon_proto_rawDescGZIP(), []int{95} } type InstallerResultRequest struct { @@ -5663,7 +6439,7 @@ type InstallerResultRequest struct { func (x *InstallerResultRequest) Reset() { *x = InstallerResultRequest{} - mi := &file_daemon_proto_msgTypes[85] + mi := &file_daemon_proto_msgTypes[96] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5675,7 +6451,7 @@ func (x *InstallerResultRequest) String() string { func (*InstallerResultRequest) ProtoMessage() {} func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[85] + mi := &file_daemon_proto_msgTypes[96] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5688,7 +6464,7 @@ func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use InstallerResultRequest.ProtoReflect.Descriptor instead. func (*InstallerResultRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{85} + return file_daemon_proto_rawDescGZIP(), []int{96} } type InstallerResultResponse struct { @@ -5701,7 +6477,7 @@ type InstallerResultResponse struct { func (x *InstallerResultResponse) Reset() { *x = InstallerResultResponse{} - mi := &file_daemon_proto_msgTypes[86] + mi := &file_daemon_proto_msgTypes[97] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5713,7 +6489,7 @@ func (x *InstallerResultResponse) String() string { func (*InstallerResultResponse) ProtoMessage() {} func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[86] + mi := &file_daemon_proto_msgTypes[97] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5726,7 +6502,7 @@ func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use InstallerResultResponse.ProtoReflect.Descriptor instead. func (*InstallerResultResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{86} + return file_daemon_proto_rawDescGZIP(), []int{97} } func (x *InstallerResultResponse) GetSuccess() bool { @@ -5759,7 +6535,7 @@ type ExposeServiceRequest struct { func (x *ExposeServiceRequest) Reset() { *x = ExposeServiceRequest{} - mi := &file_daemon_proto_msgTypes[87] + mi := &file_daemon_proto_msgTypes[98] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5771,7 +6547,7 @@ func (x *ExposeServiceRequest) String() string { func (*ExposeServiceRequest) ProtoMessage() {} func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[87] + mi := &file_daemon_proto_msgTypes[98] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5784,7 +6560,7 @@ func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceRequest.ProtoReflect.Descriptor instead. func (*ExposeServiceRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{87} + return file_daemon_proto_rawDescGZIP(), []int{98} } func (x *ExposeServiceRequest) GetPort() uint32 { @@ -5855,7 +6631,7 @@ type ExposeServiceEvent struct { func (x *ExposeServiceEvent) Reset() { *x = ExposeServiceEvent{} - mi := &file_daemon_proto_msgTypes[88] + mi := &file_daemon_proto_msgTypes[99] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5867,7 +6643,7 @@ func (x *ExposeServiceEvent) String() string { func (*ExposeServiceEvent) ProtoMessage() {} func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[88] + mi := &file_daemon_proto_msgTypes[99] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5880,7 +6656,7 @@ func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceEvent.ProtoReflect.Descriptor instead. func (*ExposeServiceEvent) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{88} + return file_daemon_proto_rawDescGZIP(), []int{99} } func (x *ExposeServiceEvent) GetEvent() isExposeServiceEvent_Event { @@ -5921,7 +6697,7 @@ type ExposeServiceReady struct { func (x *ExposeServiceReady) Reset() { *x = ExposeServiceReady{} - mi := &file_daemon_proto_msgTypes[89] + mi := &file_daemon_proto_msgTypes[100] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5933,7 +6709,7 @@ func (x *ExposeServiceReady) String() string { func (*ExposeServiceReady) ProtoMessage() {} func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[89] + mi := &file_daemon_proto_msgTypes[100] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5946,7 +6722,7 @@ func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceReady.ProtoReflect.Descriptor instead. func (*ExposeServiceReady) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{89} + return file_daemon_proto_rawDescGZIP(), []int{100} } func (x *ExposeServiceReady) GetServiceName() string { @@ -5977,6 +6753,288 @@ func (x *ExposeServiceReady) GetPortAutoAssigned() bool { return false } +type StartCaptureRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + TextOutput bool `protobuf:"varint,1,opt,name=text_output,json=textOutput,proto3" json:"text_output,omitempty"` + SnapLen uint32 `protobuf:"varint,2,opt,name=snap_len,json=snapLen,proto3" json:"snap_len,omitempty"` + Duration *durationpb.Duration `protobuf:"bytes,3,opt,name=duration,proto3" json:"duration,omitempty"` + FilterExpr string `protobuf:"bytes,4,opt,name=filter_expr,json=filterExpr,proto3" json:"filter_expr,omitempty"` + Verbose bool `protobuf:"varint,5,opt,name=verbose,proto3" json:"verbose,omitempty"` + Ascii bool `protobuf:"varint,6,opt,name=ascii,proto3" json:"ascii,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StartCaptureRequest) Reset() { + *x = StartCaptureRequest{} + mi := &file_daemon_proto_msgTypes[101] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StartCaptureRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StartCaptureRequest) ProtoMessage() {} + +func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[101] + 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 StartCaptureRequest.ProtoReflect.Descriptor instead. +func (*StartCaptureRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{101} +} + +func (x *StartCaptureRequest) GetTextOutput() bool { + if x != nil { + return x.TextOutput + } + return false +} + +func (x *StartCaptureRequest) GetSnapLen() uint32 { + if x != nil { + return x.SnapLen + } + return 0 +} + +func (x *StartCaptureRequest) GetDuration() *durationpb.Duration { + if x != nil { + return x.Duration + } + return nil +} + +func (x *StartCaptureRequest) GetFilterExpr() string { + if x != nil { + return x.FilterExpr + } + return "" +} + +func (x *StartCaptureRequest) GetVerbose() bool { + if x != nil { + return x.Verbose + } + return false +} + +func (x *StartCaptureRequest) GetAscii() bool { + if x != nil { + return x.Ascii + } + return false +} + +type CapturePacket struct { + state protoimpl.MessageState `protogen:"open.v1"` + Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CapturePacket) Reset() { + *x = CapturePacket{} + mi := &file_daemon_proto_msgTypes[102] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CapturePacket) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CapturePacket) ProtoMessage() {} + +func (x *CapturePacket) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[102] + 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 CapturePacket.ProtoReflect.Descriptor instead. +func (*CapturePacket) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{102} +} + +func (x *CapturePacket) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +type StartBundleCaptureRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // timeout auto-stops the capture after this duration. + // Clamped to a server-side maximum (10 minutes). Zero or unset defaults to the maximum. + Timeout *durationpb.Duration `protobuf:"bytes,1,opt,name=timeout,proto3" json:"timeout,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StartBundleCaptureRequest) Reset() { + *x = StartBundleCaptureRequest{} + mi := &file_daemon_proto_msgTypes[103] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StartBundleCaptureRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StartBundleCaptureRequest) ProtoMessage() {} + +func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[103] + 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 StartBundleCaptureRequest.ProtoReflect.Descriptor instead. +func (*StartBundleCaptureRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{103} +} + +func (x *StartBundleCaptureRequest) GetTimeout() *durationpb.Duration { + if x != nil { + return x.Timeout + } + return nil +} + +type StartBundleCaptureResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StartBundleCaptureResponse) Reset() { + *x = StartBundleCaptureResponse{} + mi := &file_daemon_proto_msgTypes[104] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StartBundleCaptureResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StartBundleCaptureResponse) ProtoMessage() {} + +func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[104] + 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 StartBundleCaptureResponse.ProtoReflect.Descriptor instead. +func (*StartBundleCaptureResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{104} +} + +type StopBundleCaptureRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StopBundleCaptureRequest) Reset() { + *x = StopBundleCaptureRequest{} + mi := &file_daemon_proto_msgTypes[105] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StopBundleCaptureRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StopBundleCaptureRequest) ProtoMessage() {} + +func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[105] + 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 StopBundleCaptureRequest.ProtoReflect.Descriptor instead. +func (*StopBundleCaptureRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{105} +} + +type StopBundleCaptureResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StopBundleCaptureResponse) Reset() { + *x = StopBundleCaptureResponse{} + mi := &file_daemon_proto_msgTypes[106] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StopBundleCaptureResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StopBundleCaptureResponse) ProtoMessage() {} + +func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[106] + 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 StopBundleCaptureResponse.ProtoReflect.Descriptor instead. +func (*StopBundleCaptureResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{106} +} + type PortInfo_Range struct { state protoimpl.MessageState `protogen:"open.v1"` Start uint32 `protobuf:"varint,1,opt,name=start,proto3" json:"start,omitempty"` @@ -5987,7 +7045,7 @@ type PortInfo_Range struct { func (x *PortInfo_Range) Reset() { *x = PortInfo_Range{} - mi := &file_daemon_proto_msgTypes[91] + mi := &file_daemon_proto_msgTypes[108] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5999,7 +7057,7 @@ func (x *PortInfo_Range) String() string { func (*PortInfo_Range) ProtoMessage() {} func (x *PortInfo_Range) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[91] + mi := &file_daemon_proto_msgTypes[108] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6012,7 +7070,7 @@ func (x *PortInfo_Range) ProtoReflect() protoreflect.Message { // Deprecated: Use PortInfo_Range.ProtoReflect.Descriptor instead. func (*PortInfo_Range) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{30, 0} + return file_daemon_proto_rawDescGZIP(), []int{28, 0} } func (x *PortInfo_Range) GetStart() uint32 { @@ -6034,15 +7092,7 @@ var File_daemon_proto protoreflect.FileDescriptor const file_daemon_proto_rawDesc = "" + "\n" + "\fdaemon.proto\x12\x06daemon\x1a google/protobuf/descriptor.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/duration.proto\"\x0e\n" + - "\fEmptyRequest\"\x7f\n" + - "\x12OSLifecycleRequest\x128\n" + - "\x04type\x18\x01 \x01(\x0e2$.daemon.OSLifecycleRequest.CycleTypeR\x04type\"/\n" + - "\tCycleType\x12\v\n" + - "\aUNKNOWN\x10\x00\x12\t\n" + - "\x05SLEEP\x10\x01\x12\n" + - "\n" + - "\x06WAKEUP\x10\x02\"\x15\n" + - "\x13OSLifecycleResponse\"\xb6\x12\n" + + "\fEmptyRequest\"\xdb\x14\n" + "\fLoginRequest\x12\x1a\n" + "\bsetupKey\x18\x01 \x01(\tR\bsetupKey\x12&\n" + "\fpreSharedKey\x18\x02 \x01(\tB\x02\x18\x01R\fpreSharedKey\x12$\n" + @@ -6086,7 +7136,11 @@ const file_daemon_proto_rawDesc = "" + "\x1cenableSSHLocalPortForwarding\x18$ \x01(\bH\x17R\x1cenableSSHLocalPortForwarding\x88\x01\x01\x12I\n" + "\x1denableSSHRemotePortForwarding\x18% \x01(\bH\x18R\x1denableSSHRemotePortForwarding\x88\x01\x01\x12+\n" + "\x0edisableSSHAuth\x18& \x01(\bH\x19R\x0edisableSSHAuth\x88\x01\x01\x12+\n" + - "\x0esshJWTCacheTTL\x18' \x01(\x05H\x1aR\x0esshJWTCacheTTL\x88\x01\x01B\x13\n" + + "\x0esshJWTCacheTTL\x18' \x01(\x05H\x1aR\x0esshJWTCacheTTL\x88\x01\x01\x12&\n" + + "\fdisable_ipv6\x18( \x01(\bH\x1bR\vdisableIpv6\x88\x01\x01\x125\n" + + "\x14enable_local_metrics\x18) \x01(\bH\x1cR\x12enableLocalMetrics\x88\x01\x01\x127\n" + + "\x15local_metrics_address\x18* \x01(\tH\x1dR\x13localMetricsAddress\x88\x01\x01\x121\n" + + "\x11remoteJobsAllowed\x18+ \x01(\bH\x1eR\x11remoteJobsAllowed\x88\x01\x01B\x13\n" + "\x11_rosenpassEnabledB\x10\n" + "\x0e_interfaceNameB\x10\n" + "\x0e_wireguardPortB\x17\n" + @@ -6113,7 +7167,11 @@ const file_daemon_proto_rawDesc = "" + "\x1d_enableSSHLocalPortForwardingB \n" + "\x1e_enableSSHRemotePortForwardingB\x11\n" + "\x0f_disableSSHAuthB\x11\n" + - "\x0f_sshJWTCacheTTL\"\xb5\x01\n" + + "\x0f_sshJWTCacheTTLB\x0f\n" + + "\r_disable_ipv6B\x17\n" + + "\x15_enable_local_metricsB\x18\n" + + "\x16_local_metrics_addressB\x14\n" + + "\x12_remoteJobsAllowed\"\xb5\x01\n" + "\rLoginResponse\x12$\n" + "\rneedsSSOLogin\x18\x01 \x01(\bR\rneedsSSOLogin\x12\x1a\n" + "\buserCode\x18\x02 \x01(\tR\buserCode\x12(\n" + @@ -6123,10 +7181,11 @@ 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\"v\n" + + "\x05email\x18\x01 \x01(\tR\x05email\"\x8c\x01\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\x01B\x0e\n" + + "\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01\x12\x14\n" + + "\x05async\x18\x04 \x01(\bR\x05asyncB\x0e\n" + "\f_profileNameB\v\n" + "\t_usernameJ\x04\b\x03\x10\x04\"\f\n" + "\n" + @@ -6135,18 +7194,19 @@ const file_daemon_proto_rawDesc = "" + "\x11getFullPeerStatus\x18\x01 \x01(\bR\x11getFullPeerStatus\x12(\n" + "\x0fshouldRunProbes\x18\x02 \x01(\bR\x0fshouldRunProbes\x12'\n" + "\fwaitForReady\x18\x03 \x01(\bH\x00R\fwaitForReady\x88\x01\x01B\x0f\n" + - "\r_waitForReady\"\x82\x01\n" + + "\r_waitForReady\"\xca\x01\n" + "\x0eStatusResponse\x12\x16\n" + "\x06status\x18\x01 \x01(\tR\x06status\x122\n" + "\n" + "fullStatus\x18\x02 \x01(\v2\x12.daemon.FullStatusR\n" + "fullStatus\x12$\n" + - "\rdaemonVersion\x18\x03 \x01(\tR\rdaemonVersion\"\r\n" + + "\rdaemonVersion\x18\x03 \x01(\tR\rdaemonVersion\x12F\n" + + "\x10sessionExpiresAt\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x10sessionExpiresAt\"\r\n" + "\vDownRequest\"\x0e\n" + "\fDownResponse\"P\n" + "\x10GetConfigRequest\x12 \n" + "\vprofileName\x18\x01 \x01(\tR\vprofileName\x12\x1a\n" + - "\busername\x18\x02 \x01(\tR\busername\"\xdb\b\n" + + "\busername\x18\x02 \x01(\tR\busername\"\xd8\t\n" + "\x11GetConfigResponse\x12$\n" + "\rmanagementUrl\x18\x01 \x01(\tR\rmanagementUrl\x12\x1e\n" + "\n" + @@ -6177,7 +7237,10 @@ const file_daemon_proto_rawDesc = "" + "\x1cenableSSHLocalPortForwarding\x18\x16 \x01(\bR\x1cenableSSHLocalPortForwarding\x12D\n" + "\x1denableSSHRemotePortForwarding\x18\x17 \x01(\bR\x1denableSSHRemotePortForwarding\x12&\n" + "\x0edisableSSHAuth\x18\x19 \x01(\bR\x0edisableSSHAuth\x12&\n" + - "\x0esshJWTCacheTTL\x18\x1a \x01(\x05R\x0esshJWTCacheTTL\"\xfe\x05\n" + + "\x0esshJWTCacheTTL\x18\x1a \x01(\x05R\x0esshJWTCacheTTL\x12!\n" + + "\fdisable_ipv6\x18\x1b \x01(\bR\vdisableIpv6\x12,\n" + + "\x11remoteJobsAllowed\x18\x1d \x01(\bR\x11remoteJobsAllowed\x12*\n" + + "\x10mDMManagedFields\x18\x1c \x03(\tR\x10mDMManagedFields\"\x92\x06\n" + "\tPeerState\x12\x0e\n" + "\x02IP\x18\x01 \x01(\tR\x02IP\x12\x16\n" + "\x06pubKey\x18\x02 \x01(\tR\x06pubKey\x12\x1e\n" + @@ -6201,7 +7264,8 @@ const file_daemon_proto_rawDesc = "" + "\frelayAddress\x18\x12 \x01(\tR\frelayAddress\x12\x1e\n" + "\n" + "sshHostKey\x18\x13 \x01(\fR\n" + - "sshHostKey\"\xf0\x01\n" + + "sshHostKey\x12\x12\n" + + "\x04ipv6\x18\x14 \x01(\tR\x04ipv6\"\x9c\x02\n" + "\x0eLocalPeerState\x12\x0e\n" + "\x02IP\x18\x01 \x01(\tR\x02IP\x12\x16\n" + "\x06pubKey\x18\x02 \x01(\tR\x06pubKey\x12(\n" + @@ -6209,7 +7273,9 @@ const file_daemon_proto_rawDesc = "" + "\x04fqdn\x18\x04 \x01(\tR\x04fqdn\x12*\n" + "\x10rosenpassEnabled\x18\x05 \x01(\bR\x10rosenpassEnabled\x120\n" + "\x13rosenpassPermissive\x18\x06 \x01(\bR\x13rosenpassPermissive\x12\x1a\n" + - "\bnetworks\x18\a \x03(\tR\bnetworks\"S\n" + + "\bnetworks\x18\a \x03(\tR\bnetworks\x12\x12\n" + + "\x04ipv6\x18\b \x01(\tR\x04ipv6\x12\x16\n" + + "\x06wgPort\x18\t \x01(\x05R\x06wgPort\"S\n" + "\vSignalState\x12\x10\n" + "\x03URL\x18\x01 \x01(\tR\x03URL\x12\x1c\n" + "\tconnected\x18\x02 \x01(\bR\tconnected\x12\x14\n" + @@ -6217,12 +7283,13 @@ const file_daemon_proto_rawDesc = "" + "\x0fManagementState\x12\x10\n" + "\x03URL\x18\x01 \x01(\tR\x03URL\x12\x1c\n" + "\tconnected\x18\x02 \x01(\bR\tconnected\x12\x14\n" + - "\x05error\x18\x03 \x01(\tR\x05error\"R\n" + + "\x05error\x18\x03 \x01(\tR\x05error\"p\n" + "\n" + "RelayState\x12\x10\n" + "\x03URI\x18\x01 \x01(\tR\x03URI\x12\x1c\n" + "\tavailable\x18\x02 \x01(\bR\tavailable\x12\x14\n" + - "\x05error\x18\x03 \x01(\tR\x05error\"r\n" + + "\x05error\x18\x03 \x01(\tR\x05error\x12\x1c\n" + + "\ttransport\x18\x04 \x01(\tR\ttransport\"r\n" + "\fNSGroupState\x12\x18\n" + "\aservers\x18\x01 \x03(\tR\aservers\x12\x18\n" + "\adomains\x18\x02 \x03(\tR\adomains\x12\x18\n" + @@ -6236,7 +7303,7 @@ const file_daemon_proto_rawDesc = "" + "\fportForwards\x18\x05 \x03(\tR\fportForwards\"^\n" + "\x0eSSHServerState\x12\x18\n" + "\aenabled\x18\x01 \x01(\bR\aenabled\x122\n" + - "\bsessions\x18\x02 \x03(\v2\x16.daemon.SSHSessionInfoR\bsessions\"\xaf\x04\n" + + "\bsessions\x18\x02 \x03(\v2\x16.daemon.SSHSessionInfoR\bsessions\"\xdb\x04\n" + "\n" + "FullStatus\x12A\n" + "\x0fmanagementState\x18\x01 \x01(\v2\x17.daemon.ManagementStateR\x0fmanagementState\x125\n" + @@ -6250,7 +7317,8 @@ const file_daemon_proto_rawDesc = "" + "\x06events\x18\a \x03(\v2\x13.daemon.SystemEventR\x06events\x124\n" + "\x15lazyConnectionEnabled\x18\t \x01(\bR\x15lazyConnectionEnabled\x12>\n" + "\x0esshServerState\x18\n" + - " \x01(\v2\x16.daemon.SSHServerStateR\x0esshServerState\"\x15\n" + + " \x01(\v2\x16.daemon.SSHServerStateR\x0esshServerState\x12*\n" + + "\x10networksRevision\x18\v \x01(\x04R\x10networksRevision\"\x15\n" + "\x13ListNetworksRequest\"?\n" + "\x14ListNetworksResponse\x12'\n" + "\x06routes\x18\x01 \x03(\v2\x0f.daemon.NetworkR\x06routes\"a\n" + @@ -6286,14 +7354,19 @@ const file_daemon_proto_rawDesc = "" + "\x12translatedHostname\x18\x04 \x01(\tR\x12translatedHostname\x128\n" + "\x0etranslatedPort\x18\x05 \x01(\v2\x10.daemon.PortInfoR\x0etranslatedPort\"G\n" + "\x17ForwardingRulesResponse\x12,\n" + - "\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\x94\x01\n" + + "\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\x84\x02\n" + "\x12DebugBundleRequest\x12\x1c\n" + "\tanonymize\x18\x01 \x01(\bR\tanonymize\x12\x1e\n" + "\n" + "systemInfo\x18\x03 \x01(\bR\n" + "systemInfo\x12\x1c\n" + "\tuploadURL\x18\x04 \x01(\tR\tuploadURL\x12\"\n" + - "\flogFileCount\x18\x05 \x01(\rR\flogFileCount\"}\n" + + "\flogFileCount\x18\x05 \x01(\rR\flogFileCount\x12\x1e\n" + + "\n" + + "cliVersion\x18\x06 \x01(\tR\n" + + "cliVersion\x12&\n" + + "\x0euploadInsecure\x18\a \x01(\bR\x0euploadInsecure\x12&\n" + + "\x0eanonymizeLevel\x18\b \x01(\tR\x0eanonymizeLevel\"}\n" + "\x13DebugBundleResponse\x12\x12\n" + "\x04path\x18\x01 \x01(\tR\x04path\x12 \n" + "\vuploadedKey\x18\x02 \x01(\tR\vuploadedKey\x120\n" + @@ -6303,7 +7376,10 @@ const file_daemon_proto_rawDesc = "" + "\x05level\x18\x01 \x01(\x0e2\x10.daemon.LogLevelR\x05level\"<\n" + "\x12SetLogLevelRequest\x12&\n" + "\x05level\x18\x01 \x01(\x0e2\x10.daemon.LogLevelR\x05level\"\x15\n" + - "\x13SetLogLevelResponse\"\x1b\n" + + "\x13SetLogLevelResponse\"*\n" + + "\x14RegisterUILogRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\"\x17\n" + + "\x15RegisterUILogResponse\"\x1b\n" + "\x05State\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\"\x13\n" + "\x11ListStatesRequest\";\n" + @@ -6389,8 +7465,9 @@ const file_daemon_proto_rawDesc = "" + "\vprofileName\x18\x01 \x01(\tH\x00R\vprofileName\x88\x01\x01\x12\x1f\n" + "\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01B\x0e\n" + "\f_profileNameB\v\n" + - "\t_username\"\x17\n" + - "\x15SwitchProfileResponse\"\xdf\x10\n" + + "\t_username\"'\n" + + "\x15SwitchProfileResponse\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"\x84\x13\n" + "\x10SetConfigRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\x12 \n" + "\vprofileName\x18\x02 \x01(\tR\vprofileName\x12$\n" + @@ -6429,7 +7506,11 @@ const file_daemon_proto_rawDesc = "" + "\x1cenableSSHLocalPortForwarding\x18\x1f \x01(\bH\x14R\x1cenableSSHLocalPortForwarding\x88\x01\x01\x12I\n" + "\x1denableSSHRemotePortForwarding\x18 \x01(\bH\x15R\x1denableSSHRemotePortForwarding\x88\x01\x01\x12+\n" + "\x0edisableSSHAuth\x18! \x01(\bH\x16R\x0edisableSSHAuth\x88\x01\x01\x12+\n" + - "\x0esshJWTCacheTTL\x18\" \x01(\x05H\x17R\x0esshJWTCacheTTL\x88\x01\x01B\x13\n" + + "\x0esshJWTCacheTTL\x18\" \x01(\x05H\x17R\x0esshJWTCacheTTL\x88\x01\x01\x12&\n" + + "\fdisable_ipv6\x18# \x01(\bH\x18R\vdisableIpv6\x88\x01\x01\x125\n" + + "\x14enable_local_metrics\x18$ \x01(\bH\x19R\x12enableLocalMetrics\x88\x01\x01\x127\n" + + "\x15local_metrics_address\x18% \x01(\tH\x1aR\x13localMetricsAddress\x88\x01\x01\x121\n" + + "\x11remoteJobsAllowed\x18& \x01(\bH\x1bR\x11remoteJobsAllowed\x88\x01\x01B\x13\n" + "\x11_rosenpassEnabledB\x10\n" + "\x0e_interfaceNameB\x10\n" + "\x0e_wireguardPortB\x17\n" + @@ -6453,38 +7534,58 @@ const file_daemon_proto_rawDesc = "" + "\x1d_enableSSHLocalPortForwardingB \n" + "\x1e_enableSSHRemotePortForwardingB\x11\n" + "\x0f_disableSSHAuthB\x11\n" + - "\x0f_sshJWTCacheTTL\"\x13\n" + + "\x0f_sshJWTCacheTTLB\x0f\n" + + "\r_disable_ipv6B\x17\n" + + "\x15_enable_local_metricsB\x18\n" + + "\x16_local_metrics_addressB\x14\n" + + "\x12_remoteJobsAllowed\"\x13\n" + "\x11SetConfigResponse\"Q\n" + "\x11AddProfileRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\x12 \n" + - "\vprofileName\x18\x02 \x01(\tR\vprofileName\"\x14\n" + - "\x12AddProfileResponse\"T\n" + + "\vprofileName\x18\x02 \x01(\tR\vprofileName\"$\n" + + "\x12AddProfileResponse\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"r\n" + + "\x14RenameProfileRequest\x12\x1a\n" + + "\busername\x18\x01 \x01(\tR\busername\x12\x16\n" + + "\x06handle\x18\x02 \x01(\tR\x06handle\x12&\n" + + "\x0enewProfileName\x18\x03 \x01(\tR\x0enewProfileName\"?\n" + + "\x15RenameProfileResponse\x12&\n" + + "\x0eoldProfileName\x18\x01 \x01(\tR\x0eoldProfileName\"T\n" + "\x14RemoveProfileRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\x12 \n" + - "\vprofileName\x18\x02 \x01(\tR\vprofileName\"\x17\n" + - "\x15RemoveProfileResponse\"1\n" + + "\vprofileName\x18\x02 \x01(\tR\vprofileName\"'\n" + + "\x15RemoveProfileResponse\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"1\n" + "\x13ListProfilesRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\"C\n" + "\x14ListProfilesResponse\x12+\n" + - "\bprofiles\x18\x01 \x03(\v2\x0f.daemon.ProfileR\bprofiles\":\n" + + "\bprofiles\x18\x01 \x03(\v2\x0f.daemon.ProfileR\bprofiles\"J\n" + "\aProfile\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1b\n" + - "\tis_active\x18\x02 \x01(\bR\bisActive\"\x19\n" + - "\x17GetActiveProfileRequest\"X\n" + + "\tis_active\x18\x02 \x01(\bR\bisActive\x12\x0e\n" + + "\x02id\x18\x03 \x01(\tR\x02id\"\x19\n" + + "\x17GetActiveProfileRequest\"h\n" + "\x18GetActiveProfileResponse\x12 \n" + "\vprofileName\x18\x01 \x01(\tR\vprofileName\x12\x1a\n" + - "\busername\x18\x02 \x01(\tR\busername\"t\n" + + "\busername\x18\x02 \x01(\tR\busername\x12\x0e\n" + + "\x02id\x18\x03 \x01(\tR\x02id\"t\n" + "\rLogoutRequest\x12%\n" + "\vprofileName\x18\x01 \x01(\tH\x00R\vprofileName\x88\x01\x01\x12\x1f\n" + "\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01B\x0e\n" + "\f_profileNameB\v\n" + "\t_username\"\x10\n" + - "\x0eLogoutResponse\"\x14\n" + - "\x12GetFeaturesRequest\"\xa3\x01\n" + + "\x0eLogoutResponse\"\x15\n" + + "\x13WailsUIReadyRequest\"\x16\n" + + "\x14WailsUIReadyResponse\"\x14\n" + + "\x12GetFeaturesRequest\"\xf6\x01\n" + "\x13GetFeaturesResponse\x12)\n" + "\x10disable_profiles\x18\x01 \x01(\bR\x0fdisableProfiles\x126\n" + "\x17disable_update_settings\x18\x02 \x01(\bR\x15disableUpdateSettings\x12)\n" + - "\x10disable_networks\x18\x03 \x01(\bR\x0fdisableNetworks\"\x16\n" + + "\x10disable_networks\x18\x03 \x01(\bR\x0fdisableNetworks\x127\n" + + "\x15disable_advanced_view\x18\x04 \x01(\bH\x00R\x13disableAdvancedView\x88\x01\x01B\x18\n" + + "\x16_disable_advanced_view\"3\n" + + "\x19MDMManagedFieldsViolation\x12\x16\n" + + "\x06fields\x18\x01 \x03(\tR\x06fields\"\x16\n" + "\x14TriggerUpdateRequest\"M\n" + "\x15TriggerUpdateResponse\x12\x18\n" + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x1a\n" + @@ -6497,9 +7598,10 @@ const file_daemon_proto_rawDesc = "" + "sshHostKey\x12\x16\n" + "\x06peerIP\x18\x02 \x01(\tR\x06peerIP\x12\x1a\n" + "\bpeerFQDN\x18\x03 \x01(\tR\bpeerFQDN\x12\x14\n" + - "\x05found\x18\x04 \x01(\bR\x05found\"9\n" + + "\x05found\x18\x04 \x01(\bR\x05found\"k\n" + "\x15RequestJWTAuthRequest\x12\x17\n" + - "\x04hint\x18\x01 \x01(\tH\x00R\x04hint\x88\x01\x01B\a\n" + + "\x04hint\x18\x01 \x01(\tH\x00R\x04hint\x88\x01\x01\x120\n" + + "\x13hasGraphicalSession\x18\x02 \x01(\bR\x13hasGraphicalSessionB\a\n" + "\x05_hint\"\x9a\x02\n" + "\x16RequestJWTAuthResponse\x12(\n" + "\x0fverificationURI\x18\x01 \x01(\tR\x0fverificationURI\x128\n" + @@ -6519,7 +7621,28 @@ const file_daemon_proto_rawDesc = "" + "\x14WaitJWTTokenResponse\x12\x14\n" + "\x05token\x18\x01 \x01(\tR\x05token\x12\x1c\n" + "\ttokenType\x18\x02 \x01(\tR\ttokenType\x12\x1c\n" + - "\texpiresIn\x18\x03 \x01(\x03R\texpiresIn\"\x18\n" + + "\texpiresIn\x18\x03 \x01(\x03R\texpiresIn\"u\n" + + "\x1fRequestExtendAuthSessionRequest\x12\x17\n" + + "\x04hint\x18\x01 \x01(\tH\x00R\x04hint\x88\x01\x01\x120\n" + + "\x13hasGraphicalSession\x18\x02 \x01(\bR\x13hasGraphicalSessionB\a\n" + + "\x05_hint\"\xe0\x01\n" + + " RequestExtendAuthSessionResponse\x12(\n" + + "\x0fverificationURI\x18\x01 \x01(\tR\x0fverificationURI\x128\n" + + "\x17verificationURIComplete\x18\x02 \x01(\tR\x17verificationURIComplete\x12\x1a\n" + + "\buserCode\x18\x03 \x01(\tR\buserCode\x12\x1e\n" + + "\n" + + "deviceCode\x18\x04 \x01(\tR\n" + + "deviceCode\x12\x1c\n" + + "\texpiresIn\x18\x05 \x01(\x03R\texpiresIn\"Z\n" + + "\x1cWaitExtendAuthSessionRequest\x12\x1e\n" + + "\n" + + "deviceCode\x18\x01 \x01(\tR\n" + + "deviceCode\x12\x1a\n" + + "\buserCode\x18\x02 \x01(\tR\buserCode\"g\n" + + "\x1dWaitExtendAuthSessionResponse\x12F\n" + + "\x10sessionExpiresAt\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\x10sessionExpiresAt\"\x1e\n" + + "\x1cDismissSessionWarningRequest\"\x1f\n" + + "\x1dDismissSessionWarningResponse\"\x18\n" + "\x16StartCPUProfileRequest\"\x19\n" + "\x17StartCPUProfileResponse\"\x17\n" + "\x15StopCPUProfileRequest\"\x18\n" + @@ -6548,7 +7671,23 @@ const file_daemon_proto_rawDesc = "" + "\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" + + "\x12port_auto_assigned\x18\x04 \x01(\bR\x10portAutoAssigned\"\xd9\x01\n" + + "\x13StartCaptureRequest\x12\x1f\n" + + "\vtext_output\x18\x01 \x01(\bR\n" + + "textOutput\x12\x19\n" + + "\bsnap_len\x18\x02 \x01(\rR\asnapLen\x125\n" + + "\bduration\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\bduration\x12\x1f\n" + + "\vfilter_expr\x18\x04 \x01(\tR\n" + + "filterExpr\x12\x18\n" + + "\averbose\x18\x05 \x01(\bR\averbose\x12\x14\n" + + "\x05ascii\x18\x06 \x01(\bR\x05ascii\"#\n" + + "\rCapturePacket\x12\x12\n" + + "\x04data\x18\x01 \x01(\fR\x04data\"P\n" + + "\x19StartBundleCaptureRequest\x123\n" + + "\atimeout\x18\x01 \x01(\v2\x19.google.protobuf.DurationR\atimeout\"\x1c\n" + + "\x1aStartBundleCaptureResponse\"\x1a\n" + + "\x18StopBundleCaptureRequest\"\x1b\n" + + "\x19StopBundleCaptureResponse*b\n" + "\bLogLevel\x12\v\n" + "\aUNKNOWN\x10\x00\x12\t\n" + "\x05PANIC\x10\x01\x12\t\n" + @@ -6566,12 +7705,13 @@ const file_daemon_proto_rawDesc = "" + "\n" + "EXPOSE_UDP\x10\x03\x12\x0e\n" + "\n" + - "EXPOSE_TLS\x10\x042\xfc\x15\n" + + "EXPOSE_TLS\x10\x042\xa3\x1c\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" + "\x02Up\x12\x11.daemon.UpRequest\x1a\x12.daemon.UpResponse\"\x00\x129\n" + - "\x06Status\x12\x15.daemon.StatusRequest\x1a\x16.daemon.StatusResponse\"\x00\x123\n" + + "\x06Status\x12\x15.daemon.StatusRequest\x1a\x16.daemon.StatusResponse\"\x00\x12D\n" + + "\x0fSubscribeStatus\x12\x15.daemon.StatusRequest\x1a\x16.daemon.StatusResponse\"\x000\x01\x123\n" + "\x04Down\x12\x13.daemon.DownRequest\x1a\x14.daemon.DownResponse\"\x00\x12B\n" + "\tGetConfig\x12\x18.daemon.GetConfigRequest\x1a\x19.daemon.GetConfigResponse\"\x00\x12K\n" + "\fListNetworks\x12\x1b.daemon.ListNetworksRequest\x1a\x1c.daemon.ListNetworksResponse\"\x00\x12Q\n" + @@ -6587,13 +7727,18 @@ const file_daemon_proto_rawDesc = "" + "CleanState\x12\x19.daemon.CleanStateRequest\x1a\x1a.daemon.CleanStateResponse\"\x00\x12H\n" + "\vDeleteState\x12\x1a.daemon.DeleteStateRequest\x1a\x1b.daemon.DeleteStateResponse\"\x00\x12u\n" + "\x1aSetSyncResponsePersistence\x12).daemon.SetSyncResponsePersistenceRequest\x1a*.daemon.SetSyncResponsePersistenceResponse\"\x00\x12H\n" + - "\vTracePacket\x12\x1a.daemon.TracePacketRequest\x1a\x1b.daemon.TracePacketResponse\"\x00\x12D\n" + + "\vTracePacket\x12\x1a.daemon.TracePacketRequest\x1a\x1b.daemon.TracePacketResponse\"\x00\x12F\n" + + "\fStartCapture\x12\x1b.daemon.StartCaptureRequest\x1a\x15.daemon.CapturePacket\"\x000\x01\x12]\n" + + "\x12StartBundleCapture\x12!.daemon.StartBundleCaptureRequest\x1a\".daemon.StartBundleCaptureResponse\"\x00\x12Z\n" + + "\x11StopBundleCapture\x12 .daemon.StopBundleCaptureRequest\x1a!.daemon.StopBundleCaptureResponse\"\x00\x12D\n" + "\x0fSubscribeEvents\x12\x18.daemon.SubscribeRequest\x1a\x13.daemon.SystemEvent\"\x000\x01\x12B\n" + "\tGetEvents\x12\x18.daemon.GetEventsRequest\x1a\x19.daemon.GetEventsResponse\"\x00\x12N\n" + + "\rRegisterUILog\x12\x1c.daemon.RegisterUILogRequest\x1a\x1d.daemon.RegisterUILogResponse\"\x00\x12N\n" + "\rSwitchProfile\x12\x1c.daemon.SwitchProfileRequest\x1a\x1d.daemon.SwitchProfileResponse\"\x00\x12B\n" + "\tSetConfig\x12\x18.daemon.SetConfigRequest\x1a\x19.daemon.SetConfigResponse\"\x00\x12E\n" + "\n" + "AddProfile\x12\x19.daemon.AddProfileRequest\x1a\x1a.daemon.AddProfileResponse\"\x00\x12N\n" + + "\rRenameProfile\x12\x1c.daemon.RenameProfileRequest\x1a\x1d.daemon.RenameProfileResponse\"\x00\x12N\n" + "\rRemoveProfile\x12\x1c.daemon.RemoveProfileRequest\x1a\x1d.daemon.RemoveProfileResponse\"\x00\x12K\n" + "\fListProfiles\x12\x1b.daemon.ListProfilesRequest\x1a\x1c.daemon.ListProfilesResponse\"\x00\x12W\n" + "\x10GetActiveProfile\x12\x1f.daemon.GetActiveProfileRequest\x1a .daemon.GetActiveProfileResponse\"\x00\x129\n" + @@ -6602,12 +7747,15 @@ const file_daemon_proto_rawDesc = "" + "\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" + + "\fWaitJWTToken\x12\x1b.daemon.WaitJWTTokenRequest\x1a\x1c.daemon.WaitJWTTokenResponse\"\x00\x12o\n" + + "\x18RequestExtendAuthSession\x12'.daemon.RequestExtendAuthSessionRequest\x1a(.daemon.RequestExtendAuthSessionResponse\"\x00\x12f\n" + + "\x15WaitExtendAuthSession\x12$.daemon.WaitExtendAuthSessionRequest\x1a%.daemon.WaitExtendAuthSessionResponse\"\x00\x12f\n" + + "\x15DismissSessionWarning\x12$.daemon.DismissSessionWarningRequest\x1a%.daemon.DismissSessionWarningResponse\"\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" + + "\x0eStopCPUProfile\x12\x1d.daemon.StopCPUProfileRequest\x1a\x1e.daemon.StopCPUProfileResponse\"\x00\x12W\n" + "\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" + "\rExposeService\x12\x1c.daemon.ExposeServiceRequest\x1a\x1a.daemon.ExposeServiceEvent\"\x000\x01\x12K\n" + + "\fWailsUIReady\x12\x1b.daemon.WailsUIReadyRequest\x1a\x1c.daemon.WailsUIReadyResponse\"\x00B\bZ\x06/protob\x06proto3" var ( file_daemon_proto_rawDescOnce sync.Once @@ -6621,226 +7769,263 @@ func file_daemon_proto_rawDescGZIP() []byte { return file_daemon_proto_rawDescData } -var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 5) -var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 93) +var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 4) +var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 110) var file_daemon_proto_goTypes = []any{ (LogLevel)(0), // 0: daemon.LogLevel (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 + (SystemEvent_Severity)(0), // 2: daemon.SystemEvent.Severity + (SystemEvent_Category)(0), // 3: daemon.SystemEvent.Category + (*EmptyRequest)(nil), // 4: daemon.EmptyRequest + (*LoginRequest)(nil), // 5: daemon.LoginRequest + (*LoginResponse)(nil), // 6: daemon.LoginResponse + (*WaitSSOLoginRequest)(nil), // 7: daemon.WaitSSOLoginRequest + (*WaitSSOLoginResponse)(nil), // 8: daemon.WaitSSOLoginResponse + (*UpRequest)(nil), // 9: daemon.UpRequest + (*UpResponse)(nil), // 10: daemon.UpResponse + (*StatusRequest)(nil), // 11: daemon.StatusRequest + (*StatusResponse)(nil), // 12: daemon.StatusResponse + (*DownRequest)(nil), // 13: daemon.DownRequest + (*DownResponse)(nil), // 14: daemon.DownResponse + (*GetConfigRequest)(nil), // 15: daemon.GetConfigRequest + (*GetConfigResponse)(nil), // 16: daemon.GetConfigResponse + (*PeerState)(nil), // 17: daemon.PeerState + (*LocalPeerState)(nil), // 18: daemon.LocalPeerState + (*SignalState)(nil), // 19: daemon.SignalState + (*ManagementState)(nil), // 20: daemon.ManagementState + (*RelayState)(nil), // 21: daemon.RelayState + (*NSGroupState)(nil), // 22: daemon.NSGroupState + (*SSHSessionInfo)(nil), // 23: daemon.SSHSessionInfo + (*SSHServerState)(nil), // 24: daemon.SSHServerState + (*FullStatus)(nil), // 25: daemon.FullStatus + (*ListNetworksRequest)(nil), // 26: daemon.ListNetworksRequest + (*ListNetworksResponse)(nil), // 27: daemon.ListNetworksResponse + (*SelectNetworksRequest)(nil), // 28: daemon.SelectNetworksRequest + (*SelectNetworksResponse)(nil), // 29: daemon.SelectNetworksResponse + (*IPList)(nil), // 30: daemon.IPList + (*Network)(nil), // 31: daemon.Network + (*PortInfo)(nil), // 32: daemon.PortInfo + (*ForwardingRule)(nil), // 33: daemon.ForwardingRule + (*ForwardingRulesResponse)(nil), // 34: daemon.ForwardingRulesResponse + (*DebugBundleRequest)(nil), // 35: daemon.DebugBundleRequest + (*DebugBundleResponse)(nil), // 36: daemon.DebugBundleResponse + (*GetLogLevelRequest)(nil), // 37: daemon.GetLogLevelRequest + (*GetLogLevelResponse)(nil), // 38: daemon.GetLogLevelResponse + (*SetLogLevelRequest)(nil), // 39: daemon.SetLogLevelRequest + (*SetLogLevelResponse)(nil), // 40: daemon.SetLogLevelResponse + (*RegisterUILogRequest)(nil), // 41: daemon.RegisterUILogRequest + (*RegisterUILogResponse)(nil), // 42: daemon.RegisterUILogResponse + (*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 + (*RenameProfileRequest)(nil), // 66: daemon.RenameProfileRequest + (*RenameProfileResponse)(nil), // 67: daemon.RenameProfileResponse + (*RemoveProfileRequest)(nil), // 68: daemon.RemoveProfileRequest + (*RemoveProfileResponse)(nil), // 69: daemon.RemoveProfileResponse + (*ListProfilesRequest)(nil), // 70: daemon.ListProfilesRequest + (*ListProfilesResponse)(nil), // 71: daemon.ListProfilesResponse + (*Profile)(nil), // 72: daemon.Profile + (*GetActiveProfileRequest)(nil), // 73: daemon.GetActiveProfileRequest + (*GetActiveProfileResponse)(nil), // 74: daemon.GetActiveProfileResponse + (*LogoutRequest)(nil), // 75: daemon.LogoutRequest + (*LogoutResponse)(nil), // 76: daemon.LogoutResponse + (*WailsUIReadyRequest)(nil), // 77: daemon.WailsUIReadyRequest + (*WailsUIReadyResponse)(nil), // 78: daemon.WailsUIReadyResponse + (*GetFeaturesRequest)(nil), // 79: daemon.GetFeaturesRequest + (*GetFeaturesResponse)(nil), // 80: daemon.GetFeaturesResponse + (*MDMManagedFieldsViolation)(nil), // 81: daemon.MDMManagedFieldsViolation + (*TriggerUpdateRequest)(nil), // 82: daemon.TriggerUpdateRequest + (*TriggerUpdateResponse)(nil), // 83: daemon.TriggerUpdateResponse + (*GetPeerSSHHostKeyRequest)(nil), // 84: daemon.GetPeerSSHHostKeyRequest + (*GetPeerSSHHostKeyResponse)(nil), // 85: daemon.GetPeerSSHHostKeyResponse + (*RequestJWTAuthRequest)(nil), // 86: daemon.RequestJWTAuthRequest + (*RequestJWTAuthResponse)(nil), // 87: daemon.RequestJWTAuthResponse + (*WaitJWTTokenRequest)(nil), // 88: daemon.WaitJWTTokenRequest + (*WaitJWTTokenResponse)(nil), // 89: daemon.WaitJWTTokenResponse + (*RequestExtendAuthSessionRequest)(nil), // 90: daemon.RequestExtendAuthSessionRequest + (*RequestExtendAuthSessionResponse)(nil), // 91: daemon.RequestExtendAuthSessionResponse + (*WaitExtendAuthSessionRequest)(nil), // 92: daemon.WaitExtendAuthSessionRequest + (*WaitExtendAuthSessionResponse)(nil), // 93: daemon.WaitExtendAuthSessionResponse + (*DismissSessionWarningRequest)(nil), // 94: daemon.DismissSessionWarningRequest + (*DismissSessionWarningResponse)(nil), // 95: daemon.DismissSessionWarningResponse + (*StartCPUProfileRequest)(nil), // 96: daemon.StartCPUProfileRequest + (*StartCPUProfileResponse)(nil), // 97: daemon.StartCPUProfileResponse + (*StopCPUProfileRequest)(nil), // 98: daemon.StopCPUProfileRequest + (*StopCPUProfileResponse)(nil), // 99: daemon.StopCPUProfileResponse + (*InstallerResultRequest)(nil), // 100: daemon.InstallerResultRequest + (*InstallerResultResponse)(nil), // 101: daemon.InstallerResultResponse + (*ExposeServiceRequest)(nil), // 102: daemon.ExposeServiceRequest + (*ExposeServiceEvent)(nil), // 103: daemon.ExposeServiceEvent + (*ExposeServiceReady)(nil), // 104: daemon.ExposeServiceReady + (*StartCaptureRequest)(nil), // 105: daemon.StartCaptureRequest + (*CapturePacket)(nil), // 106: daemon.CapturePacket + (*StartBundleCaptureRequest)(nil), // 107: daemon.StartBundleCaptureRequest + (*StartBundleCaptureResponse)(nil), // 108: daemon.StartBundleCaptureResponse + (*StopBundleCaptureRequest)(nil), // 109: daemon.StopBundleCaptureRequest + (*StopBundleCaptureResponse)(nil), // 110: daemon.StopBundleCaptureResponse + nil, // 111: daemon.Network.ResolvedIPsEntry + (*PortInfo_Range)(nil), // 112: daemon.PortInfo.Range + nil, // 113: daemon.SystemEvent.MetadataEntry + (*durationpb.Duration)(nil), // 114: google.protobuf.Duration + (*timestamppb.Timestamp)(nil), // 115: google.protobuf.Timestamp } var file_daemon_proto_depIdxs = []int32{ - 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 - 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 + 114, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 25, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus + 115, // 2: daemon.StatusResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp + 115, // 3: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp + 115, // 4: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp + 114, // 5: daemon.PeerState.latency:type_name -> google.protobuf.Duration + 23, // 6: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo + 20, // 7: daemon.FullStatus.managementState:type_name -> daemon.ManagementState + 19, // 8: daemon.FullStatus.signalState:type_name -> daemon.SignalState + 18, // 9: daemon.FullStatus.localPeerState:type_name -> daemon.LocalPeerState + 17, // 10: daemon.FullStatus.peers:type_name -> daemon.PeerState + 21, // 11: daemon.FullStatus.relays:type_name -> daemon.RelayState + 22, // 12: daemon.FullStatus.dns_servers:type_name -> daemon.NSGroupState + 57, // 13: daemon.FullStatus.events:type_name -> daemon.SystemEvent + 24, // 14: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState + 31, // 15: daemon.ListNetworksResponse.routes:type_name -> daemon.Network + 111, // 16: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry + 112, // 17: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range + 32, // 18: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo + 32, // 19: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo + 33, // 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 + 115, // 28: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp + 113, // 29: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry + 57, // 30: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent + 114, // 31: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 72, // 32: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile + 115, // 33: daemon.WaitExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp + 1, // 34: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol + 104, // 35: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady + 114, // 36: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration + 114, // 37: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration + 30, // 38: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList + 5, // 39: daemon.DaemonService.Login:input_type -> daemon.LoginRequest + 7, // 40: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest + 9, // 41: daemon.DaemonService.Up:input_type -> daemon.UpRequest + 11, // 42: daemon.DaemonService.Status:input_type -> daemon.StatusRequest + 11, // 43: daemon.DaemonService.SubscribeStatus:input_type -> daemon.StatusRequest + 13, // 44: daemon.DaemonService.Down:input_type -> daemon.DownRequest + 15, // 45: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest + 26, // 46: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest + 28, // 47: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest + 28, // 48: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest + 4, // 49: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest + 35, // 50: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest + 37, // 51: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest + 39, // 52: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest + 44, // 53: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest + 46, // 54: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest + 48, // 55: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest + 50, // 56: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest + 53, // 57: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest + 105, // 58: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest + 107, // 59: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest + 109, // 60: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest + 56, // 61: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest + 58, // 62: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest + 41, // 63: daemon.DaemonService.RegisterUILog:input_type -> daemon.RegisterUILogRequest + 60, // 64: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest + 62, // 65: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest + 64, // 66: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest + 66, // 67: daemon.DaemonService.RenameProfile:input_type -> daemon.RenameProfileRequest + 68, // 68: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest + 70, // 69: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest + 73, // 70: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest + 75, // 71: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest + 79, // 72: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest + 82, // 73: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest + 84, // 74: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest + 86, // 75: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest + 88, // 76: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest + 90, // 77: daemon.DaemonService.RequestExtendAuthSession:input_type -> daemon.RequestExtendAuthSessionRequest + 92, // 78: daemon.DaemonService.WaitExtendAuthSession:input_type -> daemon.WaitExtendAuthSessionRequest + 94, // 79: daemon.DaemonService.DismissSessionWarning:input_type -> daemon.DismissSessionWarningRequest + 96, // 80: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest + 98, // 81: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest + 100, // 82: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest + 102, // 83: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest + 77, // 84: daemon.DaemonService.WailsUIReady:input_type -> daemon.WailsUIReadyRequest + 6, // 85: daemon.DaemonService.Login:output_type -> daemon.LoginResponse + 8, // 86: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse + 10, // 87: daemon.DaemonService.Up:output_type -> daemon.UpResponse + 12, // 88: daemon.DaemonService.Status:output_type -> daemon.StatusResponse + 12, // 89: daemon.DaemonService.SubscribeStatus:output_type -> daemon.StatusResponse + 14, // 90: daemon.DaemonService.Down:output_type -> daemon.DownResponse + 16, // 91: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse + 27, // 92: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse + 29, // 93: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse + 29, // 94: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse + 34, // 95: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse + 36, // 96: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse + 38, // 97: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse + 40, // 98: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse + 45, // 99: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse + 47, // 100: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse + 49, // 101: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse + 51, // 102: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse + 55, // 103: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse + 106, // 104: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket + 108, // 105: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse + 110, // 106: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse + 57, // 107: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent + 59, // 108: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse + 42, // 109: daemon.DaemonService.RegisterUILog:output_type -> daemon.RegisterUILogResponse + 61, // 110: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse + 63, // 111: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse + 65, // 112: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse + 67, // 113: daemon.DaemonService.RenameProfile:output_type -> daemon.RenameProfileResponse + 69, // 114: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse + 71, // 115: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse + 74, // 116: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse + 76, // 117: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse + 80, // 118: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse + 83, // 119: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse + 85, // 120: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse + 87, // 121: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse + 89, // 122: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse + 91, // 123: daemon.DaemonService.RequestExtendAuthSession:output_type -> daemon.RequestExtendAuthSessionResponse + 93, // 124: daemon.DaemonService.WaitExtendAuthSession:output_type -> daemon.WaitExtendAuthSessionResponse + 95, // 125: daemon.DaemonService.DismissSessionWarning:output_type -> daemon.DismissSessionWarningResponse + 97, // 126: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse + 99, // 127: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse + 101, // 128: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse + 103, // 129: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent + 78, // 130: daemon.DaemonService.WailsUIReady:output_type -> daemon.WailsUIReadyResponse + 85, // [85:131] is the sub-list for method output_type + 39, // [39:85] is the sub-list for method input_type + 39, // [39:39] is the sub-list for extension type_name + 39, // [39:39] is the sub-list for extension extendee + 0, // [0:39] is the sub-list for field type_name } func init() { file_daemon_proto_init() } @@ -6848,10 +8033,10 @@ func file_daemon_proto_init() { if File_daemon_proto != nil { return } - file_daemon_proto_msgTypes[3].OneofWrappers = []any{} + file_daemon_proto_msgTypes[1].OneofWrappers = []any{} + file_daemon_proto_msgTypes[5].OneofWrappers = []any{} file_daemon_proto_msgTypes[7].OneofWrappers = []any{} - file_daemon_proto_msgTypes[9].OneofWrappers = []any{} - file_daemon_proto_msgTypes[30].OneofWrappers = []any{ + file_daemon_proto_msgTypes[28].OneofWrappers = []any{ (*PortInfo_Port)(nil), (*PortInfo_Range_)(nil), } @@ -6859,9 +8044,11 @@ func file_daemon_proto_init() { file_daemon_proto_msgTypes[50].OneofWrappers = []any{} 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[77].OneofWrappers = []any{} - file_daemon_proto_msgTypes[88].OneofWrappers = []any{ + file_daemon_proto_msgTypes[71].OneofWrappers = []any{} + file_daemon_proto_msgTypes[76].OneofWrappers = []any{} + file_daemon_proto_msgTypes[82].OneofWrappers = []any{} + file_daemon_proto_msgTypes[86].OneofWrappers = []any{} + file_daemon_proto_msgTypes[99].OneofWrappers = []any{ (*ExposeServiceEvent_Ready)(nil), } type x struct{} @@ -6869,8 +8056,8 @@ func file_daemon_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)), - NumEnums: 5, - NumMessages: 93, + NumEnums: 4, + NumMessages: 110, NumExtensions: 0, NumServices: 1, }, diff --git a/client/proto/daemon.pb.gw.go b/client/proto/daemon.pb.gw.go new file mode 100644 index 000000000..b64dfeea1 --- /dev/null +++ b/client/proto/daemon.pb.gw.go @@ -0,0 +1,2921 @@ +// Code generated by protoc-gen-grpc-gateway. DO NOT EDIT. +// source: daemon.proto + +/* +Package proto is a reverse proxy. + +It translates gRPC into RESTful JSON APIs. +*/ +package proto + +import ( + "context" + "errors" + "io" + "net/http" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "github.com/grpc-ecosystem/grpc-gateway/v2/utilities" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/grpclog" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +// Suppress "imported and not used" errors +var ( + _ codes.Code + _ io.Reader + _ status.Status + _ = errors.New + _ = runtime.String + _ = utilities.NewDoubleArray + _ = metadata.Join +) + +func request_DaemonService_Login_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq LoginRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.Login(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_Login_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq LoginRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.Login(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_WaitSSOLogin_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq WaitSSOLoginRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.WaitSSOLogin(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_WaitSSOLogin_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq WaitSSOLoginRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.WaitSSOLogin(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_Up_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq UpRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.Up(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_Up_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq UpRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.Up(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_Status_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq StatusRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.Status(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_Status_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq StatusRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.Status(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_SubscribeStatus_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (DaemonService_SubscribeStatusClient, runtime.ServerMetadata, error) { + var ( + protoReq StatusRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + stream, err := client.SubscribeStatus(ctx, &protoReq) + if err != nil { + return nil, metadata, err + } + header, err := stream.Header() + if err != nil { + return nil, metadata, err + } + metadata.HeaderMD = header + return stream, metadata, nil +} + +func request_DaemonService_Down_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq DownRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.Down(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_Down_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq DownRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.Down(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_GetConfig_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetConfigRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.GetConfig(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_GetConfig_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetConfigRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.GetConfig(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_ListNetworks_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListNetworksRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.ListNetworks(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_ListNetworks_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListNetworksRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.ListNetworks(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_SelectNetworks_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SelectNetworksRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.SelectNetworks(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_SelectNetworks_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SelectNetworksRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.SelectNetworks(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_DeselectNetworks_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SelectNetworksRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.DeselectNetworks(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_DeselectNetworks_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SelectNetworksRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.DeselectNetworks(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_ForwardingRules_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq EmptyRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.ForwardingRules(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_ForwardingRules_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq EmptyRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.ForwardingRules(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_DebugBundle_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq DebugBundleRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.DebugBundle(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_DebugBundle_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq DebugBundleRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.DebugBundle(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_GetLogLevel_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetLogLevelRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.GetLogLevel(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_GetLogLevel_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetLogLevelRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.GetLogLevel(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_SetLogLevel_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SetLogLevelRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.SetLogLevel(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_SetLogLevel_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SetLogLevelRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.SetLogLevel(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_ListStates_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListStatesRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.ListStates(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_ListStates_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListStatesRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.ListStates(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_CleanState_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq CleanStateRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.CleanState(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_CleanState_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq CleanStateRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.CleanState(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_DeleteState_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq DeleteStateRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.DeleteState(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_DeleteState_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq DeleteStateRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.DeleteState(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_SetSyncResponsePersistence_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SetSyncResponsePersistenceRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.SetSyncResponsePersistence(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_SetSyncResponsePersistence_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SetSyncResponsePersistenceRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.SetSyncResponsePersistence(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_TracePacket_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq TracePacketRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.TracePacket(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_TracePacket_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq TracePacketRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.TracePacket(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_StartCapture_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (DaemonService_StartCaptureClient, runtime.ServerMetadata, error) { + var ( + protoReq StartCaptureRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + stream, err := client.StartCapture(ctx, &protoReq) + if err != nil { + return nil, metadata, err + } + header, err := stream.Header() + if err != nil { + return nil, metadata, err + } + metadata.HeaderMD = header + return stream, metadata, nil +} + +func request_DaemonService_StartBundleCapture_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq StartBundleCaptureRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.StartBundleCapture(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_StartBundleCapture_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq StartBundleCaptureRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.StartBundleCapture(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_StopBundleCapture_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq StopBundleCaptureRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.StopBundleCapture(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_StopBundleCapture_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq StopBundleCaptureRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.StopBundleCapture(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_SubscribeEvents_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (DaemonService_SubscribeEventsClient, runtime.ServerMetadata, error) { + var ( + protoReq SubscribeRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + stream, err := client.SubscribeEvents(ctx, &protoReq) + if err != nil { + return nil, metadata, err + } + header, err := stream.Header() + if err != nil { + return nil, metadata, err + } + metadata.HeaderMD = header + return stream, metadata, nil +} + +func request_DaemonService_GetEvents_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetEventsRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.GetEvents(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_GetEvents_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetEventsRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.GetEvents(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_RegisterUILog_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RegisterUILogRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.RegisterUILog(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_RegisterUILog_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RegisterUILogRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.RegisterUILog(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_SwitchProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SwitchProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.SwitchProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_SwitchProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SwitchProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.SwitchProfile(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_SetConfig_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SetConfigRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.SetConfig(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_SetConfig_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SetConfigRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.SetConfig(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_AddProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq AddProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.AddProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_AddProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq AddProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.AddProfile(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_RenameProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RenameProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.RenameProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_RenameProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RenameProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.RenameProfile(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_RemoveProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RemoveProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.RemoveProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_RemoveProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RemoveProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.RemoveProfile(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_ListProfiles_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListProfilesRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.ListProfiles(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_ListProfiles_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListProfilesRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.ListProfiles(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_GetActiveProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetActiveProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.GetActiveProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_GetActiveProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetActiveProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.GetActiveProfile(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_Logout_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq LogoutRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.Logout(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_Logout_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq LogoutRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.Logout(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_GetFeatures_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetFeaturesRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.GetFeatures(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_GetFeatures_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetFeaturesRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.GetFeatures(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_TriggerUpdate_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq TriggerUpdateRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.TriggerUpdate(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_TriggerUpdate_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq TriggerUpdateRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.TriggerUpdate(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_GetPeerSSHHostKey_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetPeerSSHHostKeyRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.GetPeerSSHHostKey(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_GetPeerSSHHostKey_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetPeerSSHHostKeyRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.GetPeerSSHHostKey(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_RequestJWTAuth_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RequestJWTAuthRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.RequestJWTAuth(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_RequestJWTAuth_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RequestJWTAuthRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.RequestJWTAuth(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_WaitJWTToken_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq WaitJWTTokenRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.WaitJWTToken(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_WaitJWTToken_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq WaitJWTTokenRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.WaitJWTToken(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_RequestExtendAuthSession_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RequestExtendAuthSessionRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.RequestExtendAuthSession(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_RequestExtendAuthSession_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RequestExtendAuthSessionRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.RequestExtendAuthSession(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_WaitExtendAuthSession_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq WaitExtendAuthSessionRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.WaitExtendAuthSession(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_WaitExtendAuthSession_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq WaitExtendAuthSessionRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.WaitExtendAuthSession(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_DismissSessionWarning_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq DismissSessionWarningRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.DismissSessionWarning(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_DismissSessionWarning_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq DismissSessionWarningRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.DismissSessionWarning(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_StartCPUProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq StartCPUProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.StartCPUProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_StartCPUProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq StartCPUProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.StartCPUProfile(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_StopCPUProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq StopCPUProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.StopCPUProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_StopCPUProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq StopCPUProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.StopCPUProfile(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_GetInstallerResult_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq InstallerResultRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.GetInstallerResult(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_GetInstallerResult_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq InstallerResultRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.GetInstallerResult(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_ExposeService_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (DaemonService_ExposeServiceClient, runtime.ServerMetadata, error) { + var ( + protoReq ExposeServiceRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + stream, err := client.ExposeService(ctx, &protoReq) + if err != nil { + return nil, metadata, err + } + header, err := stream.Header() + if err != nil { + return nil, metadata, err + } + metadata.HeaderMD = header + return stream, metadata, nil +} + +func request_DaemonService_WailsUIReady_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq WailsUIReadyRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.WailsUIReady(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_WailsUIReady_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq WailsUIReadyRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.WailsUIReady(ctx, &protoReq) + return msg, metadata, err +} + +// RegisterDaemonServiceHandlerServer registers the http handlers for service DaemonService to "mux". +// UnaryRPC :call DaemonServiceServer directly. +// StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906. +// Note that using this registration option will cause many gRPC library features to stop working. Consider using RegisterDaemonServiceHandlerFromEndpoint instead. +// GRPC interceptors will not work for this type of registration. To use interceptors, you must use the "runtime.WithMiddlewares" option in the "runtime.NewServeMux" call. +func RegisterDaemonServiceHandlerServer(ctx context.Context, mux *runtime.ServeMux, server DaemonServiceServer) error { + mux.Handle(http.MethodPost, pattern_DaemonService_Login_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/Login", runtime.WithHTTPPathPattern("/daemon.DaemonService/Login")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_Login_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_Login_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_WaitSSOLogin_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/WaitSSOLogin", runtime.WithHTTPPathPattern("/daemon.DaemonService/WaitSSOLogin")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_WaitSSOLogin_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_WaitSSOLogin_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_Up_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/Up", runtime.WithHTTPPathPattern("/daemon.DaemonService/Up")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_Up_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_Up_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_Status_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/Status", runtime.WithHTTPPathPattern("/daemon.DaemonService/Status")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_Status_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_Status_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + + mux.Handle(http.MethodPost, pattern_DaemonService_SubscribeStatus_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + err := status.Error(codes.Unimplemented, "streaming calls are not yet supported in the in-process transport") + _, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + }) + mux.Handle(http.MethodPost, pattern_DaemonService_Down_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/Down", runtime.WithHTTPPathPattern("/daemon.DaemonService/Down")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_Down_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_Down_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetConfig_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/GetConfig", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetConfig")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_GetConfig_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetConfig_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_ListNetworks_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/ListNetworks", runtime.WithHTTPPathPattern("/daemon.DaemonService/ListNetworks")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_ListNetworks_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_ListNetworks_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SelectNetworks_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/SelectNetworks", runtime.WithHTTPPathPattern("/daemon.DaemonService/SelectNetworks")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_SelectNetworks_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SelectNetworks_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_DeselectNetworks_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/DeselectNetworks", runtime.WithHTTPPathPattern("/daemon.DaemonService/DeselectNetworks")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_DeselectNetworks_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_DeselectNetworks_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_ForwardingRules_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/ForwardingRules", runtime.WithHTTPPathPattern("/daemon.DaemonService/ForwardingRules")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_ForwardingRules_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_ForwardingRules_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_DebugBundle_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/DebugBundle", runtime.WithHTTPPathPattern("/daemon.DaemonService/DebugBundle")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_DebugBundle_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_DebugBundle_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetLogLevel_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/GetLogLevel", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetLogLevel")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_GetLogLevel_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetLogLevel_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SetLogLevel_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/SetLogLevel", runtime.WithHTTPPathPattern("/daemon.DaemonService/SetLogLevel")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_SetLogLevel_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SetLogLevel_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_ListStates_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/ListStates", runtime.WithHTTPPathPattern("/daemon.DaemonService/ListStates")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_ListStates_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_ListStates_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_CleanState_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/CleanState", runtime.WithHTTPPathPattern("/daemon.DaemonService/CleanState")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_CleanState_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_CleanState_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_DeleteState_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/DeleteState", runtime.WithHTTPPathPattern("/daemon.DaemonService/DeleteState")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_DeleteState_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_DeleteState_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SetSyncResponsePersistence_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/SetSyncResponsePersistence", runtime.WithHTTPPathPattern("/daemon.DaemonService/SetSyncResponsePersistence")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_SetSyncResponsePersistence_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SetSyncResponsePersistence_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_TracePacket_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/TracePacket", runtime.WithHTTPPathPattern("/daemon.DaemonService/TracePacket")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_TracePacket_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_TracePacket_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + + mux.Handle(http.MethodPost, pattern_DaemonService_StartCapture_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + err := status.Error(codes.Unimplemented, "streaming calls are not yet supported in the in-process transport") + _, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + }) + mux.Handle(http.MethodPost, pattern_DaemonService_StartBundleCapture_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/StartBundleCapture", runtime.WithHTTPPathPattern("/daemon.DaemonService/StartBundleCapture")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_StartBundleCapture_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_StartBundleCapture_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_StopBundleCapture_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/StopBundleCapture", runtime.WithHTTPPathPattern("/daemon.DaemonService/StopBundleCapture")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_StopBundleCapture_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_StopBundleCapture_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + + mux.Handle(http.MethodPost, pattern_DaemonService_SubscribeEvents_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + err := status.Error(codes.Unimplemented, "streaming calls are not yet supported in the in-process transport") + _, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetEvents_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/GetEvents", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetEvents")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_GetEvents_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetEvents_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_RegisterUILog_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/RegisterUILog", runtime.WithHTTPPathPattern("/daemon.DaemonService/RegisterUILog")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_RegisterUILog_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_RegisterUILog_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SwitchProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/SwitchProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/SwitchProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_SwitchProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SwitchProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SetConfig_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/SetConfig", runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_SetConfig_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SetConfig_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_AddProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/AddProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/AddProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_AddProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_AddProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_RenameProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/RenameProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/RenameProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_RenameProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_RenameProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_RemoveProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/RemoveProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/RemoveProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_RemoveProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_RemoveProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_ListProfiles_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/ListProfiles", runtime.WithHTTPPathPattern("/daemon.DaemonService/ListProfiles")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_ListProfiles_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_ListProfiles_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetActiveProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/GetActiveProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetActiveProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_GetActiveProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetActiveProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_Logout_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/Logout", runtime.WithHTTPPathPattern("/daemon.DaemonService/Logout")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_Logout_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_Logout_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetFeatures_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/GetFeatures", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetFeatures")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_GetFeatures_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetFeatures_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_TriggerUpdate_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/TriggerUpdate", runtime.WithHTTPPathPattern("/daemon.DaemonService/TriggerUpdate")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_TriggerUpdate_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_TriggerUpdate_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetPeerSSHHostKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/GetPeerSSHHostKey", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetPeerSSHHostKey")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_GetPeerSSHHostKey_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetPeerSSHHostKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_RequestJWTAuth_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/RequestJWTAuth", runtime.WithHTTPPathPattern("/daemon.DaemonService/RequestJWTAuth")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_RequestJWTAuth_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_RequestJWTAuth_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_WaitJWTToken_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/WaitJWTToken", runtime.WithHTTPPathPattern("/daemon.DaemonService/WaitJWTToken")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_WaitJWTToken_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_WaitJWTToken_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_RequestExtendAuthSession_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/RequestExtendAuthSession", runtime.WithHTTPPathPattern("/daemon.DaemonService/RequestExtendAuthSession")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_RequestExtendAuthSession_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_RequestExtendAuthSession_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_WaitExtendAuthSession_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/WaitExtendAuthSession", runtime.WithHTTPPathPattern("/daemon.DaemonService/WaitExtendAuthSession")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_WaitExtendAuthSession_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_WaitExtendAuthSession_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_DismissSessionWarning_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/DismissSessionWarning", runtime.WithHTTPPathPattern("/daemon.DaemonService/DismissSessionWarning")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_DismissSessionWarning_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_DismissSessionWarning_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_StartCPUProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/StartCPUProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/StartCPUProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_StartCPUProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_StartCPUProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_StopCPUProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/StopCPUProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/StopCPUProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_StopCPUProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_StopCPUProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetInstallerResult_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/GetInstallerResult", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetInstallerResult")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_GetInstallerResult_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetInstallerResult_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + + mux.Handle(http.MethodPost, pattern_DaemonService_ExposeService_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + err := status.Error(codes.Unimplemented, "streaming calls are not yet supported in the in-process transport") + _, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + }) + mux.Handle(http.MethodPost, pattern_DaemonService_WailsUIReady_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/WailsUIReady", runtime.WithHTTPPathPattern("/daemon.DaemonService/WailsUIReady")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_WailsUIReady_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_WailsUIReady_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + + return nil +} + +// RegisterDaemonServiceHandlerFromEndpoint is same as RegisterDaemonServiceHandler but +// automatically dials to "endpoint" and closes the connection when "ctx" gets done. +func RegisterDaemonServiceHandlerFromEndpoint(ctx context.Context, mux *runtime.ServeMux, endpoint string, opts []grpc.DialOption) (err error) { + conn, err := grpc.NewClient(endpoint, opts...) + if err != nil { + return err + } + defer func() { + if err != nil { + if cerr := conn.Close(); cerr != nil { + grpclog.Errorf("Failed to close conn to %s: %v", endpoint, cerr) + } + return + } + go func() { + <-ctx.Done() + if cerr := conn.Close(); cerr != nil { + grpclog.Errorf("Failed to close conn to %s: %v", endpoint, cerr) + } + }() + }() + return RegisterDaemonServiceHandler(ctx, mux, conn) +} + +// RegisterDaemonServiceHandler registers the http handlers for service DaemonService to "mux". +// The handlers forward requests to the grpc endpoint over "conn". +func RegisterDaemonServiceHandler(ctx context.Context, mux *runtime.ServeMux, conn *grpc.ClientConn) error { + return RegisterDaemonServiceHandlerClient(ctx, mux, NewDaemonServiceClient(conn)) +} + +// RegisterDaemonServiceHandlerClient registers the http handlers for service DaemonService +// to "mux". The handlers forward requests to the grpc endpoint over the given implementation of "DaemonServiceClient". +// Note: the gRPC framework executes interceptors within the gRPC handler. If the passed in "DaemonServiceClient" +// doesn't go through the normal gRPC flow (creating a gRPC client etc.) then it will be up to the passed in +// "DaemonServiceClient" to call the correct interceptors. This client ignores the HTTP middlewares. +func RegisterDaemonServiceHandlerClient(ctx context.Context, mux *runtime.ServeMux, client DaemonServiceClient) error { + mux.Handle(http.MethodPost, pattern_DaemonService_Login_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/Login", runtime.WithHTTPPathPattern("/daemon.DaemonService/Login")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_Login_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_Login_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_WaitSSOLogin_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/WaitSSOLogin", runtime.WithHTTPPathPattern("/daemon.DaemonService/WaitSSOLogin")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_WaitSSOLogin_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_WaitSSOLogin_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_Up_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/Up", runtime.WithHTTPPathPattern("/daemon.DaemonService/Up")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_Up_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_Up_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_Status_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/Status", runtime.WithHTTPPathPattern("/daemon.DaemonService/Status")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_Status_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_Status_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SubscribeStatus_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/SubscribeStatus", runtime.WithHTTPPathPattern("/daemon.DaemonService/SubscribeStatus")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_SubscribeStatus_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SubscribeStatus_0(annotatedContext, mux, outboundMarshaler, w, req, func() (proto.Message, error) { return resp.Recv() }, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_Down_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/Down", runtime.WithHTTPPathPattern("/daemon.DaemonService/Down")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_Down_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_Down_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetConfig_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/GetConfig", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetConfig")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_GetConfig_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetConfig_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_ListNetworks_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/ListNetworks", runtime.WithHTTPPathPattern("/daemon.DaemonService/ListNetworks")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_ListNetworks_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_ListNetworks_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SelectNetworks_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/SelectNetworks", runtime.WithHTTPPathPattern("/daemon.DaemonService/SelectNetworks")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_SelectNetworks_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SelectNetworks_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_DeselectNetworks_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/DeselectNetworks", runtime.WithHTTPPathPattern("/daemon.DaemonService/DeselectNetworks")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_DeselectNetworks_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_DeselectNetworks_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_ForwardingRules_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/ForwardingRules", runtime.WithHTTPPathPattern("/daemon.DaemonService/ForwardingRules")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_ForwardingRules_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_ForwardingRules_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_DebugBundle_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/DebugBundle", runtime.WithHTTPPathPattern("/daemon.DaemonService/DebugBundle")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_DebugBundle_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_DebugBundle_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetLogLevel_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/GetLogLevel", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetLogLevel")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_GetLogLevel_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetLogLevel_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SetLogLevel_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/SetLogLevel", runtime.WithHTTPPathPattern("/daemon.DaemonService/SetLogLevel")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_SetLogLevel_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SetLogLevel_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_ListStates_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/ListStates", runtime.WithHTTPPathPattern("/daemon.DaemonService/ListStates")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_ListStates_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_ListStates_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_CleanState_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/CleanState", runtime.WithHTTPPathPattern("/daemon.DaemonService/CleanState")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_CleanState_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_CleanState_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_DeleteState_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/DeleteState", runtime.WithHTTPPathPattern("/daemon.DaemonService/DeleteState")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_DeleteState_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_DeleteState_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SetSyncResponsePersistence_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/SetSyncResponsePersistence", runtime.WithHTTPPathPattern("/daemon.DaemonService/SetSyncResponsePersistence")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_SetSyncResponsePersistence_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SetSyncResponsePersistence_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_TracePacket_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/TracePacket", runtime.WithHTTPPathPattern("/daemon.DaemonService/TracePacket")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_TracePacket_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_TracePacket_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_StartCapture_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/StartCapture", runtime.WithHTTPPathPattern("/daemon.DaemonService/StartCapture")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_StartCapture_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_StartCapture_0(annotatedContext, mux, outboundMarshaler, w, req, func() (proto.Message, error) { return resp.Recv() }, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_StartBundleCapture_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/StartBundleCapture", runtime.WithHTTPPathPattern("/daemon.DaemonService/StartBundleCapture")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_StartBundleCapture_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_StartBundleCapture_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_StopBundleCapture_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/StopBundleCapture", runtime.WithHTTPPathPattern("/daemon.DaemonService/StopBundleCapture")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_StopBundleCapture_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_StopBundleCapture_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SubscribeEvents_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/SubscribeEvents", runtime.WithHTTPPathPattern("/daemon.DaemonService/SubscribeEvents")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_SubscribeEvents_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SubscribeEvents_0(annotatedContext, mux, outboundMarshaler, w, req, func() (proto.Message, error) { return resp.Recv() }, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetEvents_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/GetEvents", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetEvents")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_GetEvents_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetEvents_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_RegisterUILog_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/RegisterUILog", runtime.WithHTTPPathPattern("/daemon.DaemonService/RegisterUILog")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_RegisterUILog_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_RegisterUILog_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SwitchProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/SwitchProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/SwitchProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_SwitchProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SwitchProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SetConfig_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/SetConfig", runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_SetConfig_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SetConfig_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_AddProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/AddProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/AddProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_AddProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_AddProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_RenameProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/RenameProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/RenameProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_RenameProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_RenameProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_RemoveProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/RemoveProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/RemoveProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_RemoveProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_RemoveProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_ListProfiles_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/ListProfiles", runtime.WithHTTPPathPattern("/daemon.DaemonService/ListProfiles")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_ListProfiles_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_ListProfiles_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetActiveProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/GetActiveProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetActiveProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_GetActiveProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetActiveProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_Logout_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/Logout", runtime.WithHTTPPathPattern("/daemon.DaemonService/Logout")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_Logout_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_Logout_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetFeatures_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/GetFeatures", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetFeatures")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_GetFeatures_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetFeatures_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_TriggerUpdate_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/TriggerUpdate", runtime.WithHTTPPathPattern("/daemon.DaemonService/TriggerUpdate")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_TriggerUpdate_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_TriggerUpdate_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetPeerSSHHostKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/GetPeerSSHHostKey", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetPeerSSHHostKey")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_GetPeerSSHHostKey_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetPeerSSHHostKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_RequestJWTAuth_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/RequestJWTAuth", runtime.WithHTTPPathPattern("/daemon.DaemonService/RequestJWTAuth")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_RequestJWTAuth_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_RequestJWTAuth_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_WaitJWTToken_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/WaitJWTToken", runtime.WithHTTPPathPattern("/daemon.DaemonService/WaitJWTToken")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_WaitJWTToken_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_WaitJWTToken_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_RequestExtendAuthSession_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/RequestExtendAuthSession", runtime.WithHTTPPathPattern("/daemon.DaemonService/RequestExtendAuthSession")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_RequestExtendAuthSession_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_RequestExtendAuthSession_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_WaitExtendAuthSession_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/WaitExtendAuthSession", runtime.WithHTTPPathPattern("/daemon.DaemonService/WaitExtendAuthSession")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_WaitExtendAuthSession_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_WaitExtendAuthSession_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_DismissSessionWarning_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/DismissSessionWarning", runtime.WithHTTPPathPattern("/daemon.DaemonService/DismissSessionWarning")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_DismissSessionWarning_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_DismissSessionWarning_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_StartCPUProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/StartCPUProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/StartCPUProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_StartCPUProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_StartCPUProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_StopCPUProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/StopCPUProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/StopCPUProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_StopCPUProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_StopCPUProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetInstallerResult_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/GetInstallerResult", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetInstallerResult")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_GetInstallerResult_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetInstallerResult_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_ExposeService_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/ExposeService", runtime.WithHTTPPathPattern("/daemon.DaemonService/ExposeService")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_ExposeService_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_ExposeService_0(annotatedContext, mux, outboundMarshaler, w, req, func() (proto.Message, error) { return resp.Recv() }, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_WailsUIReady_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/WailsUIReady", runtime.WithHTTPPathPattern("/daemon.DaemonService/WailsUIReady")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_WailsUIReady_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_WailsUIReady_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + return nil +} + +var ( + pattern_DaemonService_Login_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "Login"}, "")) + pattern_DaemonService_WaitSSOLogin_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "WaitSSOLogin"}, "")) + pattern_DaemonService_Up_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "Up"}, "")) + pattern_DaemonService_Status_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "Status"}, "")) + pattern_DaemonService_SubscribeStatus_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "SubscribeStatus"}, "")) + pattern_DaemonService_Down_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "Down"}, "")) + pattern_DaemonService_GetConfig_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetConfig"}, "")) + pattern_DaemonService_ListNetworks_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ListNetworks"}, "")) + pattern_DaemonService_SelectNetworks_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "SelectNetworks"}, "")) + pattern_DaemonService_DeselectNetworks_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "DeselectNetworks"}, "")) + pattern_DaemonService_ForwardingRules_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ForwardingRules"}, "")) + pattern_DaemonService_DebugBundle_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "DebugBundle"}, "")) + pattern_DaemonService_GetLogLevel_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetLogLevel"}, "")) + pattern_DaemonService_SetLogLevel_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "SetLogLevel"}, "")) + pattern_DaemonService_ListStates_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ListStates"}, "")) + pattern_DaemonService_CleanState_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "CleanState"}, "")) + pattern_DaemonService_DeleteState_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "DeleteState"}, "")) + pattern_DaemonService_SetSyncResponsePersistence_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "SetSyncResponsePersistence"}, "")) + pattern_DaemonService_TracePacket_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "TracePacket"}, "")) + pattern_DaemonService_StartCapture_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "StartCapture"}, "")) + pattern_DaemonService_StartBundleCapture_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "StartBundleCapture"}, "")) + pattern_DaemonService_StopBundleCapture_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "StopBundleCapture"}, "")) + pattern_DaemonService_SubscribeEvents_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "SubscribeEvents"}, "")) + pattern_DaemonService_GetEvents_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetEvents"}, "")) + pattern_DaemonService_RegisterUILog_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "RegisterUILog"}, "")) + pattern_DaemonService_SwitchProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "SwitchProfile"}, "")) + pattern_DaemonService_SetConfig_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "SetConfig"}, "")) + pattern_DaemonService_AddProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "AddProfile"}, "")) + pattern_DaemonService_RenameProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "RenameProfile"}, "")) + pattern_DaemonService_RemoveProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "RemoveProfile"}, "")) + pattern_DaemonService_ListProfiles_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ListProfiles"}, "")) + pattern_DaemonService_GetActiveProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetActiveProfile"}, "")) + pattern_DaemonService_Logout_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "Logout"}, "")) + pattern_DaemonService_GetFeatures_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetFeatures"}, "")) + pattern_DaemonService_TriggerUpdate_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "TriggerUpdate"}, "")) + pattern_DaemonService_GetPeerSSHHostKey_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetPeerSSHHostKey"}, "")) + pattern_DaemonService_RequestJWTAuth_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "RequestJWTAuth"}, "")) + pattern_DaemonService_WaitJWTToken_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "WaitJWTToken"}, "")) + pattern_DaemonService_RequestExtendAuthSession_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "RequestExtendAuthSession"}, "")) + pattern_DaemonService_WaitExtendAuthSession_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "WaitExtendAuthSession"}, "")) + pattern_DaemonService_DismissSessionWarning_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "DismissSessionWarning"}, "")) + pattern_DaemonService_StartCPUProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "StartCPUProfile"}, "")) + pattern_DaemonService_StopCPUProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "StopCPUProfile"}, "")) + pattern_DaemonService_GetInstallerResult_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetInstallerResult"}, "")) + pattern_DaemonService_ExposeService_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ExposeService"}, "")) + pattern_DaemonService_WailsUIReady_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "WailsUIReady"}, "")) +) + +var ( + forward_DaemonService_Login_0 = runtime.ForwardResponseMessage + forward_DaemonService_WaitSSOLogin_0 = runtime.ForwardResponseMessage + forward_DaemonService_Up_0 = runtime.ForwardResponseMessage + forward_DaemonService_Status_0 = runtime.ForwardResponseMessage + forward_DaemonService_SubscribeStatus_0 = runtime.ForwardResponseStream + forward_DaemonService_Down_0 = runtime.ForwardResponseMessage + forward_DaemonService_GetConfig_0 = runtime.ForwardResponseMessage + forward_DaemonService_ListNetworks_0 = runtime.ForwardResponseMessage + forward_DaemonService_SelectNetworks_0 = runtime.ForwardResponseMessage + forward_DaemonService_DeselectNetworks_0 = runtime.ForwardResponseMessage + forward_DaemonService_ForwardingRules_0 = runtime.ForwardResponseMessage + forward_DaemonService_DebugBundle_0 = runtime.ForwardResponseMessage + forward_DaemonService_GetLogLevel_0 = runtime.ForwardResponseMessage + forward_DaemonService_SetLogLevel_0 = runtime.ForwardResponseMessage + forward_DaemonService_ListStates_0 = runtime.ForwardResponseMessage + forward_DaemonService_CleanState_0 = runtime.ForwardResponseMessage + forward_DaemonService_DeleteState_0 = runtime.ForwardResponseMessage + forward_DaemonService_SetSyncResponsePersistence_0 = runtime.ForwardResponseMessage + forward_DaemonService_TracePacket_0 = runtime.ForwardResponseMessage + forward_DaemonService_StartCapture_0 = runtime.ForwardResponseStream + forward_DaemonService_StartBundleCapture_0 = runtime.ForwardResponseMessage + forward_DaemonService_StopBundleCapture_0 = runtime.ForwardResponseMessage + forward_DaemonService_SubscribeEvents_0 = runtime.ForwardResponseStream + forward_DaemonService_GetEvents_0 = runtime.ForwardResponseMessage + forward_DaemonService_RegisterUILog_0 = runtime.ForwardResponseMessage + forward_DaemonService_SwitchProfile_0 = runtime.ForwardResponseMessage + forward_DaemonService_SetConfig_0 = runtime.ForwardResponseMessage + forward_DaemonService_AddProfile_0 = runtime.ForwardResponseMessage + forward_DaemonService_RenameProfile_0 = runtime.ForwardResponseMessage + forward_DaemonService_RemoveProfile_0 = runtime.ForwardResponseMessage + forward_DaemonService_ListProfiles_0 = runtime.ForwardResponseMessage + forward_DaemonService_GetActiveProfile_0 = runtime.ForwardResponseMessage + forward_DaemonService_Logout_0 = runtime.ForwardResponseMessage + forward_DaemonService_GetFeatures_0 = runtime.ForwardResponseMessage + forward_DaemonService_TriggerUpdate_0 = runtime.ForwardResponseMessage + forward_DaemonService_GetPeerSSHHostKey_0 = runtime.ForwardResponseMessage + forward_DaemonService_RequestJWTAuth_0 = runtime.ForwardResponseMessage + forward_DaemonService_WaitJWTToken_0 = runtime.ForwardResponseMessage + forward_DaemonService_RequestExtendAuthSession_0 = runtime.ForwardResponseMessage + forward_DaemonService_WaitExtendAuthSession_0 = runtime.ForwardResponseMessage + forward_DaemonService_DismissSessionWarning_0 = runtime.ForwardResponseMessage + forward_DaemonService_StartCPUProfile_0 = runtime.ForwardResponseMessage + forward_DaemonService_StopCPUProfile_0 = runtime.ForwardResponseMessage + forward_DaemonService_GetInstallerResult_0 = runtime.ForwardResponseMessage + forward_DaemonService_ExposeService_0 = runtime.ForwardResponseStream + forward_DaemonService_WailsUIReady_0 = runtime.ForwardResponseMessage +) diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index 19976660c..3953f9c15 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -24,6 +24,12 @@ service DaemonService { // Status of the service. rpc Status(StatusRequest) returns (StatusResponse) {} + // SubscribeStatus pushes a fresh StatusResponse on connection state + // changes (Connected / Disconnected / Connecting / address change / + // peers list change). The first message on the stream is the current + // snapshot, so a freshly-subscribed UI doesn't need to also call Status. + rpc SubscribeStatus(StatusRequest) returns (stream StatusResponse) {} + // Down stops engine work in the daemon. rpc Down(DownRequest) returns (DownResponse) {} @@ -64,16 +70,34 @@ service DaemonService { rpc TracePacket(TracePacketRequest) returns (TracePacketResponse) {} + // StartCapture begins streaming packet capture on the WireGuard interface. + // Requires --enable-capture set at service install/reconfigure time. + rpc StartCapture(StartCaptureRequest) returns (stream CapturePacket) {} + + // StartBundleCapture begins capturing packets to a server-side temp file + // for inclusion in the next debug bundle. Auto-stops after the given timeout. + rpc StartBundleCapture(StartBundleCaptureRequest) returns (StartBundleCaptureResponse) {} + + // StopBundleCapture stops the running bundle capture. Idempotent. + rpc StopBundleCapture(StopBundleCaptureRequest) returns (StopBundleCaptureResponse) {} + rpc SubscribeEvents(SubscribeRequest) returns (stream SystemEvent) {} rpc GetEvents(GetEventsRequest) returns (GetEventsResponse) {} + // RegisterUILog records the desktop UI's absolute log path so the daemon's + // debug bundle can collect it (the daemon runs as root and can't resolve the + // user's config dir). + rpc RegisterUILog(RegisterUILogRequest) returns (RegisterUILogResponse) {} + rpc SwitchProfile(SwitchProfileRequest) returns (SwitchProfileResponse) {} rpc SetConfig(SetConfigRequest) returns (SetConfigResponse) {} rpc AddProfile(AddProfileRequest) returns (AddProfileResponse) {} + rpc RenameProfile(RenameProfileRequest) returns (RenameProfileResponse) {} + rpc RemoveProfile(RemoveProfileRequest) returns (RemoveProfileResponse) {} rpc ListProfiles(ListProfilesRequest) returns (ListProfilesResponse) {} @@ -98,36 +122,44 @@ service DaemonService { // WaitJWTToken waits for JWT authentication completion rpc WaitJWTToken(WaitJWTTokenRequest) returns (WaitJWTTokenResponse) {} + // RequestExtendAuthSession initiates an SSO session-extension flow. + // The daemon prepares a PKCE/device-code request against the IdP and + // returns the verification URI; the UI is expected to open it. The flow + // state is kept in the daemon until WaitExtendAuthSession completes it. + rpc RequestExtendAuthSession(RequestExtendAuthSessionRequest) returns (RequestExtendAuthSessionResponse) {} + + // WaitExtendAuthSession blocks until the user finishes the SSO step + // started by RequestExtendAuthSession, then forwards the resulting JWT + // to the management server's ExtendAuthSession RPC. Returns the new + // session expiry deadline. The tunnel stays up the entire time. + rpc WaitExtendAuthSession(WaitExtendAuthSessionRequest) returns (WaitExtendAuthSessionResponse) {} + + // DismissSessionWarning records that the user clicked "Dismiss" on the + // T-WarningLead interactive notification, suppressing the auto-opened + // SessionAboutToExpire dialog that would otherwise fire at + // T-FinalWarningLead for the current deadline. Idempotent and best-effort: + // a missed call only means the fallback dialog will still appear. + rpc DismissSessionWarning(DismissSessionWarningRequest) returns (DismissSessionWarningResponse) {} + // StartCPUProfile starts CPU profiling in the daemon rpc StartCPUProfile(StartCPUProfileRequest) returns (StartCPUProfileResponse) {} // StopCPUProfile stops CPU profiling in the daemon rpc StopCPUProfile(StopCPUProfileRequest) returns (StopCPUProfileResponse) {} - 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) {} + + // WailsUIReady is a no-op probe the Wails UI calls once at startup. The UI + // only cares whether the daemon implements it: an Unimplemented response + // means the daemon predates this UI and is too old to drive it. + rpc WailsUIReady(WailsUIReadyRequest) returns (WailsUIReadyResponse) {} } -message OSLifecycleRequest { - // avoid collision with loglevel enum - enum CycleType { - UNKNOWN = 0; - SLEEP = 1; - WAKEUP = 2; - } - - CycleType type = 1; -} - -message OSLifecycleResponse {} - - message LoginRequest { // setupKey netbird setup key. string setupKey = 1; @@ -209,6 +241,13 @@ message LoginRequest { optional bool enableSSHRemotePortForwarding = 37; optional bool disableSSHAuth = 38; optional int32 sshJWTCacheTTL = 39; + optional bool disable_ipv6 = 40; + + optional bool enable_local_metrics = 41; + optional string local_metrics_address = 42; + // remoteJobsAllowed opts the peer into management-requested remote jobs + // (e.g. debug bundles). Absent leaves the stored value unchanged. + optional bool remoteJobsAllowed = 43; } message LoginResponse { @@ -231,6 +270,12 @@ message UpRequest { optional string profileName = 1; optional string username = 2; reserved 3; + // async instructs the daemon to start the connection attempt and return + // immediately without waiting for the engine to become ready. Status updates + // are delivered via the SubscribeStatus stream. When false (the default) the + // RPC blocks until the engine is running or gives up, which is the behaviour + // needed by the CLI. + bool async = 4; } message UpResponse {} @@ -248,6 +293,10 @@ message StatusResponse{ FullStatus fullStatus = 2; // NetBird daemon version string daemonVersion = 3; + // Absolute UTC instant at which the peer's SSO session expires. + // Unset when the peer is not SSO-registered or login expiration is disabled. + // The UI derives "warning active" from this value and its own clock. + google.protobuf.Timestamp sessionExpiresAt = 4; } message DownRequest {} @@ -316,6 +365,17 @@ message GetConfigResponse { bool disableSSHAuth = 25; int32 sshJWTCacheTTL = 26; + + bool disable_ipv6 = 27; + + bool remoteJobsAllowed = 29; + + // mDMManagedFields lists the names of configuration keys whose value is + // currently enforced by an MDM policy. Names match mdm.Key* constants + // (e.g. "managementURL", "disableClientRoutes"). UI/CLI clients should + // render the corresponding inputs as read-only and display a "managed + // by MDM" indicator. + repeated string mDMManagedFields = 28; } // PeerState contains the latest state of a peer @@ -338,6 +398,7 @@ message PeerState { google.protobuf.Duration latency = 17; string relayAddress = 18; bytes sshHostKey = 19; + string ipv6 = 20; } // LocalPeerState contains the latest state of the local peer @@ -349,6 +410,8 @@ message LocalPeerState { bool rosenpassEnabled = 5; bool rosenpassPermissive = 6; repeated string networks = 7; + string ipv6 = 8; + int32 wgPort = 9; } // SignalState contains the latest state of a signal connection @@ -370,6 +433,9 @@ message RelayState { string URI = 1; bool available = 2; string error = 3; + // transport is the negotiated relay transport (e.g. "ws", "quic"), + // empty for stun/turn probes or when not connected. + string transport = 4; } message NSGroupState { @@ -408,6 +474,12 @@ message FullStatus { bool lazyConnectionEnabled = 9; SSHServerState sshServerState = 10; + + // networksRevision bumps whenever the set of routed networks (route and + // exit-node candidates) or their selected state changes. The UI fingerprints + // on it to know when to re-fetch ListNetworks via the push stream, instead + // of polling on every status snapshot. + uint64 networksRevision = 11; } // Networks @@ -471,6 +543,16 @@ message DebugBundleRequest { bool systemInfo = 3; string uploadURL = 4; uint32 logFileCount = 5; + string cliVersion = 6; + // uploadInsecure allows uploading to an http endpoint or one with an + // untrusted TLS certificate. Restricted to privileged callers; for + // self-hosted upload servers. + bool uploadInsecure = 7; + // anonymizeLevel selects how much the anonymizer redacts: "default" + // (or empty) keeps internal IP ranges, "strict" also anonymizes them. + // Unknown values are treated as "strict". Only meaningful with anonymize; + // "strict" implies it. + string anonymizeLevel = 8; } message DebugBundleResponse { @@ -504,6 +586,13 @@ message SetLogLevelRequest { message SetLogLevelResponse { } +message RegisterUILogRequest { + string path = 1; +} + +message RegisterUILogResponse { +} + // State represents a daemon state entry message State { string name = 1; @@ -613,11 +702,18 @@ message GetEventsResponse { } message SwitchProfileRequest { + // profileName is treated as a handle: exact ID, unique ID prefix, or + // unique display name. The daemon resolves it server-side. optional string profileName = 1; optional string username = 2; } -message SwitchProfileResponse {} +message SwitchProfileResponse { + // id is the resolved on-disk ID of the profile that became active. + // Lets CLI clients update their local active-profile state without + // duplicating the resolution logic. + string id = 1; +} message SetConfigRequest { string username = 1; @@ -677,23 +773,55 @@ message SetConfigRequest { optional bool enableSSHRemotePortForwarding = 32; optional bool disableSSHAuth = 33; optional int32 sshJWTCacheTTL = 34; + optional bool disable_ipv6 = 35; + + optional bool enable_local_metrics = 36; + optional string local_metrics_address = 37; + // remoteJobsAllowed opts the peer into management-requested remote jobs + // (e.g. debug bundles). Absent leaves the stored value unchanged. + optional bool remoteJobsAllowed = 38; } message SetConfigResponse{} message AddProfileRequest { string username = 1; + // profileName carries the human-readable display name for the new + // profile. The on-disk filename is a separately-generated ID. string profileName = 2; } -message AddProfileResponse {} +message AddProfileResponse { + // id is the generated on-disk ID of the new profile. CLI clients + // display a truncated form, UI clients can ignore it. + string id = 1; +} + +message RenameProfileRequest { + string username = 1; + // handle: an exact ID, a unique ID prefix, or a unique display name. + string handle = 2; + // newProfileName is the new human-readable display name for the profile. + string newProfileName = 3; +} + +message RenameProfileResponse { + // confirm the old profile name after resolving handle. + string oldProfileName = 1; +} message RemoveProfileRequest { string username = 1; + // profileName is treated as a handle: an exact ID, a unique ID + // prefix, or a unique display name. Resolution happens server-side. string profileName = 2; } -message RemoveProfileResponse {} +message RemoveProfileResponse { + // id is the full resolved ID of the removed profile, so callers can + // confirm exactly which profile a name/prefix handle resolved to. + string id = 1; +} message ListProfilesRequest { string username = 1; @@ -706,6 +834,7 @@ message ListProfilesResponse { message Profile { string name = 1; bool is_active = 2; + string id = 3; } message GetActiveProfileRequest {} @@ -713,6 +842,7 @@ message GetActiveProfileRequest {} message GetActiveProfileResponse { string profileName = 1; string username = 2; + string id = 3; } message LogoutRequest { @@ -722,12 +852,31 @@ message LogoutRequest { message LogoutResponse {} +message WailsUIReadyRequest {} + +message WailsUIReadyResponse {} + message GetFeaturesRequest{} message GetFeaturesResponse{ bool disable_profiles = 1; bool disable_update_settings = 2; bool disable_networks = 3; + // disableAdvancedView gates the upcoming UI revision's advanced + // section. Tristate: unset = no MDM directive, the UI applies its + // own default; true = MDM enforces disable; false = MDM enforces + // enable. Sourced exclusively from the MDM policy — no CLI / + // config flag backs this value. + optional bool disable_advanced_view = 4; +} + +// MDMManagedFieldsViolation is attached as a gRPC error detail on a +// FailedPrecondition status returned from SetConfig (and similar mutating +// RPCs) when the caller tries to modify one or more MDM-enforced fields. +// The fields list contains the offending key names; the entire request is +// rejected (no partial apply). +message MDMManagedFieldsViolation { + repeated string fields = 1; } message TriggerUpdateRequest {} @@ -759,6 +908,10 @@ message GetPeerSSHHostKeyResponse { message RequestJWTAuthRequest { // hint for OIDC login_hint parameter (typically email address) optional string hint = 1; + // hasGraphicalSession tells the daemon that the caller has a graphical session, + // which decides whether PKCE or the device code flow is preferred. The daemon + // cannot detect this itself: it does not inherit the session environment. + bool hasGraphicalSession = 2; } // RequestJWTAuthResponse contains authentication flow information @@ -797,6 +950,59 @@ message WaitJWTTokenResponse { int64 expiresIn = 3; } +// RequestExtendAuthSessionRequest kicks off the session-extension SSO flow. +message RequestExtendAuthSessionRequest { + // Optional OIDC login_hint (typically the user's email) to pre-fill the + // IdP login form. + optional string hint = 1; + // hasGraphicalSession tells the daemon that the caller has a graphical session, + // which decides whether PKCE or the device code flow is preferred. The daemon + // cannot detect this itself: it does not inherit the session environment. + bool hasGraphicalSession = 2; +} + +// RequestExtendAuthSessionResponse carries the verification URI the UI +// should open in a browser. The daemon retains the flow state and resolves +// it via WaitExtendAuthSession. +message RequestExtendAuthSessionResponse { + // verification URI for the user to open in the browser + string verificationURI = 1; + // complete verification URI (with embedded user code) + string verificationURIComplete = 2; + // user code to enter on verification URI (for device-code flows) + string userCode = 3; + // device code for matching the WaitExtendAuthSession call to this flow + string deviceCode = 4; + // expiration time in seconds for the device code / PKCE flow + int64 expiresIn = 5; +} + +// WaitExtendAuthSessionRequest is sent by the UI after it opens the +// verification URI. The daemon blocks on this call until the user +// completes (or aborts) the SSO step. +message WaitExtendAuthSessionRequest { + // device code returned by RequestExtendAuthSession + string deviceCode = 1; + // user code for verification + string userCode = 2; +} + +// WaitExtendAuthSessionResponse carries the refreshed deadline returned +// by the management server. Unset when the management server reports the +// peer is not eligible for session extension. +message WaitExtendAuthSessionResponse { + google.protobuf.Timestamp sessionExpiresAt = 1; +} + +// DismissSessionWarningRequest is sent by the UI when the user clicks +// "Dismiss" on the T-WarningLead notification. +message DismissSessionWarningRequest {} + +// DismissSessionWarningResponse acknowledges the dismissal. Carries no +// payload — the daemon's only obligation is to silence the upcoming +// T-FinalWarningLead fallback for the current deadline. +message DismissSessionWarningResponse {} + // StartCPUProfileRequest for starting CPU profiling message StartCPUProfileRequest {} @@ -848,3 +1054,26 @@ message ExposeServiceReady { string domain = 3; bool port_auto_assigned = 4; } + +message StartCaptureRequest { + bool text_output = 1; + uint32 snap_len = 2; + google.protobuf.Duration duration = 3; + string filter_expr = 4; + bool verbose = 5; + bool ascii = 6; +} + +message CapturePacket { + bytes data = 1; +} + +message StartBundleCaptureRequest { + // timeout auto-stops the capture after this duration. + // Clamped to a server-side maximum (10 minutes). Zero or unset defaults to the maximum. + google.protobuf.Duration timeout = 1; +} + +message StartBundleCaptureResponse {} +message StopBundleCaptureRequest {} +message StopBundleCaptureResponse {} diff --git a/client/proto/daemon_gateway_test.go b/client/proto/daemon_gateway_test.go new file mode 100644 index 000000000..20031e9d9 --- /dev/null +++ b/client/proto/daemon_gateway_test.go @@ -0,0 +1,80 @@ +package proto + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + + gatewayruntime "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" +) + +func TestGatewayServerRoutesCoverDaemonRPCs(t *testing.T) { + mux := gatewayruntime.NewServeMux() + if err := RegisterDaemonServiceHandlerServer(context.Background(), mux, UnimplementedDaemonServiceServer{}); err != nil { + t.Fatalf("register daemon gateway server handlers: %v", err) + } + + assertAllDaemonGatewayRoutesRegistered(t, mux) +} + +func TestGatewayClientRoutesCoverDaemonRPCs(t *testing.T) { + listener := bufconn.Listen(1024 * 1024) + server := grpc.NewServer() + RegisterDaemonServiceServer(server, UnimplementedDaemonServiceServer{}) + go func() { + if err := server.Serve(listener); err != nil && err != grpc.ErrServerStopped { + t.Errorf("serve bufconn gRPC server: %v", err) + } + }() + t.Cleanup(func() { + server.Stop() + _ = listener.Close() + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + mux := gatewayruntime.NewServeMux() + opts := []grpc.DialOption{ + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { + return listener.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + } + if err := RegisterDaemonServiceHandlerFromEndpoint(ctx, mux, "passthrough:///bufnet", opts); err != nil { + t.Fatalf("register daemon gateway client handlers: %v", err) + } + + assertAllDaemonGatewayRoutesRegistered(t, mux) +} + +func assertAllDaemonGatewayRoutesRegistered(t *testing.T, mux http.Handler) { + t.Helper() + for _, method := range DaemonService_ServiceDesc.Methods { + assertGatewayRouteRegistered(t, mux, method.MethodName) + } + for _, stream := range DaemonService_ServiceDesc.Streams { + assertGatewayRouteRegistered(t, mux, stream.StreamName) + } +} + +func assertGatewayRouteRegistered(t *testing.T, mux http.Handler, methodName string) { + t.Helper() + + path := "/daemon.DaemonService/" + methodName + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader("{}")) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + + mux.ServeHTTP(res, req) + + if res.Code == http.StatusNotFound { + t.Fatalf("gateway route for %s is not registered", methodName) + } +} diff --git a/client/proto/daemon_grpc.pb.go b/client/proto/daemon_grpc.pb.go index e5bd89597..2d01d474d 100644 --- a/client/proto/daemon_grpc.pb.go +++ b/client/proto/daemon_grpc.pb.go @@ -1,4 +1,8 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc v6.33.1 +// source: daemon.proto package proto @@ -11,8 +15,57 @@ import ( // This is a compile-time assertion to ensure that this generated file // is compatible with the grpc package it is being compiled against. -// Requires gRPC-Go v1.32.0 or later. -const _ = grpc.SupportPackageIsVersion7 +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + DaemonService_Login_FullMethodName = "/daemon.DaemonService/Login" + DaemonService_WaitSSOLogin_FullMethodName = "/daemon.DaemonService/WaitSSOLogin" + DaemonService_Up_FullMethodName = "/daemon.DaemonService/Up" + DaemonService_Status_FullMethodName = "/daemon.DaemonService/Status" + DaemonService_SubscribeStatus_FullMethodName = "/daemon.DaemonService/SubscribeStatus" + DaemonService_Down_FullMethodName = "/daemon.DaemonService/Down" + DaemonService_GetConfig_FullMethodName = "/daemon.DaemonService/GetConfig" + DaemonService_ListNetworks_FullMethodName = "/daemon.DaemonService/ListNetworks" + DaemonService_SelectNetworks_FullMethodName = "/daemon.DaemonService/SelectNetworks" + DaemonService_DeselectNetworks_FullMethodName = "/daemon.DaemonService/DeselectNetworks" + DaemonService_ForwardingRules_FullMethodName = "/daemon.DaemonService/ForwardingRules" + DaemonService_DebugBundle_FullMethodName = "/daemon.DaemonService/DebugBundle" + DaemonService_GetLogLevel_FullMethodName = "/daemon.DaemonService/GetLogLevel" + DaemonService_SetLogLevel_FullMethodName = "/daemon.DaemonService/SetLogLevel" + DaemonService_ListStates_FullMethodName = "/daemon.DaemonService/ListStates" + DaemonService_CleanState_FullMethodName = "/daemon.DaemonService/CleanState" + DaemonService_DeleteState_FullMethodName = "/daemon.DaemonService/DeleteState" + DaemonService_SetSyncResponsePersistence_FullMethodName = "/daemon.DaemonService/SetSyncResponsePersistence" + DaemonService_TracePacket_FullMethodName = "/daemon.DaemonService/TracePacket" + DaemonService_StartCapture_FullMethodName = "/daemon.DaemonService/StartCapture" + DaemonService_StartBundleCapture_FullMethodName = "/daemon.DaemonService/StartBundleCapture" + DaemonService_StopBundleCapture_FullMethodName = "/daemon.DaemonService/StopBundleCapture" + DaemonService_SubscribeEvents_FullMethodName = "/daemon.DaemonService/SubscribeEvents" + DaemonService_GetEvents_FullMethodName = "/daemon.DaemonService/GetEvents" + DaemonService_RegisterUILog_FullMethodName = "/daemon.DaemonService/RegisterUILog" + DaemonService_SwitchProfile_FullMethodName = "/daemon.DaemonService/SwitchProfile" + DaemonService_SetConfig_FullMethodName = "/daemon.DaemonService/SetConfig" + DaemonService_AddProfile_FullMethodName = "/daemon.DaemonService/AddProfile" + DaemonService_RenameProfile_FullMethodName = "/daemon.DaemonService/RenameProfile" + DaemonService_RemoveProfile_FullMethodName = "/daemon.DaemonService/RemoveProfile" + DaemonService_ListProfiles_FullMethodName = "/daemon.DaemonService/ListProfiles" + DaemonService_GetActiveProfile_FullMethodName = "/daemon.DaemonService/GetActiveProfile" + DaemonService_Logout_FullMethodName = "/daemon.DaemonService/Logout" + DaemonService_GetFeatures_FullMethodName = "/daemon.DaemonService/GetFeatures" + DaemonService_TriggerUpdate_FullMethodName = "/daemon.DaemonService/TriggerUpdate" + DaemonService_GetPeerSSHHostKey_FullMethodName = "/daemon.DaemonService/GetPeerSSHHostKey" + DaemonService_RequestJWTAuth_FullMethodName = "/daemon.DaemonService/RequestJWTAuth" + DaemonService_WaitJWTToken_FullMethodName = "/daemon.DaemonService/WaitJWTToken" + DaemonService_RequestExtendAuthSession_FullMethodName = "/daemon.DaemonService/RequestExtendAuthSession" + DaemonService_WaitExtendAuthSession_FullMethodName = "/daemon.DaemonService/WaitExtendAuthSession" + DaemonService_DismissSessionWarning_FullMethodName = "/daemon.DaemonService/DismissSessionWarning" + DaemonService_StartCPUProfile_FullMethodName = "/daemon.DaemonService/StartCPUProfile" + DaemonService_StopCPUProfile_FullMethodName = "/daemon.DaemonService/StopCPUProfile" + DaemonService_GetInstallerResult_FullMethodName = "/daemon.DaemonService/GetInstallerResult" + DaemonService_ExposeService_FullMethodName = "/daemon.DaemonService/ExposeService" + DaemonService_WailsUIReady_FullMethodName = "/daemon.DaemonService/WailsUIReady" +) // DaemonServiceClient is the client API for DaemonService service. // @@ -27,6 +80,11 @@ type DaemonServiceClient interface { Up(ctx context.Context, in *UpRequest, opts ...grpc.CallOption) (*UpResponse, error) // Status of the service. Status(ctx context.Context, in *StatusRequest, opts ...grpc.CallOption) (*StatusResponse, error) + // SubscribeStatus pushes a fresh StatusResponse on connection state + // changes (Connected / Disconnected / Connecting / address change / + // peers list change). The first message on the stream is the current + // snapshot, so a freshly-subscribed UI doesn't need to also call Status. + SubscribeStatus(ctx context.Context, in *StatusRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[StatusResponse], error) // Down stops engine work in the daemon. Down(ctx context.Context, in *DownRequest, opts ...grpc.CallOption) (*DownResponse, error) // GetConfig of the daemon. @@ -53,11 +111,24 @@ type DaemonServiceClient interface { // SetSyncResponsePersistence enables or disables sync response persistence SetSyncResponsePersistence(ctx context.Context, in *SetSyncResponsePersistenceRequest, opts ...grpc.CallOption) (*SetSyncResponsePersistenceResponse, error) TracePacket(ctx context.Context, in *TracePacketRequest, opts ...grpc.CallOption) (*TracePacketResponse, error) - SubscribeEvents(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (DaemonService_SubscribeEventsClient, error) + // StartCapture begins streaming packet capture on the WireGuard interface. + // Requires --enable-capture set at service install/reconfigure time. + StartCapture(ctx context.Context, in *StartCaptureRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[CapturePacket], error) + // StartBundleCapture begins capturing packets to a server-side temp file + // for inclusion in the next debug bundle. Auto-stops after the given timeout. + StartBundleCapture(ctx context.Context, in *StartBundleCaptureRequest, opts ...grpc.CallOption) (*StartBundleCaptureResponse, error) + // StopBundleCapture stops the running bundle capture. Idempotent. + StopBundleCapture(ctx context.Context, in *StopBundleCaptureRequest, opts ...grpc.CallOption) (*StopBundleCaptureResponse, error) + SubscribeEvents(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SystemEvent], error) GetEvents(ctx context.Context, in *GetEventsRequest, opts ...grpc.CallOption) (*GetEventsResponse, error) + // RegisterUILog records the desktop UI's absolute log path so the daemon's + // debug bundle can collect it (the daemon runs as root and can't resolve the + // user's config dir). + RegisterUILog(ctx context.Context, in *RegisterUILogRequest, opts ...grpc.CallOption) (*RegisterUILogResponse, error) SwitchProfile(ctx context.Context, in *SwitchProfileRequest, opts ...grpc.CallOption) (*SwitchProfileResponse, error) SetConfig(ctx context.Context, in *SetConfigRequest, opts ...grpc.CallOption) (*SetConfigResponse, error) AddProfile(ctx context.Context, in *AddProfileRequest, opts ...grpc.CallOption) (*AddProfileResponse, error) + RenameProfile(ctx context.Context, in *RenameProfileRequest, opts ...grpc.CallOption) (*RenameProfileResponse, error) RemoveProfile(ctx context.Context, in *RemoveProfileRequest, opts ...grpc.CallOption) (*RemoveProfileResponse, error) ListProfiles(ctx context.Context, in *ListProfilesRequest, opts ...grpc.CallOption) (*ListProfilesResponse, error) GetActiveProfile(ctx context.Context, in *GetActiveProfileRequest, opts ...grpc.CallOption) (*GetActiveProfileResponse, error) @@ -73,14 +144,33 @@ type DaemonServiceClient interface { RequestJWTAuth(ctx context.Context, in *RequestJWTAuthRequest, opts ...grpc.CallOption) (*RequestJWTAuthResponse, error) // WaitJWTToken waits for JWT authentication completion WaitJWTToken(ctx context.Context, in *WaitJWTTokenRequest, opts ...grpc.CallOption) (*WaitJWTTokenResponse, error) + // RequestExtendAuthSession initiates an SSO session-extension flow. + // The daemon prepares a PKCE/device-code request against the IdP and + // returns the verification URI; the UI is expected to open it. The flow + // state is kept in the daemon until WaitExtendAuthSession completes it. + RequestExtendAuthSession(ctx context.Context, in *RequestExtendAuthSessionRequest, opts ...grpc.CallOption) (*RequestExtendAuthSessionResponse, error) + // WaitExtendAuthSession blocks until the user finishes the SSO step + // started by RequestExtendAuthSession, then forwards the resulting JWT + // to the management server's ExtendAuthSession RPC. Returns the new + // session expiry deadline. The tunnel stays up the entire time. + WaitExtendAuthSession(ctx context.Context, in *WaitExtendAuthSessionRequest, opts ...grpc.CallOption) (*WaitExtendAuthSessionResponse, error) + // DismissSessionWarning records that the user clicked "Dismiss" on the + // T-WarningLead interactive notification, suppressing the auto-opened + // SessionAboutToExpire dialog that would otherwise fire at + // T-FinalWarningLead for the current deadline. Idempotent and best-effort: + // a missed call only means the fallback dialog will still appear. + DismissSessionWarning(ctx context.Context, in *DismissSessionWarningRequest, opts ...grpc.CallOption) (*DismissSessionWarningResponse, error) // StartCPUProfile starts CPU profiling in the daemon StartCPUProfile(ctx context.Context, in *StartCPUProfileRequest, opts ...grpc.CallOption) (*StartCPUProfileResponse, error) // StopCPUProfile stops CPU profiling in the daemon 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) + ExposeService(ctx context.Context, in *ExposeServiceRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ExposeServiceEvent], error) + // WailsUIReady is a no-op probe the Wails UI calls once at startup. The UI + // only cares whether the daemon implements it: an Unimplemented response + // means the daemon predates this UI and is too old to drive it. + WailsUIReady(ctx context.Context, in *WailsUIReadyRequest, opts ...grpc.CallOption) (*WailsUIReadyResponse, error) } type daemonServiceClient struct { @@ -92,8 +182,9 @@ func NewDaemonServiceClient(cc grpc.ClientConnInterface) DaemonServiceClient { } func (c *daemonServiceClient) Login(ctx context.Context, in *LoginRequest, opts ...grpc.CallOption) (*LoginResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(LoginResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/Login", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_Login_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -101,8 +192,9 @@ func (c *daemonServiceClient) Login(ctx context.Context, in *LoginRequest, opts } func (c *daemonServiceClient) WaitSSOLogin(ctx context.Context, in *WaitSSOLoginRequest, opts ...grpc.CallOption) (*WaitSSOLoginResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(WaitSSOLoginResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/WaitSSOLogin", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_WaitSSOLogin_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -110,8 +202,9 @@ func (c *daemonServiceClient) WaitSSOLogin(ctx context.Context, in *WaitSSOLogin } func (c *daemonServiceClient) Up(ctx context.Context, in *UpRequest, opts ...grpc.CallOption) (*UpResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(UpResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/Up", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_Up_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -119,17 +212,38 @@ func (c *daemonServiceClient) Up(ctx context.Context, in *UpRequest, opts ...grp } func (c *daemonServiceClient) Status(ctx context.Context, in *StatusRequest, opts ...grpc.CallOption) (*StatusResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(StatusResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/Status", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_Status_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } return out, nil } +func (c *daemonServiceClient) SubscribeStatus(ctx context.Context, in *StatusRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[StatusResponse], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[0], DaemonService_SubscribeStatus_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[StatusRequest, StatusResponse]{ClientStream: 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 +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type DaemonService_SubscribeStatusClient = grpc.ServerStreamingClient[StatusResponse] + func (c *daemonServiceClient) Down(ctx context.Context, in *DownRequest, opts ...grpc.CallOption) (*DownResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(DownResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/Down", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_Down_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -137,8 +251,9 @@ func (c *daemonServiceClient) Down(ctx context.Context, in *DownRequest, opts .. } func (c *daemonServiceClient) GetConfig(ctx context.Context, in *GetConfigRequest, opts ...grpc.CallOption) (*GetConfigResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(GetConfigResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/GetConfig", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_GetConfig_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -146,8 +261,9 @@ func (c *daemonServiceClient) GetConfig(ctx context.Context, in *GetConfigReques } func (c *daemonServiceClient) ListNetworks(ctx context.Context, in *ListNetworksRequest, opts ...grpc.CallOption) (*ListNetworksResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ListNetworksResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/ListNetworks", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_ListNetworks_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -155,8 +271,9 @@ func (c *daemonServiceClient) ListNetworks(ctx context.Context, in *ListNetworks } func (c *daemonServiceClient) SelectNetworks(ctx context.Context, in *SelectNetworksRequest, opts ...grpc.CallOption) (*SelectNetworksResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(SelectNetworksResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/SelectNetworks", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_SelectNetworks_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -164,8 +281,9 @@ func (c *daemonServiceClient) SelectNetworks(ctx context.Context, in *SelectNetw } func (c *daemonServiceClient) DeselectNetworks(ctx context.Context, in *SelectNetworksRequest, opts ...grpc.CallOption) (*SelectNetworksResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(SelectNetworksResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/DeselectNetworks", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_DeselectNetworks_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -173,8 +291,9 @@ func (c *daemonServiceClient) DeselectNetworks(ctx context.Context, in *SelectNe } func (c *daemonServiceClient) ForwardingRules(ctx context.Context, in *EmptyRequest, opts ...grpc.CallOption) (*ForwardingRulesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ForwardingRulesResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/ForwardingRules", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_ForwardingRules_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -182,8 +301,9 @@ func (c *daemonServiceClient) ForwardingRules(ctx context.Context, in *EmptyRequ } func (c *daemonServiceClient) DebugBundle(ctx context.Context, in *DebugBundleRequest, opts ...grpc.CallOption) (*DebugBundleResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(DebugBundleResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/DebugBundle", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_DebugBundle_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -191,8 +311,9 @@ func (c *daemonServiceClient) DebugBundle(ctx context.Context, in *DebugBundleRe } func (c *daemonServiceClient) GetLogLevel(ctx context.Context, in *GetLogLevelRequest, opts ...grpc.CallOption) (*GetLogLevelResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(GetLogLevelResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/GetLogLevel", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_GetLogLevel_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -200,8 +321,9 @@ func (c *daemonServiceClient) GetLogLevel(ctx context.Context, in *GetLogLevelRe } func (c *daemonServiceClient) SetLogLevel(ctx context.Context, in *SetLogLevelRequest, opts ...grpc.CallOption) (*SetLogLevelResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(SetLogLevelResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/SetLogLevel", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_SetLogLevel_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -209,8 +331,9 @@ func (c *daemonServiceClient) SetLogLevel(ctx context.Context, in *SetLogLevelRe } func (c *daemonServiceClient) ListStates(ctx context.Context, in *ListStatesRequest, opts ...grpc.CallOption) (*ListStatesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ListStatesResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/ListStates", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_ListStates_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -218,8 +341,9 @@ func (c *daemonServiceClient) ListStates(ctx context.Context, in *ListStatesRequ } func (c *daemonServiceClient) CleanState(ctx context.Context, in *CleanStateRequest, opts ...grpc.CallOption) (*CleanStateResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(CleanStateResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/CleanState", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_CleanState_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -227,8 +351,9 @@ func (c *daemonServiceClient) CleanState(ctx context.Context, in *CleanStateRequ } func (c *daemonServiceClient) DeleteState(ctx context.Context, in *DeleteStateRequest, opts ...grpc.CallOption) (*DeleteStateResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(DeleteStateResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/DeleteState", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_DeleteState_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -236,8 +361,9 @@ func (c *daemonServiceClient) DeleteState(ctx context.Context, in *DeleteStateRe } func (c *daemonServiceClient) SetSyncResponsePersistence(ctx context.Context, in *SetSyncResponsePersistenceRequest, opts ...grpc.CallOption) (*SetSyncResponsePersistenceResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(SetSyncResponsePersistenceResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/SetSyncResponsePersistence", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_SetSyncResponsePersistence_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -245,20 +371,22 @@ func (c *daemonServiceClient) SetSyncResponsePersistence(ctx context.Context, in } func (c *daemonServiceClient) TracePacket(ctx context.Context, in *TracePacketRequest, opts ...grpc.CallOption) (*TracePacketResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(TracePacketResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/TracePacket", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_TracePacket_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } return out, nil } -func (c *daemonServiceClient) SubscribeEvents(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (DaemonService_SubscribeEventsClient, error) { - stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[0], "/daemon.DaemonService/SubscribeEvents", opts...) +func (c *daemonServiceClient) StartCapture(ctx context.Context, in *StartCaptureRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[CapturePacket], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[1], DaemonService_StartCapture_FullMethodName, cOpts...) if err != nil { return nil, err } - x := &daemonServiceSubscribeEventsClient{stream} + x := &grpc.GenericClientStream[StartCaptureRequest, CapturePacket]{ClientStream: stream} if err := x.ClientStream.SendMsg(in); err != nil { return nil, err } @@ -268,26 +396,62 @@ func (c *daemonServiceClient) SubscribeEvents(ctx context.Context, in *Subscribe return x, nil } -type DaemonService_SubscribeEventsClient interface { - Recv() (*SystemEvent, error) - grpc.ClientStream -} +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type DaemonService_StartCaptureClient = grpc.ServerStreamingClient[CapturePacket] -type daemonServiceSubscribeEventsClient struct { - grpc.ClientStream -} - -func (x *daemonServiceSubscribeEventsClient) Recv() (*SystemEvent, error) { - m := new(SystemEvent) - if err := x.ClientStream.RecvMsg(m); err != nil { +func (c *daemonServiceClient) StartBundleCapture(ctx context.Context, in *StartBundleCaptureRequest, opts ...grpc.CallOption) (*StartBundleCaptureResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StartBundleCaptureResponse) + err := c.cc.Invoke(ctx, DaemonService_StartBundleCapture_FullMethodName, in, out, cOpts...) + if err != nil { return nil, err } - return m, nil + return out, nil } +func (c *daemonServiceClient) StopBundleCapture(ctx context.Context, in *StopBundleCaptureRequest, opts ...grpc.CallOption) (*StopBundleCaptureResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StopBundleCaptureResponse) + err := c.cc.Invoke(ctx, DaemonService_StopBundleCapture_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *daemonServiceClient) SubscribeEvents(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SystemEvent], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[2], DaemonService_SubscribeEvents_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[SubscribeRequest, SystemEvent]{ClientStream: 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 +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type DaemonService_SubscribeEventsClient = grpc.ServerStreamingClient[SystemEvent] + func (c *daemonServiceClient) GetEvents(ctx context.Context, in *GetEventsRequest, opts ...grpc.CallOption) (*GetEventsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(GetEventsResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/GetEvents", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_GetEvents_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *daemonServiceClient) RegisterUILog(ctx context.Context, in *RegisterUILogRequest, opts ...grpc.CallOption) (*RegisterUILogResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RegisterUILogResponse) + err := c.cc.Invoke(ctx, DaemonService_RegisterUILog_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -295,8 +459,9 @@ func (c *daemonServiceClient) GetEvents(ctx context.Context, in *GetEventsReques } func (c *daemonServiceClient) SwitchProfile(ctx context.Context, in *SwitchProfileRequest, opts ...grpc.CallOption) (*SwitchProfileResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(SwitchProfileResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/SwitchProfile", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_SwitchProfile_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -304,8 +469,9 @@ func (c *daemonServiceClient) SwitchProfile(ctx context.Context, in *SwitchProfi } func (c *daemonServiceClient) SetConfig(ctx context.Context, in *SetConfigRequest, opts ...grpc.CallOption) (*SetConfigResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(SetConfigResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/SetConfig", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_SetConfig_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -313,8 +479,19 @@ func (c *daemonServiceClient) SetConfig(ctx context.Context, in *SetConfigReques } func (c *daemonServiceClient) AddProfile(ctx context.Context, in *AddProfileRequest, opts ...grpc.CallOption) (*AddProfileResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(AddProfileResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/AddProfile", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_AddProfile_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *daemonServiceClient) RenameProfile(ctx context.Context, in *RenameProfileRequest, opts ...grpc.CallOption) (*RenameProfileResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RenameProfileResponse) + err := c.cc.Invoke(ctx, DaemonService_RenameProfile_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -322,8 +499,9 @@ func (c *daemonServiceClient) AddProfile(ctx context.Context, in *AddProfileRequ } func (c *daemonServiceClient) RemoveProfile(ctx context.Context, in *RemoveProfileRequest, opts ...grpc.CallOption) (*RemoveProfileResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(RemoveProfileResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/RemoveProfile", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_RemoveProfile_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -331,8 +509,9 @@ func (c *daemonServiceClient) RemoveProfile(ctx context.Context, in *RemoveProfi } func (c *daemonServiceClient) ListProfiles(ctx context.Context, in *ListProfilesRequest, opts ...grpc.CallOption) (*ListProfilesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ListProfilesResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/ListProfiles", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_ListProfiles_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -340,8 +519,9 @@ func (c *daemonServiceClient) ListProfiles(ctx context.Context, in *ListProfiles } func (c *daemonServiceClient) GetActiveProfile(ctx context.Context, in *GetActiveProfileRequest, opts ...grpc.CallOption) (*GetActiveProfileResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(GetActiveProfileResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/GetActiveProfile", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_GetActiveProfile_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -349,8 +529,9 @@ func (c *daemonServiceClient) GetActiveProfile(ctx context.Context, in *GetActiv } func (c *daemonServiceClient) Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*LogoutResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(LogoutResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/Logout", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_Logout_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -358,8 +539,9 @@ func (c *daemonServiceClient) Logout(ctx context.Context, in *LogoutRequest, opt } func (c *daemonServiceClient) GetFeatures(ctx context.Context, in *GetFeaturesRequest, opts ...grpc.CallOption) (*GetFeaturesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(GetFeaturesResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/GetFeatures", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_GetFeatures_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -367,8 +549,9 @@ func (c *daemonServiceClient) GetFeatures(ctx context.Context, in *GetFeaturesRe } func (c *daemonServiceClient) TriggerUpdate(ctx context.Context, in *TriggerUpdateRequest, opts ...grpc.CallOption) (*TriggerUpdateResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(TriggerUpdateResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/TriggerUpdate", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_TriggerUpdate_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -376,8 +559,9 @@ func (c *daemonServiceClient) TriggerUpdate(ctx context.Context, in *TriggerUpda } func (c *daemonServiceClient) GetPeerSSHHostKey(ctx context.Context, in *GetPeerSSHHostKeyRequest, opts ...grpc.CallOption) (*GetPeerSSHHostKeyResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(GetPeerSSHHostKeyResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/GetPeerSSHHostKey", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_GetPeerSSHHostKey_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -385,8 +569,9 @@ func (c *daemonServiceClient) GetPeerSSHHostKey(ctx context.Context, in *GetPeer } func (c *daemonServiceClient) RequestJWTAuth(ctx context.Context, in *RequestJWTAuthRequest, opts ...grpc.CallOption) (*RequestJWTAuthResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(RequestJWTAuthResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/RequestJWTAuth", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_RequestJWTAuth_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -394,8 +579,39 @@ func (c *daemonServiceClient) RequestJWTAuth(ctx context.Context, in *RequestJWT } func (c *daemonServiceClient) WaitJWTToken(ctx context.Context, in *WaitJWTTokenRequest, opts ...grpc.CallOption) (*WaitJWTTokenResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(WaitJWTTokenResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/WaitJWTToken", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_WaitJWTToken_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *daemonServiceClient) RequestExtendAuthSession(ctx context.Context, in *RequestExtendAuthSessionRequest, opts ...grpc.CallOption) (*RequestExtendAuthSessionResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RequestExtendAuthSessionResponse) + err := c.cc.Invoke(ctx, DaemonService_RequestExtendAuthSession_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *daemonServiceClient) WaitExtendAuthSession(ctx context.Context, in *WaitExtendAuthSessionRequest, opts ...grpc.CallOption) (*WaitExtendAuthSessionResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(WaitExtendAuthSessionResponse) + err := c.cc.Invoke(ctx, DaemonService_WaitExtendAuthSession_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *daemonServiceClient) DismissSessionWarning(ctx context.Context, in *DismissSessionWarningRequest, opts ...grpc.CallOption) (*DismissSessionWarningResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DismissSessionWarningResponse) + err := c.cc.Invoke(ctx, DaemonService_DismissSessionWarning_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -403,8 +619,9 @@ func (c *daemonServiceClient) WaitJWTToken(ctx context.Context, in *WaitJWTToken } func (c *daemonServiceClient) StartCPUProfile(ctx context.Context, in *StartCPUProfileRequest, opts ...grpc.CallOption) (*StartCPUProfileResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(StartCPUProfileResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/StartCPUProfile", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_StartCPUProfile_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -412,17 +629,9 @@ func (c *daemonServiceClient) StartCPUProfile(ctx context.Context, in *StartCPUP } func (c *daemonServiceClient) StopCPUProfile(ctx context.Context, in *StopCPUProfileRequest, opts ...grpc.CallOption) (*StopCPUProfileResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(StopCPUProfileResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/StopCPUProfile", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *daemonServiceClient) NotifyOSLifecycle(ctx context.Context, in *OSLifecycleRequest, opts ...grpc.CallOption) (*OSLifecycleResponse, error) { - out := new(OSLifecycleResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/NotifyOSLifecycle", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_StopCPUProfile_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -430,20 +639,22 @@ func (c *daemonServiceClient) NotifyOSLifecycle(ctx context.Context, in *OSLifec } func (c *daemonServiceClient) GetInstallerResult(ctx context.Context, in *InstallerResultRequest, opts ...grpc.CallOption) (*InstallerResultResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(InstallerResultResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/GetInstallerResult", in, out, opts...) + err := c.cc.Invoke(ctx, DaemonService_GetInstallerResult_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } 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...) +func (c *daemonServiceClient) ExposeService(ctx context.Context, in *ExposeServiceRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ExposeServiceEvent], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[3], DaemonService_ExposeService_FullMethodName, cOpts...) if err != nil { return nil, err } - x := &daemonServiceExposeServiceClient{stream} + x := &grpc.GenericClientStream[ExposeServiceRequest, ExposeServiceEvent]{ClientStream: stream} if err := x.ClientStream.SendMsg(in); err != nil { return nil, err } @@ -453,26 +664,22 @@ func (c *daemonServiceClient) ExposeService(ctx context.Context, in *ExposeServi return x, nil } -type DaemonService_ExposeServiceClient interface { - Recv() (*ExposeServiceEvent, error) - grpc.ClientStream -} +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type DaemonService_ExposeServiceClient = grpc.ServerStreamingClient[ExposeServiceEvent] -type daemonServiceExposeServiceClient struct { - grpc.ClientStream -} - -func (x *daemonServiceExposeServiceClient) Recv() (*ExposeServiceEvent, error) { - m := new(ExposeServiceEvent) - if err := x.ClientStream.RecvMsg(m); err != nil { +func (c *daemonServiceClient) WailsUIReady(ctx context.Context, in *WailsUIReadyRequest, opts ...grpc.CallOption) (*WailsUIReadyResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(WailsUIReadyResponse) + err := c.cc.Invoke(ctx, DaemonService_WailsUIReady_FullMethodName, in, out, cOpts...) + if err != nil { return nil, err } - return m, nil + return out, nil } // DaemonServiceServer is the server API for DaemonService service. // All implementations must embed UnimplementedDaemonServiceServer -// for forward compatibility +// for forward compatibility. type DaemonServiceServer interface { // Login uses setup key to prepare configuration for the daemon. Login(context.Context, *LoginRequest) (*LoginResponse, error) @@ -483,6 +690,11 @@ type DaemonServiceServer interface { Up(context.Context, *UpRequest) (*UpResponse, error) // Status of the service. Status(context.Context, *StatusRequest) (*StatusResponse, error) + // SubscribeStatus pushes a fresh StatusResponse on connection state + // changes (Connected / Disconnected / Connecting / address change / + // peers list change). The first message on the stream is the current + // snapshot, so a freshly-subscribed UI doesn't need to also call Status. + SubscribeStatus(*StatusRequest, grpc.ServerStreamingServer[StatusResponse]) error // Down stops engine work in the daemon. Down(context.Context, *DownRequest) (*DownResponse, error) // GetConfig of the daemon. @@ -509,11 +721,24 @@ type DaemonServiceServer interface { // SetSyncResponsePersistence enables or disables sync response persistence SetSyncResponsePersistence(context.Context, *SetSyncResponsePersistenceRequest) (*SetSyncResponsePersistenceResponse, error) TracePacket(context.Context, *TracePacketRequest) (*TracePacketResponse, error) - SubscribeEvents(*SubscribeRequest, DaemonService_SubscribeEventsServer) error + // StartCapture begins streaming packet capture on the WireGuard interface. + // Requires --enable-capture set at service install/reconfigure time. + StartCapture(*StartCaptureRequest, grpc.ServerStreamingServer[CapturePacket]) error + // StartBundleCapture begins capturing packets to a server-side temp file + // for inclusion in the next debug bundle. Auto-stops after the given timeout. + StartBundleCapture(context.Context, *StartBundleCaptureRequest) (*StartBundleCaptureResponse, error) + // StopBundleCapture stops the running bundle capture. Idempotent. + StopBundleCapture(context.Context, *StopBundleCaptureRequest) (*StopBundleCaptureResponse, error) + SubscribeEvents(*SubscribeRequest, grpc.ServerStreamingServer[SystemEvent]) error GetEvents(context.Context, *GetEventsRequest) (*GetEventsResponse, error) + // RegisterUILog records the desktop UI's absolute log path so the daemon's + // debug bundle can collect it (the daemon runs as root and can't resolve the + // user's config dir). + RegisterUILog(context.Context, *RegisterUILogRequest) (*RegisterUILogResponse, error) SwitchProfile(context.Context, *SwitchProfileRequest) (*SwitchProfileResponse, error) SetConfig(context.Context, *SetConfigRequest) (*SetConfigResponse, error) AddProfile(context.Context, *AddProfileRequest) (*AddProfileResponse, error) + RenameProfile(context.Context, *RenameProfileRequest) (*RenameProfileResponse, error) RemoveProfile(context.Context, *RemoveProfileRequest) (*RemoveProfileResponse, error) ListProfiles(context.Context, *ListProfilesRequest) (*ListProfilesResponse, error) GetActiveProfile(context.Context, *GetActiveProfileRequest) (*GetActiveProfileResponse, error) @@ -529,133 +754,183 @@ type DaemonServiceServer interface { RequestJWTAuth(context.Context, *RequestJWTAuthRequest) (*RequestJWTAuthResponse, error) // WaitJWTToken waits for JWT authentication completion WaitJWTToken(context.Context, *WaitJWTTokenRequest) (*WaitJWTTokenResponse, error) + // RequestExtendAuthSession initiates an SSO session-extension flow. + // The daemon prepares a PKCE/device-code request against the IdP and + // returns the verification URI; the UI is expected to open it. The flow + // state is kept in the daemon until WaitExtendAuthSession completes it. + RequestExtendAuthSession(context.Context, *RequestExtendAuthSessionRequest) (*RequestExtendAuthSessionResponse, error) + // WaitExtendAuthSession blocks until the user finishes the SSO step + // started by RequestExtendAuthSession, then forwards the resulting JWT + // to the management server's ExtendAuthSession RPC. Returns the new + // session expiry deadline. The tunnel stays up the entire time. + WaitExtendAuthSession(context.Context, *WaitExtendAuthSessionRequest) (*WaitExtendAuthSessionResponse, error) + // DismissSessionWarning records that the user clicked "Dismiss" on the + // T-WarningLead interactive notification, suppressing the auto-opened + // SessionAboutToExpire dialog that would otherwise fire at + // T-FinalWarningLead for the current deadline. Idempotent and best-effort: + // a missed call only means the fallback dialog will still appear. + DismissSessionWarning(context.Context, *DismissSessionWarningRequest) (*DismissSessionWarningResponse, error) // StartCPUProfile starts CPU profiling in the daemon StartCPUProfile(context.Context, *StartCPUProfileRequest) (*StartCPUProfileResponse, error) // StopCPUProfile stops CPU profiling in the daemon 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 + ExposeService(*ExposeServiceRequest, grpc.ServerStreamingServer[ExposeServiceEvent]) error + // WailsUIReady is a no-op probe the Wails UI calls once at startup. The UI + // only cares whether the daemon implements it: an Unimplemented response + // means the daemon predates this UI and is too old to drive it. + WailsUIReady(context.Context, *WailsUIReadyRequest) (*WailsUIReadyResponse, error) mustEmbedUnimplementedDaemonServiceServer() } -// UnimplementedDaemonServiceServer must be embedded to have forward compatible implementations. -type UnimplementedDaemonServiceServer struct { -} +// UnimplementedDaemonServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedDaemonServiceServer struct{} func (UnimplementedDaemonServiceServer) Login(context.Context, *LoginRequest) (*LoginResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method Login not implemented") + return nil, status.Error(codes.Unimplemented, "method Login not implemented") } func (UnimplementedDaemonServiceServer) WaitSSOLogin(context.Context, *WaitSSOLoginRequest) (*WaitSSOLoginResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method WaitSSOLogin not implemented") + return nil, status.Error(codes.Unimplemented, "method WaitSSOLogin not implemented") } func (UnimplementedDaemonServiceServer) Up(context.Context, *UpRequest) (*UpResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method Up not implemented") + return nil, status.Error(codes.Unimplemented, "method Up not implemented") } func (UnimplementedDaemonServiceServer) Status(context.Context, *StatusRequest) (*StatusResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method Status not implemented") + return nil, status.Error(codes.Unimplemented, "method Status not implemented") +} +func (UnimplementedDaemonServiceServer) SubscribeStatus(*StatusRequest, grpc.ServerStreamingServer[StatusResponse]) error { + return status.Error(codes.Unimplemented, "method SubscribeStatus not implemented") } func (UnimplementedDaemonServiceServer) Down(context.Context, *DownRequest) (*DownResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method Down not implemented") + return nil, status.Error(codes.Unimplemented, "method Down not implemented") } func (UnimplementedDaemonServiceServer) GetConfig(context.Context, *GetConfigRequest) (*GetConfigResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetConfig not implemented") + return nil, status.Error(codes.Unimplemented, "method GetConfig not implemented") } func (UnimplementedDaemonServiceServer) ListNetworks(context.Context, *ListNetworksRequest) (*ListNetworksResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ListNetworks not implemented") + return nil, status.Error(codes.Unimplemented, "method ListNetworks not implemented") } func (UnimplementedDaemonServiceServer) SelectNetworks(context.Context, *SelectNetworksRequest) (*SelectNetworksResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method SelectNetworks not implemented") + return nil, status.Error(codes.Unimplemented, "method SelectNetworks not implemented") } func (UnimplementedDaemonServiceServer) DeselectNetworks(context.Context, *SelectNetworksRequest) (*SelectNetworksResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method DeselectNetworks not implemented") + return nil, status.Error(codes.Unimplemented, "method DeselectNetworks not implemented") } func (UnimplementedDaemonServiceServer) ForwardingRules(context.Context, *EmptyRequest) (*ForwardingRulesResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ForwardingRules not implemented") + return nil, status.Error(codes.Unimplemented, "method ForwardingRules not implemented") } func (UnimplementedDaemonServiceServer) DebugBundle(context.Context, *DebugBundleRequest) (*DebugBundleResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method DebugBundle not implemented") + return nil, status.Error(codes.Unimplemented, "method DebugBundle not implemented") } func (UnimplementedDaemonServiceServer) GetLogLevel(context.Context, *GetLogLevelRequest) (*GetLogLevelResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetLogLevel not implemented") + return nil, status.Error(codes.Unimplemented, "method GetLogLevel not implemented") } func (UnimplementedDaemonServiceServer) SetLogLevel(context.Context, *SetLogLevelRequest) (*SetLogLevelResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method SetLogLevel not implemented") + return nil, status.Error(codes.Unimplemented, "method SetLogLevel not implemented") } func (UnimplementedDaemonServiceServer) ListStates(context.Context, *ListStatesRequest) (*ListStatesResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ListStates not implemented") + return nil, status.Error(codes.Unimplemented, "method ListStates not implemented") } func (UnimplementedDaemonServiceServer) CleanState(context.Context, *CleanStateRequest) (*CleanStateResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method CleanState not implemented") + return nil, status.Error(codes.Unimplemented, "method CleanState not implemented") } func (UnimplementedDaemonServiceServer) DeleteState(context.Context, *DeleteStateRequest) (*DeleteStateResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method DeleteState not implemented") + return nil, status.Error(codes.Unimplemented, "method DeleteState not implemented") } func (UnimplementedDaemonServiceServer) SetSyncResponsePersistence(context.Context, *SetSyncResponsePersistenceRequest) (*SetSyncResponsePersistenceResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method SetSyncResponsePersistence not implemented") + return nil, status.Error(codes.Unimplemented, "method SetSyncResponsePersistence not implemented") } func (UnimplementedDaemonServiceServer) TracePacket(context.Context, *TracePacketRequest) (*TracePacketResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method TracePacket not implemented") + return nil, status.Error(codes.Unimplemented, "method TracePacket not implemented") } -func (UnimplementedDaemonServiceServer) SubscribeEvents(*SubscribeRequest, DaemonService_SubscribeEventsServer) error { - return status.Errorf(codes.Unimplemented, "method SubscribeEvents not implemented") +func (UnimplementedDaemonServiceServer) StartCapture(*StartCaptureRequest, grpc.ServerStreamingServer[CapturePacket]) error { + return status.Error(codes.Unimplemented, "method StartCapture not implemented") +} +func (UnimplementedDaemonServiceServer) StartBundleCapture(context.Context, *StartBundleCaptureRequest) (*StartBundleCaptureResponse, error) { + return nil, status.Error(codes.Unimplemented, "method StartBundleCapture not implemented") +} +func (UnimplementedDaemonServiceServer) StopBundleCapture(context.Context, *StopBundleCaptureRequest) (*StopBundleCaptureResponse, error) { + return nil, status.Error(codes.Unimplemented, "method StopBundleCapture not implemented") +} +func (UnimplementedDaemonServiceServer) SubscribeEvents(*SubscribeRequest, grpc.ServerStreamingServer[SystemEvent]) error { + return status.Error(codes.Unimplemented, "method SubscribeEvents not implemented") } func (UnimplementedDaemonServiceServer) GetEvents(context.Context, *GetEventsRequest) (*GetEventsResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetEvents not implemented") + return nil, status.Error(codes.Unimplemented, "method GetEvents not implemented") +} +func (UnimplementedDaemonServiceServer) RegisterUILog(context.Context, *RegisterUILogRequest) (*RegisterUILogResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RegisterUILog not implemented") } func (UnimplementedDaemonServiceServer) SwitchProfile(context.Context, *SwitchProfileRequest) (*SwitchProfileResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method SwitchProfile not implemented") + return nil, status.Error(codes.Unimplemented, "method SwitchProfile not implemented") } func (UnimplementedDaemonServiceServer) SetConfig(context.Context, *SetConfigRequest) (*SetConfigResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method SetConfig not implemented") + return nil, status.Error(codes.Unimplemented, "method SetConfig not implemented") } func (UnimplementedDaemonServiceServer) AddProfile(context.Context, *AddProfileRequest) (*AddProfileResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method AddProfile not implemented") + return nil, status.Error(codes.Unimplemented, "method AddProfile not implemented") +} +func (UnimplementedDaemonServiceServer) RenameProfile(context.Context, *RenameProfileRequest) (*RenameProfileResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RenameProfile not implemented") } func (UnimplementedDaemonServiceServer) RemoveProfile(context.Context, *RemoveProfileRequest) (*RemoveProfileResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method RemoveProfile not implemented") + return nil, status.Error(codes.Unimplemented, "method RemoveProfile not implemented") } func (UnimplementedDaemonServiceServer) ListProfiles(context.Context, *ListProfilesRequest) (*ListProfilesResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ListProfiles not implemented") + return nil, status.Error(codes.Unimplemented, "method ListProfiles not implemented") } func (UnimplementedDaemonServiceServer) GetActiveProfile(context.Context, *GetActiveProfileRequest) (*GetActiveProfileResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetActiveProfile not implemented") + return nil, status.Error(codes.Unimplemented, "method GetActiveProfile not implemented") } func (UnimplementedDaemonServiceServer) Logout(context.Context, *LogoutRequest) (*LogoutResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method Logout not implemented") + return nil, status.Error(codes.Unimplemented, "method Logout not implemented") } func (UnimplementedDaemonServiceServer) GetFeatures(context.Context, *GetFeaturesRequest) (*GetFeaturesResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetFeatures not implemented") + return nil, status.Error(codes.Unimplemented, "method GetFeatures not implemented") } func (UnimplementedDaemonServiceServer) TriggerUpdate(context.Context, *TriggerUpdateRequest) (*TriggerUpdateResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method TriggerUpdate not implemented") + return nil, status.Error(codes.Unimplemented, "method TriggerUpdate not implemented") } func (UnimplementedDaemonServiceServer) GetPeerSSHHostKey(context.Context, *GetPeerSSHHostKeyRequest) (*GetPeerSSHHostKeyResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetPeerSSHHostKey not implemented") + return nil, status.Error(codes.Unimplemented, "method GetPeerSSHHostKey not implemented") } func (UnimplementedDaemonServiceServer) RequestJWTAuth(context.Context, *RequestJWTAuthRequest) (*RequestJWTAuthResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method RequestJWTAuth not implemented") + return nil, status.Error(codes.Unimplemented, "method RequestJWTAuth not implemented") } func (UnimplementedDaemonServiceServer) WaitJWTToken(context.Context, *WaitJWTTokenRequest) (*WaitJWTTokenResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method WaitJWTToken not implemented") + return nil, status.Error(codes.Unimplemented, "method WaitJWTToken not implemented") +} +func (UnimplementedDaemonServiceServer) RequestExtendAuthSession(context.Context, *RequestExtendAuthSessionRequest) (*RequestExtendAuthSessionResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RequestExtendAuthSession not implemented") +} +func (UnimplementedDaemonServiceServer) WaitExtendAuthSession(context.Context, *WaitExtendAuthSessionRequest) (*WaitExtendAuthSessionResponse, error) { + return nil, status.Error(codes.Unimplemented, "method WaitExtendAuthSession not implemented") +} +func (UnimplementedDaemonServiceServer) DismissSessionWarning(context.Context, *DismissSessionWarningRequest) (*DismissSessionWarningResponse, error) { + return nil, status.Error(codes.Unimplemented, "method DismissSessionWarning not implemented") } func (UnimplementedDaemonServiceServer) StartCPUProfile(context.Context, *StartCPUProfileRequest) (*StartCPUProfileResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method StartCPUProfile not implemented") + return nil, status.Error(codes.Unimplemented, "method StartCPUProfile not implemented") } func (UnimplementedDaemonServiceServer) StopCPUProfile(context.Context, *StopCPUProfileRequest) (*StopCPUProfileResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method StopCPUProfile not implemented") -} -func (UnimplementedDaemonServiceServer) NotifyOSLifecycle(context.Context, *OSLifecycleRequest) (*OSLifecycleResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method NotifyOSLifecycle not implemented") + return nil, status.Error(codes.Unimplemented, "method StopCPUProfile not implemented") } func (UnimplementedDaemonServiceServer) GetInstallerResult(context.Context, *InstallerResultRequest) (*InstallerResultResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetInstallerResult not implemented") + return nil, status.Error(codes.Unimplemented, "method GetInstallerResult not implemented") } -func (UnimplementedDaemonServiceServer) ExposeService(*ExposeServiceRequest, DaemonService_ExposeServiceServer) error { - return status.Errorf(codes.Unimplemented, "method ExposeService not implemented") +func (UnimplementedDaemonServiceServer) ExposeService(*ExposeServiceRequest, grpc.ServerStreamingServer[ExposeServiceEvent]) error { + return status.Error(codes.Unimplemented, "method ExposeService not implemented") +} +func (UnimplementedDaemonServiceServer) WailsUIReady(context.Context, *WailsUIReadyRequest) (*WailsUIReadyResponse, error) { + return nil, status.Error(codes.Unimplemented, "method WailsUIReady not implemented") } func (UnimplementedDaemonServiceServer) mustEmbedUnimplementedDaemonServiceServer() {} +func (UnimplementedDaemonServiceServer) testEmbeddedByValue() {} // UnsafeDaemonServiceServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to DaemonServiceServer will @@ -665,6 +940,13 @@ type UnsafeDaemonServiceServer interface { } func RegisterDaemonServiceServer(s grpc.ServiceRegistrar, srv DaemonServiceServer) { + // If the following call panics, it indicates UnimplementedDaemonServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } s.RegisterService(&DaemonService_ServiceDesc, srv) } @@ -678,7 +960,7 @@ func _DaemonService_Login_Handler(srv interface{}, ctx context.Context, dec func } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/Login", + FullMethod: DaemonService_Login_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).Login(ctx, req.(*LoginRequest)) @@ -696,7 +978,7 @@ func _DaemonService_WaitSSOLogin_Handler(srv interface{}, ctx context.Context, d } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/WaitSSOLogin", + FullMethod: DaemonService_WaitSSOLogin_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).WaitSSOLogin(ctx, req.(*WaitSSOLoginRequest)) @@ -714,7 +996,7 @@ func _DaemonService_Up_Handler(srv interface{}, ctx context.Context, dec func(in } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/Up", + FullMethod: DaemonService_Up_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).Up(ctx, req.(*UpRequest)) @@ -732,7 +1014,7 @@ func _DaemonService_Status_Handler(srv interface{}, ctx context.Context, dec fun } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/Status", + FullMethod: DaemonService_Status_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).Status(ctx, req.(*StatusRequest)) @@ -740,6 +1022,17 @@ func _DaemonService_Status_Handler(srv interface{}, ctx context.Context, dec fun return interceptor(ctx, in, info, handler) } +func _DaemonService_SubscribeStatus_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(StatusRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(DaemonServiceServer).SubscribeStatus(m, &grpc.GenericServerStream[StatusRequest, StatusResponse]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type DaemonService_SubscribeStatusServer = grpc.ServerStreamingServer[StatusResponse] + func _DaemonService_Down_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(DownRequest) if err := dec(in); err != nil { @@ -750,7 +1043,7 @@ func _DaemonService_Down_Handler(srv interface{}, ctx context.Context, dec func( } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/Down", + FullMethod: DaemonService_Down_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).Down(ctx, req.(*DownRequest)) @@ -768,7 +1061,7 @@ func _DaemonService_GetConfig_Handler(srv interface{}, ctx context.Context, dec } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/GetConfig", + FullMethod: DaemonService_GetConfig_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).GetConfig(ctx, req.(*GetConfigRequest)) @@ -786,7 +1079,7 @@ func _DaemonService_ListNetworks_Handler(srv interface{}, ctx context.Context, d } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/ListNetworks", + FullMethod: DaemonService_ListNetworks_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).ListNetworks(ctx, req.(*ListNetworksRequest)) @@ -804,7 +1097,7 @@ func _DaemonService_SelectNetworks_Handler(srv interface{}, ctx context.Context, } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/SelectNetworks", + FullMethod: DaemonService_SelectNetworks_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).SelectNetworks(ctx, req.(*SelectNetworksRequest)) @@ -822,7 +1115,7 @@ func _DaemonService_DeselectNetworks_Handler(srv interface{}, ctx context.Contex } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/DeselectNetworks", + FullMethod: DaemonService_DeselectNetworks_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).DeselectNetworks(ctx, req.(*SelectNetworksRequest)) @@ -840,7 +1133,7 @@ func _DaemonService_ForwardingRules_Handler(srv interface{}, ctx context.Context } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/ForwardingRules", + FullMethod: DaemonService_ForwardingRules_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).ForwardingRules(ctx, req.(*EmptyRequest)) @@ -858,7 +1151,7 @@ func _DaemonService_DebugBundle_Handler(srv interface{}, ctx context.Context, de } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/DebugBundle", + FullMethod: DaemonService_DebugBundle_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).DebugBundle(ctx, req.(*DebugBundleRequest)) @@ -876,7 +1169,7 @@ func _DaemonService_GetLogLevel_Handler(srv interface{}, ctx context.Context, de } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/GetLogLevel", + FullMethod: DaemonService_GetLogLevel_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).GetLogLevel(ctx, req.(*GetLogLevelRequest)) @@ -894,7 +1187,7 @@ func _DaemonService_SetLogLevel_Handler(srv interface{}, ctx context.Context, de } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/SetLogLevel", + FullMethod: DaemonService_SetLogLevel_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).SetLogLevel(ctx, req.(*SetLogLevelRequest)) @@ -912,7 +1205,7 @@ func _DaemonService_ListStates_Handler(srv interface{}, ctx context.Context, dec } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/ListStates", + FullMethod: DaemonService_ListStates_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).ListStates(ctx, req.(*ListStatesRequest)) @@ -930,7 +1223,7 @@ func _DaemonService_CleanState_Handler(srv interface{}, ctx context.Context, dec } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/CleanState", + FullMethod: DaemonService_CleanState_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).CleanState(ctx, req.(*CleanStateRequest)) @@ -948,7 +1241,7 @@ func _DaemonService_DeleteState_Handler(srv interface{}, ctx context.Context, de } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/DeleteState", + FullMethod: DaemonService_DeleteState_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).DeleteState(ctx, req.(*DeleteStateRequest)) @@ -966,7 +1259,7 @@ func _DaemonService_SetSyncResponsePersistence_Handler(srv interface{}, ctx cont } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/SetSyncResponsePersistence", + FullMethod: DaemonService_SetSyncResponsePersistence_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).SetSyncResponsePersistence(ctx, req.(*SetSyncResponsePersistenceRequest)) @@ -984,7 +1277,7 @@ func _DaemonService_TracePacket_Handler(srv interface{}, ctx context.Context, de } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/TracePacket", + FullMethod: DaemonService_TracePacket_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).TracePacket(ctx, req.(*TracePacketRequest)) @@ -992,26 +1285,63 @@ func _DaemonService_TracePacket_Handler(srv interface{}, ctx context.Context, de return interceptor(ctx, in, info, handler) } +func _DaemonService_StartCapture_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(StartCaptureRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(DaemonServiceServer).StartCapture(m, &grpc.GenericServerStream[StartCaptureRequest, CapturePacket]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type DaemonService_StartCaptureServer = grpc.ServerStreamingServer[CapturePacket] + +func _DaemonService_StartBundleCapture_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(StartBundleCaptureRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).StartBundleCapture(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_StartBundleCapture_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).StartBundleCapture(ctx, req.(*StartBundleCaptureRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DaemonService_StopBundleCapture_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(StopBundleCaptureRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).StopBundleCapture(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_StopBundleCapture_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).StopBundleCapture(ctx, req.(*StopBundleCaptureRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _DaemonService_SubscribeEvents_Handler(srv interface{}, stream grpc.ServerStream) error { m := new(SubscribeRequest) if err := stream.RecvMsg(m); err != nil { return err } - return srv.(DaemonServiceServer).SubscribeEvents(m, &daemonServiceSubscribeEventsServer{stream}) + return srv.(DaemonServiceServer).SubscribeEvents(m, &grpc.GenericServerStream[SubscribeRequest, SystemEvent]{ServerStream: stream}) } -type DaemonService_SubscribeEventsServer interface { - Send(*SystemEvent) error - grpc.ServerStream -} - -type daemonServiceSubscribeEventsServer struct { - grpc.ServerStream -} - -func (x *daemonServiceSubscribeEventsServer) Send(m *SystemEvent) error { - return x.ServerStream.SendMsg(m) -} +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type DaemonService_SubscribeEventsServer = grpc.ServerStreamingServer[SystemEvent] func _DaemonService_GetEvents_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(GetEventsRequest) @@ -1023,7 +1353,7 @@ func _DaemonService_GetEvents_Handler(srv interface{}, ctx context.Context, dec } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/GetEvents", + FullMethod: DaemonService_GetEvents_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).GetEvents(ctx, req.(*GetEventsRequest)) @@ -1031,6 +1361,24 @@ func _DaemonService_GetEvents_Handler(srv interface{}, ctx context.Context, dec return interceptor(ctx, in, info, handler) } +func _DaemonService_RegisterUILog_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RegisterUILogRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).RegisterUILog(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_RegisterUILog_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).RegisterUILog(ctx, req.(*RegisterUILogRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _DaemonService_SwitchProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(SwitchProfileRequest) if err := dec(in); err != nil { @@ -1041,7 +1389,7 @@ func _DaemonService_SwitchProfile_Handler(srv interface{}, ctx context.Context, } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/SwitchProfile", + FullMethod: DaemonService_SwitchProfile_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).SwitchProfile(ctx, req.(*SwitchProfileRequest)) @@ -1059,7 +1407,7 @@ func _DaemonService_SetConfig_Handler(srv interface{}, ctx context.Context, dec } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/SetConfig", + FullMethod: DaemonService_SetConfig_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).SetConfig(ctx, req.(*SetConfigRequest)) @@ -1077,7 +1425,7 @@ func _DaemonService_AddProfile_Handler(srv interface{}, ctx context.Context, dec } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/AddProfile", + FullMethod: DaemonService_AddProfile_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).AddProfile(ctx, req.(*AddProfileRequest)) @@ -1085,6 +1433,24 @@ func _DaemonService_AddProfile_Handler(srv interface{}, ctx context.Context, dec return interceptor(ctx, in, info, handler) } +func _DaemonService_RenameProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RenameProfileRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).RenameProfile(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_RenameProfile_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).RenameProfile(ctx, req.(*RenameProfileRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _DaemonService_RemoveProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(RemoveProfileRequest) if err := dec(in); err != nil { @@ -1095,7 +1461,7 @@ func _DaemonService_RemoveProfile_Handler(srv interface{}, ctx context.Context, } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/RemoveProfile", + FullMethod: DaemonService_RemoveProfile_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).RemoveProfile(ctx, req.(*RemoveProfileRequest)) @@ -1113,7 +1479,7 @@ func _DaemonService_ListProfiles_Handler(srv interface{}, ctx context.Context, d } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/ListProfiles", + FullMethod: DaemonService_ListProfiles_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).ListProfiles(ctx, req.(*ListProfilesRequest)) @@ -1131,7 +1497,7 @@ func _DaemonService_GetActiveProfile_Handler(srv interface{}, ctx context.Contex } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/GetActiveProfile", + FullMethod: DaemonService_GetActiveProfile_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).GetActiveProfile(ctx, req.(*GetActiveProfileRequest)) @@ -1149,7 +1515,7 @@ func _DaemonService_Logout_Handler(srv interface{}, ctx context.Context, dec fun } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/Logout", + FullMethod: DaemonService_Logout_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).Logout(ctx, req.(*LogoutRequest)) @@ -1167,7 +1533,7 @@ func _DaemonService_GetFeatures_Handler(srv interface{}, ctx context.Context, de } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/GetFeatures", + FullMethod: DaemonService_GetFeatures_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).GetFeatures(ctx, req.(*GetFeaturesRequest)) @@ -1185,7 +1551,7 @@ func _DaemonService_TriggerUpdate_Handler(srv interface{}, ctx context.Context, } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/TriggerUpdate", + FullMethod: DaemonService_TriggerUpdate_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).TriggerUpdate(ctx, req.(*TriggerUpdateRequest)) @@ -1203,7 +1569,7 @@ func _DaemonService_GetPeerSSHHostKey_Handler(srv interface{}, ctx context.Conte } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/GetPeerSSHHostKey", + FullMethod: DaemonService_GetPeerSSHHostKey_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).GetPeerSSHHostKey(ctx, req.(*GetPeerSSHHostKeyRequest)) @@ -1221,7 +1587,7 @@ func _DaemonService_RequestJWTAuth_Handler(srv interface{}, ctx context.Context, } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/RequestJWTAuth", + FullMethod: DaemonService_RequestJWTAuth_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).RequestJWTAuth(ctx, req.(*RequestJWTAuthRequest)) @@ -1239,7 +1605,7 @@ func _DaemonService_WaitJWTToken_Handler(srv interface{}, ctx context.Context, d } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/WaitJWTToken", + FullMethod: DaemonService_WaitJWTToken_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).WaitJWTToken(ctx, req.(*WaitJWTTokenRequest)) @@ -1247,6 +1613,60 @@ func _DaemonService_WaitJWTToken_Handler(srv interface{}, ctx context.Context, d return interceptor(ctx, in, info, handler) } +func _DaemonService_RequestExtendAuthSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RequestExtendAuthSessionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).RequestExtendAuthSession(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_RequestExtendAuthSession_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).RequestExtendAuthSession(ctx, req.(*RequestExtendAuthSessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DaemonService_WaitExtendAuthSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(WaitExtendAuthSessionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).WaitExtendAuthSession(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_WaitExtendAuthSession_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).WaitExtendAuthSession(ctx, req.(*WaitExtendAuthSessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DaemonService_DismissSessionWarning_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DismissSessionWarningRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).DismissSessionWarning(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_DismissSessionWarning_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).DismissSessionWarning(ctx, req.(*DismissSessionWarningRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _DaemonService_StartCPUProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(StartCPUProfileRequest) if err := dec(in); err != nil { @@ -1257,7 +1677,7 @@ func _DaemonService_StartCPUProfile_Handler(srv interface{}, ctx context.Context } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/StartCPUProfile", + FullMethod: DaemonService_StartCPUProfile_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).StartCPUProfile(ctx, req.(*StartCPUProfileRequest)) @@ -1275,7 +1695,7 @@ func _DaemonService_StopCPUProfile_Handler(srv interface{}, ctx context.Context, } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/StopCPUProfile", + FullMethod: DaemonService_StopCPUProfile_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).StopCPUProfile(ctx, req.(*StopCPUProfileRequest)) @@ -1283,24 +1703,6 @@ func _DaemonService_StopCPUProfile_Handler(srv interface{}, ctx context.Context, return interceptor(ctx, in, info, handler) } -func _DaemonService_NotifyOSLifecycle_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(OSLifecycleRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(DaemonServiceServer).NotifyOSLifecycle(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: "/daemon.DaemonService/NotifyOSLifecycle", - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(DaemonServiceServer).NotifyOSLifecycle(ctx, req.(*OSLifecycleRequest)) - } - return interceptor(ctx, in, info, handler) -} - func _DaemonService_GetInstallerResult_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(InstallerResultRequest) if err := dec(in); err != nil { @@ -1311,7 +1713,7 @@ func _DaemonService_GetInstallerResult_Handler(srv interface{}, ctx context.Cont } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/daemon.DaemonService/GetInstallerResult", + FullMethod: DaemonService_GetInstallerResult_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(DaemonServiceServer).GetInstallerResult(ctx, req.(*InstallerResultRequest)) @@ -1324,20 +1726,28 @@ func _DaemonService_ExposeService_Handler(srv interface{}, stream grpc.ServerStr if err := stream.RecvMsg(m); err != nil { return err } - return srv.(DaemonServiceServer).ExposeService(m, &daemonServiceExposeServiceServer{stream}) + return srv.(DaemonServiceServer).ExposeService(m, &grpc.GenericServerStream[ExposeServiceRequest, ExposeServiceEvent]{ServerStream: stream}) } -type DaemonService_ExposeServiceServer interface { - Send(*ExposeServiceEvent) error - grpc.ServerStream -} +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type DaemonService_ExposeServiceServer = grpc.ServerStreamingServer[ExposeServiceEvent] -type daemonServiceExposeServiceServer struct { - grpc.ServerStream -} - -func (x *daemonServiceExposeServiceServer) Send(m *ExposeServiceEvent) error { - return x.ServerStream.SendMsg(m) +func _DaemonService_WailsUIReady_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(WailsUIReadyRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).WailsUIReady(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_WailsUIReady_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).WailsUIReady(ctx, req.(*WailsUIReadyRequest)) + } + return interceptor(ctx, in, info, handler) } // DaemonService_ServiceDesc is the grpc.ServiceDesc for DaemonService service. @@ -1419,10 +1829,22 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{ MethodName: "TracePacket", Handler: _DaemonService_TracePacket_Handler, }, + { + MethodName: "StartBundleCapture", + Handler: _DaemonService_StartBundleCapture_Handler, + }, + { + MethodName: "StopBundleCapture", + Handler: _DaemonService_StopBundleCapture_Handler, + }, { MethodName: "GetEvents", Handler: _DaemonService_GetEvents_Handler, }, + { + MethodName: "RegisterUILog", + Handler: _DaemonService_RegisterUILog_Handler, + }, { MethodName: "SwitchProfile", Handler: _DaemonService_SwitchProfile_Handler, @@ -1435,6 +1857,10 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{ MethodName: "AddProfile", Handler: _DaemonService_AddProfile_Handler, }, + { + MethodName: "RenameProfile", + Handler: _DaemonService_RenameProfile_Handler, + }, { MethodName: "RemoveProfile", Handler: _DaemonService_RemoveProfile_Handler, @@ -1471,6 +1897,18 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{ MethodName: "WaitJWTToken", Handler: _DaemonService_WaitJWTToken_Handler, }, + { + MethodName: "RequestExtendAuthSession", + Handler: _DaemonService_RequestExtendAuthSession_Handler, + }, + { + MethodName: "WaitExtendAuthSession", + Handler: _DaemonService_WaitExtendAuthSession_Handler, + }, + { + MethodName: "DismissSessionWarning", + Handler: _DaemonService_DismissSessionWarning_Handler, + }, { MethodName: "StartCPUProfile", Handler: _DaemonService_StartCPUProfile_Handler, @@ -1479,16 +1917,26 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{ MethodName: "StopCPUProfile", Handler: _DaemonService_StopCPUProfile_Handler, }, - { - MethodName: "NotifyOSLifecycle", - Handler: _DaemonService_NotifyOSLifecycle_Handler, - }, { MethodName: "GetInstallerResult", Handler: _DaemonService_GetInstallerResult_Handler, }, + { + MethodName: "WailsUIReady", + Handler: _DaemonService_WailsUIReady_Handler, + }, }, Streams: []grpc.StreamDesc{ + { + StreamName: "SubscribeStatus", + Handler: _DaemonService_SubscribeStatus_Handler, + ServerStreams: true, + }, + { + StreamName: "StartCapture", + Handler: _DaemonService_StartCapture_Handler, + ServerStreams: true, + }, { StreamName: "SubscribeEvents", Handler: _DaemonService_SubscribeEvents_Handler, diff --git a/client/proto/generate.sh b/client/proto/generate.sh index e659cef90..d73367d12 100755 --- a/client/proto/generate.sh +++ b/client/proto/generate.sh @@ -1,17 +1,22 @@ -#!/bin/bash +#!/usr/bin/env bash set -e -if ! which realpath > /dev/null 2>&1 -then - echo realpath is not installed - echo run: brew install coreutils - exit 1 +if ! which realpath >/dev/null 2>&1; then + echo realpath is not installed + echo run: brew install coreutils + exit 1 fi old_pwd=$(pwd) -script_path=$(dirname $(realpath "$0")) +script_path=$(dirname "$(realpath "$0")") cd "$script_path" go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.6 -go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.1 -protoc -I ./ ./daemon.proto --go_out=../ --go-grpc_out=../ --experimental_allow_proto3_optional +go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.1 +go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@v2.26.3 +protoc -I ./ ./daemon.proto \ + --go_out=../ \ + --go-grpc_out=../ \ + --grpc-gateway_out=../ \ + --grpc-gateway_opt=generate_unbound_methods=true \ + --experimental_allow_proto3_optional cd "$old_pwd" diff --git a/client/proto/metadata.go b/client/proto/metadata.go new file mode 100644 index 000000000..9b1dbd16e --- /dev/null +++ b/client/proto/metadata.go @@ -0,0 +1,61 @@ +package proto + +// SystemEvent metadata markers. The daemon stamps these on internal control +// events it publishes over SubscribeEvents (profile-list refresh, log-level +// change); the desktop UI recognises them and acts on them instead of +// surfacing them as user-facing notifications. +// +// These live in the proto package — the shared contract both the daemon +// (client/server) and the UI (client/ui/services) already import — so producer +// and consumer reference the same constant rather than duplicating literals. +// This file is hand-written and not touched by protoc. +const ( + // MetadataKindKey is the SystemEvent.metadata key carrying the event-kind + // marker (one of the MetadataKind* values below). + MetadataKindKey = "kind" + + // MetadataKindProfileListChanged marks a CLI-driven profile add/remove that + // should nudge the UI's profile views to refresh. + MetadataKindProfileListChanged = "profile-list-changed" + // MetadataKindLogLevelChanged marks a daemon log-level change (or the + // per-subscription snapshot) that drives the GUI's file logging on/off. + MetadataKindLogLevelChanged = "log-level-changed" + + // MetadataProfileKey carries the profile name for + // MetadataKindProfileListChanged. + MetadataProfileKey = "profile" + // MetadataLevelKey carries the lowercase logrus level name for + // MetadataKindLogLevelChanged. + MetadataLevelKey = "level" +) + +// SystemEvent metadata markers for daemon config-change events. The daemon +// publishes a SYSTEM-category event whenever its effective Config is +// replaced (engine spawn, Up RPC, MDM policy diff); the UI re-fetches its +// cached config/features in response and, for the MDM source, shows a +// localised toast. Producer (client/server) and consumer (client/ui) share +// these so neither duplicates the wire literals. +const ( + // MetadataTypeKey is the SystemEvent.metadata key carrying the + // config-change event type (one of the MetadataType* values below). + MetadataTypeKey = "type" + // MetadataTypeConfigChanged marks a config replacement that should nudge + // UIs to re-fetch their cached config + features. UserMessage is empty so + // the change is silent; the source is carried in MetadataSourceKey. + MetadataTypeConfigChanged = "config_changed" + // MetadataTypePolicyApplied marks an MDM-policy-driven config change. The + // daemon stamps it with a (non-localised) UserMessage; the UI suppresses + // that and builds its own localised toast off the paired config_changed + // event instead. + MetadataTypePolicyApplied = "policy_applied" + + // MetadataSourceKey is the SystemEvent.metadata key carrying what + // triggered a config_changed event (one of the MetadataSource* values). + MetadataSourceKey = "source" + // MetadataSourceStartup marks a config_changed from the daemon Start path. + MetadataSourceStartup = "startup" + // MetadataSourceUpRPC marks a config_changed from the Up RPC. + MetadataSourceUpRPC = "up_rpc" + // MetadataSourceMDM marks a config_changed driven by an MDM policy diff. + MetadataSourceMDM = "mdm" +) diff --git a/client/server/capture.go b/client/server/capture.go new file mode 100644 index 000000000..308c00338 --- /dev/null +++ b/client/server/capture.go @@ -0,0 +1,365 @@ +package server + +import ( + "context" + "io" + "os" + "sync" + "time" + + log "github.com/sirupsen/logrus" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/util/capture" +) + +const maxBundleCaptureDuration = 10 * time.Minute + +// bundleCapture holds the state of an in-progress capture destined for the +// debug bundle. The lifecycle is: +// +// StartBundleCapture → capture running, writing to temp file +// StopBundleCapture → capture stopped, temp file available +// DebugBundle → temp file included in zip, then cleaned up +type bundleCapture struct { + mu sync.Mutex + sess *capture.Session + file *os.File + engine *internal.Engine + cancel context.CancelFunc + stopped bool +} + +// stop halts the capture session and closes the pcap writer. Idempotent. +func (bc *bundleCapture) stop() { + bc.mu.Lock() + defer bc.mu.Unlock() + + if bc.stopped { + return + } + bc.stopped = true + + if bc.cancel != nil { + bc.cancel() + } + if bc.sess != nil { + bc.sess.Stop() + } +} + +// path returns the temp file path, or "" if no file exists. +func (bc *bundleCapture) path() string { + if bc.file == nil { + return "" + } + return bc.file.Name() +} + +// cleanup removes the temp file. +func (bc *bundleCapture) cleanup() { + if bc.file == nil { + return + } + name := bc.file.Name() + if err := bc.file.Close(); err != nil { + log.Debugf("close bundle capture file: %v", err) + } + if err := os.Remove(name); err != nil && !os.IsNotExist(err) { + log.Debugf("remove bundle capture file: %v", err) + } + bc.file = nil +} + +// StartCapture streams a pcap or text packet capture over gRPC. +// Gated by the --enable-capture service flag. +func (s *Server) StartCapture(req *proto.StartCaptureRequest, stream proto.DaemonService_StartCaptureServer) error { + if !s.captureEnabled { + return status.Error(codes.PermissionDenied, + "packet capture is disabled; reinstall or reconfigure the service with --enable-capture") + } + + if d := req.GetDuration(); d != nil && d.AsDuration() < 0 { + return status.Error(codes.InvalidArgument, "duration must not be negative") + } + + matcher, err := parseCaptureFilter(req) + if err != nil { + return status.Errorf(codes.InvalidArgument, "invalid filter: %v", err) + } + + pr, pw := io.Pipe() + + opts := capture.Options{ + Matcher: matcher, + SnapLen: req.GetSnapLen(), + Verbose: req.GetVerbose(), + ASCII: req.GetAscii(), + } + if req.GetTextOutput() { + opts.TextOutput = pw + } else { + opts.Output = pw + } + + sess, err := capture.NewSession(opts) + if err != nil { + pw.Close() + return status.Errorf(codes.Internal, "create capture session: %v", err) + } + + engine, err := s.claimCapture(sess) + if err != nil { + sess.Stop() + pw.Close() + return err + } + + if err := engine.SetCapture(sess); err != nil { + s.releaseCapture(sess) + sess.Stop() + pw.Close() + return status.Errorf(codes.Internal, "set capture: %v", err) + } + + // Send an empty initial message to signal that the capture was accepted. + // The client waits for this before printing the banner, so it must arrive + // before any packet data. + if err := stream.Send(&proto.CapturePacket{}); err != nil { + s.clearCaptureIfOwner(sess, engine) + sess.Stop() + pw.Close() + return status.Errorf(codes.Internal, "send initial message: %v", err) + } + + ctx := stream.Context() + if d := req.GetDuration(); d != nil { + if dur := d.AsDuration(); dur > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, dur) + defer cancel() + } + } + + go func() { + <-ctx.Done() + s.clearCaptureIfOwner(sess, engine) + sess.Stop() + pw.Close() + }() + defer pr.Close() + + log.Infof("packet capture started (text=%v, expr=%q)", req.GetTextOutput(), req.GetFilterExpr()) + defer func() { + stats := sess.Stats() + log.Infof("packet capture stopped: %d packets, %d bytes, %d dropped", + stats.Packets, stats.Bytes, stats.Dropped) + }() + + return streamToGRPC(pr, stream) +} + +func streamToGRPC(r io.Reader, stream proto.DaemonService_StartCaptureServer) error { + buf := make([]byte, 32*1024) + for { + n, readErr := r.Read(buf) + if n > 0 { + if err := stream.Send(&proto.CapturePacket{Data: buf[:n]}); err != nil { + log.Debugf("capture stream send: %v", err) + return nil //nolint:nilerr // client disconnected + } + } + if readErr != nil { + return nil //nolint:nilerr // pipe closed, capture stopped normally + } + } +} + +// StartBundleCapture begins capturing packets to a server-side temp file for +// inclusion in the next debug bundle. Not gated by --enable-capture since the +// output stays on the server (same trust level as CPU profiling). +// +// A timeout auto-stops the capture as a safety net if StopBundleCapture is +// never called (e.g. CLI crash). +func (s *Server) StartBundleCapture(_ context.Context, req *proto.StartBundleCaptureRequest) (*proto.StartBundleCaptureResponse, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + + s.stopBundleCaptureLocked() + s.cleanupBundleCapture() + + if s.activeCapture != nil { + return nil, status.Error(codes.FailedPrecondition, "another capture is already running") + } + + engine, err := s.getCaptureEngineLocked() + if err != nil { + // Not fatal: kernel mode or not connected. Log and return success + // so the debug bundle still generates without capture data. + log.Warnf("packet capture unavailable, skipping: %v", err) + return &proto.StartBundleCaptureResponse{}, nil + } + + timeout := req.GetTimeout().AsDuration() + if timeout <= 0 || timeout > maxBundleCaptureDuration { + timeout = maxBundleCaptureDuration + } + + f, err := os.CreateTemp("", "netbird.capture.*.pcap") + if err != nil { + return nil, status.Errorf(codes.Internal, "create temp file: %v", err) + } + + sess, err := capture.NewSession(capture.Options{Output: f}) + if err != nil { + f.Close() + os.Remove(f.Name()) + return nil, status.Errorf(codes.Internal, "create capture session: %v", err) + } + + if err := engine.SetCapture(sess); err != nil { + sess.Stop() + f.Close() + os.Remove(f.Name()) + log.Warnf("packet capture unavailable (no filtered device), skipping: %v", err) + return &proto.StartBundleCaptureResponse{}, nil + } + s.activeCapture = sess + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + bc := &bundleCapture{ + sess: sess, + file: f, + engine: engine, + cancel: cancel, + } + + s.bundleCapture = bc + + go func() { + <-ctx.Done() + s.mutex.Lock() + if s.bundleCapture == bc { + s.stopBundleCaptureLocked() + } else { + bc.stop() + } + s.mutex.Unlock() + log.Infof("bundle capture auto-stopped after timeout") + }() + log.Infof("bundle capture started (timeout=%s, file=%s)", timeout, f.Name()) + + return &proto.StartBundleCaptureResponse{}, nil +} + +// StopBundleCapture stops the running bundle capture. Idempotent. +func (s *Server) StopBundleCapture(_ context.Context, _ *proto.StopBundleCaptureRequest) (*proto.StopBundleCaptureResponse, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + + s.stopBundleCaptureLocked() + return &proto.StopBundleCaptureResponse{}, nil +} + +// stopBundleCaptureLocked stops the bundle capture if running. Must hold s.mutex. +func (s *Server) stopBundleCaptureLocked() { + if s.bundleCapture == nil { + return + } + bc := s.bundleCapture + if bc.engine != nil && s.activeCapture == bc.sess { + if err := bc.engine.SetCapture(nil); err != nil { + log.Debugf("clear bundle capture: %v", err) + } + s.activeCapture = nil + } + bc.stop() + + stats := bc.sess.Stats() + log.Infof("bundle capture stopped: %d packets, %d bytes, %d dropped", + stats.Packets, stats.Bytes, stats.Dropped) +} + +// bundleCapturePath returns the temp file path if a capture has been taken, +// stops any running capture, and returns "". Called from DebugBundle. +// Must hold s.mutex. +func (s *Server) bundleCapturePath() string { + if s.bundleCapture == nil { + return "" + } + + s.bundleCapture.stop() + return s.bundleCapture.path() +} + +// cleanupBundleCapture removes the temp file and clears state. Must hold s.mutex. +func (s *Server) cleanupBundleCapture() { + if s.bundleCapture == nil { + return + } + s.bundleCapture.cleanup() + s.bundleCapture = nil +} + +// claimCapture reserves the engine's capture slot for sess. Returns +// FailedPrecondition if another capture is already active. +func (s *Server) claimCapture(sess *capture.Session) (*internal.Engine, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + + if s.activeCapture != nil { + return nil, status.Error(codes.FailedPrecondition, "another capture is already running") + } + engine, err := s.getCaptureEngineLocked() + if err != nil { + return nil, err + } + s.activeCapture = sess + return engine, nil +} + +// releaseCapture clears the active-capture owner if it still matches sess. +func (s *Server) releaseCapture(sess *capture.Session) { + s.mutex.Lock() + defer s.mutex.Unlock() + if s.activeCapture == sess { + s.activeCapture = nil + } +} + +// clearCaptureIfOwner clears engine's capture slot only if sess still owns it. +func (s *Server) clearCaptureIfOwner(sess *capture.Session, engine *internal.Engine) { + s.mutex.Lock() + defer s.mutex.Unlock() + if s.activeCapture != sess { + return + } + if err := engine.SetCapture(nil); err != nil { + log.Debugf("clear capture: %v", err) + } + s.activeCapture = nil +} + +func (s *Server) getCaptureEngineLocked() (*internal.Engine, error) { + if s.connectClient == nil { + return nil, status.Error(codes.FailedPrecondition, "client not connected") + } + engine := s.connectClient.Engine() + if engine == nil { + return nil, status.Error(codes.FailedPrecondition, "engine not initialized") + } + return engine, nil +} + +// parseCaptureFilter returns a Matcher from the request. +// Returns nil (match all) when no filter expression is set. +func parseCaptureFilter(req *proto.StartCaptureRequest) (capture.Matcher, error) { + expr := req.GetFilterExpr() + if expr == "" { + return nil, nil //nolint:nilnil // nil Matcher means "match all" + } + return capture.ParseFilter(expr) +} diff --git a/client/server/debug.go b/client/server/debug.go index 81708e576..8f4a506b4 100644 --- a/client/server/debug.go +++ b/client/server/debug.go @@ -7,17 +7,63 @@ import ( "context" "errors" "fmt" + "path/filepath" "runtime/pprof" + "strings" + "time" log "github.com/sirupsen/logrus" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + "github.com/netbirdio/netbird/client/anonymize" "github.com/netbirdio/netbird/client/internal/debug" + "github.com/netbirdio/netbird/client/internal/ipcauth" "github.com/netbirdio/netbird/client/proto" mgmProto "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/version" ) // DebugBundle creates a debug bundle and returns the location. -func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (resp *proto.DebugBundleResponse, err error) { +func (s *Server) DebugBundle(callerCtx context.Context, req *proto.DebugBundleRequest) (resp *proto.DebugBundleResponse, err error) { + if err := requirePrivilegeForUploadURL(callerCtx, req.GetUploadURL(), req.GetUploadInsecure()); err != nil { + return nil, err + } + + // The UI log is opened as whoever asked for this bundle, so a caller only + // collects a log it owns (privileged callers excepted). ok is false on a + // socket that carries no identity, which skips the UI log. + callerID, callerIdentified := ipcauth.CallerIdentity(callerCtx) + + path, managementURL, err := s.generateDebugBundle(req, uiLogOpener(callerID, callerIdentified)) + if err != nil { + return nil, err + } + + if req.GetUploadURL() == "" { + return &proto.DebugBundleResponse{Path: path}, nil + } + + // The upload runs without s.mutex held: it does network I/O to a possibly + // slow destination and must not block the other RPCs that take the lock. The + // bounded context is a backstop against a hung connection. + uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + key, err := debug.UploadDebugBundle(uploadCtx, req.GetUploadURL(), managementURL, path, req.GetUploadInsecure()) + if err != nil { + log.Errorf("failed to upload debug bundle to %s: %v", req.GetUploadURL(), err) + return &proto.DebugBundleResponse{Path: path, UploadFailureReason: err.Error()}, nil + } + + log.Infof("debug bundle uploaded to %s with key %s", req.GetUploadURL(), key) + + return &proto.DebugBundleResponse{Path: path, UploadedKey: key}, nil +} + +// generateDebugBundle builds the bundle under s.mutex and returns its path plus +// the management URL captured under the lock, so the caller can run the upload +// without holding the lock. +func (s *Server) generateDebugBundle(req *proto.DebugBundleRequest, uiOpener debug.LogOpener) (path string, managementURL string, err error) { s.mutex.Lock() defer s.mutex.Unlock() @@ -43,14 +89,19 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) ( }() } - // Prepare refresh callback for health probes + capturePath := s.bundleCapturePath() + defer s.cleanupBundleCapture() + var refreshStatus func() if s.connectClient != nil { engine := s.connectClient.Engine() if engine != nil { refreshStatus = func() { log.Debug("refreshing system health status for debug bundle") - engine.RunHealthProbes(true) + // Background ctx: the bundle wants a full, fresh probe regardless + // of the DebugBundle RPC client's lifetime. The engine's own ctx + // still aborts it on shutdown. + engine.RunHealthProbes(context.Background(), true) } } } @@ -61,34 +112,33 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) ( StatusRecorder: s.statusRecorder, SyncResponse: syncResponse, LogPath: s.logFile, + UILogPath: s.uiLogPath, + UILogOpener: uiOpener, CPUProfile: cpuProfileData, + CapturePath: capturePath, RefreshStatus: refreshStatus, ClientMetrics: clientMetrics, + DaemonVersion: version.NetbirdVersion(), + CliVersion: req.CliVersion, }, debug.BundleConfig{ Anonymize: req.GetAnonymize(), + AnonymizeLevel: anonymize.ParseLevel(req.GetAnonymizeLevel()), IncludeSystemInfo: req.GetSystemInfo(), LogFileCount: req.GetLogFileCount(), }, ) - path, err := bundleGenerator.Generate() + path, err = bundleGenerator.Generate() if err != nil { - return nil, fmt.Errorf("generate debug bundle: %w", err) + return "", "", fmt.Errorf("generate debug bundle: %w", err) } - if req.GetUploadURL() == "" { - return &proto.DebugBundleResponse{Path: path}, nil - } - key, err := debug.UploadDebugBundle(context.Background(), req.GetUploadURL(), s.config.ManagementURL.String(), path) - if err != nil { - log.Errorf("failed to upload debug bundle to %s: %v", req.GetUploadURL(), err) - return &proto.DebugBundleResponse{Path: path, UploadFailureReason: err.Error()}, nil + if s.config != nil && s.config.ManagementURL != nil { + managementURL = s.config.ManagementURL.String() } - log.Infof("debug bundle uploaded to %s with key %s", req.GetUploadURL(), key) - - return &proto.DebugBundleResponse{Path: path, UploadedKey: key}, nil + return path, managementURL, nil } // GetLogLevel gets the current logging level for the server. @@ -118,9 +168,48 @@ func (s *Server) SetLogLevel(_ context.Context, req *proto.SetLogLevelRequest) ( log.Infof("Log level set to %s", level.String()) + // Signal the desktop UI so it can attach/detach its gui-client.log. Rides + // the SubscribeEvents stream as a marked event (see publishLogLevelChanged). + s.publishLogLevelChanged(level.String()) + return &proto.SetLogLevelResponse{}, nil } +// RegisterUILog records the desktop UI's absolute log path so DebugBundle can +// collect the GUI log. The daemon runs as root and can't resolve the user's +// config dir, so the UI reports it. Last-writer-wins (one UI per socket). +// +// The path arrives over an IPC any local user can reach and is later opened by +// a root daemon, so it is constrained to the file name the UI writes and to a +// local absolute path. Authorization happens when DebugBundle opens it: the +// bundle refuses a file its requester does not own. A caller the daemon cannot +// identify cannot register a path at all. +func (s *Server) RegisterUILog(callerCtx context.Context, req *proto.RegisterUILogRequest) (*proto.RegisterUILogResponse, error) { + if _, ok := ipcauth.CallerIdentity(callerCtx); !ok { + return nil, gstatus.Error(codes.PermissionDenied, + "registering a UI log path requires a control channel that carries the caller's identity") + } + + path := filepath.Clean(req.GetPath()) + if !filepath.IsAbs(path) || filepath.Base(path) != uiLogFileName { + return nil, gstatus.Errorf(codes.InvalidArgument, "UI log path must be an absolute path ending in %s", uiLogFileName) + } + // filepath.IsAbs accepts a Windows UNC path (\\host\share\...) and a device + // path (\\.\, \\?\); opening one would make the root daemon reach a remote + // or device namespace. Require a plain local path. + if strings.HasPrefix(path, `\\`) { + return nil, gstatus.Error(codes.InvalidArgument, "UI log path must be a local path, not a UNC or device path") + } + + s.mutex.Lock() + defer s.mutex.Unlock() + + s.uiLogPath = path + log.Infof("registered UI log path %s", s.uiLogPath) + + return &proto.RegisterUILogResponse{}, nil +} + // SetSyncResponsePersistence sets the sync response persistence for the server. func (s *Server) SetSyncResponsePersistence(_ context.Context, req *proto.SetSyncResponsePersistenceRequest) (*proto.SetSyncResponsePersistenceResponse, error) { s.mutex.Lock() diff --git a/client/server/debug_gate.go b/client/server/debug_gate.go new file mode 100644 index 000000000..983a13aaf --- /dev/null +++ b/client/server/debug_gate.go @@ -0,0 +1,99 @@ +//go:build !android && !ios + +package server + +import ( + "context" + "fmt" + "net/url" + "os" + "strings" + + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/configs" + "github.com/netbirdio/netbird/client/internal/debug" + "github.com/netbirdio/netbird/client/internal/ipcauth" + "github.com/netbirdio/netbird/upload-server/types" +) + +// uiLogFileName is the only file name the daemon accepts as a UI log path. The +// UI (writer), this validation, and the bundle collector all read it from +// configs so they cannot drift. +const uiLogFileName = configs.UILogFile + +// uiLogOpener opens the registered UI log, and its rotated siblings, on behalf +// of the caller requesting the bundle: OpenOwnedFile then collects the log only +// when that caller owns it (or is privileged). identified is false on a socket +// that carries no caller identity, in which case nothing is opened. +func uiLogOpener(id ipcauth.Identity, identified bool) debug.LogOpener { + return func(path string) (*os.File, error) { + if !identified { + return nil, fmt.Errorf("bundle requester has no verified identity") + } + return ipcauth.OpenOwnedFile(id, path) + } +} + +// requirePrivilegeForUploadURL restricts where the daemon may send a debug +// bundle. The bundle holds the daemon's own logs and state, and the daemon +// fetches the upload URL itself, so an unrestricted endpoint turns the daemon +// into both an exfiltration channel and a request forwarder that reaches +// services only it can talk to. +// +// The upload service NetBird publishes is open to any caller, since that is what +// the CLI and the desktop UI use. Any other endpoint, self-hosted upload servers +// included, requires a privileged caller. Plaintext is refused for everyone: the +// daemon fetches the URL and then PUTs the bundle to whatever that fetch returns, +// so an http hop is a place to intercept the bundle or the redirect. +// +// insecure relaxes transport security (http, or an untrusted TLS certificate) +// for a self-hosted server. It weakens a root-privileged upload, so it is +// refused for an unprivileged caller regardless of the host. +func requirePrivilegeForUploadURL(ctx context.Context, rawURL string, insecure bool) error { + if rawURL == "" { + return nil + } + + parsed, err := url.Parse(rawURL) + if err != nil { + return gstatus.Errorf(codes.InvalidArgument, "parse upload URL: %v", err) + } + + // --insecure relaxes https to http or an untrusted certificate; it does not + // widen the URL to arbitrary schemes, so a host and http/https are required + // before the insecure branch takes over. + if parsed.Host == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") { + return gstatus.Errorf(codes.InvalidArgument, "upload URL must be http or https with a host") + } + + if insecure { + return denyPrivileged(ctx, + "uploading a debug bundle without transport security (--upload-bundle-insecure)", + ipcauth.ElevatedCommand("netbird debug bundle -U --upload-bundle-insecure --upload-bundle-url ")) + } + + if parsed.Scheme != "https" { + return gstatus.Errorf(codes.InvalidArgument, "upload URL must use https, got scheme %q", parsed.Scheme) + } + + if isDefaultUploadService(parsed) { + return nil + } + + return denyPrivileged(ctx, + "uploading a debug bundle to an upload service other than the default one", + ipcauth.ElevatedCommand("netbird debug bundle -U --upload-bundle-url ")) +} + +// isDefaultUploadService reports whether the URL points at the upload service +// NetBird runs. Only the host is compared: the service's path may differ between +// releases, and the host is what decides who receives the bundle. +func isDefaultUploadService(parsed *url.URL) bool { + defaultURL, err := url.Parse(types.DefaultBundleURL) + if err != nil { + return false + } + return parsed.Scheme == defaultURL.Scheme && strings.EqualFold(parsed.Host, defaultURL.Host) +} diff --git a/client/server/debug_gate_test.go b/client/server/debug_gate_test.go new file mode 100644 index 000000000..e958fc581 --- /dev/null +++ b/client/server/debug_gate_test.go @@ -0,0 +1,157 @@ +//go:build !android && !ios + +package server + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/ipcauth" + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/upload-server/types" +) + +func TestRegisterUILogRefusesUnidentifiedCaller(t *testing.T) { + s := &Server{} + + _, err := s.RegisterUILog(noIdentityCtx(), &proto.RegisterUILogRequest{ + Path: filepath.Join(t.TempDir(), uiLogFileName), + }) + + if gstatus.Code(err) != codes.PermissionDenied { + t.Fatalf("code = %v, want PermissionDenied", gstatus.Code(err)) + } +} + +func TestRegisterUILogRefusesForeignPath(t *testing.T) { + secret := "/etc/shadow" + if runtime.GOOS == "windows" { + secret = `C:\Windows\System32\config\SAM` + } + + tests := []struct { + name string + path string + }{ + {"empty", ""}, + {"relative", filepath.Join("netbird", uiLogFileName)}, + {"another file", secret}, + {"directory of the log", t.TempDir()}, + {"unc path", `\\attacker\share\` + uiLogFileName}, + {"device path", `\\.\C:\` + uiLogFileName}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s := &Server{} + + _, err := s.RegisterUILog(userCtx(), &proto.RegisterUILogRequest{Path: tc.path}) + + if gstatus.Code(err) != codes.InvalidArgument { + t.Fatalf("code = %v, want InvalidArgument", gstatus.Code(err)) + } + if s.uiLogPath != "" { + t.Fatalf("path %q was recorded despite the refusal", s.uiLogPath) + } + }) + } +} + +func TestRegisterUILogRecordsPath(t *testing.T) { + s := &Server{} + path := filepath.Join(t.TempDir(), uiLogFileName) + + if _, err := s.RegisterUILog(userCtx(), &proto.RegisterUILogRequest{Path: path}); err != nil { + t.Fatalf("register: %v", err) + } + + if s.uiLogPath != path { + t.Fatalf("path = %q, want %q", s.uiLogPath, path) + } +} + +// The UI log is opened as the bundle requester, so a second local user cannot +// collect a log they do not own, and an unidentified requester collects nothing. +func TestUILogOpenerBindsToRequester(t *testing.T) { + path := filepath.Join(t.TempDir(), uiLogFileName) + if err := os.WriteFile(path, []byte("log line"), 0600); err != nil { + t.Fatalf("write log: %v", err) + } + + // A different unprivileged user than the file's owner: refused. + if _, err := uiLogOpener(unprivilegedIdentity(), true)(path); err == nil { + t.Fatal("expected a file the requester does not own to be refused") + } + + // No verified identity: refused. + if _, err := uiLogOpener(ipcauth.Identity{}, false)(path); err == nil { + t.Fatal("expected an unidentified requester to be refused") + } + + // The requester that owns the file: allowed. The test process created it, so + // its own identity is the owner (and a privileged runner is exempt anyway). + owner, err := ipcauth.CurrentProcessIdentity() + if err != nil { + t.Fatalf("current identity: %v", err) + } + f, err := uiLogOpener(owner, true)(path) + if err != nil { + t.Fatalf("expected the owning requester to be allowed, got %v", err) + } + _ = f.Close() +} + +func TestRequirePrivilegeForUploadURL(t *testing.T) { + tests := []struct { + name string + url string + insecure bool + unprivOK bool + invalid bool + rootAlso bool + }{ + {name: "no upload", url: "", unprivOK: true}, + {name: "default service", url: types.DefaultBundleURL, unprivOK: true}, + {name: "default service, other path", url: "https://upload.debug.netbird.io/other", unprivOK: true}, + {name: "loopback exfiltration endpoint", url: "https://127.0.0.1:8080/upload-url", rootAlso: true}, + {name: "custom upload service", url: "https://attacker.example/upload-url", rootAlso: true}, + {name: "plaintext default host", url: "http://upload.debug.netbird.io/upload-url", invalid: true}, + {name: "plaintext custom host", url: "http://attacker.example/upload-url", invalid: true}, + {name: "unsupported scheme", url: "file:///etc/shadow", invalid: true}, + // insecure relaxes transport security; privileged only, whatever the host. + {name: "insecure http custom", url: "http://selfhosted.local/upload-url", insecure: true, rootAlso: true}, + {name: "insecure https custom", url: "https://selfhosted.local/upload-url", insecure: true, rootAlso: true}, + {name: "insecure default host", url: types.DefaultBundleURL, insecure: true, rootAlso: true}, + // --insecure must not widen the URL to non-http(s) schemes or a hostless URL. + {name: "insecure file scheme", url: "file:///etc/shadow", insecure: true, invalid: true}, + {name: "insecure hostless", url: "https:///upload-url", insecure: true, invalid: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := requirePrivilegeForUploadURL(userCtx(), tc.url, tc.insecure) + + switch { + case tc.invalid: + if gstatus.Code(err) != codes.InvalidArgument { + t.Fatalf("code = %v, want InvalidArgument", gstatus.Code(err)) + } + return + case tc.unprivOK: + assertAllowed(t, err) + return + default: + assertDenied(t, err) + } + + if tc.rootAlso { + assertAllowed(t, requirePrivilegeForUploadURL(rootCtx(), tc.url, tc.insecure)) + } + }) + } +} diff --git a/client/server/event.go b/client/server/event.go index d93151c96..753a051e7 100644 --- a/client/server/event.go +++ b/client/server/event.go @@ -1,7 +1,9 @@ package server import ( + "github.com/google/uuid" log "github.com/sirupsen/logrus" + "google.golang.org/protobuf/types/known/timestamppb" "github.com/netbirdio/netbird/client/proto" ) @@ -16,6 +18,15 @@ func (s *Server) SubscribeEvents(req *proto.SubscribeRequest, stream proto.Daemo log.Debug("client subscribed to events") s.startUpdateManagerForGUI() + // Replay the current log level to this subscriber so a freshly-connected UI + // learns it even when the daemon was already started with --log-level debug + // (the change-driven publishLogLevelChanged only fires on SetLogLevel). Sent + // directly on this stream rather than via PublishEvent so it reaches only + // the new subscriber, not every connected client. + if err := s.sendCurrentLogLevel(stream); err != nil { + return err + } + for { select { case event := <-subscription.Events(): @@ -28,3 +39,24 @@ func (s *Server) SubscribeEvents(req *proto.SubscribeRequest, stream proto.Daemo } } } + +// sendCurrentLogLevel sends a marked log-level-changed SystemEvent carrying the +// daemon's current level directly to one subscriber. Mirrors the shape +// publishLogLevelChanged emits so the UI's dispatchSystemEvent handles both the +// same way. +func (s *Server) sendCurrentLogLevel(stream proto.DaemonService_SubscribeEventsServer) error { + level := log.GetLevel().String() + event := &proto.SystemEvent{ + Id: uuid.New().String(), + Severity: proto.SystemEvent_INFO, + Category: proto.SystemEvent_SYSTEM, + Message: "Log level changed", + Metadata: map[string]string{proto.MetadataKindKey: proto.MetadataKindLogLevelChanged, proto.MetadataLevelKey: level}, + Timestamp: timestamppb.Now(), + } + if err := stream.Send(event); err != nil { + log.Warnf("error sending initial log level event: %v", err) + return err + } + return nil +} diff --git a/client/server/extend_authsession_test.go b/client/server/extend_authsession_test.go new file mode 100644 index 000000000..a1a048a7c --- /dev/null +++ b/client/server/extend_authsession_test.go @@ -0,0 +1,42 @@ +package server + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" +) + +func TestInnermostStatus(t *testing.T) { + t.Run("wrapped gRPC status", func(t *testing.T) { + inner := gstatus.Error(codes.PermissionDenied, "peer is already registered by a different User or a Setup Key") + // Mirror the daemon wrap chain: engine wraps with %w, mgm error is the inner status. + wrapped := fmt.Errorf("extend auth session on management: %w", inner) + + st := innermostStatus(wrapped) + require.NotNil(t, st) + require.Equal(t, codes.PermissionDenied, st.Code()) + require.Equal(t, "peer is already registered by a different User or a Setup Key", st.Message()) + }) + + t.Run("deepest status wins over an outer one", func(t *testing.T) { + inner := gstatus.Error(codes.PermissionDenied, "deepest") + chain := fmt.Errorf("outer: %w", fmt.Errorf("mid: %w", inner)) + + st := innermostStatus(chain) + require.NotNil(t, st) + require.Equal(t, codes.PermissionDenied, st.Code()) + require.Equal(t, "deepest", st.Message()) + }) + + t.Run("no status in chain", func(t *testing.T) { + require.Nil(t, innermostStatus(errors.New("plain error"))) + }) + + t.Run("nil error", func(t *testing.T) { + require.Nil(t, innermostStatus(nil)) + }) +} diff --git a/client/server/jwt_cache.go b/client/server/jwt_cache.go index 21e170517..73cec046d 100644 --- a/client/server/jwt_cache.go +++ b/client/server/jwt_cache.go @@ -6,11 +6,21 @@ import ( "github.com/awnumar/memguard" log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/ipcauth" ) type jwtCache struct { - mu sync.RWMutex - enclave *memguard.Enclave + mu sync.RWMutex + enclave *memguard.Enclave + owner *ipcauth.Identity + + // generation counts the invalidations. A caller that starts an + // authentication takes the generation first and hands it back to store, so + // a token obtained under a session that ended while the IdP was being + // polled cannot land in the cache the new session is using. + generation uint64 + expiresAt time.Time timer *time.Timer maxTokenSize int @@ -22,10 +32,23 @@ func newJWTCache() *jwtCache { } } -func (c *jwtCache) store(token string, maxAge time.Duration) { +func (c *jwtCache) currentGeneration() uint64 { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.generation +} + +// store keeps the token only while generation is still the current one, and +// reports whether it did. See the generation field. +func (c *jwtCache) store(token string, owner ipcauth.Identity, maxAge time.Duration, generation uint64) bool { c.mu.Lock() defer c.mu.Unlock() + if c.generation != generation { + return false + } + c.cleanup() if c.timer != nil { @@ -35,6 +58,7 @@ func (c *jwtCache) store(token string, maxAge time.Duration) { tokenBytes := []byte(token) c.enclave = memguard.NewEnclave(tokenBytes) + c.owner = &owner c.expiresAt = time.Now().Add(maxAge) var timer *time.Timer @@ -49,9 +73,12 @@ func (c *jwtCache) store(token string, maxAge time.Duration) { log.Debugf("JWT token cache expired after %v, securely wiped from memory", maxAge) }) c.timer = timer + + return true } -func (c *jwtCache) get() (string, bool) { +// get returns the cached token to the identity that stored it. +func (c *jwtCache) get(caller ipcauth.Identity) (string, bool) { c.mu.RLock() defer c.mu.RUnlock() @@ -59,6 +86,11 @@ func (c *jwtCache) get() (string, bool) { return "", false } + if c.owner == nil || !c.owner.SameUser(caller) { + log.Warnf("refusing the cached SSH JWT: caller %s is not the identity that obtained it", caller) + return "", false + } + buffer, err := c.enclave.Open() if err != nil { log.Debugf("Failed to open JWT token enclave: %v", err) @@ -70,10 +102,23 @@ func (c *jwtCache) get() (string, bool) { return token, true } +func (c *jwtCache) clear() { + c.mu.Lock() + defer c.mu.Unlock() + + if c.timer != nil { + c.timer.Stop() + c.timer = nil + } + c.cleanup() + c.generation++ +} + // cleanup destroys the secure enclave, must be called with lock held func (c *jwtCache) cleanup() { if c.enclave != nil { c.enclave = nil } + c.owner = nil c.expiresAt = time.Time{} } diff --git a/client/server/jwt_cache_test.go b/client/server/jwt_cache_test.go new file mode 100644 index 000000000..11d208ade --- /dev/null +++ b/client/server/jwt_cache_test.go @@ -0,0 +1,176 @@ +package server + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/ipcauth" +) + +const testTTL = time.Minute + +func unixCaller(uid uint32) ipcauth.Identity { + return ipcauth.Identity{UID: uid, GID: uid} +} + +func windowsCaller(sid string) ipcauth.Identity { + return ipcauth.Identity{SID: sid} +} + +func TestJWTCache_ServesTheOwner(t *testing.T) { + c := newJWTCache() + owner := unixCaller(1000) + c.store("token-for-1000", owner, testTTL, c.currentGeneration()) + + got, found := c.get(owner) + + require.True(t, found, "the identity that stored the token must get it back") + assert.Equal(t, "token-for-1000", got) +} + +// The disclosure this cache guards against: one local account collecting the +// SSH JWT another account's authentication put in the daemon-wide cache. +func TestJWTCache_RefusesAnotherLocalUser(t *testing.T) { + tests := []struct { + name string + owner ipcauth.Identity + caller ipcauth.Identity + }{ + {"different uid", unixCaller(1000), unixCaller(65534)}, + {"root is not the owner either", unixCaller(1000), unixCaller(0)}, + {"different sid", windowsCaller("S-1-5-21-1-2-3-1001"), windowsCaller("S-1-5-21-1-2-3-1002")}, + {"windows caller against a unix owner", unixCaller(0), windowsCaller("S-1-5-18")}, + {"unix caller against a windows owner", windowsCaller("S-1-5-18"), unixCaller(0)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := newJWTCache() + c.store("victim-token", tt.owner, testTTL, c.currentGeneration()) + + got, found := c.get(tt.caller) + + assert.False(t, found, "a caller that is not the owner must get a miss") + assert.Empty(t, got) + }) + } +} + +func TestJWTCache_EmptyCacheMatchesNobody(t *testing.T) { + c := newJWTCache() + + got, found := c.get(unixCaller(0)) + + assert.False(t, found) + assert.Empty(t, got) +} + +// An entry with no recorded owner must match nobody, root included: an +// unidentified caller arrives as the zero Identity, which carries uid 0. This +// pins the nil-owner guard rather than the comparison, so it sets up an entry +// that exists and then drops its owner. +func TestJWTCache_UnownedEntryMatchesNobody(t *testing.T) { + c := newJWTCache() + c.store("token", unixCaller(1000), testTTL, c.currentGeneration()) + c.owner = nil + + got, found := c.get(unixCaller(0)) + + assert.False(t, found) + assert.Empty(t, got) +} + +// The same user calling once elevated and once not is still the same user, so +// hiding their own token from them would be wrong. +func TestJWTCache_ElevationDoesNotChangeTheOwner(t *testing.T) { + c := newJWTCache() + sid := "S-1-5-21-1-2-3-1001" + owner := windowsCaller(sid) + owner.Elevated = true + c.store("token", owner, testTTL, c.currentGeneration()) + + got, found := c.get(windowsCaller(sid)) + + require.True(t, found) + assert.Equal(t, "token", got) +} + +func TestJWTCache_Expiry(t *testing.T) { + c := newJWTCache() + owner := unixCaller(1000) + c.store("token", owner, testTTL, c.currentGeneration()) + c.expiresAt = time.Now().Add(-time.Second) + + _, found := c.get(owner) + + assert.False(t, found) +} + +// Logout and SwitchProfile call clear — Down deliberately does not: the NetBird +// session the token speaks for is over, so not even its owner may have it back. +func TestJWTCache_ClearDropsTheEntry(t *testing.T) { + c := newJWTCache() + owner := unixCaller(1000) + c.store("token", owner, testTTL, c.currentGeneration()) + + c.clear() + + _, found := c.get(owner) + assert.False(t, found) + assert.Nil(t, c.owner, "clear must forget the owner too") + assert.Nil(t, c.timer, "clear must stop the expiry timer") +} + +// WaitJWTToken polls the IdP unlocked, so a logout or a profile switch can +// clear the cache while a flow is still in the air. The token that flow returns +// belongs to the session that ended, so it must not land in the cache the new +// session is using. +func TestJWTCache_StoreFromAnEndedSessionIsDropped(t *testing.T) { + c := newJWTCache() + owner := unixCaller(1000) + + // The generation a caller takes when its authentication starts. + generation := c.currentGeneration() + + c.clear() // logout or profile switch, while the IdP is still being polled + + stored := c.store("stale-token", owner, testTTL, generation) + + assert.False(t, stored, "a token from an ended session must not be cached") + _, found := c.get(owner) + assert.False(t, found, "the cache must stay empty after the session ended") +} + +// The same caller must still be able to store once it re-reads the generation, so +// the guard does not wedge the cache after any invalidation. +func TestJWTCache_StoreWorksAgainAfterClear(t *testing.T) { + c := newJWTCache() + owner := unixCaller(1000) + + c.clear() + + require.True(t, c.store("token", owner, testTTL, c.currentGeneration())) + + got, found := c.get(owner) + require.True(t, found) + assert.Equal(t, "token", got) +} + +func TestJWTCache_StoreReplacesThePreviousOwner(t *testing.T) { + c := newJWTCache() + first := unixCaller(1000) + second := unixCaller(1001) + + c.store("first-token", first, testTTL, c.currentGeneration()) + c.store("second-token", second, testTTL, c.currentGeneration()) + + _, found := c.get(first) + assert.False(t, found, "the previous owner must not reach the new token") + + got, found := c.get(second) + require.True(t, found) + assert.Equal(t, "second-token", got) +} diff --git a/client/server/lock_order_test.go b/client/server/lock_order_test.go new file mode 100644 index 000000000..457e3db34 --- /dev/null +++ b/client/server/lock_order_test.go @@ -0,0 +1,51 @@ +package server + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/proto" +) + +// The daemon takes guardedConfigMu before s.mutex. authorizeAndPrepareLogin +// takes s.mutex while holding guardedConfigMu, so a SetConfig that grabbed +// s.mutex first and then waited for guardedConfigMu would deadlock the daemon +// against a concurrent login: two unprivileged IPC calls are enough. +// +// The held guardedConfigMu below stands in for that login. While SetConfig waits +// for it, s.mutex must stay free, otherwise the login waiting for s.mutex could +// never release guardedConfigMu. +func TestSetConfig_TakesGuardedConfigMuBeforeServerMutex(t *testing.T) { + s, ctx, profName, username, _ := setupServerWithProfile(t) + + s.guardedConfigMu.Lock() + + done := make(chan error, 1) + go func() { + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + }) + done <- err + }() + + require.Never(t, func() bool { + if !s.mutex.TryLock() { + return true + } + s.mutex.Unlock() + return false + }, 500*time.Millisecond, 10*time.Millisecond, + "SetConfig held s.mutex while waiting for guardedConfigMu, which deadlocks against a concurrent login") + + s.guardedConfigMu.Unlock() + + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("SetConfig did not finish after guardedConfigMu was released") + } +} diff --git a/client/server/login_gate_test.go b/client/server/login_gate_test.go new file mode 100644 index 000000000..de62a8180 --- /dev/null +++ b/client/server/login_gate_test.go @@ -0,0 +1,127 @@ +package server + +import ( + "context" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/proto" +) + +// A refused login must not leave the profile switched. Login can both switch +// profiles and carry the guarded config fields, so the gate has to run before the +// switch: otherwise a caller whose change is refused still gets the side effect of +// activating whichever profile the request named. +func TestLogin_RefusedChangeLeavesTheProfileAlone(t *testing.T) { + s, _, activeProfile, username, _ := setupServerWithProfile(t) + + // Login reads process state off the daemon's root context. + s.rootCtx = internal.CtxInitState(context.Background()) + + // A second profile that runs the SSH server, which is what makes repointing + // its management binding a privileged change. + target := "ssh-enabled" + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, target+".json"), + ManagementURL: "https://api.netbird.io:443", + ServerSSHAllowed: boolPtr(true), + }) + require.NoError(t, err) + + _, err = s.Login(userCtx(), &proto.LoginRequest{ + ProfileName: &target, + Username: &username, + ManagementUrl: "https://mgmt.attacker.example:443", + }) + require.Error(t, err, "an unprivileged caller must not move the management URL of an SSH-enabled profile") + require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want a privilege refusal, got %v", err) + + active, err := s.profileManager.GetActiveProfileState() + require.NoError(t, err) + require.Equal(t, profilemanager.ID(activeProfile), active.ID, + "the refused login switched the active profile anyway") +} + +// A caller whose change becomes privileged only after its first check must be +// refused without having cancelled a login or switched profiles: the first check is +// unsynchronized, so the SSH server can be enabled by a concurrent privileged +// request in between, and the authoritative check happens before any side effect. +func TestLogin_ChangeThatBecomesPrivilegedMidRequestHasNoSideEffects(t *testing.T) { + s, _, activeProfile, username, _ := setupServerWithProfile(t) + s.rootCtx = internal.CtxInitState(context.Background()) + + // The target profile has SSH off, so the first check lets the request through. + target := "ssh-later" + targetPath := filepath.Join(profilemanager.DefaultConfigPathDir, target+".json") + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: targetPath, + ManagementURL: "https://api.netbird.io:443", + ServerSSHAllowed: boolPtr(false), + }) + require.NoError(t, err) + + cancelled := false + s.actCancel = func() { cancelled = true } + + // Stand in for a privileged SetConfig that enables the SSH server between the + // two checks, which is the interleaving the lock has to make safe. + afterLoginPreCheck = func() { + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: targetPath, + ServerSSHAllowed: boolPtr(true), + }) + require.NoError(t, err) + } + t.Cleanup(func() { afterLoginPreCheck = nil }) + + _, err = s.Login(userCtx(), &proto.LoginRequest{ + ProfileName: &target, + Username: &username, + ManagementUrl: "https://mgmt.attacker.example:443", + }) + require.Error(t, err) + require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want a privilege refusal, got %v", err) + require.False(t, cancelled, "the refused login cancelled the login already in progress") + + active, err := s.profileManager.GetActiveProfileState() + require.NoError(t, err) + require.Equal(t, profilemanager.ID(activeProfile), active.ID, "the refused login switched the active profile anyway") + + stored, err := profilemanager.ReadConfig(targetPath) + require.NoError(t, err) + require.Equal(t, "https://api.netbird.io:443", stored.ManagementURL.String(), "the refused login moved the management URL") +} + +// Login cancels whatever login is already in progress before starting its own. A +// refused caller must not get that far, otherwise anyone able to reach the socket +// can abort someone else's login by sending a request that is denied. +func TestLogin_RefusedChangeLeavesAnInProgressLoginAlone(t *testing.T) { + s, _, _, username, _ := setupServerWithProfile(t) + s.rootCtx = internal.CtxInitState(context.Background()) + + target := "ssh-enabled" + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, target+".json"), + ManagementURL: "https://api.netbird.io:443", + ServerSSHAllowed: boolPtr(true), + }) + require.NoError(t, err) + + cancelled := false + s.actCancel = func() { cancelled = true } + + _, err = s.Login(userCtx(), &proto.LoginRequest{ + ProfileName: &target, + Username: &username, + ManagementUrl: "https://mgmt.attacker.example:443", + }) + require.Error(t, err) + require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want a privilege refusal, got %v", err) + require.False(t, cancelled, "the refused login cancelled the login already in progress") +} diff --git a/client/server/login_outcome_test.go b/client/server/login_outcome_test.go new file mode 100644 index 000000000..7ebf04f92 --- /dev/null +++ b/client/server/login_outcome_test.go @@ -0,0 +1,110 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/proto" +) + +// A login that never reached Management is not a decision about the peer's +// credentials, so it must come back as a retryable error rather than an SSO +// prompt: the user cannot finish a browser login while Management is down, and +// the CLI's own backoff resolves the outage on its own once the daemon reports +// the failure. Reproduces `netbird down; netbird up` printing a device-code URL +// because Management happened to be restarting when the daemon dialed it. +func TestLogin_ManagementUnreachableIsReturnedInsteadOfDemandingSSO(t *testing.T) { + s, _, _, username, _ := setupServerWithProfile(t) + s.rootCtx = internal.CtxInitState(context.Background()) + + unreachable := errors.New("create connection: dial context: context deadline exceeded") + attempts := 0 + s.isLoginRequiredFn = func(context.Context) (bool, error) { + attempts++ + return false, unreachable + } + + resp, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username}) + require.Error(t, err) + require.ErrorIs(t, err, unreachable, "the transport failure was replaced by something else") + require.Nil(t, resp, "a failed login must not answer with a login response") + require.Equal(t, 1, attempts) + require.Nil(t, s.oauthAuthFlow.flow, "the daemon started an SSO flow for a peer whose login was never decided") + + status, err := internal.CtxGetState(s.rootCtx).Status() + require.NoError(t, err) + require.Equal(t, internal.StatusLoginFailed, status, + "a peer that could not reach Management is not waiting on a login") +} + +// The counterpart: Management refusing the peer's credentials is a decision, and +// the SSO flow still has to start for it. The profile carries an unusable +// private key so the flow setup fails immediately instead of dialing, which is +// enough to show the branch was entered — the refusal itself is never what comes +// back out. +func TestLogin_AuthRefusalStartsSSOFlow(t *testing.T) { + s, _, _, username, cfgPath := setupServerWithProfile(t) + s.rootCtx = internal.CtxInitState(context.Background()) + breakProfilePrivateKey(t, cfgPath) + + s.isLoginRequiredFn = func(context.Context) (bool, error) { + return true, nil + } + + _, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username}) + require.Error(t, err) + + status, stateErr := internal.CtxGetState(s.rootCtx).Status() + require.NoError(t, stateErr) + require.Equal(t, internal.StatusLoginFailed, status, + "the SSO flow setup was never reached with the broken key") +} + +func TestLogin_SetupKeyStillRunsWhenPeerNeedsLogin(t *testing.T) { + s, _, _, username, _ := setupServerWithProfile(t) + s.rootCtx = internal.CtxInitState(context.Background()) + + s.isLoginRequiredFn = func(context.Context) (bool, error) { + return true, nil + } + + var keysTried []string + s.loginAttemptFn = func(_ context.Context, setupKey, _ string) (internal.StatusType, error) { + keysTried = append(keysTried, setupKey) + return "", nil + } + + setupKey := "A2C8E32F-AEB2-4B45-8FD3-8A0C1B2D3E4F" + resp, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username, SetupKey: setupKey}) + require.NoError(t, err, "the probe's outcome leaked out as the login result") + require.NotNil(t, resp) + require.Equal(t, []string{setupKey}, keysTried, "the setup key never reached the login attempt") + require.Nil(t, s.oauthAuthFlow.flow, "a setup-key login started an SSO flow") + + status, err := internal.CtxGetState(s.rootCtx).Status() + require.NoError(t, err) + require.Equal(t, internal.StatusIdle, status) +} + +// breakProfilePrivateKey replaces the profile's private key with an unparseable +// one, which makes any attempt to build a Management client fail on the spot. +func breakProfilePrivateKey(t *testing.T, cfgPath string) { + t.Helper() + + raw, err := os.ReadFile(cfgPath) + require.NoError(t, err) + + var cfg map[string]any + require.NoError(t, json.Unmarshal(raw, &cfg)) + cfg["PrivateKey"] = "not-a-key" + + patched, err := json.Marshal(cfg) + require.NoError(t, err) + require.NoError(t, os.WriteFile(cfgPath, patched, 0o600)) +} diff --git a/client/server/login_overrides_test.go b/client/server/login_overrides_test.go new file mode 100644 index 000000000..5a2298764 --- /dev/null +++ b/client/server/login_overrides_test.go @@ -0,0 +1,93 @@ +package server + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/profilemanager" +) + +func TestPersistLoginOverrides(t *testing.T) { + strPtr := func(s string) *string { return &s } + + tests := []struct { + name string + initialMgmtURL string + initialPSK string + newMgmtURL string + newPSK *string + wantMgmtURL string + wantPSK string + }{ + { + name: "persist new management URL", + initialMgmtURL: "https://old.example.com:33073", + newMgmtURL: "https://new.example.com:33073", + wantMgmtURL: "https://new.example.com:33073", + }, + { + name: "persist new pre-shared key", + initialMgmtURL: "https://existing.example.com:33073", + initialPSK: "old-key", + newPSK: strPtr("new-key"), + wantMgmtURL: "https://existing.example.com:33073", + wantPSK: "new-key", + }, + { + name: "persist both", + initialMgmtURL: "https://old.example.com:33073", + initialPSK: "old-key", + newMgmtURL: "https://new.example.com:33073", + newPSK: strPtr("new-key"), + wantMgmtURL: "https://new.example.com:33073", + wantPSK: "new-key", + }, + { + name: "no inputs preserves existing", + initialMgmtURL: "https://existing.example.com:33073", + initialPSK: "existing-key", + wantMgmtURL: "https://existing.example.com:33073", + wantPSK: "existing-key", + }, + { + name: "empty PSK pointer is ignored", + initialMgmtURL: "https://existing.example.com:33073", + initialPSK: "existing-key", + newPSK: strPtr(""), + wantMgmtURL: "https://existing.example.com:33073", + wantPSK: "existing-key", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + origDefault := profilemanager.DefaultConfigPath + t.Cleanup(func() { profilemanager.DefaultConfigPath = origDefault }) + + dir := t.TempDir() + profilemanager.DefaultConfigPath = filepath.Join(dir, "default.json") + + seed := profilemanager.ConfigInput{ + ConfigPath: profilemanager.DefaultConfigPath, + ManagementURL: tt.initialMgmtURL, + } + if tt.initialPSK != "" { + seed.PreSharedKey = strPtr(tt.initialPSK) + } + _, err := profilemanager.UpdateOrCreateConfig(seed) + require.NoError(t, err, "seed config") + + activeProf := &profilemanager.ActiveProfileState{ID: "default"} + err = persistLoginOverrides(activeProf, tt.newMgmtURL, tt.newPSK) + require.NoError(t, err, "persistLoginOverrides") + + cfg, err := profilemanager.ReadConfig(profilemanager.DefaultConfigPath) + require.NoError(t, err, "read back config") + + require.Equal(t, tt.wantMgmtURL, cfg.ManagementURL.String(), "management URL") + require.Equal(t, tt.wantPSK, cfg.PreSharedKey, "pre-shared key") + }) + } +} diff --git a/client/server/logout_gate_test.go b/client/server/logout_gate_test.go new file mode 100644 index 000000000..2d84d1b6a --- /dev/null +++ b/client/server/logout_gate_test.go @@ -0,0 +1,200 @@ +package server + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/proto" +) + +// unreachableManagementURL keeps a test that is expected to stop at a gate from +// reaching the network if the gate ever regresses: the profiles a logout must +// not touch point here, so a leak fails fast instead of contacting a real +// management server. +const unreachableManagementURL = "https://127.0.0.1:9" + +// enableSSHOnProfile rewrites the profile config at cfgPath with the SSH server +// enabled. Deregistering an SSH-enabled profile is a privileged change, so an +// unprivileged caller is refused by requirePrivilegeForDeregistration before any +// management connection is attempted, which is what keeps these tests offline. +func enableSSHOnProfile(t *testing.T, cfgPath string) { + t.Helper() + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: cfgPath, + ManagementURL: "https://api.netbird.io:443", + ServerSSHAllowed: boolPtr(true), + }) + require.NoError(t, err) +} + +// Logging out of the profile the daemon is already running is a deregistration, +// not profile management, so the profiles-disabled kill switch must not block +// it. The desktop UI always addresses logout by profile (both the profile menu +// and the session-expiration dialog), so gating it left users with +// disableProfiles enforced unable to log out at all. +func TestLogout_ActiveProfileAllowedWhenProfilesDisabled(t *testing.T) { + s, _, activeProfile, username, cfgPath := setupServerWithProfile(t) + s.rootCtx = internal.CtxInitState(context.Background()) + enableSSHOnProfile(t, cfgPath) + + s.profilesDisabled = true + + _, err := s.Logout(userCtx(), &proto.LogoutRequest{ + ProfileName: &activeProfile, + Username: &username, + }) + + require.Error(t, err, "the SSH privilege gate is expected to refuse this unprivileged caller") + require.Equal(t, codes.PermissionDenied, gstatus.Code(err), + "logout of the active profile must reach the deregistration path, not be refused as profile management: %v", err) + require.NotContains(t, gstatus.Convert(err).Message(), errProfilesDisabled) +} + +// A profile-addressed logout that targets some *other* profile does manage +// profiles, so it stays gated: with profiles disabled the daemon must not +// deregister a peer the user is not currently running. +func TestLogout_OtherProfileStaysGatedWhenProfilesDisabled(t *testing.T) { + s, _, _, username, _ := setupServerWithProfile(t) + s.rootCtx = internal.CtxInitState(context.Background()) + + other := "other-profile" + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, other+".json"), + ManagementURL: unreachableManagementURL, + }) + require.NoError(t, err) + + s.profilesDisabled = true + + _, err = s.Logout(userCtx(), &proto.LogoutRequest{ + ProfileName: &other, + Username: &username, + }) + + require.Error(t, err) + require.Equal(t, codes.Unavailable, gstatus.Code(err), "want the profiles-disabled refusal, got %v", err) + require.Contains(t, gstatus.Convert(err).Message(), errProfilesDisabled) +} + +// A legacy profile ID is a display name, so two users can hold the same ID in +// their own profile directories. Matching on the ID alone would let one user's +// logout pass the gate against the other user's active profile, so the username +// is part of the comparison. +func TestLogout_ForeignUserProfileStaysGatedWhenProfilesDisabled(t *testing.T) { + s, _, _, username, _ := setupServerWithProfile(t) + s.rootCtx = internal.CtxInitState(context.Background()) + + // A legacy-style profile whose ID is its filename stem, and an active state + // claiming that same ID for a different user. + shared := "shared-legacy-name" + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, shared+".json"), + ManagementURL: unreachableManagementURL, + }) + require.NoError(t, err) + require.NoError(t, s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{ + ID: profilemanager.ID(shared), + Username: "someone-else", + })) + + s.profilesDisabled = true + + _, err = s.Logout(userCtx(), &proto.LogoutRequest{ + ProfileName: &shared, + Username: &username, + }) + + require.Error(t, err) + require.Equal(t, codes.Unavailable, gstatus.Code(err), + "another user's profile must not pass the gate on an ID match alone: %v", err) +} + +// Deregistering a namesake profile must not go out with the running config. +// logoutFromProfile reuses the connected client's config when the target is the +// active profile, and on an ID-only match a shared legacy ID made it reuse it +// for another user's profile, deregistering the active peer instead. +func TestLogout_ForeignUserProfileDoesNotUseTheRunningConfig(t *testing.T) { + s, _, _, username, cfgPath := setupServerWithProfile(t) + s.rootCtx = internal.CtxInitState(context.Background()) + + // The running config has the SSH server enabled, so reusing it would be + // refused with PermissionDenied. The namesake profile does not, so the + // correct path gets as far as dialing its own unreachable management URL. + enableSSHOnProfile(t, cfgPath) + running, err := profilemanager.GetConfig(cfgPath) + require.NoError(t, err) + s.config = running + s.connectClient = newDummyConnectClient(context.Background()) + + shared := "shared-legacy-name" + _, err = profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, shared+".json"), + ManagementURL: unreachableManagementURL, + }) + require.NoError(t, err) + require.NoError(t, s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{ + ID: profilemanager.ID(shared), + Username: "someone-else", + })) + + // Bounded so the deregistration the fixed path attempts fails on the dial + // rather than sitting in gRPC backoff for the whole test timeout. + ctx, cancel := context.WithTimeout(userCtx(), 2*time.Second) + t.Cleanup(cancel) + + _, err = s.Logout(ctx, &proto.LogoutRequest{ + ProfileName: &shared, + Username: &username, + }) + + require.Error(t, err) + require.NotEqual(t, codes.PermissionDenied, gstatus.Code(err), + "the namesake profile was deregistered with the running config: %v", err) +} + +// The connection teardown follows the profile that is active when the logout +// completes, not the one seen before it started: Login switches profiles under +// guardedConfigMu, which the logout path does not hold, so a login that landed +// meanwhile must keep its connection. +func TestCleanupAfterProfileLogout_FollowsTheCurrentActiveProfile(t *testing.T) { + s, _, activeProfile, username, _ := setupServerWithProfile(t) + s.rootCtx = internal.CtxInitState(context.Background()) + + state := internal.CtxGetState(s.rootCtx) + + s.cleanupAfterProfileLogout("some-other-profile", username) + status, err := state.Status() + require.NoError(t, err) + require.NotEqual(t, internal.StatusNeedsLogin, status, + "logging out of a profile that is not active must not ask for a new login") + + s.cleanupAfterProfileLogout(profilemanager.ID(activeProfile), username) + status, err = state.Status() + require.NoError(t, err) + require.Equal(t, internal.StatusNeedsLogin, status, + "logging out of the active profile must ask for a new login") +} + +// With profiles enabled the gate is out of the way on both surfaces; the active +// profile still reaches the deregistration path. +func TestLogout_ActiveProfileAllowedWhenProfilesEnabled(t *testing.T) { + s, _, activeProfile, username, cfgPath := setupServerWithProfile(t) + s.rootCtx = internal.CtxInitState(context.Background()) + enableSSHOnProfile(t, cfgPath) + + _, err := s.Logout(userCtx(), &proto.LogoutRequest{ + ProfileName: &activeProfile, + Username: &username, + }) + + require.Error(t, err, "the SSH privilege gate is expected to refuse this unprivileged caller") + require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want the privilege refusal, got %v", err) +} diff --git a/client/server/mdm.go b/client/server/mdm.go new file mode 100644 index 000000000..7a47b2a57 --- /dev/null +++ b/client/server/mdm.go @@ -0,0 +1,330 @@ +package server + +import ( + "context" + "fmt" + "time" + + log "github.com/sirupsen/logrus" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/mdm" + "github.com/netbirdio/netbird/client/proto" +) + +// onMDMPolicyChange is invoked by the MDM reload ticker every time the +// OS-native managed-config store reports a diff vs the last observation. +// +// Restart sequence: +// 1. Cancel the active engine context (terminates connectWithRetryRuns). +// 2. Wait briefly for that goroutine to exit (giveUpChan is closed on exit). +// 3. Re-resolve Config from disk + MDM policy (Config.apply re-runs +// applyMDMPolicy with the freshly loaded Policy). +// 4. Spawn a fresh connectWithRetryRuns with the new context and config. +// 5. Broadcast a SystemEvent so any GUI / CLI subscriber (SubscribeEvents +// RPC) can refresh its cached config view without polling. +// +// The callback runs in the ticker's own goroutine. Ticker has already +// logged the per-key diff before invoking this hook. +func (s *Server) onMDMPolicyChange(_, _ *mdm.Policy) error { + log.Warn("MDM policy changed; restarting engine to apply new configuration") + + // Hold s.mutex for the entire restart sequence (cancel + quiescence + // wait + re-spawn). Any concurrent Up/Down/Status arriving while + // MDM is restarting blocks on the Lock until we are done — they + // then observe the post-restart state coherently. This is safe + // because the connectWithRetryRuns goroutine no longer acquires + // s.mutex in its defer (intent vs. goroutine-alive concerns are + // fully separated; see the connectionGoroutineRunning helper). + s.mutex.Lock() + defer s.mutex.Unlock() + + if !s.clientRunning { + // The client is not running, so there's no engine to restart. + return nil + } + if s.actCancel != nil { + s.actCancel() + } + + // Wait for previous connectWithRetryRuns to exit so we don't end up + // with two goroutines fighting over the same status recorder + engine. + // The teardown engages a fan-out of engine goroutines (peer workers, + // signal handler, route manager, ...). close(clientGiveUpChan) + // happens in the function-scope defer of connectWithRetryRuns, on + // every exit path (ctx cancel, backoff exhausted, panic) — see the + // defer in server.go. + if s.clientGiveUpChan != nil { + select { + case <-s.clientGiveUpChan: + case <-time.After(10 * time.Second): + return fmt.Errorf("failed to restart the engine due to timeout") + } + } + + if err := s.restartEngineForMDMLocked(); err != nil { + log.Errorf("MDM restart failed: %v", err) + return err + } + + // publishConfigChangedEvent has already fired inside + // restartEngineForMDMLocked with source="mdm". Emit an MDM-specific + // user-visible toast so the operator knows their IT policy was + // applied (UserMessage != "" triggers the GUI notifier). + s.statusRecorder.PublishEvent( + proto.SystemEvent_INFO, + proto.SystemEvent_SYSTEM, + "MDM policy applied", + "NetBird configuration was updated by your IT policy.", + map[string]string{ + proto.MetadataSourceKey: proto.MetadataSourceMDM, + proto.MetadataTypeKey: proto.MetadataTypePolicyApplied, + }, + ) + return nil +} + +// publishConfigChangedEvent broadcasts a SystemEvent informing any active +// SubscribeEvents subscriber (typically the GUI tray) that the daemon's +// effective Config has been replaced and any cached client-side view +// should be refreshed. Callers pass a stable `source` label so the GUI +// can distinguish a startup spawn from a user-triggered Up or an +// MDM-driven restart. Reusing the SYSTEM category keeps the proto enum +// stable; metadata.type="config_changed" routes to the GUI's refresh +// handler. UserMessage is left empty so the system tray does not toast +// for every internal restart; the MDM path emits a separate +// "policy_applied" event (with UserMessage) for that purpose. +func (s *Server) publishConfigChangedEvent(source string) { + if s.statusRecorder == nil { + return + } + s.statusRecorder.PublishEvent( + proto.SystemEvent_INFO, + proto.SystemEvent_SYSTEM, + fmt.Sprintf("daemon config changed (source=%s)", source), + "", + map[string]string{ + proto.MetadataSourceKey: source, + proto.MetadataTypeKey: proto.MetadataTypeConfigChanged, + }, + ) +} + +// restartEngineForMDMLocked re-resolves the active profile config +// (re-running applyMDMPolicy via Config.apply) and re-spawns +// connectWithRetryRuns. Mirrors the tail of Server.Start so a runtime +// MDM change behaves identically to a fresh boot under the new policy. +// +// MUST be called with s.mutex held — onMDMPolicyChange holds the lock +// for the entire restart sequence (cancel + quiescence wait + re-spawn) +// so concurrent Up/Down/Status RPCs observe a coherent post-restart +// state. +func (s *Server) restartEngineForMDMLocked() error { + activeProf, err := s.profileManager.GetActiveProfileState() + if err != nil { + return fmt.Errorf("get active profile state: %w", err) + } + config, _, err := s.getConfig(activeProf) + if err != nil { + return fmt.Errorf("get active profile config: %w", err) + } + + s.config = config + s.statusRecorder.UpdateManagementAddress(config.ManagementURL.String()) + s.statusRecorder.UpdateRosenpass(config.RosenpassEnabled, config.RosenpassPermissive) + + ctx, cancel := context.WithCancel(s.rootCtx) + s.actCancel = cancel + s.clientRunning = true + s.clientRunningChan = make(chan struct{}) + s.clientGiveUpChan = make(chan struct{}) + log.Info("MDM restart: spawning connectWithRetryRuns with re-resolved config") + go s.connectWithRetryRuns(ctx, config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan) + s.publishConfigChangedEvent(proto.MetadataSourceMDM) + return nil +} + +// mdmManagedFieldConflicts returns the names of MDM-managed keys whose +// requested value in the SetConfigRequest differs from the MDM-enforced +// value. A field set to the same value the policy already enforces is +// treated as a no-op echo (the GUI tray sends a full Config snapshot on +// every toggle, so most fields in a typical request match the policy +// exactly and must NOT be flagged as conflicts). The redacted PSK +// sentinel ("**********") returned by GetConfig is recognised and +// treated as no-op so the UI can safely round-trip it. +func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) []string { + if msg == nil { + return nil + } + + pskGot := msg.OptionalPreSharedKey + if pskGot != nil && *pskGot == mdm.PreSharedKeyRedactedSentinel { + pskGot = nil + } + + return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{ + mdm.ConflictURL(mdm.KeyManagementURL, msg.ManagementUrl), + mdm.ConflictStringPtr(mdm.KeyPreSharedKey, pskGot), + mdm.ConflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled), + mdm.ConflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), + mdm.ConflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect), + mdm.ConflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed), + mdm.ConflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed), + mdm.ConflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes), + mdm.ConflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), + mdm.ConflictBool(mdm.KeyBlockInbound, msg.BlockInbound), + mdm.ConflictInt64(mdm.KeyWireguardPort, msg.WireguardPort), + mdm.ConflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics), + mdm.ConflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress), + }) +} + +// setConfigRequestHasConfigOverrides reports whether the SetConfigRequest +// carries ANY field that would actually mutate the persisted config. +// The CLI builds a SetConfigRequest unconditionally on every +// `netbird up` (see setupSetConfigReq in cmd/up.go) — a plain +// `netbird up` produces a request with every field at its zero value; +// the gate must skip such no-op invocations or it would always fire +// even when the user did not pass any --flag. Returns false on a nil +// msg; true when any management/admin URL, PSK, DNS/NAT list+clean +// flag, interface/port/MTU, or any optional bool/duration field is set. +func setConfigRequestHasConfigOverrides(msg *proto.SetConfigRequest) bool { + if msg == nil { + return false + } + return msg.ManagementUrl != "" || + msg.AdminURL != "" || + msg.OptionalPreSharedKey != nil || + len(msg.CustomDNSAddress) > 0 || + len(msg.NatExternalIPs) > 0 || msg.CleanNATExternalIPs || + len(msg.ExtraIFaceBlacklist) > 0 || + len(msg.DnsLabels) > 0 || msg.CleanDNSLabels || + msg.DnsRouteInterval != nil || + msg.RosenpassEnabled != nil || + msg.RosenpassPermissive != nil || + msg.InterfaceName != nil || + msg.WireguardPort != nil || + msg.Mtu != nil || + msg.DisableAutoConnect != nil || + msg.ServerSSHAllowed != nil || + msg.RemoteJobsAllowed != nil || + msg.NetworkMonitor != nil || + msg.DisableClientRoutes != nil || + msg.DisableServerRoutes != nil || + msg.DisableDns != nil || + msg.DisableFirewall != nil || + msg.BlockLanAccess != nil || + msg.DisableNotifications != nil || + msg.BlockInbound != nil || + msg.DisableIpv6 != nil || + msg.EnableSSHRoot != nil || + msg.EnableSSHSFTP != nil || + msg.EnableSSHLocalPortForwarding != nil || + msg.EnableSSHRemotePortForwarding != nil || + msg.DisableSSHAuth != nil || + msg.SshJWTCacheTTL != nil || + msg.EnableLocalMetrics != nil || + msg.LocalMetricsAddress != nil +} + +// loginRequestHasConfigOverrides reports whether the LoginRequest +// carries ANY field that would mutate persisted daemon configuration +// (as opposed to pure-auth fields like setupKey, hostname, hint, +// profileName, username). Used by the Login handler to decide whether +// the `--disable-update-settings` / MDM gates must run: a re-auth that +// changes nothing about the configuration is always allowed. +func loginRequestHasConfigOverrides(msg *proto.LoginRequest) bool { + if msg == nil { + return false + } + return msg.ManagementUrl != "" || + msg.AdminURL != "" || + msg.PreSharedKey != "" || //nolint:staticcheck // SA1019: legacy proto field still accepted by Login + msg.OptionalPreSharedKey != nil || + len(msg.CustomDNSAddress) > 0 || + len(msg.NatExternalIPs) > 0 || msg.CleanNATExternalIPs || + msg.RosenpassEnabled != nil || + msg.InterfaceName != nil || + msg.WireguardPort != nil || + msg.DisableAutoConnect != nil || + msg.ServerSSHAllowed != nil || + msg.RemoteJobsAllowed != nil || + msg.RosenpassPermissive != nil || + len(msg.ExtraIFaceBlacklist) > 0 || + msg.NetworkMonitor != nil || + msg.DnsRouteInterval != nil || + msg.DisableClientRoutes != nil || + msg.DisableServerRoutes != nil || + msg.DisableDns != nil || + msg.DisableFirewall != nil || + msg.BlockLanAccess != nil || + msg.DisableNotifications != nil || + len(msg.DnsLabels) > 0 || msg.CleanDNSLabels || + msg.BlockInbound != nil || + msg.EnableLocalMetrics != nil || + msg.LocalMetricsAddress != nil +} + +// loginRequestMDMConflicts mirrors mdmManagedFieldConflicts but for the +// LoginRequest surface. Same value-aware semantics: a field set to the +// MDM-enforced value is a no-op echo, not a conflict; only a divergent +// value is flagged. PSK has two proto fields — PreSharedKey (deprecated) +// and OptionalPreSharedKey (current); either route trips the gate if it +// diverges from the MDM-enforced PSK. OptionalPreSharedKey wins when +// both are set; the redaction sentinel ("**********") is accepted as +// a no-op echo. +func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []string { + if msg == nil { + return nil + } + + pskGot := msg.OptionalPreSharedKey + if pskGot == nil && msg.PreSharedKey != "" { //nolint:staticcheck // SA1019: legacy proto field still accepted by Login + pskGot = &msg.PreSharedKey //nolint:staticcheck // SA1019 + } + if pskGot != nil && *pskGot == mdm.PreSharedKeyRedactedSentinel { + pskGot = nil + } + + return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{ + mdm.ConflictURL(mdm.KeyManagementURL, msg.ManagementUrl), + mdm.ConflictStringPtr(mdm.KeyPreSharedKey, pskGot), + mdm.ConflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled), + mdm.ConflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), + mdm.ConflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect), + mdm.ConflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed), + mdm.ConflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed), + mdm.ConflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes), + mdm.ConflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), + mdm.ConflictBool(mdm.KeyBlockInbound, msg.BlockInbound), + mdm.ConflictInt64(mdm.KeyWireguardPort, msg.WireguardPort), + mdm.ConflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics), + mdm.ConflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress), + }) +} + +// rejectMDMManagedFieldConflicts returns a FailedPrecondition gRPC error +// with an MDMManagedFieldsViolation detail when any of the requested +// fields tries to change an MDM-enforced value to something else, and +// nil otherwise. The whole request is rejected on any conflict; non- +// conflicting fields in the same request are not applied either (no +// partial apply). +func rejectMDMManagedFieldConflicts(conflicts []string) error { + if len(conflicts) == 0 { + return nil + } + log.Warnf("MDM rejected request: tried to modify %d managed key(s): %v", + len(conflicts), conflicts) + st := gstatus.New( + codes.FailedPrecondition, + fmt.Sprintf("fields managed by MDM cannot be modified: %v", conflicts), + ) + detailed, err := st.WithDetails(&proto.MDMManagedFieldsViolation{Fields: conflicts}) + if err != nil { + // Detail attachment is best-effort; fall back to the plain status + // so the caller still gets a usable FailedPrecondition. + return st.Err() + } + return detailed.Err() +} diff --git a/client/server/network.go b/client/server/network.go index 76c5af40e..69eaabf8a 100644 --- a/client/server/network.go +++ b/client/server/network.go @@ -8,7 +8,6 @@ import ( "sort" "strings" - "golang.org/x/exp/maps" "google.golang.org/grpc/codes" gstatus "google.golang.org/grpc/status" @@ -18,10 +17,11 @@ import ( ) type selectRoute struct { - NetID route.NetID - Network netip.Prefix - Domains domain.List - Selected bool + NetID route.NetID + Network netip.Prefix + Domains domain.List + Selected bool + extraNetworks []netip.Prefix } // ListNetworks returns a list of all available networks. @@ -29,7 +29,7 @@ func (s *Server) ListNetworks(context.Context, *proto.ListNetworksRequest) (*pro s.mutex.Lock() defer s.mutex.Unlock() - if s.networksDisabled { + if s.checkNetworksDisabled() { return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled) } @@ -50,18 +50,32 @@ func (s *Server) ListNetworks(context.Context, *proto.ListNetworksRequest) (*pro routesMap := routeMgr.GetClientRoutesWithNetID() routeSelector := routeMgr.GetRouteSelector() + v6ExitMerged := route.V6ExitMergeSet(routesMap) + var routes []*selectRoute for id, rt := range routesMap { if len(rt) == 0 { continue } - route := &selectRoute{ + // Skip v6 exit nodes that are merged into their v4 counterpart. + if _, ok := v6ExitMerged[id]; ok { + continue + } + + r := &selectRoute{ NetID: id, Network: rt[0].Network, Domains: rt[0].Domains, Selected: routeSelector.IsSelected(id), } - routes = append(routes, route) + + // Merge paired v6 exit node prefix into this entry. + v6ID := route.NetID(string(id) + route.V6ExitSuffix) + if _, ok := v6ExitMerged[v6ID]; ok && len(routesMap[v6ID]) > 0 { + r.extraNetworks = []netip.Prefix{routesMap[v6ID][0].Network} + } + + routes = append(routes, r) } sort.Slice(routes, func(i, j int) bool { @@ -82,9 +96,13 @@ func (s *Server) ListNetworks(context.Context, *proto.ListNetworksRequest) (*pro resolvedDomains := s.statusRecorder.GetResolvedDomainsStates() var pbRoutes []*proto.Network for _, route := range routes { + rangeStr := route.Network.String() + for _, extra := range route.extraNetworks { + rangeStr += ", " + extra.String() + } pbRoute := &proto.Network{ ID: string(route.NetID), - Range: route.Network.String(), + Range: rangeStr, Domains: route.Domains.ToSafeStringList(), ResolvedIPs: map[string]*proto.IPList{}, Selected: route.Selected, @@ -124,7 +142,7 @@ func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequ s.mutex.Lock() defer s.mutex.Unlock() - if s.networksDisabled { + if s.checkNetworksDisabled() { return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled) } @@ -142,17 +160,11 @@ func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequ return nil, fmt.Errorf("no route manager") } - routeSelector := routeManager.GetRouteSelector() if req.GetAll() { - routeSelector.SelectAllRoutes() - } else { - routes := toNetIDs(req.GetNetworkIDs()) - netIdRoutes := maps.Keys(routeManager.GetClientRoutesWithNetID()) - if err := routeSelector.SelectRoutes(routes, req.GetAppend(), netIdRoutes); err != nil { - return nil, fmt.Errorf("select routes: %w", err) - } + routeManager.SelectAllRoutes() + } else if err := routeManager.SelectRoutes(toNetIDs(req.GetNetworkIDs()), req.GetAppend()); err != nil { + return nil, err } - routeManager.TriggerSelection(routeManager.GetClientRoutes()) s.statusRecorder.PublishEvent( proto.SystemEvent_INFO, @@ -174,7 +186,7 @@ func (s *Server) DeselectNetworks(_ context.Context, req *proto.SelectNetworksRe s.mutex.Lock() defer s.mutex.Unlock() - if s.networksDisabled { + if s.checkNetworksDisabled() { return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled) } @@ -192,17 +204,11 @@ func (s *Server) DeselectNetworks(_ context.Context, req *proto.SelectNetworksRe return nil, fmt.Errorf("no route manager") } - routeSelector := routeManager.GetRouteSelector() if req.GetAll() { - routeSelector.DeselectAllRoutes() - } else { - routes := toNetIDs(req.GetNetworkIDs()) - netIdRoutes := maps.Keys(routeManager.GetClientRoutesWithNetID()) - if err := routeSelector.DeselectRoutes(routes, netIdRoutes); err != nil { - return nil, fmt.Errorf("deselect routes: %w", err) - } + routeManager.DeselectAllRoutes() + } else if err := routeManager.DeselectRoutes(toNetIDs(req.GetNetworkIDs())); err != nil { + return nil, err } - routeManager.TriggerSelection(routeManager.GetClientRoutes()) s.statusRecorder.PublishEvent( proto.SystemEvent_INFO, diff --git a/client/server/panic_windows.go b/client/server/panic_windows.go index 8592f12ad..4bed6662f 100644 --- a/client/server/panic_windows.go +++ b/client/server/panic_windows.go @@ -3,6 +3,7 @@ package server import ( + "errors" "fmt" "os" "path" @@ -69,7 +70,7 @@ func setStdHandle(f *os.File) error { handle := f.Fd() r0, _, e1 := setStdHandleFn.Call(stdErrorHandle, handle) if r0 == 0 { - if e1 != nil { + if !errors.Is(e1, syscall.Errno(0)) { return e1 } return syscall.EINVAL diff --git a/client/server/probe_throttle.go b/client/server/probe_throttle.go new file mode 100644 index 000000000..ec6137e15 --- /dev/null +++ b/client/server/probe_throttle.go @@ -0,0 +1,88 @@ +package server + +import ( + "context" + "sync" + "time" + + log "github.com/sirupsen/logrus" +) + +// healthProbeRunner runs the full, expensive probe (network round-trips to +// management, signal and the relays) and reports whether every component was +// healthy. ctx cancels the probe when the caller gives up. Satisfied by +// *internal.Engine. +type healthProbeRunner interface { + RunHealthProbes(ctx context.Context, waitForResult bool) bool +} + +// statsRefresher does the cheap WireGuard-stats refresh callers fall back to +// when a fresh probe isn't warranted. Satisfied by *peer.Status. +type statsRefresher interface { + RefreshWireGuardStats() error +} + +// probeThrottle rate-limits and single-flights the daemon's health probes. +// +// Health probes are expensive (network round-trips to management, signal and +// the relays), while Status(GetFullPeerStatus=true) RPCs can arrive frequently +// and concurrently — the desktop UI alone issues one per connect/disconnect. +// probeThrottle keeps that load bounded with two rules: +// +// - Single-flight: only one probe runs at a time. Callers that pile up while +// a probe is in flight share its result instead of each launching another, +// even when that probe failed. A failed probe therefore does not make every +// waiter re-probe in turn; the next, non-overlapping caller can try again. +// - Throttle: after a fully successful probe the result is cached for +// interval. While any component is unhealthy the cache is not advanced, so +// later callers keep probing frequently and notice recovery quickly — the +// intentional "probe often while unhealthy" behaviour from the original +// design. +type probeThrottle struct { + interval time.Duration + + mu sync.Mutex + lastOK time.Time // last fully-successful probe; drives the throttle window + completedAt time.Time // when the most recent probe finished; drives single-flight sharing +} + +func newProbeThrottle(interval time.Duration) *probeThrottle { + return &probeThrottle{interval: interval} +} + +// Run decides whether to run a fresh health probe or serve the most recent +// result. It serialises concurrent callers: at most one runner.RunHealthProbes +// executes at a time and the rest call refresher.RefreshWireGuardStats and read +// the snapshot it produced. +// +// Both calls run while the throttle's lock is held, so a slow probe blocks +// other callers until it completes — that blocking is the single-flight +// guarantee. ctx is forwarded to RunHealthProbes so a caller that gives up +// cancels the in-flight probe (and any caller still queued on the lock falls +// through quickly once it acquires it, since the probe ctx is already done). +func (t *probeThrottle) Run(ctx context.Context, runner healthProbeRunner, refresher statsRefresher, waitForResult bool) { + entered := time.Now() + + t.mu.Lock() + defer t.mu.Unlock() + + // A probe that finished after we entered ran while we were waiting on the + // lock — i.e. a peer in the same burst already probed for us, so share its + // result rather than launch another. This holds even when that probe + // failed, so a failed probe doesn't make every waiter re-probe in turn. + sharedRecentProbe := t.completedAt.After(entered) + throttled := time.Since(t.lastOK) <= t.interval + + if sharedRecentProbe || throttled { + if err := refresher.RefreshWireGuardStats(); err != nil { + log.Debugf("failed to refresh WireGuard stats: %v", err) + } + return + } + + healthy := runner.RunHealthProbes(ctx, waitForResult) + t.completedAt = time.Now() + if healthy { + t.lastOK = t.completedAt + } +} diff --git a/client/server/probe_throttle_test.go b/client/server/probe_throttle_test.go new file mode 100644 index 000000000..cae776fa4 --- /dev/null +++ b/client/server/probe_throttle_test.go @@ -0,0 +1,109 @@ +package server + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" +) + +// fakeProber implements both healthProbeRunner and statsRefresher with +// caller-supplied behaviour. +type fakeProber struct { + onProbe func() bool + onRefresh func() +} + +func (f fakeProber) RunHealthProbes(context.Context, bool) bool { + return f.onProbe() +} + +func (f fakeProber) RefreshWireGuardStats() error { + if f.onRefresh != nil { + f.onRefresh() + } + return nil +} + +func TestProbeThrottle_CachesAfterSuccess(t *testing.T) { + pt := newProbeThrottle(time.Minute) + + var probes, refreshes int + prober := fakeProber{ + onProbe: func() bool { probes++; return true }, + onRefresh: func() { refreshes++ }, + } + + pt.Run(context.Background(), prober, prober, false) + pt.Run(context.Background(), prober, prober, false) + + if probes != 1 { + t.Fatalf("expected 1 probe within the throttle window, got %d", probes) + } + if refreshes != 1 { + t.Fatalf("expected the throttled caller to refresh stats once, got %d", refreshes) + } +} + +func TestProbeThrottle_StaysOpenWhileUnhealthy(t *testing.T) { + pt := newProbeThrottle(time.Minute) + + var probes int + prober := fakeProber{onProbe: func() bool { probes++; return false }} // never healthy + + // Sequential, non-overlapping callers must each re-probe while unhealthy: + // a failed probe does not advance the throttle window. + pt.Run(context.Background(), prober, prober, false) + pt.Run(context.Background(), prober, prober, false) + pt.Run(context.Background(), prober, prober, false) + + if probes != 3 { + t.Fatalf("expected every non-overlapping caller to probe while unhealthy, got %d", probes) + } +} + +func TestProbeThrottle_SingleFlightSharesResult(t *testing.T) { + pt := newProbeThrottle(time.Minute) + + var probes int32 + release := make(chan struct{}) + started := make(chan struct{}) + + // First caller blocks inside the probe until released, holding the lock so + // the others pile up behind it. + prober := fakeProber{onProbe: func() bool { + if atomic.AddInt32(&probes, 1) == 1 { + close(started) + <-release + } + return false // unhealthy — the share must happen regardless of result + }} + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + pt.Run(context.Background(), prober, prober, false) + }() + + <-started // ensure the first probe is in flight before the burst arrives + + const waiters = 9 + wg.Add(waiters) + for i := 0; i < waiters; i++ { + go func() { + defer wg.Done() + pt.Run(context.Background(), prober, prober, false) + }() + } + + // Give the waiters time to block on the lock, then let the first finish. + time.Sleep(50 * time.Millisecond) + close(release) + wg.Wait() + + if got := atomic.LoadInt32(&probes); got != 1 { + t.Fatalf("expected a concurrent burst to run exactly 1 probe, got %d", got) + } +} diff --git a/client/server/server.go b/client/server/server.go index 70e4c342f..108aa8a41 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -19,11 +19,17 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" gstatus "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" "github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/expose" + "github.com/netbirdio/netbird/client/internal/ipcauth" + "github.com/prometheus/client_golang/prometheus" + + "github.com/netbirdio/netbird/client/internal/localmetrics" "github.com/netbirdio/netbird/client/internal/profilemanager" sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler" + "github.com/netbirdio/netbird/client/mdm" "github.com/netbirdio/netbird/client/system" mgm "github.com/netbirdio/netbird/shared/management/client" "github.com/netbirdio/netbird/shared/management/domain" @@ -33,6 +39,8 @@ import ( "github.com/netbirdio/netbird/client/internal/statemanager" "github.com/netbirdio/netbird/client/internal/updater" "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/util" + "github.com/netbirdio/netbird/util/capture" "github.com/netbirdio/netbird/version" ) @@ -65,12 +73,39 @@ type Server struct { logFile string + // uiLogPath is the desktop UI's absolute log path, reported via + // RegisterUILog. Guarded by mutex. Consumed by DebugBundle so the bundle + // can collect the GUI log even though the daemon runs as root and can't + // resolve the user's config dir. Last-writer-wins (one UI per socket). + // DebugBundle opens it on behalf of the bundle requester and refuses a file + // that caller does not own, so a local user cannot read another user's log + // or a root-only file through it. + uiLogPath string + oauthAuthFlow oauthAuthFlow + // extendAuthSessionFlow holds the pending PKCE flow created by + // RequestExtendAuthSession until WaitExtendAuthSession resolves it. + // Kept separate from oauthAuthFlow (which is reserved for the SSH + // JWT path) so a concurrent SSH auth doesn't clobber the session + // extend flow or vice versa. + extendAuthSessionFlow *auth.PendingFlow + + // guardedConfigMu serializes a privilege check against the write it + // authorizes. Without it the two are separate steps over the same file, and a + // change that was allowed because the profile had the SSH server disabled + // could land after a concurrent privileged request enabled it. + guardedConfigMu sync.Mutex mutex sync.Mutex config *profilemanager.Config proto.UnimplementedDaemonServiceServer - clientRunning bool // protected by mutex + // clientRunning tracks "the daemon wants to be connected" — set true by + // Start / Up, cleared by Down / Logout. Persists across retry + // loops, signal disconnects, and ErrResetConnection cycles. NOT + // changed by connectWithRetryRuns goroutine exit — for that + // (goroutine-still-alive) check, see connectionGoroutineRunning() which + // derives from clientGiveUpChan close state. Protected by s.mutex. + clientRunning bool clientRunningChan chan struct{} clientGiveUpChan chan struct{} // closed when connectWithRetryRuns goroutine exits @@ -78,8 +113,9 @@ type Server struct { statusRecorder *peer.Status sessionWatcher *internal.SessionWatcher + localMetrics *localmetrics.Manager - lastProbe time.Time + probeThrottle *probeThrottle persistSyncResponse bool isSessionActive atomic.Bool @@ -89,24 +125,57 @@ type Server struct { profileManager *profilemanager.ServiceManager profilesDisabled bool updateSettingsDisabled bool - networksDisabled bool + captureEnabled bool + bundleCapture *bundleCapture + // activeCapture is the session currently installed on the engine; guarded by s.mutex. + activeCapture *capture.Session + networksDisabled bool sleepHandler *sleephandler.SleepHandler + // mdmTicker periodically re-reads the OS-native MDM policy and triggers + // an engine restart when the policy changes. Launched once by Start; + // stopped by the rootCtx cancellation. + mdmTicker *mdm.Ticker + + // mdmLoader is the daemon-owned source of the active MDM policy. + // Constructed once during Server.Start (with a nil PolicyFetcher on + // desktop — the build-tagged Loader.loadPlatform reads the OS + // registry / plist directly) and injected into every consumer: + // mdmTicker for its periodic reload, the SetConfig / Login MDM + // gates for conflict detection, and every Config produced via + // getConfig() so its apply() picks up the same overlay. + mdmLoader *mdm.Loader + updateManager *updater.Manager jwtCache *jwtCache + + // loginAttemptFn stands in for the Management login round trip. Tests set + // it to drive the login outcomes that need a server on the other end; + // production leaves it nil, and every login goes through loginAttempt. + loginAttemptFn func(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) + + isLoginRequiredFn func(ctx context.Context) (bool, error) } type oauthAuthFlow struct { - expiresAt time.Time - flow auth.OAuthFlow - info auth.AuthFlowInfo + expiresAt time.Time + flow auth.OAuthFlow + info auth.AuthFlowInfo + + // cacheGeneration is the SSH JWT cache's generation as of the start of the + // request that created this flow. The flow outlives a profile switch, so + // reading the generation any later — when the IdP has answered, or when the + // token finally arrives — would read the new session's one and let the old + // session's token into the new session's cache. + cacheGeneration uint64 + waitCancel context.CancelFunc } // New server instance constructor. -func New(ctx context.Context, logFile string, configFile string, profilesDisabled bool, updateSettingsDisabled bool, networksDisabled bool) *Server { +func New(ctx context.Context, logFile string, configFile string, profilesDisabled bool, updateSettingsDisabled bool, captureEnabled bool, networksDisabled bool) *Server { s := &Server{ rootCtx: ctx, logFile: logFile, @@ -115,15 +184,38 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable profileManager: profilemanager.NewServiceManager(configFile), profilesDisabled: profilesDisabled, updateSettingsDisabled: updateSettingsDisabled, + captureEnabled: captureEnabled, networksDisabled: networksDisabled, jwtCache: newJWTCache(), + extendAuthSessionFlow: auth.NewPendingFlow(), + probeThrottle: newProbeThrottle(probeThreshold), } agent := &serverAgent{s} s.sleepHandler = sleephandler.New(agent) + s.startSleepDetector() + + s.localMetrics = localmetrics.NewManager(ctx, s.statusRecorder, s.clientMetricsGatherer) return s } +// clientMetricsGatherer returns the Prometheus gatherer of the running +// engine's client metrics, or nil when no engine is running. +func (s *Server) clientMetricsGatherer() prometheus.Gatherer { + s.mutex.Lock() + connectClient := s.connectClient + s.mutex.Unlock() + + if connectClient == nil { + return nil + } + engine := connectClient.Engine() + if engine == nil { + return nil + } + return engine.GetClientMetrics().PrometheusGatherer() +} + func (s *Server) Start() error { s.mutex.Lock() defer s.mutex.Unlock() @@ -133,12 +225,21 @@ func (s *Server) Start() error { } state := internal.CtxGetState(s.rootCtx) + // Every contextState.Set in the connect/login/server paths must push a + // SubscribeStatus snapshot, otherwise transitions that don't happen to + // be accompanied by a Mark{Management,Signal,...} call (e.g. plain + // StatusNeedsLogin after a PermissionDenied login, StatusLoginFailed + // after OAuth init failure, StatusIdle in the Login defer) leave the + // UI stuck on the previous status until the next unrelated peer event. + // Binding the recorder here means new state.Set callsites don't have + // to opt in individually. + state.SetOnChange(s.statusRecorder.NotifyStateChange) if err := handlePanicLog(); err != nil { log.Warnf("failed to redirect stderr: %v", err) } - if err := restoreResidualState(s.rootCtx, s.profileManager.GetStatePath()); err != nil { + if err := RestoreResidualState(s.rootCtx, s.profileManager.GetStatePath()); err != nil { log.Warnf(errRestoreResidualState, err) } @@ -148,6 +249,23 @@ func (s *Server) Start() error { s.updateManager.CheckUpdateSuccess(s.rootCtx) } + // MDM policy reload ticker: every minute the desktop daemon re-reads + // the OS-native managed-config store and, on diff vs the previous + // observation, cancels the active engine context so connectWithRetry- + // Runs re-resolves Config (re-running profilemanager.Config.apply which + // applies the freshly-read MDM policy as the last layer) and brings + // the engine back with the new values. + if s.mdmLoader == nil { + // Desktop builds pass a nil PolicyFetcher: the Loader's + // build-tagged loadPlatform reads the OS source directly + // (registry on Windows, plist on macOS, no-op elsewhere). + s.mdmLoader = mdm.NewLoader(nil) + } + if s.mdmTicker == nil { + s.mdmTicker = mdm.NewTicker(mdm.DefaultReloadInterval, s.mdmLoader) + go s.mdmTicker.Run(s.rootCtx, s.onMDMPolicyChange) + } + // 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. @@ -184,7 +302,7 @@ func (s *Server) Start() error { s.statusRecorder.UpdateManagementAddress(config.ManagementURL.String()) s.statusRecorder.UpdateRosenpass(config.RosenpassEnabled, config.RosenpassPermissive) - s.statusRecorder.UpdateLazyConnection(config.LazyConnectionEnabled) + s.localMetrics.Reconcile(config.LocalMetricsEnabled, config.LocalMetricsAddress) if s.sessionWatcher == nil { s.sessionWatcher = internal.NewSessionWatcher(s.rootCtx, s.statusRecorder) @@ -206,17 +324,31 @@ func (s *Server) Start() error { s.clientRunningChan = make(chan struct{}) s.clientGiveUpChan = make(chan struct{}) go s.connectWithRetryRuns(ctx, config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan) + s.publishConfigChangedEvent(proto.MetadataSourceStartup) 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. +// +// The goroutine's exit is signalled to the daemon via close(giveUpChan) +// — placed in the function-scope defer so every return path (panic, +// DisableAutoConnect early-exit, backoff exhausted, ctx cancel) closes +// it. Callers that need to observe "is the goroutine still alive?" use +// Server.connectionGoroutineRunning() which non-blockingly checks the close state +// of clientGiveUpChan. The defer does NOT touch s.mutex; the daemon's +// "intent" (clientRunning) is maintained by the RPC handlers, not by this +// goroutine. func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profilemanager.Config, statusRecorder *peer.Status, runningChan chan struct{}, giveUpChan chan struct{}) { + // close(giveUpChan) MUST run on every exit path (DisableAutoConnect + // return, backoff.Retry return, panic) — Down() blocks for up to 5s + // waiting on this signal before flipping the state to Idle, and a + // missed close leaves Down() always hitting the timeout. defer func() { - s.mutex.Lock() - s.clientRunning = false - s.mutex.Unlock() + if giveUpChan != nil { + close(giveUpChan) + } }() if s.config.DisableAutoConnect { @@ -251,6 +383,15 @@ func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profil runOperation := func() error { err := s.connect(ctx, profileConfig, statusRecorder, runningChan) if err != nil { + // PermissionDenied means the daemon transitioned to NeedsLogin + // inside connect(). Without backoff.Permanent the outer retry + // re-enters connect(), which resets the state to Connecting and + // makes the tray flicker between NeedsLogin and Connecting until + // the user logs in. Stop retrying and let the state stick. + if s, ok := gstatus.FromError(err); ok && s.Code() == codes.PermissionDenied { + log.Debugf("run client connection exited with PermissionDenied, waiting for login") + return backoff.Permanent(err) + } log.Debugf("run client connection exited with error: %v. Will retry in the background", err) return err } @@ -262,13 +403,57 @@ func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profil if err := backoff.Retry(runOperation, backOff); err != nil { log.Errorf("operation failed: %v", err) } + // giveUpChan is closed by the function-scope defer. +} - if giveUpChan != nil { - close(giveUpChan) +// connectionGoroutineRunning reports whether the connectWithRetryRuns goroutine is +// still running. Returns false when no goroutine has ever been started +// AND when the most recent one has already closed clientGiveUpChan on +// exit (whether due to ctx cancel, DisableAutoConnect single-shot +// completion, or backoff retry exhaustion). +// +// MUST be called with s.mutex held — accesses s.clientGiveUpChan which +// is written by Start/Up under the same lock. +func (s *Server) connectionGoroutineRunning() bool { + if s.clientGiveUpChan == nil { + return false + } + select { + case <-s.clientGiveUpChan: + return false + default: + return true } } -// loginAttempt attempts to login using the provided information. it returns a status in case something fails +// attemptLogin runs a login round trip against Management, or the stand-in a +// test installed in place of it. +func (s *Server) attemptLogin(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) { + if s.loginAttemptFn != nil { + return s.loginAttemptFn(ctx, setupKey, jwtToken) + } + return s.loginAttempt(ctx, setupKey, jwtToken) +} + +func (s *Server) isLoginRequired(ctx context.Context) (bool, error) { + if s.isLoginRequiredFn != nil { + return s.isLoginRequiredFn(ctx) + } + + authClient, err := auth.NewAuth(ctx, s.config.PrivateKey, s.config.ManagementURL, s.config) + if err != nil { + log.Errorf("failed to create auth client: %v", err) + return false, err + } + defer authClient.Close() + + return authClient.IsLoginRequired(ctx) +} + +// loginAttempt attempts to login using the provided information. It returns +// StatusNeedsLogin when Management refused the peer's credentials and +// StatusLoginFailed for every other failure, so callers can tell an +// authentication decision apart from a login that never got made. func (s *Server) loginAttempt(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) { authClient, err := auth.NewAuth(ctx, s.config.PrivateKey, s.config.ManagementURL, s.config) if err != nil { @@ -294,57 +479,113 @@ func (s *Server) loginAttempt(ctx context.Context, setupKey, jwtToken string) (i // Login uses setup key to prepare configuration for the daemon. func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigRequest) (*proto.SetConfigResponse, error) { + // Privilege gate: refuse the parts of the request that would let a local + // user turn the root daemon into a root shell. Held across the write so the + // config cannot gain the SSH server between the decision and the update. + // + // Taken before s.mutex: authorizeAndPrepareLogin takes s.mutex while holding + // guardedConfigMu, so acquiring the two in the other order here would let a + // concurrent login deadlock the daemon. + s.guardedConfigMu.Lock() + defer s.guardedConfigMu.Unlock() + s.mutex.Lock() defer s.mutex.Unlock() - if s.checkUpdateSettingsDisabled() { - return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled) + // Skip the update-settings gate when the request carries no actual + // overrides: the CLI builds a SetConfigRequest unconditionally on + // every `netbird up` (setupSetConfigReq in cmd/up.go), so a plain + // `netbird up` would otherwise always trip the gate and surface a + // misleading "setConfig method is not available" warning, even when + // the user did not pass any config flag. + if setConfigRequestHasConfigOverrides(msg) { + if s.checkUpdateSettingsDisabled() { + return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled) + } } - profState := profilemanager.ActiveProfileState{ - Name: msg.ProfileName, - Username: msg.Username, + // MDM gate: refuse the whole request if any of its fields is enforced + // by the active MDM policy. The error carries an MDMManagedFields- + // Violation detail listing the offending key names. Non-conflicting + // fields in the same request are not applied either. + policy := s.mdmLoader.Load() + if err := rejectMDMManagedFieldConflicts(mdmManagedFieldConflicts(msg, policy)); err != nil { + return nil, err } - profPath, err := profState.FilePath() + stored, err := s.storedProfileConfig(msg.ProfileName, msg.Username) if err != nil { - log.Errorf("failed to get active profile file path: %v", err) - return nil, fmt.Errorf("failed to get active profile file path: %w", err) + return nil, err + } + if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromSetConfig(msg)); err != nil { + return nil, err } + config, err := s.setConfigInputFromRequest(msg) + if err != nil { + return nil, err + } + + updatedConf, err := profilemanager.UpdateConfig(config) + if err != nil { + log.Errorf("failed to update profile config: %v", err) + return nil, fmt.Errorf("failed to update profile config: %w", err) + } + + if activeProf, err := s.profileManager.GetActiveProfileState(); err == nil { + if activePath, err := activeProf.FilePath(); err == nil && activePath == config.ConfigPath { + s.localMetrics.Reconcile(updatedConf.LocalMetricsEnabled, updatedConf.LocalMetricsAddress) + } + } + + return &proto.SetConfigResponse{}, nil +} + +// setConfigInputFromRequest translates a SetConfigRequest into the +// profilemanager.ConfigInput that profilemanager.UpdateConfig consumes. +// Pure mapping with no business logic beyond presence-aware copying of +// optional fields and the "empty / clean" semantics for the two slice +// fields (DNS labels, NAT external IPs). Extracted from SetConfig to +// keep the handler's cognitive complexity below the SonarCube +// threshold; the body is intentionally linear because each proto +// field is its own optional case. Returns the resolved ConfigInput +// and a non-nil error only when the active profile file path cannot +// be determined. +func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profilemanager.ConfigInput, error) { var config profilemanager.ConfigInput + resolved, err := s.resolveProfileHandle(msg.ProfileName, msg.Username) + if err != nil { + log.Errorf("failed to resolve profile %q: %v", msg.ProfileName, err) + return config, err + } + profPath := resolved.Path + if profPath == "" { + profPath = profilemanager.DefaultConfigPath + } config.ConfigPath = profPath if msg.ManagementUrl != "" { config.ManagementURL = msg.ManagementUrl } - if msg.AdminURL != "" { config.AdminURL = msg.AdminURL } - if msg.InterfaceName != nil { config.InterfaceName = msg.InterfaceName } - if msg.WireguardPort != nil { wgPort := int(*msg.WireguardPort) config.WireguardPort = &wgPort } - if msg.OptionalPreSharedKey != nil { - if *msg.OptionalPreSharedKey != "" { - config.PreSharedKey = msg.OptionalPreSharedKey - } + config.PreSharedKey = msg.OptionalPreSharedKey } if msg.CleanDNSLabels { config.DNSLabels = domain.List{} - } else if msg.DnsLabels != nil { - dnsLabels := domain.FromPunycodeList(msg.DnsLabels) - config.DNSLabels = dnsLabels + config.DNSLabels = domain.FromPunycodeList(msg.DnsLabels) } if msg.CleanNATExternalIPs { @@ -357,7 +598,6 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques if string(msg.CustomDNSAddress) == "empty" { config.CustomDNSAddress = []byte{} } - config.ExtraIFaceBlackList = msg.ExtraIFaceBlacklist if msg.DnsRouteInterval != nil { @@ -367,8 +607,11 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques config.RosenpassEnabled = msg.RosenpassEnabled config.RosenpassPermissive = msg.RosenpassPermissive + config.LocalMetricsEnabled = msg.EnableLocalMetrics + config.LocalMetricsAddress = msg.LocalMetricsAddress config.DisableAutoConnect = msg.DisableAutoConnect config.ServerSSHAllowed = msg.ServerSSHAllowed + config.RemoteJobsAllowed = msg.RemoteJobsAllowed config.NetworkMonitor = msg.NetworkMonitor config.DisableClientRoutes = msg.DisableClientRoutes config.DisableServerRoutes = msg.DisableServerRoutes @@ -376,8 +619,8 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques config.DisableFirewall = msg.DisableFirewall config.BlockLANAccess = msg.BlockLanAccess config.DisableNotifications = msg.DisableNotifications - config.LazyConnectionEnabled = msg.LazyConnectionEnabled config.BlockInbound = msg.BlockInbound + config.DisableIPv6 = msg.DisableIpv6 config.EnableSSHRoot = msg.EnableSSHRoot config.EnableSSHSFTP = msg.EnableSSHSFTP config.EnableSSHLocalPortForwarding = msg.EnableSSHLocalPortForwarding @@ -389,47 +632,30 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques ttl := int(*msg.SshJWTCacheTTL) config.SSHJWTCacheTTL = &ttl } - if msg.Mtu != nil { mtu := uint16(*msg.Mtu) config.MTU = &mtu } - - if _, err := profilemanager.UpdateConfig(config); err != nil { - log.Errorf("failed to update profile config: %v", err) - return nil, fmt.Errorf("failed to update profile config: %w", err) - } - - return &proto.SetConfigResponse{}, nil + return config, nil } // Login uses setup key to prepare configuration for the daemon. func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*proto.LoginResponse, error) { - s.mutex.Lock() - if s.actCancel != nil { - s.actCancel() - } - ctx, cancel := context.WithCancel(callerCtx) - - md, ok := metadata.FromIncomingContext(callerCtx) - if ok { - ctx = metadata.NewOutgoingContext(ctx, md) - } - - s.actCancel = cancel - s.mutex.Unlock() - - if err := restoreResidualState(s.rootCtx, s.profileManager.GetStatePath()); err != nil { - log.Warnf(errRestoreResidualState, err) - } - - state := internal.CtxGetState(s.rootCtx) - defer func() { - status, err := state.Status() - if err != nil || (status != internal.StatusNeedsLogin && status != internal.StatusLoginFailed) { - state.Set(internal.StatusIdle) + // Config-override gates. LoginRequest carries the same surface as + // SetConfigRequest (managementUrl, PSK, ssh/rosenpass/port toggles, + // ...), so the same protections must apply. Without these the CLI + // command `netbird up --management-url=X` (which falls through to + // Login when SetConfig is rejected — see cmd/up.go) would silently + // bypass `--disable-update-settings` and any MDM policy. + if loginRequestHasConfigOverrides(msg) { + if s.checkUpdateSettingsDisabled() { + return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled) } - }() + policy := s.mdmLoader.Load() + if err := rejectMDMManagedFieldConflicts(loginRequestMDMConflicts(msg, policy)); err != nil { + return nil, err + } + } activeProf, err := s.profileManager.GetActiveProfileState() if err != nil { @@ -437,41 +663,45 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro return nil, fmt.Errorf("failed to get active profile state: %w", err) } - if msg.ProfileName != nil { - if *msg.ProfileName != "default" && (msg.Username == nil || *msg.Username == "") { - log.Errorf("profile name is set to %s, but username is not provided", *msg.ProfileName) - return nil, fmt.Errorf("profile name is set to %s, but username is not provided", *msg.ProfileName) - } - - var username string - if *msg.ProfileName != "default" { - username = *msg.Username - } - - if *msg.ProfileName != activeProf.Name && username != activeProf.Username { - if s.checkProfilesDisabled() { - log.Errorf("profiles are disabled, you cannot use this feature without profiles enabled") - return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled) - } - - log.Infof("switching to profile %s for user '%s'", *msg.ProfileName, username) - if err := s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: *msg.ProfileName, - Username: username, - }); err != nil { - log.Errorf("failed to set active profile state: %v", err) - return nil, fmt.Errorf("failed to set active profile state: %w", err) - } - } - } - - activeProf, err = s.profileManager.GetActiveProfileState() + // Privilege gate: same restrictions as SetConfig, since LoginRequest can carry + // the same fields. It runs before anything here changes daemon state, so a + // refused login neither switches the profile nor cancels a login already in + // progress, and it reads the profile the request targets, which is the one the + // switch below would activate. + stored, err := s.storedLoginConfig(activeProf, msg) if err != nil { - log.Errorf("failed to get active profile state: %v", err) - return nil, fmt.Errorf("failed to get active profile state: %w", err) + return nil, err + } + if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromLogin(msg)); err != nil { + return nil, err } - log.Infof("active profile: %s for %s", activeProf.Name, activeProf.Username) + state := internal.CtxGetState(s.rootCtx) + status := state.CurrentStatus() + if status == internal.StatusConnected { + return &proto.LoginResponse{}, nil + } + + defer func() { + status, err := state.Status() + if err != nil || (status != internal.StatusNeedsLogin && status != internal.StatusLoginFailed) { + state.Set(internal.StatusIdle) + } + }() + + ctx, activeProf, err := s.authorizeAndPrepareLogin(callerCtx, msg, activeProf) + if err != nil { + // The RPC boundary is where this gets recorded: nothing logs handler + // errors for us, and a caller that retries would otherwise leave no + // trace in the daemon log. A refusal is skipped because the gate has + // already logged the decision, with the caller's identity. + if gstatus.Code(err) != codes.PermissionDenied { + log.Errorf("failed to prepare login: %v", err) + } + return nil, err + } + + log.Infof("active profile: %s for %s", activeProf.ID, activeProf.Username) s.mutex.Lock() @@ -491,64 +721,35 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro s.config = config s.mutex.Unlock() - if _, err := s.loginAttempt(ctx, "", ""); err == nil { + s.localMetrics.Reconcile(config.LocalMetricsEnabled, config.LocalMetricsAddress) + + // A probe that errors leaves the login undecided: Management unreachable, a + // restart mid-request, an internal error. Those are returned for the caller + // to retry, because turning them into an SSO prompt asks the user to solve + // something that is not theirs to solve, and a browser login cannot succeed + // while Management is unreachable anyway. Only Management refusing the + // peer's key is a decision, and IsLoginRequired reports that as + // needsLogin=true rather than an error. + needsLogin, err := s.isLoginRequired(ctx) + if err != nil { + state.Set(internal.StatusLoginFailed) + return nil, err + } + if !needsLogin { state.Set(internal.StatusIdle) return &proto.LoginResponse{}, nil } - state.Set(internal.StatusConnecting) - if msg.SetupKey == "" { - hint := "" - if msg.Hint != nil { - hint = *msg.Hint - } - oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.IsUnixDesktopClient, false, hint) - if err != nil { - state.Set(internal.StatusLoginFailed) - return nil, err - } - - if s.oauthAuthFlow.flow != nil && s.oauthAuthFlow.flow.GetClientID(ctx) == oAuthFlow.GetClientID(ctx) { - if s.oauthAuthFlow.expiresAt.After(time.Now().Add(90 * time.Second)) { - log.Debugf("using previous oauth flow info") - return &proto.LoginResponse{ - NeedsSSOLogin: true, - VerificationURI: s.oauthAuthFlow.info.VerificationURI, - VerificationURIComplete: s.oauthAuthFlow.info.VerificationURIComplete, - UserCode: s.oauthAuthFlow.info.UserCode, - }, nil - } else { - log.Warnf("canceling previous waiting execution") - if s.oauthAuthFlow.waitCancel != nil { - s.oauthAuthFlow.waitCancel() - } - } - } - - authInfo, err := oAuthFlow.RequestAuthInfo(ctx) - if err != nil { - log.Errorf("getting a request OAuth flow failed: %v", err) - return nil, err - } - - s.mutex.Lock() - s.oauthAuthFlow.flow = oAuthFlow - s.oauthAuthFlow.info = authInfo - s.oauthAuthFlow.expiresAt = time.Now().Add(time.Duration(authInfo.ExpiresIn) * time.Second) - s.mutex.Unlock() - - state.Set(internal.StatusNeedsLogin) - - return &proto.LoginResponse{ - NeedsSSOLogin: true, - VerificationURI: authInfo.VerificationURI, - VerificationURIComplete: authInfo.VerificationURIComplete, - UserCode: authInfo.UserCode, - }, nil + return s.beginSSOLogin(ctx, config, msg) } - if loginStatus, err := s.loginAttempt(ctx, msg.SetupKey, ""); err != nil { + // Setup-key path: we are about to dial Management with the key, so the + // Connecting paint is meaningful here — unlike the SSO branch above, + // which returns NeedsLogin and parks on the browser leg. + state.Set(internal.StatusConnecting) + + if loginStatus, err := s.attemptLogin(ctx, msg.SetupKey, ""); err != nil { state.Set(loginStatus) return nil, err } @@ -556,8 +757,113 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro return &proto.LoginResponse{}, nil } -// WaitSSOLogin uses the userCode to validate the TokenInfo and -// waits for the user to continue with the login on a browser +// beginSSOLogin starts the browser leg of a login that carries no setup key and +// returns the response that parks the caller on it. +func (s *Server) beginSSOLogin(ctx context.Context, config *profilemanager.Config, msg *proto.LoginRequest) (*proto.LoginResponse, error) { + state := internal.CtxGetState(s.rootCtx) + + hint := "" + if msg.Hint != nil { + hint = *msg.Hint + } + oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.IsUnixDesktopClient, false, hint) + if err != nil { + state.Set(internal.StatusLoginFailed) + return nil, err + } + + if resp := s.pendingOAuthFlowResponse(ctx, oAuthFlow); resp != nil { + state.Set(internal.StatusNeedsLogin) + return resp, nil + } + + authInfo, err := oAuthFlow.RequestAuthInfo(ctx) + if err != nil { + log.Errorf("getting a request OAuth flow failed: %v", err) + return nil, err + } + + s.mutex.Lock() + s.oauthAuthFlow.flow = oAuthFlow + s.oauthAuthFlow.info = authInfo + s.oauthAuthFlow.expiresAt = time.Now().Add(time.Duration(authInfo.ExpiresIn) * time.Second) + s.mutex.Unlock() + + state.Set(internal.StatusNeedsLogin) + + return &proto.LoginResponse{ + NeedsSSOLogin: true, + VerificationURI: authInfo.VerificationURI, + VerificationURIComplete: authInfo.VerificationURIComplete, + UserCode: authInfo.UserCode, + }, nil +} + +// pendingOAuthFlowResponse returns the in-flight flow's response when it +// targets the same IdP client and has enough time left for the user to finish +// the browser leg, so a second login joins the pending flow instead of opening +// a competing one. A flow too close to expiry has its waiter cancelled and nil +// returned, leaving the caller to start a fresh flow. +func (s *Server) pendingOAuthFlowResponse(ctx context.Context, oAuthFlow auth.OAuthFlow) *proto.LoginResponse { + if s.oauthAuthFlow.flow == nil || s.oauthAuthFlow.flow.GetClientID(ctx) != oAuthFlow.GetClientID(ctx) { + return nil + } + + if s.oauthAuthFlow.expiresAt.After(time.Now().Add(90 * time.Second)) { + log.Debugf("using previous oauth flow info") + return &proto.LoginResponse{ + NeedsSSOLogin: true, + VerificationURI: s.oauthAuthFlow.info.VerificationURI, + VerificationURIComplete: s.oauthAuthFlow.info.VerificationURIComplete, + UserCode: s.oauthAuthFlow.info.UserCode, + } + } + + log.Warnf("canceling previous waiting execution") + if s.oauthAuthFlow.waitCancel != nil { + s.oauthAuthFlow.waitCancel() + } + + return nil +} + +// WaitSSOLogin validates the supplied userCode against the in-flight OAuth +// device/PKCE flow and blocks until the user finishes the browser leg. +// +// The daemon holds StatusNeedsLogin for the whole browser wait (set on +// entry): the login is not done until the token returns, so a client that +// (re)attaches mid-wait — a restarted UI, a second `netbird up` — reads +// "login required" and offers the affordance, instead of a Connecting that +// never resolves. The wait is also tied to the caller's context (see the +// goroutine below), so a client that goes away cancels the wait instead of +// orphaning it on rootCtx until the device-code window expires. +// +// State transitions on exit: +// +// ┌──────────────────────────────────────────┬──────────────────────────────────┐ +// │ Outcome │ contextState │ +// ├──────────────────────────────────────────┼──────────────────────────────────┤ +// │ Success → loginAttempt ok │ NeedsLogin held; the caller's Up │ +// │ │ drives Connecting → Connected │ +// │ Success → loginAttempt → still-NeedsLogin│ StatusNeedsLogin (loginAttempt) │ +// │ Success → loginAttempt error │ StatusLoginFailed (loginAttempt) │ +// │ UserCode mismatch │ StatusLoginFailed │ +// │ WaitToken: context.Canceled │ NeedsLogin held. Caller gone │ +// │ (caller went away — UI restart / │ (UI/CLI) → a fresh client │ +// │ Ctrl+C — or internal abort: profile │ shows the login affordance; │ +// │ switch / app quit / another │ internal aborts are │ +// │ WaitSSOLogin via actCancel/waitCancel) │ overwritten by the next Up. │ +// │ WaitToken: context.DeadlineExceeded │ StatusNeedsLogin │ +// │ (OAuth device-code window expired │ (retryable; the UI's "Connect" │ +// │ while waiting on the browser leg) │ re-enters the Login flow) │ +// │ WaitToken: any other error │ StatusLoginFailed │ +// │ (access_denied, expired_token, HTTP │ (genuine auth/IO failure; │ +// │ failure, token validation rejection) │ surfaced verbatim to caller) │ +// └──────────────────────────────────────────┴──────────────────────────────────┘ +// +// The defer still applies a StatusIdle fallback for the early +// oauth-flow-not-initialized return (before the entry Set), so a half state +// doesn't leak when there is nothing to wait on. func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLoginRequest) (*proto.WaitSSOLoginResponse, error) { s.mutex.Lock() if s.actCancel != nil { @@ -565,6 +871,21 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin } ctx, cancel := context.WithCancel(s.rootCtx) + // Tie the in-flight browser wait to the caller. ctx stays rooted in + // rootCtx so CtxGetState resolves the daemon's contextState, but if the + // UI window or CLI that drove the login goes away mid-flow (restart, + // Ctrl+C) the gRPC callerCtx cancels and we cancel the wait instead of + // orphaning it on rootCtx until the OAuth device-code window expires. + // The goroutine exits as soon as either context completes, so it can't + // outlive the RPC. + go func() { + select { + case <-callerCtx.Done(): + cancel() + case <-ctx.Done(): + } + }() + md, ok := metadata.FromIncomingContext(callerCtx) if ok { ctx = metadata.NewOutgoingContext(ctx, md) @@ -590,7 +911,11 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin } }() - state.Set(internal.StatusConnecting) + // Hold NeedsLogin for the whole browser wait — the login is not done + // until the token returns, so a client that (re)attaches mid-wait + // (restarted UI, second `netbird up`) reads "login required" and offers + // the affordance instead of a Connecting that never resolves. + state.Set(internal.StatusNeedsLogin) s.mutex.Lock() flowInfo := s.oauthAuthFlow.info @@ -617,7 +942,30 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin s.mutex.Lock() s.oauthAuthFlow.expiresAt = time.Now() s.mutex.Unlock() - state.Set(internal.StatusLoginFailed) + switch { + case errors.Is(err, context.Canceled): + // External abort. If our caller cancelled (the client closed + // the browser-login popup, or the UI went away — callerCtx is + // done), clear the abandoned OAuth flow so a fresh Login starts + // a new device code instead of reusing this one. The entry + // NeedsLogin stays in place, so a reattaching client shows the + // login affordance. An internal abort (actCancel from a new + // Login/WaitSSOLogin, callerCtx still live) leaves the flow for + // the new owner — don't clobber it. + if callerCtx.Err() != nil { + s.mutex.Lock() + s.oauthAuthFlow = oauthAuthFlow{} + s.mutex.Unlock() + } + case errors.Is(err, context.DeadlineExceeded): + // OAuth device-code window expired with no user action. + // Retryable — leave the daemon in NeedsLogin so the UI + // keeps the Login affordance instead of reading as a + // hard failure. + state.Set(internal.StatusNeedsLogin) + default: + state.Set(internal.StatusLoginFailed) + } log.Errorf("waiting for browser login failed: %v", err) return nil, err } @@ -626,11 +974,12 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin s.oauthAuthFlow.expiresAt = time.Now() s.mutex.Unlock() - if loginStatus, err := s.loginAttempt(ctx, "", tokenInfo.GetTokenToUse()); err != nil { + if loginStatus, err := s.attemptLogin(ctx, "", tokenInfo.GetTokenToUse()); err != nil { state.Set(loginStatus) return nil, err } + log.Infof("SSO login flow finished, returning success to caller") return &proto.WaitSSOLoginResponse{ Email: tokenInfo.Email, }, nil @@ -638,8 +987,15 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin // Up starts engine work in the daemon. func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpResponse, error) { + log.Infof("up request received") s.mutex.Lock() - if s.clientRunning { + // clientRunning is the daemon-intent flag (set by previous Up/Start, cleared + // by Down). connectionGoroutineRunning() reports whether the previous retry-loop + // goroutine is still trying. When intent is up AND goroutine is alive, + // the existing engine is on the job — just wait for it. When intent + // is up but the goroutine has given up (backoff exhausted) OR when + // intent is down, fall through to spawn a fresh retry loop. + if s.clientRunning && s.connectionGoroutineRunning() { state := internal.CtxGetState(s.rootCtx) status, err := state.Status() if err != nil { @@ -653,7 +1009,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR return s.waitForUp(callerCtx) } - if err := restoreResidualState(callerCtx, s.profileManager.GetStatePath()); err != nil { + if err := RestoreResidualState(callerCtx, s.profileManager.GetStatePath()); err != nil { log.Warnf(errRestoreResidualState, err) } @@ -668,6 +1024,22 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR return nil, err } + // StatusNeedsLogin is a legitimate fresh-start entry state: a successful + // WaitSSOLogin deliberately leaves the daemon in NeedsLogin (the login is + // done, the token is in hand, but the engine hasn't been brought up yet — + // see WaitSSOLogin's state-transition table). The same holds after a + // mid-session expiry tore the engine down (clientRunning == false) and the + // user re-authenticated. In both cases the caller's Up is expected to drive + // the connection; treat NeedsLogin like Idle and reset to Idle so the + // engine's own StatusConnecting → StatusConnected progression starts from a + // clean slate. Without this, the first Up after an SSO login fails with + // "up already in progress" and the user has to trigger Up a second time + // (CLI: re-run `netbird up`; GUI: click Connect again). + if status == internal.StatusNeedsLogin { + status = internal.StatusIdle + state.Set(internal.StatusIdle) + } + if status != internal.StatusIdle { s.mutex.Unlock() return nil, fmt.Errorf("up already in progress: current status %s", status) @@ -698,10 +1070,10 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR } if msg != nil && msg.ProfileName != nil { - if err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != 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) + return nil, err } } @@ -712,7 +1084,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR return nil, fmt.Errorf("failed to get active profile state: %w", err) } - log.Infof("active profile: %s for %s", activeProf.Name, activeProf.Username) + log.Infof("active profile: %s for %s", activeProf.ID, activeProf.Username) config, _, err := s.getConfig(activeProf) if err != nil { @@ -724,14 +1096,19 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR s.statusRecorder.UpdateManagementAddress(s.config.ManagementURL.String()) s.statusRecorder.UpdateRosenpass(s.config.RosenpassEnabled, s.config.RosenpassPermissive) + s.localMetrics.Reconcile(s.config.LocalMetricsEnabled, s.config.LocalMetricsAddress) s.clientRunning = true s.clientRunningChan = make(chan struct{}) s.clientGiveUpChan = make(chan struct{}) go s.connectWithRetryRuns(ctx, s.config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan) + s.publishConfigChangedEvent(proto.MetadataSourceUpRPC) s.mutex.Unlock() + if msg.GetAsync() { + return &proto.UpResponse{}, nil + } return s.waitForUp(callerCtx) } @@ -755,34 +1132,117 @@ func (s *Server) waitForUp(callerCtx context.Context) (*proto.UpResponse, error) } } -func (s *Server) switchProfileIfNeeded(profileName string, userName *string, activeProf *profilemanager.ActiveProfileState) error { - if profileName != "default" && (userName == nil || *userName == "") { - log.Errorf("profile name is set to %s, but username is not provided", profileName) - return fmt.Errorf("profile name is set to %s, but username is not provided", profileName) +// storedProfileConfig loads the on-disk config of the profile a request +// targets, so a privileged-change decision can be made against the values the +// profile currently holds. A profile that has no config file yet yields nil, +// which every caller must read as "nothing enabled yet". +func (s *Server) storedProfileConfig(handle, username string) (*profilemanager.Config, error) { + resolved, err := s.resolveProfileHandle(handle, username) + if err != nil { + return nil, err + } + + path := resolved.Path + if path == "" { + path = profilemanager.DefaultConfigPath + } + + return s.storedConfigAtPath(path) +} + +// storedLoginConfig loads the on-disk config of the profile a login request +// targets: the one it names, or the active one when it names none. Used to decide +// a privileged change before the request is allowed to switch profiles. +func (s *Server) storedLoginConfig(activeProf *profilemanager.ActiveProfileState, msg *proto.LoginRequest) (*profilemanager.Config, error) { + if msg.ProfileName == nil { + cfgPath, err := activeProf.FilePath() + if err != nil { + return nil, fmt.Errorf("active profile file path: %w", err) + } + return s.storedConfigAtPath(cfgPath) + } + + // Mirrors switchProfileIfNeeded: the default profile resolves without a + // username, so this reads the same profile the switch would activate. + handle := *msg.ProfileName + username := "" + if handle != profilemanager.DefaultProfileName { + username = msg.GetUsername() + } + return s.storedProfileConfig(handle, username) +} + +// storedConfigAtPath reads a profile config file, yielding nil when it does not +// exist yet. +func (s *Server) storedConfigAtPath(path string) (*profilemanager.Config, error) { + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return nil, nil //nolint:nilnil + } + return nil, fmt.Errorf("stat profile config: %w", err) + } + + cfg, err := profilemanager.GetConfig(path) + if err != nil { + return nil, fmt.Errorf("read profile config: %w", err) + } + return cfg, nil +} + +// resolveProfileHandle resolves a wire-level profile handle (display +// name, ID, or unique ID prefix) to a concrete profile. Returns gRPC +// status errors so handlers can return them directly. +func (s *Server) resolveProfileHandle(handle, username string) (*profilemanager.Profile, error) { + p, err := s.profileManager.ResolveProfile(handle, username) + if err == nil { + return p, nil + } + var amb *profilemanager.ErrAmbiguousHandle + if errors.As(err, &amb) { + return nil, gstatus.Errorf(codes.InvalidArgument, "%v", amb) + } + if errors.Is(err, profilemanager.ErrProfileNotFound) { + return nil, gstatus.Errorf(codes.NotFound, "profile %q not found", handle) + } + return nil, fmt.Errorf("resolve profile: %w", err) +} + +// switchProfileIfNeeded resolves the user-supplied handle, updates the +// active profile state if it differs from the current one, and returns +// the resolved profile so callers can include its ID in RPC responses. +func (s *Server) switchProfileIfNeeded(handle string, userName *string, activeProf *profilemanager.ActiveProfileState) (*profilemanager.Profile, error) { + if handle != profilemanager.DefaultProfileName && (userName == nil || *userName == "") { + log.Errorf("profile name is set to %s, but username is not provided", handle) + return nil, fmt.Errorf("profile name is set to %s, but username is not provided", handle) } var username string - if profileName != "default" { + if handle != profilemanager.DefaultProfileName { username = *userName } - if profileName != activeProf.Name || username != activeProf.Username { + resolved, err := s.resolveProfileHandle(handle, username) + if err != nil { + return nil, err + } + + if resolved.ID != activeProf.ID || username != activeProf.Username { if s.checkProfilesDisabled() { log.Errorf("profiles are disabled, you cannot use this feature without profiles enabled") - return gstatus.Errorf(codes.Unavailable, errProfilesDisabled) + return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled) } - log.Infof("switching to profile %s for user %s", profileName, username) + log.Infof("switching to profile %s (%s) for user %s", resolved.Name, resolved.ID, username) if err := s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: profileName, + ID: resolved.ID, Username: username, }); err != nil { log.Errorf("failed to set active profile state: %v", err) - return fmt.Errorf("failed to set active profile state: %w", err) + return nil, fmt.Errorf("failed to set active profile state: %w", err) } } - return nil + return resolved, nil } // SwitchProfile switches the active profile in the daemon. @@ -797,9 +1257,9 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi } if msg != nil && msg.ProfileName != nil { - if err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil { + if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil { log.Errorf("failed to switch profile: %v", err) - return nil, fmt.Errorf("failed to switch profile: %w", err) + return nil, err } } activeProf, err = s.profileManager.GetActiveProfileState() @@ -814,8 +1274,15 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi } s.config = config + s.localMetrics.Reconcile(config.LocalMetricsEnabled, config.LocalMetricsAddress) - return &proto.SwitchProfileResponse{}, nil + s.jwtCache.clear() + + if msg != nil && msg.ProfileName != nil { + s.publishProfileListChanged(*msg.ProfileName) + } + + return &proto.SwitchProfileResponse{Id: activeProf.ID.String()}, nil } // Down engine work in the daemon. @@ -826,28 +1293,45 @@ func (s *Server) Down(ctx context.Context, _ *proto.DownRequest) (*proto.DownRes if err := s.cleanupConnection(); err != nil { s.mutex.Unlock() - // todo review to update the status in case any type of error + if errors.Is(err, ErrServiceNotUp) { + log.Debugf("Down called while service not up: %v", err) + return nil, err + } log.Errorf("failed to shut down properly: %v", err) return nil, err } - state := internal.CtxGetState(s.rootCtx) - state.Set(internal.StatusIdle) - s.mutex.Unlock() // Wait for the connectWithRetryRuns goroutine to finish with a short timeout. // This prevents the goroutine from setting ErrResetConnection after Down() returns. - // The giveUpChan is closed at the end of connectWithRetryRuns. + // The giveUpChan is closed by the goroutine's deferred cleanup (see + // connectWithRetryRuns) on every exit path. A timeout here typically + // means the goroutine is still wedged inside a slow teardown step. if giveUpChan != nil { select { case <-giveUpChan: - log.Debugf("client goroutine finished successfully") + log.Debugf("client goroutine finished, giveUpChan closed") case <-time.After(5 * time.Second): log.Warnf("timeout waiting for client goroutine to finish, proceeding anyway") } } + // Set Idle only after the retry goroutine has exited (or timed out). + // Setting it earlier races with the goroutine's own Set(StatusConnecting) + // at the top of each retry attempt, which would leave the snapshot + // stuck at Connecting long after the user asked to disconnect. + internal.CtxGetState(s.rootCtx).Set(internal.StatusIdle) + + // Clear stale management/signal errors so the next Up() (typically for a + // different profile) starts with a clean status snapshot. Without this, + // a managementError left over from a LoginFailed cycle persists in the + // statusRecorder and appears in the new profile's initial + // SubscribeStatus snapshot, making the new profile look like it also + // failed to log in. + s.statusRecorder.MarkManagementDisconnected(nil) + s.statusRecorder.MarkSignalDisconnected(nil) + return &proto.DownResponse{}, nil } @@ -858,6 +1342,12 @@ func (s *Server) cleanupConnection() error { return ErrServiceNotUp } + // Daemon intent flips to "down" — all callers (Down RPC, + // Logout RPC handlers) tear down the connection because the user + // explicitly asked for it. MDM restart does NOT go through this + // path, so its clientRunning stays true. + s.clientRunning = false + // Capture the engine reference before cancelling the context. // After actCancel(), the connectWithRetryRuns goroutine wakes up // and sets connectClient.engine = nil, causing connectClient.Stop() @@ -873,9 +1363,13 @@ func (s *Server) cleanupConnection() error { return nil } + // TODO: consider calling s.connectClient.Stop() instead of engine.Stop(). + // actCancel() lets the run loop stop the engine too, so both stop it + // concurrently; ConnectClient.Stop cancels and waits for the run loop, + // making the run loop the sole owner of engine shutdown. if engine != nil { if err := engine.Stop(); err != nil { - return err + log.Errorf("failed to stop engine during cleanup: %v", err) } } @@ -899,32 +1393,66 @@ func (s *Server) Logout(ctx context.Context, msg *proto.LogoutRequest) (*proto.L } func (s *Server) handleProfileLogout(ctx context.Context, msg *proto.LogoutRequest) (*proto.LogoutResponse, error) { - if err := s.validateProfileOperation(*msg.ProfileName, true); err != nil { - return nil, err - } - if msg.Username == nil || *msg.Username == "" { return nil, gstatus.Errorf(codes.InvalidArgument, "username must be provided when profile name is specified") } username := *msg.Username - if err := s.logoutFromProfile(ctx, *msg.ProfileName, username); err != nil { - log.Errorf("failed to logout from profile %s: %v", *msg.ProfileName, err) + resolved, err := s.resolveProfileHandle(*msg.ProfileName, username) + if err != nil { + return nil, err + } + + activeProf, err := s.profileManager.GetActiveProfileState() + if err != nil { + return nil, gstatus.Errorf(codes.FailedPrecondition, "failed to get active profile state: %v", err) + } + + if err := s.validateProfileLogout(resolved.ID, isActiveProfile(activeProf, resolved.ID, username)); err != nil { + return nil, err + } + + if err := s.logoutFromProfile(ctx, resolved, username); err != nil { + log.Errorf("failed to logout from profile %s: %v", resolved.ID, err) + // A refused deregistration is already a status error carrying the reason + // and the command to run; rewrapping it as Internal would flatten both + // into a gRPC dump for the user. + if _, isStatus := gstatus.FromError(err); isStatus { + return nil, err + } return nil, gstatus.Errorf(codes.Internal, "logout: %v", err) } - activeProf, _ := s.profileManager.GetActiveProfileState() - if activeProf != nil && activeProf.Name == *msg.ProfileName { - if err := s.cleanupConnection(); err != nil && !errors.Is(err, ErrServiceNotUp) { - log.Errorf("failed to cleanup connection: %v", err) - } - state := internal.CtxGetState(s.rootCtx) - state.Set(internal.StatusNeedsLogin) - } + s.cleanupAfterProfileLogout(resolved.ID, username) return &proto.LogoutResponse{}, nil } +// cleanupAfterProfileLogout tears the connection down and asks for a new login +// when the profile that was just deregistered is the one the daemon is running. +// The active profile is read again here rather than reused from the pre-flight +// check: Login switches profiles under guardedConfigMu, which this path does not +// hold, so a login that landed meanwhile must not have its fresh connection +// dropped by a logout that targeted the profile it replaced. +func (s *Server) cleanupAfterProfileLogout(id profilemanager.ID, username string) { + activeProf, err := s.profileManager.GetActiveProfileState() + if err != nil { + log.Errorf("failed to get active profile state after logout from profile %s: %v", id, err) + return + } + + if !isActiveProfile(activeProf, id, username) { + return + } + + if err := s.cleanupConnection(); err != nil && !errors.Is(err, ErrServiceNotUp) { + log.Errorf("failed to cleanup connection: %v", err) + } + s.jwtCache.clear() + state := internal.CtxGetState(s.rootCtx) + state.Set(internal.StatusNeedsLogin) +} + func (s *Server) handleActiveProfileLogout(ctx context.Context) (*proto.LogoutResponse, error) { if s.config == nil { activeProf, err := s.profileManager.GetActiveProfileState() @@ -949,6 +1477,7 @@ func (s *Server) handleActiveProfileLogout(ctx context.Context) (*proto.LogoutRe log.Errorf("failed to cleanup connection: %v", err) return nil, err } + s.jwtCache.clear() state := internal.CtxGetState(s.rootCtx) state.Set(internal.StatusNeedsLogin) @@ -956,7 +1485,7 @@ func (s *Server) handleActiveProfileLogout(ctx context.Context) (*proto.LogoutRe return &proto.LogoutResponse{}, nil } -// GetConfig reads config file and returns Config and whether the config file already existed. Errors out if it does not exist +// getConfig reads config file and returns Config and whether the config file already existed. Errors out if it does not exist func (s *Server) getConfig(activeProf *profilemanager.ActiveProfileState) (*profilemanager.Config, bool, error) { cfgPath, err := activeProf.FilePath() if err != nil { @@ -973,60 +1502,71 @@ func (s *Server) getConfig(activeProf *profilemanager.ActiveProfileState) (*prof return nil, false, fmt.Errorf("failed to get config: %w", err) } + // Apply the daemon-owned MDM policy on top of the just-resolved + // Config. profilemanager's apply() initialises the policy to + // empty — the Loader lives outside Config, so this overlay step + // is driven externally here. + config.ApplyMDMPolicy(s.mdmLoader.Load()) + return config, configExisted, nil } -func (s *Server) canRemoveProfile(profileName string) error { - if profileName == profilemanager.DefaultProfileName { - return fmt.Errorf("remove profile with reserved name: %s", profilemanager.DefaultProfileName) +// validateProfileLogout gates a profile-addressed logout. Deregistering the +// profile the daemon already runs is what a plain `netbird logout` does, so the +// profiles-disabled kill switch must not block it. Logging out of any other +// profile is profile management and stays gated. +func (s *Server) validateProfileLogout(id profilemanager.ID, isActive bool) error { + if id == "" { + return gstatus.Errorf(codes.InvalidArgument, "profile name must be provided") } - activeProf, err := s.profileManager.GetActiveProfileState() - if err == nil && activeProf.Name == profileName { - return fmt.Errorf("remove active profile: %s", profileName) + if isActive { + return nil } - return nil -} - -func (s *Server) validateProfileOperation(profileName string, allowActiveProfile bool) error { if s.checkProfilesDisabled() { return gstatus.Errorf(codes.Unavailable, errProfilesDisabled) } - if profileName == "" { - return gstatus.Errorf(codes.InvalidArgument, "profile name must be provided") - } - - if !allowActiveProfile { - if err := s.canRemoveProfile(profileName); err != nil { - return gstatus.Errorf(codes.InvalidArgument, "%v", err) - } - } - return nil } -// logoutFromProfile logs out from a specific profile by loading its config and sending logout request -func (s *Server) logoutFromProfile(ctx context.Context, profileName, username string) error { +// isActiveProfile reports whether id is the profile the daemon runs for +// username. The username is part of the comparison because legacy profile IDs +// are display names, which two users can both hold; the default profile is +// shared by every user and carries no username. +func isActiveProfile(activeProf *profilemanager.ActiveProfileState, id profilemanager.ID, username string) bool { + if activeProf == nil || activeProf.ID != id { + return false + } + + return id == profilemanager.DefaultProfileName || activeProf.Username == username +} + +// logoutFromProfile deregisters profile, reusing the running config when +// profile is the one the daemon is connected with. The username takes part in +// that decision for the same reason it does in the logout gate: a legacy +// profile ID is a display name two users can share, and sending the running +// config for a namesake would deregister the active peer instead of the +// requested one. +func (s *Server) logoutFromProfile(ctx context.Context, profile *profilemanager.Profile, username string) error { activeProf, err := s.profileManager.GetActiveProfileState() - if err == nil && activeProf.Name == profileName && s.connectClient != nil { + if err == nil && isActiveProfile(activeProf, profile.ID, username) && s.connectClient != nil { return s.sendLogoutRequest(ctx) } - profileState := &profilemanager.ActiveProfileState{ - Name: profileName, - Username: username, - } - profilePath, err := profileState.FilePath() - if err != nil { - return fmt.Errorf("get profile path: %w", err) + cfgPath := profile.Path + if cfgPath == "" { + cfgPath = profilemanager.DefaultConfigPath } - config, err := profilemanager.GetConfig(profilePath) + config, err := profilemanager.GetConfig(cfgPath) if err != nil { - return fmt.Errorf("profile '%s' not found", profileName) + return fmt.Errorf("profile '%s' not found", profile.ID) } + // Honour any MDM-enforced ManagementURL when issuing the logout + // RPC: the user-stored value may have been overridden by policy. + config.ApplyMDMPolicy(s.mdmLoader.Load()) return s.sendLogoutRequestWithConfig(ctx, config) } @@ -1036,6 +1576,13 @@ func (s *Server) sendLogoutRequest(ctx context.Context) error { } func (s *Server) sendLogoutRequestWithConfig(ctx context.Context, config *profilemanager.Config) error { + // Privilege gate: deregistering frees this machine's key to be registered + // against another management server, which is only restricted while the SSH + // server makes that a privilege handover. + if err := requirePrivilegeForDeregistration(ctx, config); err != nil { + return err + } + key, err := wgtypes.ParseKey(config.PrivateKey) if err != nil { return fmt.Errorf("parse private key: %w", err) @@ -1052,7 +1599,19 @@ func (s *Server) sendLogoutRequestWithConfig(ctx context.Context, config *profil } }() - return mgmClient.Logout() + if err := mgmClient.Logout(); err != nil { + // The peer is already gone from the management server (e.g. deleted + // from the dashboard). The logout's goal — deregistering this peer — + // is therefore already satisfied, so treat NotFound as success rather + // than blocking the logout/profile-removal flow. + if logoutPeerGone(err) { + log.Infof("peer already removed from management server, treating logout as successful") + return nil + } + return err + } + + return nil } // Status returns the daemon status @@ -1061,10 +1620,14 @@ func (s *Server) Status( msg *proto.StatusRequest, ) (*proto.StatusResponse, error) { s.mutex.Lock() - clientRunning := s.clientRunning + // Only wait if the retry-loop goroutine is alive and making + // progress. clientRunning=true with connectionGoroutineRunning=false means the + // backoff has given up — there is nothing to wait for; let the + // caller observe the failed status directly. + alive := s.connectionGoroutineRunning() s.mutex.Unlock() - if msg.WaitForReady != nil && *msg.WaitForReady && clientRunning { + if msg.WaitForReady != nil && *msg.WaitForReady && alive { state := internal.CtxGetState(s.rootCtx) status, err := state.Status() if err != nil { @@ -1101,9 +1664,24 @@ func (s *Server) Status( } } - status, err := internal.CtxGetState(s.rootCtx).Status() + return s.buildStatusResponse(ctx, msg) +} + +// buildStatusResponse composes a StatusResponse from the current daemon +// state. Shared between the unary Status RPC and the SubscribeStatus +// stream so both paths return identical snapshots. ctx scopes the health +// probe runProbes may trigger — a caller that disconnects cancels it. +func (s *Server) buildStatusResponse(ctx context.Context, msg *proto.StatusRequest) (*proto.StatusResponse, error) { + state := internal.CtxGetState(s.rootCtx) + status, err := state.Status() if err != nil { - return nil, err + // state.Status() blanks the status when err is set (e.g. management + // retry loop wrapped a connection error). The underlying status is + // still meaningful and the failure is already surfaced via + // FullStatus.ManagementState.Error, so don't propagate err — that + // would tear down the SubscribeStatus stream and cause the UI to + // mark the daemon as unreachable on every retry. + status = state.CurrentStatus() } if status == internal.StatusNeedsLogin && s.isSessionActive.Load() { @@ -1114,15 +1692,20 @@ func (s *Server) Status( statusResponse := proto.StatusResponse{Status: string(status), DaemonVersion: version.NetbirdVersion()} + if deadline := s.statusRecorder.GetSessionExpiresAt(); !deadline.IsZero() { + statusResponse.SessionExpiresAt = timestamppb.New(deadline) + } + s.statusRecorder.UpdateManagementAddress(s.config.ManagementURL.String()) s.statusRecorder.UpdateRosenpass(s.config.RosenpassEnabled, s.config.RosenpassPermissive) if msg.GetFullPeerStatus { - s.runProbes(msg.ShouldRunProbes) + s.runProbes(ctx, msg.ShouldRunProbes) fullStatus := s.statusRecorder.GetFullStatus() pbFullStatus := fullStatus.ToProto() pbFullStatus.Events = s.statusRecorder.GetEventHistory() pbFullStatus.SshServerState = s.getSSHServerState() + pbFullStatus.NetworksRevision = s.statusRecorder.GetNetworksRevision() statusResponse.FullStatus = pbFullStatus } @@ -1235,6 +1818,20 @@ func (s *Server) getJWTCacheTTL() time.Duration { return ttl } +// cachedJWT returns the cached SSH JWT to the identity that obtained it, and a +// miss on a control channel that carries no caller identity. +func (s *Server) cachedJWT(ctx context.Context) (string, bool) { + caller, ok := ipcauth.CallerIdentity(ctx) + if !ok { + // Expected and handled on a control channel with no peer identity: the + // caller re-authenticates. daemonServerOptions warns about it once at + // startup, so this stays out of the per-request log. + log.Debug("not serving the cached SSH JWT: the caller's identity cannot be verified on this control channel") + return "", false + } + return s.jwtCache.get(caller) +} + // RequestJWTAuth initiates JWT authentication flow for SSH func (s *Server) RequestJWTAuth( ctx context.Context, @@ -1244,8 +1841,14 @@ func (s *Server) RequestJWTAuth( return nil, ctx.Err() } + // The generation is read here, with the config and under the same lock, not + // where the flow is stored below: RequestAuthInfo talks to the IdP in + // between, and a switch or a logout during that call would otherwise be + // read as the generation this flow belongs to. SwitchProfile holds + // s.mutex across its own clear(), so the pair cannot be torn. s.mutex.Lock() config := s.config + cacheGeneration := s.jwtCache.currentGeneration() s.mutex.Unlock() if config == nil { @@ -1254,7 +1857,7 @@ func (s *Server) RequestJWTAuth( jwtCacheTTL := s.getJWTCacheTTL() if jwtCacheTTL > 0 { - if cachedToken, found := s.jwtCache.get(); found { + if cachedToken, found := s.cachedJWT(ctx); found { log.Debugf("JWT token found in cache, returning cached token for SSH authentication") return &proto.RequestJWTAuthResponse{ @@ -1273,8 +1876,8 @@ func (s *Server) RequestJWTAuth( hint = profilemanager.GetLoginHint() } - isDesktop := isUnixRunningDesktop() - oAuthFlow, err := auth.NewOAuthFlow(ctx, config, isDesktop, false, hint) + // the daemon has no graphical session of its own, only the caller can answer this + oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.GetHasGraphicalSession(), false, hint) if err != nil { return nil, gstatus.Errorf(codes.Internal, "failed to create OAuth flow: %v", err) } @@ -1288,6 +1891,7 @@ func (s *Server) RequestJWTAuth( s.oauthAuthFlow.flow = oAuthFlow s.oauthAuthFlow.info = authInfo s.oauthAuthFlow.expiresAt = time.Now().Add(time.Duration(authInfo.ExpiresIn) * time.Second) + s.oauthAuthFlow.cacheGeneration = cacheGeneration s.mutex.Unlock() return &proto.RequestJWTAuthResponse{ @@ -1312,6 +1916,10 @@ func (s *Server) WaitJWTToken( s.mutex.Lock() oAuthFlow := s.oauthAuthFlow.flow authInfo := s.oauthAuthFlow.info + // Recorded when the flow was created, not read here: the flow survives a + // profile switch, and everything from RequestJWTAuth to the IdP answering + // has to count as the same session for the cache. + generation := s.oauthAuthFlow.cacheGeneration s.mutex.Unlock() if oAuthFlow == nil || authInfo.DeviceCode != req.DeviceCode { @@ -1326,11 +1934,17 @@ func (s *Server) WaitJWTToken( token := tokenInfo.GetTokenToUse() jwtCacheTTL := s.getJWTCacheTTL() - if jwtCacheTTL > 0 { - s.jwtCache.store(token, jwtCacheTTL) - log.Debugf("JWT token cached for SSH authentication, TTL: %v", jwtCacheTTL) - } else { + switch caller, ok := ipcauth.CallerIdentity(ctx); { + case jwtCacheTTL <= 0: log.Debug("JWT caching disabled, not storing token") + case !ok: + log.Debug("not caching the SSH JWT: the caller's identity cannot be verified on this control channel") + default: + if s.jwtCache.store(token, caller, jwtCacheTTL, generation) { + log.Debugf("JWT token cached for SSH authentication, TTL: %v", jwtCacheTTL) + } else { + log.Debug("not caching the SSH JWT: the session it was obtained under ended while the IdP was polled") + } } s.mutex.Lock() @@ -1343,6 +1957,154 @@ func (s *Server) WaitJWTToken( }, nil } +// RequestExtendAuthSession initiates the SSO session-extension flow and +// returns the verification URI the UI should open. The flow state is held +// in s.extendAuthSessionFlow until WaitExtendAuthSession resolves it. +func (s *Server) RequestExtendAuthSession( + ctx context.Context, + msg *proto.RequestExtendAuthSessionRequest, +) (*proto.RequestExtendAuthSessionResponse, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + + s.mutex.Lock() + config := s.config + connectClient := s.connectClient + s.mutex.Unlock() + + if config == nil { + return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not configured") + } + if connectClient == nil { + return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not running") + } + if connectClient.Engine() == nil { + return nil, gstatus.Errorf(codes.FailedPrecondition, "session can no longer be extended, log in again to reconnect") + } + + hint := "" + if msg.Hint != nil { + hint = *msg.Hint + } + if hint == "" { + hint = profilemanager.GetLoginHint() + } + + // the daemon has no graphical session of its own, only the caller can answer this + oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.GetHasGraphicalSession(), false, hint) + if err != nil { + return nil, gstatus.Errorf(codes.Internal, "failed to create OAuth flow: %v", err) + } + + authInfo, err := oAuthFlow.RequestAuthInfo(ctx) + if err != nil { + return nil, gstatus.Errorf(codes.Internal, "failed to request auth info: %v", err) + } + + s.extendAuthSessionFlow.Set(oAuthFlow, authInfo) + + return &proto.RequestExtendAuthSessionResponse{ + VerificationURI: authInfo.VerificationURI, + VerificationURIComplete: authInfo.VerificationURIComplete, + UserCode: authInfo.UserCode, + DeviceCode: authInfo.DeviceCode, + ExpiresIn: int64(authInfo.ExpiresIn), + }, nil +} + +// WaitExtendAuthSession blocks until the user completes the SSO step +// initiated by RequestExtendAuthSession, then forwards the resulting JWT +// to the management server's ExtendAuthSession RPC. The returned deadline +// is also applied locally via the engine so SubscribeStatus consumers see +// the refreshed state. +func (s *Server) WaitExtendAuthSession( + ctx context.Context, + req *proto.WaitExtendAuthSessionRequest, +) (*proto.WaitExtendAuthSessionResponse, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + + oAuthFlow, authInfo, ok := s.extendAuthSessionFlow.Get() + + s.mutex.Lock() + connectClient := s.connectClient + s.mutex.Unlock() + + if !ok || authInfo.DeviceCode != req.DeviceCode { + return nil, gstatus.Errorf(codes.InvalidArgument, "invalid device code or no active extend-session flow") + } + + // Preempt a previous WaitExtendAuthSession (e.g. when the tray + // notification and the about-to-expire dialog both start a flow on + // the same deadline). The older waiter exits via context.Canceled; + // the new one takes over the IdP poll. + s.extendAuthSessionFlow.CancelWait() + + waitCtx, cancel := context.WithCancel(ctx) + defer cancel() + s.extendAuthSessionFlow.SetWaitCancel(cancel) + + tokenInfo, err := oAuthFlow.WaitToken(waitCtx, authInfo) + if err != nil { + if errors.Is(err, context.Canceled) { + return nil, gstatus.Errorf(codes.Canceled, "extend-session flow preempted") + } + return nil, gstatus.Errorf(codes.Internal, "failed to obtain JWT token: %v", err) + } + + // Clear pending flow before talking to mgm so a retry can re-initiate. + s.extendAuthSessionFlow.Clear() + + if connectClient == nil { + return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not running") + } + engine := connectClient.Engine() + if engine == nil { + return nil, gstatus.Errorf(codes.FailedPrecondition, "engine is not initialised") + } + + deadline, err := engine.ExtendAuthSession(ctx, tokenInfo.GetTokenToUse()) + if err != nil { + // Log the full wrapped chain, but return only the innermost gRPC + // status (code + clean desc) so the UI shows the root cause, not + // the daemon's wrapping layers. + log.Errorf("management ExtendAuthSession failed: %v", err) + if st := innermostStatus(err); st != nil { + return nil, gstatus.Error(st.Code(), st.Message()) + } + return nil, gstatus.Errorf(codes.Internal, "%v", err) + } + + resp := &proto.WaitExtendAuthSessionResponse{} + if !deadline.IsZero() { + resp.SessionExpiresAt = timestamppb.New(deadline) + } + return resp, nil +} + +// DismissSessionWarning forwards the user's "Dismiss" click on the +// T-WarningLead notification down to the engine's sessionWatcher so the +// T-FinalWarningLead fallback is suppressed for the current deadline. +// Best-effort: when the client/engine is not yet running the call is a +// successful no-op (the watcher has no deadline to dismiss anyway). +func (s *Server) DismissSessionWarning( + _ context.Context, + _ *proto.DismissSessionWarningRequest, +) (*proto.DismissSessionWarningResponse, error) { + s.mutex.Lock() + connectClient := s.connectClient + s.mutex.Unlock() + if connectClient == nil { + return &proto.DismissSessionWarningResponse{}, nil + } + if engine := connectClient.Engine(); engine != nil { + engine.DismissSessionWarning() + } + return &proto.DismissSessionWarningResponse{}, 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() @@ -1402,14 +2164,7 @@ func (s *Server) ExposeService(req *proto.ExposeServiceRequest, srv proto.Daemon return nil } -func isUnixRunningDesktop() bool { - if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" { - return false - } - return os.Getenv("DESKTOP_SESSION") != "" || os.Getenv("XDG_CURRENT_DESKTOP") != "" -} - -func (s *Server) runProbes(waitForProbeResult bool) { +func (s *Server) runProbes(ctx context.Context, waitForProbeResult bool) { if s.connectClient == nil { return } @@ -1419,15 +2174,7 @@ func (s *Server) runProbes(waitForProbeResult bool) { return } - if time.Since(s.lastProbe) > probeThreshold { - if engine.RunHealthProbes(waitForProbeResult) { - s.lastProbe = time.Now() - } - } else { - if err := s.statusRecorder.RefreshWireGuardStats(); err != nil { - log.Debugf("failed to refresh WireGuard stats: %v", err) - } - } + s.probeThrottle.Run(ctx, engine, s.statusRecorder, waitForProbeResult) } // GetConfig of the daemon. @@ -1439,15 +2186,14 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p return nil, ctx.Err() } - prof := profilemanager.ActiveProfileState{ - Name: req.ProfileName, - Username: req.Username, - } - - cfgPath, err := prof.FilePath() + resolved, err := s.resolveProfileHandle(req.ProfileName, req.Username) if err != nil { - log.Errorf("failed to get active profile file path: %v", err) - return nil, fmt.Errorf("failed to get active profile file path: %w", err) + log.Errorf("failed to resolve profile %q: %v", req.ProfileName, err) + return nil, err + } + cfgPath := resolved.Path + if cfgPath == "" { + cfgPath = profilemanager.DefaultConfigPath } cfg, err := profilemanager.GetConfig(cfgPath) @@ -1455,6 +2201,11 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p log.Errorf("failed to get active profile config: %v", err) return nil, fmt.Errorf("failed to get active profile config: %w", err) } + // Overlay the active MDM policy so the response's MDMManagedFields + // list reflects what the GUI / CLI must render as read-only. + // profilemanager.GetConfig itself returns a Config without the + // overlay (Loader lives outside profilemanager). + cfg.ApplyMDMPolicy(s.mdmLoader.Load()) managementURL := cfg.ManagementURL adminURL := cfg.AdminURL @@ -1476,6 +2227,7 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p disableDNS := cfg.DisableDNS disableClientRoutes := cfg.DisableClientRoutes disableServerRoutes := cfg.DisableServerRoutes + disableIPv6 := cfg.DisableIPv6 blockLANAccess := cfg.BlockLANAccess enableSSHRoot := false @@ -1517,15 +2269,16 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p Mtu: int64(cfg.MTU), DisableAutoConnect: cfg.DisableAutoConnect, ServerSSHAllowed: *cfg.ServerSSHAllowed, + RemoteJobsAllowed: util.ReturnBoolWithDefaultFalse(cfg.RemoteJobsAllowed), RosenpassEnabled: cfg.RosenpassEnabled, RosenpassPermissive: cfg.RosenpassPermissive, - LazyConnectionEnabled: cfg.LazyConnectionEnabled, BlockInbound: cfg.BlockInbound, DisableNotifications: disableNotifications, NetworkMonitor: networkMonitor, DisableDns: disableDNS, DisableClientRoutes: disableClientRoutes, DisableServerRoutes: disableServerRoutes, + DisableIpv6: disableIPv6, BlockLanAccess: blockLANAccess, EnableSSHRoot: enableSSHRoot, EnableSSHSFTP: enableSSHSFTP, @@ -1533,6 +2286,7 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p EnableSSHRemotePortForwarding: enableSSHRemotePortForwarding, DisableSSHAuth: disableSSHAuth, SshJWTCacheTTL: sshJWTCacheTTL, + MDMManagedFields: cfg.Policy().ManagedKeys(), }, nil } @@ -1549,12 +2303,43 @@ func (s *Server) AddProfile(ctx context.Context, msg *proto.AddProfileRequest) ( return nil, gstatus.Errorf(codes.InvalidArgument, "profile name and username must be provided") } - if err := s.profileManager.AddProfile(msg.ProfileName, msg.Username); err != nil { + created, err := s.profileManager.AddProfile(msg.ProfileName, msg.Username) + if err != nil { log.Errorf("failed to create profile: %v", err) return nil, fmt.Errorf("failed to create profile: %w", err) } - return &proto.AddProfileResponse{}, nil + s.publishProfileListChanged(msg.ProfileName) + + return &proto.AddProfileResponse{Id: created.ID.String()}, nil +} + +func (s *Server) RenameProfile(ctx context.Context, msg *proto.RenameProfileRequest) (*proto.RenameProfileResponse, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + + if s.checkProfilesDisabled() { + return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled) + } + + if msg.Handle == "" || msg.Username == "" || msg.NewProfileName == "" { + return nil, gstatus.Errorf(codes.InvalidArgument, "profile name, username and new profile name must be provided") + } + + resolved, err := s.resolveProfileHandle(msg.Handle, msg.Username) + if err != nil { + return nil, err + } + + err = s.profileManager.RenameProfile(resolved.ID, msg.Username, msg.NewProfileName) + if err != nil { + log.Errorf("failed to rename profile: %v", err) + return nil, fmt.Errorf("failed to rename profile: %w", err) + } + + s.publishProfileListChanged(msg.NewProfileName) + + return &proto.RenameProfileResponse{OldProfileName: resolved.Name}, nil } // RemoveProfile removes a profile from the daemon. @@ -1562,20 +2347,74 @@ func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequ s.mutex.Lock() defer s.mutex.Unlock() - if err := s.validateProfileOperation(msg.ProfileName, false); err != nil { + if s.checkProfilesDisabled() { + return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled) + } + + if msg.ProfileName == "" { + return nil, gstatus.Errorf(codes.InvalidArgument, "profile name must be provided") + } + + resolved, err := s.resolveProfileHandle(msg.ProfileName, msg.Username) + if err != nil { return nil, err } - if err := s.logoutFromProfile(ctx, msg.ProfileName, msg.Username); err != nil { - log.Warnf("failed to logout from profile %s before removal: %v", msg.ProfileName, err) + if err := s.logoutFromProfile(ctx, resolved, msg.Username); err != nil { + // Deregistration is best-effort here: the local profile is removed + // either way, so an unprivileged caller leaves the peer registered on + // the management server rather than being blocked from removing it. + log.Warnf("removing profile %s locally without deregistering it: %v", resolved.ID, err) } - if err := s.profileManager.RemoveProfile(msg.ProfileName, msg.Username); err != nil { + if err := s.profileManager.RemoveProfile(resolved.ID, msg.Username); err != nil { log.Errorf("failed to remove profile: %v", err) return nil, fmt.Errorf("failed to remove profile: %w", err) } - return &proto.RemoveProfileResponse{}, nil + s.publishProfileListChanged(msg.ProfileName) + + return &proto.RemoveProfileResponse{Id: resolved.ID.String()}, nil +} + +// publishProfileListChanged nudges the desktop UI to refresh its profile list +// after a CLI-driven add/remove. The daemon exposes no dedicated +// profile-changed RPC event, and a profile add/remove doesn't move the +// connection status, so the UI's SubscribeStatus path never fires for it (and +// the tray's status-string guard would swallow it anyway). Instead we publish +// a marked INFO/SYSTEM event over SubscribeEvents: the UI's dispatchSystemEvent +// recognises the metadata "kind" marker and translates it into its internal +// profile-changed signal that both the tray menu and the React profile views +// already subscribe to (see proto.MetadataKindProfileListChanged, recognised in +// client/ui/services/daemon_feed.go). userMessage is intentionally empty so this +// stays a silent refresh signal rather than a user-facing notification. +func (s *Server) publishProfileListChanged(profileName string) { + s.statusRecorder.PublishEvent( + proto.SystemEvent_INFO, + proto.SystemEvent_SYSTEM, + "Profile list changed", + "", + map[string]string{proto.MetadataKindKey: proto.MetadataKindProfileListChanged, proto.MetadataProfileKey: profileName}, + ) +} + +// publishLogLevelChanged signals the desktop UI that the daemon log level +// changed, so it can attach/detach its rotated gui-client.log. Like +// publishProfileListChanged, this rides the SubscribeEvents stream as a marked +// INFO/SYSTEM event (kind "log-level-changed", level the lowercase logrus +// name); the UI's dispatchSystemEvent recognises the marker and routes it to +// the logging toggle instead of an OS toast (userMessage is empty so it stays +// a silent control signal). The "level" value matches log.Level.String() +// (e.g. "debug", "info") so the UI can parse it directly. See +// proto.MetadataKindLogLevelChanged, recognised in client/ui/services/daemon_feed.go. +func (s *Server) publishLogLevelChanged(level string) { + s.statusRecorder.PublishEvent( + proto.SystemEvent_INFO, + proto.SystemEvent_SYSTEM, + "Log level changed", + "", + map[string]string{proto.MetadataKindKey: proto.MetadataKindLogLevelChanged, proto.MetadataLevelKey: level}, + ) } // ListProfiles lists all profiles in the daemon. @@ -1598,6 +2437,7 @@ func (s *Server) ListProfiles(ctx context.Context, msg *proto.ListProfilesReques } for i, profile := range profiles { response.Profiles[i] = &proto.Profile{ + Id: profile.ID.String(), Name: profile.Name, IsActive: profile.IsActive, } @@ -1606,7 +2446,9 @@ func (s *Server) ListProfiles(ctx context.Context, msg *proto.ListProfilesReques return response, nil } -// GetActiveProfile returns the active profile in the daemon. +// GetActiveProfile returns the active profile in the daemon. The ProfileName +// field carries the display name for backwards compatibility with UI clients, +// new callers should prefer Id. func (s *Server) GetActiveProfile(ctx context.Context, msg *proto.GetActiveProfileRequest) (*proto.GetActiveProfileResponse, error) { s.mutex.Lock() defer s.mutex.Unlock() @@ -1617,9 +2459,23 @@ func (s *Server) GetActiveProfile(ctx context.Context, msg *proto.GetActiveProfi return nil, fmt.Errorf("failed to get active profile state: %w", err) } + // Fallback to legacy name == ID + displayName := activeProfile.ID.String() + if activeProfile.ID != profilemanager.DefaultProfileName { + if profiles, lerr := s.profileManager.ListProfiles(activeProfile.Username); lerr == nil { + for _, p := range profiles { + if p.ID == activeProfile.ID { + displayName = p.Name + break + } + } + } + } + return &proto.GetActiveProfileResponse{ - ProfileName: activeProfile.Name, + ProfileName: displayName, Username: activeProfile.Username, + Id: activeProfile.ID.String(), }, nil } @@ -1631,12 +2487,34 @@ func (s *Server) GetFeatures(ctx context.Context, msg *proto.GetFeaturesRequest) features := &proto.GetFeaturesResponse{ DisableProfiles: s.checkProfilesDisabled(), DisableUpdateSettings: s.checkUpdateSettingsDisabled(), - DisableNetworks: s.networksDisabled, + DisableNetworks: s.checkNetworksDisabled(), + DisableAdvancedView: s.checkDisableAdvancedView(), } return features, nil } +// WailsUIReady is a no-op the Wails UI probes at startup; merely answering it +// (rather than returning Unimplemented) tells the UI this daemon is new enough. +func (s *Server) WailsUIReady(context.Context, *proto.WailsUIReadyRequest) (*proto.WailsUIReadyResponse, error) { + return &proto.WailsUIReadyResponse{}, nil +} + +// checkDisableAdvancedView reports the MDM-policy directive for the +// upcoming UI's advanced-view section. Tristate: returns nil when no +// MDM directive is set so the UI applies its own default; returns +// &true / &false when MDM explicitly enforces. No CLI flag backs +// this feature — MDM is the sole source. +func (s *Server) checkDisableAdvancedView() *bool { + if s.config == nil { + return nil + } + if v, ok := s.config.Policy().GetBool(mdm.KeyDisableAdvancedView); ok { + return &v + } + return nil +} + func (s *Server) connect(ctx context.Context, config *profilemanager.Config, statusRecorder *peer.Status, runningChan chan struct{}) error { log.Tracef("running client connection") client := internal.NewConnectClient(ctx, config, statusRecorder) @@ -1653,22 +2531,46 @@ func (s *Server) connect(ctx context.Context, config *profilemanager.Config, sta return nil } +// MDM authority: when the platform-native MDM source sets a kill switch +// key (regardless of true/false value), that value wins. The CLI flag +// supplied at service install time is the fallback used only when the +// MDM source is silent on the key. This honors the "MDM decides +// everything" semantic agreed for NET-1214 — an admin pushing +// disableX=false via MDM explicitly re-enables the feature even on a +// box installed with --disable-X. func (s *Server) checkProfilesDisabled() bool { - // Check if the environment variable is set to disable profiles - if s.profilesDisabled { - return true + if s.config != nil { + if v, ok := s.config.Policy().GetBool(mdm.KeyDisableProfiles); ok { + return v + } } + return s.profilesDisabled +} - return false +// checkNetworksDisabled reports whether the networks/exit-node feature +// is disabled on this daemon instance. Resolved MDM-first: when the +// active policy declares mdm.KeyDisableNetworks the policy value wins +// (regardless of true/false), so an admin can re-enable the feature +// via MDM even on a host that was installed with --disable-networks. +// Falls back to the s.networksDisabled CLI flag when the policy is +// silent on the key. Mirrors checkProfilesDisabled and +// checkUpdateSettingsDisabled. +func (s *Server) checkNetworksDisabled() bool { + if s.config != nil { + if v, ok := s.config.Policy().GetBool(mdm.KeyDisableNetworks); ok { + return v + } + } + return s.networksDisabled } func (s *Server) checkUpdateSettingsDisabled() bool { - // Check if the environment variable is set to disable profiles - if s.updateSettingsDisabled { - return true + if s.config != nil { + if v, ok := s.config.Policy().GetBool(mdm.KeyDisableUpdateSettings); ok { + return v + } } - - return false + return s.updateSettingsDisabled } func (s *Server) startUpdateManagerForGUI() { @@ -1756,3 +2658,117 @@ func sendTerminalNotification() error { return wallCmd.Wait() } + +// persistLoginOverrides writes management URL and pre-shared key from a LoginRequest to the +// active profile config so that subsequent reads pick them up. Empty/nil values are ignored. +// afterLoginPreCheck is a seam for tests to run a concurrent config change +// between Login's first privilege check and the authoritative one. +var afterLoginPreCheck func() + +// authorizeAndPrepareLogin makes the authoritative privilege decision for a login +// and, when it passes, carries out every state change that decision authorizes: +// cancelling an login already in progress, switching to the requested profile, and +// persisting the config overrides the request carries. +// +// All of it happens under guardedConfigMu, which SetConfig also holds across its +// own check and write. Login's earlier check refuses the ordinary case before any +// of this is reached; this one exists because that check is not synchronized +// against a concurrent privileged request that enables the SSH server, and a +// caller refused here must not have cancelled or switched anything either. +func (s *Server) authorizeAndPrepareLogin(callerCtx context.Context, msg *proto.LoginRequest, activeProf *profilemanager.ActiveProfileState) (context.Context, *profilemanager.ActiveProfileState, error) { + if afterLoginPreCheck != nil { + afterLoginPreCheck() + } + + s.guardedConfigMu.Lock() + defer s.guardedConfigMu.Unlock() + + stored, err := s.storedLoginConfig(activeProf, msg) + if err != nil { + return nil, nil, err + } + if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromLogin(msg)); err != nil { + return nil, nil, err + } + + s.mutex.Lock() + if s.actCancel != nil { + s.actCancel() + } + ctx, cancel := context.WithCancel(callerCtx) + if md, ok := metadata.FromIncomingContext(callerCtx); ok { + ctx = metadata.NewOutgoingContext(ctx, md) + } + s.actCancel = cancel + s.mutex.Unlock() + + if err := RestoreResidualState(s.rootCtx, s.profileManager.GetStatePath()); err != nil { + log.Warnf(errRestoreResidualState, err) + } + + if msg.ProfileName != nil { + if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil { + return nil, nil, fmt.Errorf("switch profile: %w", err) + } + } + + activeProf, err = s.profileManager.GetActiveProfileState() + if err != nil { + return nil, nil, fmt.Errorf("active profile state: %w", err) + } + + if err := persistLoginOverrides(activeProf, msg.ManagementUrl, msg.OptionalPreSharedKey); err != nil { + return nil, nil, fmt.Errorf("persist login overrides: %w", err) + } + + return ctx, activeProf, nil +} + +func persistLoginOverrides(activeProf *profilemanager.ActiveProfileState, managementURL string, preSharedKey *string) error { + if preSharedKey != nil && *preSharedKey == "" { + preSharedKey = nil + } + if managementURL == "" && preSharedKey == nil { + return nil + } + + cfgPath, err := activeProf.FilePath() + if err != nil { + return fmt.Errorf("active profile file path: %w", err) + } + + input := profilemanager.ConfigInput{ + ConfigPath: cfgPath, + ManagementURL: managementURL, + PreSharedKey: preSharedKey, + } + if _, err := profilemanager.UpdateOrCreateConfig(input); err != nil { + return fmt.Errorf("update config: %w", err) + } + return nil +} + +// logoutPeerGone reports whether a management Logout failed because the peer +// no longer exists server-side (gRPC NotFound), walking the wrap chain since +// the client wraps the gRPC status with fmt.Errorf. +func logoutPeerGone(err error) bool { + for e := err; e != nil; e = errors.Unwrap(e) { + if s, ok := gstatus.FromError(e); ok && s.Code() == codes.NotFound { + return true + } + } + return false +} + +// innermostStatus walks the wrap chain and returns the deepest gRPC status, +// or nil when none is present. gstatus.FromError does not unwrap, so a status +// wrapped with fmt.Errorf %w would otherwise be missed. +func innermostStatus(err error) *gstatus.Status { + var found *gstatus.Status + for e := err; e != nil; e = errors.Unwrap(e) { + if s, ok := gstatus.FromError(e); ok { + found = s + } + } + return found +} diff --git a/client/server/server_connect_test.go b/client/server/server_connect_test.go index faea7da39..dc191a44f 100644 --- a/client/server/server_connect_test.go +++ b/client/server/server_connect_test.go @@ -18,6 +18,10 @@ func newTestServer() *Server { return &Server{ rootCtx: context.Background(), statusRecorder: peer.NewRecorder(""), + // New always populates the SSH JWT cache and the logout and + // profile-switch paths call into it unconditionally, so a Server + // assembled field by field has to populate it too. + jwtCache: newJWTCache(), } } @@ -101,6 +105,7 @@ func TestCleanupConnection_ClearsConnectClient(t *testing.T) { require.NoError(t, err) assert.Nil(t, s.connectClient, "connectClient should be nil after cleanup") + assert.False(t, s.clientRunning, "clientRunning should be cleared after cleanup (intent = down)") } // TestCleanState_NilConnectClient validates that CleanState doesn't panic @@ -144,17 +149,20 @@ func TestDownThenUp_StaleRunningChan(t *testing.T) { _, cancel := context.WithCancel(context.Background()) s.actCancel = cancel - // Simulate Down(): cleanupConnection sets connectClient = nil + // Simulate Down(): cleanupConnection sets connectClient = nil and + // flips clientRunning to false (intent = down). The connectionGoroutineRunning state + // remains independent of intent — derived from clientGiveUpChan. 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) + // After cleanup: connectClient is nil, clientRunning is false (intent + // cleared by cleanupConnection), connectionGoroutineRunning may still be true + // (goroutine teardown is independent of the intent flag). 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") + assert.False(t, s.clientRunning, "clientRunning should be cleared by cleanupConnection (intent = down)") s.mutex.Unlock() // waitForUp() returns immediately due to stale closed clientRunningChan diff --git a/client/server/server_jwt_test.go b/client/server/server_jwt_test.go new file mode 100644 index 000000000..3fec5598a --- /dev/null +++ b/client/server/server_jwt_test.go @@ -0,0 +1,188 @@ +package server + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/auth" + "github.com/netbirdio/netbird/client/internal/localmetrics" + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/proto" +) + +// These cover the RPC side of the cache: the cache itself is exercised in +// jwt_cache_test.go, but a correct cache buys nothing if the handlers around it +// consult the wrong identity or forget to clear it. + +func TestCachedJWT_ServesTheOwner(t *testing.T) { + s := newTestServer() + owner := unprivilegedIdentity() + s.jwtCache.store("token", owner, testTTL, s.jwtCache.currentGeneration()) + + got, found := s.cachedJWT(ctxWithIdentity(owner)) + + require.True(t, found, "the identity that obtained the token must get it back") + assert.Equal(t, "token", got) +} + +func TestCachedJWT_RefusesAnotherCaller(t *testing.T) { + s := newTestServer() + s.jwtCache.store("token", unprivilegedIdentity(), testTTL, s.jwtCache.currentGeneration()) + + got, found := s.cachedJWT(ctxWithIdentity(privilegedIdentity())) + + assert.False(t, found, "a caller that did not obtain the token must get a miss") + assert.Empty(t, got) +} + +// A control channel that carries no caller identity — a TCP daemon socket, or a +// platform with no peer-credential primitive — cannot tell one local user from +// another, so cachedJWT must fail closed there. +func TestCachedJWT_WithoutCallerIdentity(t *testing.T) { + s := newTestServer() + s.jwtCache.store("token", unprivilegedIdentity(), testTTL, s.jwtCache.currentGeneration()) + + got, found := s.cachedJWT(context.Background()) + + assert.False(t, found) + assert.Empty(t, got) +} + +// profileFixture points the profile globals at a temp dir holding a single +// default profile, which is the one ActiveProfileState.FilePath resolves +// without consulting the current OS user. +func profileFixture(t *testing.T) string { + t.Helper() + + dir := t.TempDir() + defaultConfig := filepath.Join(dir, "default.json") + require.NoError(t, os.WriteFile(defaultConfig, []byte("{}"), 0o600)) + + origDir := profilemanager.DefaultConfigPathDir + origDefault := profilemanager.DefaultConfigPath + origState := profilemanager.ActiveProfileStatePath + origOverride := profilemanager.ConfigDirOverride + + profilemanager.DefaultConfigPathDir = dir + profilemanager.DefaultConfigPath = defaultConfig + profilemanager.ActiveProfileStatePath = filepath.Join(dir, "active_profile.json") + profilemanager.ConfigDirOverride = dir + + t.Cleanup(func() { + profilemanager.DefaultConfigPathDir = origDir + profilemanager.DefaultConfigPath = origDefault + profilemanager.ActiveProfileStatePath = origState + profilemanager.ConfigDirOverride = origOverride + }) + + return defaultConfig +} + +// A profile carries its own NetBird account, so a token obtained under the +// previous one must not survive the switch even for the local user who +// obtained it. +func TestSwitchProfile_ClearsJWTCache(t *testing.T) { + defaultConfig := profileFixture(t) + + // localmetrics.NewManager runs until its context is done, so the manager + // must not outlive the test. + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + s := newTestServer() + s.profileManager = profilemanager.NewServiceManager(defaultConfig) + s.localMetrics = localmetrics.NewManager(ctx, s.statusRecorder, nil) + + // A second profile to move to, so the request goes through + // switchProfileIfNeeded rather than the no-op path a nil request takes. + const target = "second" + username := "tester" + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, target+".json"), + ManagementURL: "https://api.netbird.io:443", + }) + require.NoError(t, err) + + owner := unprivilegedIdentity() + s.jwtCache.store("token", owner, testTTL, s.jwtCache.currentGeneration()) + + name := target + _, err = s.SwitchProfile(ctx, &proto.SwitchProfileRequest{ProfileName: &name, Username: &username}) + require.NoError(t, err) + + active, err := s.profileManager.GetActiveProfileState() + require.NoError(t, err) + require.Equal(t, profilemanager.ID(target), active.ID, "the profile must actually have changed") + + _, found := s.jwtCache.get(owner) + assert.False(t, found, "switching profile must drop the cached SSH JWT") +} + +// Down ends the connection, not the session: the peer stays enrolled and the +// token still belongs to the same NetBird identity, so `down` followed by `up` +// must not cost the owner a fresh device-code flow. +// +// The logout handlers do call cleanupConnection, and SwitchProfile does not; +// what they have in common is that each clears the cache itself, right after, +// so tearing the connection down is no longer what decides the token's fate. +func TestCleanupConnection_KeepsJWTCache(t *testing.T) { + s := newTestServer() + _, cancel := context.WithCancel(context.Background()) + s.actCancel = cancel + + owner := unprivilegedIdentity() + s.jwtCache.store("token", owner, testTTL, s.jwtCache.currentGeneration()) + + require.NoError(t, s.cleanupConnection()) + + got, found := s.jwtCache.get(owner) + require.True(t, found, "going down must not drop the cached SSH JWT") + assert.Equal(t, "token", got) +} + +// fakeOAuthFlow stands in for the IdP round trip so a test can drive +// WaitJWTToken without a real device-code flow. +type fakeOAuthFlow struct { + token string +} + +func (f *fakeOAuthFlow) RequestAuthInfo(context.Context) (auth.AuthFlowInfo, error) { + return auth.AuthFlowInfo{DeviceCode: "device-code"}, nil +} + +func (f *fakeOAuthFlow) WaitToken(context.Context, auth.AuthFlowInfo) (auth.TokenInfo, error) { + return auth.TokenInfo{AccessToken: f.token}, nil +} + +func (f *fakeOAuthFlow) GetClientID(context.Context) string { return "client-id" } + +// The flow outlives a profile switch, because SwitchProfile does not reset +// s.oauthAuthFlow. A switch between RequestJWTAuth and the IdP answering must +// still keep the token out of the cache the new profile uses, and the +// generation the flow carries is what decides it: reading the cache's own +// generation at store time would already be the new one. +func TestWaitJWTToken_DropsTokenFromASessionThatEndedBeforeTheWait(t *testing.T) { + s := newTestServer() + owner := unprivilegedIdentity() + ttl := int(testTTL.Seconds()) + s.config = &profilemanager.Config{SSHJWTCacheTTL: &ttl} + + // RequestJWTAuth ran under the previous session and recorded its generation. + s.oauthAuthFlow.flow = &fakeOAuthFlow{token: "token-from-the-old-session"} + s.oauthAuthFlow.info = auth.AuthFlowInfo{DeviceCode: "device-code"} + s.oauthAuthFlow.cacheGeneration = s.jwtCache.currentGeneration() + + // A profile switch or a logout lands before the caller reaches WaitJWTToken. + s.jwtCache.clear() + + _, err := s.WaitJWTToken(ctxWithIdentity(owner), &proto.WaitJWTTokenRequest{DeviceCode: "device-code"}) + require.NoError(t, err) + + _, found := s.jwtCache.get(owner) + assert.False(t, found, "a token whose flow started under the previous session must not be cached") +} diff --git a/client/server/server_privileged_test.go b/client/server/server_privileged_test.go new file mode 100644 index 000000000..30fe1ab1c --- /dev/null +++ b/client/server/server_privileged_test.go @@ -0,0 +1,252 @@ +//go:build privileged + +package server + +import ( + "context" + "net" + "os/user" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + "go.uber.org/mock/gomock" + + "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator" + + "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" + "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel" + "github.com/netbirdio/netbird/management/internals/modules/peers" + "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager" + nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + "github.com/netbirdio/netbird/management/server/job" + + "github.com/netbirdio/netbird/management/internals/server/config" + "github.com/netbirdio/netbird/management/server/groups" + + log "github.com/sirupsen/logrus" + "google.golang.org/grpc" + "google.golang.org/grpc/keepalive" + + "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/management/internals/modules/permissions" + "github.com/netbirdio/netbird/management/server" + "github.com/netbirdio/netbird/management/server/activity" + nbcache "github.com/netbirdio/netbird/management/server/cache" + "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" + "github.com/netbirdio/netbird/management/server/settings" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/telemetry" + mgmtProto "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/shared/signal/proto" + signalServer "github.com/netbirdio/netbird/signal/server" +) + +var ( + kaep = keepalive.EnforcementPolicy{ + MinTime: 15 * time.Second, + PermitWithoutStream: true, + } + + kasp = keepalive.ServerParameters{ + MaxConnectionIdle: 15 * time.Second, + MaxConnectionAgeGrace: 5 * time.Second, + Time: 5 * time.Second, + Timeout: 2 * time.Second, + } +) + +// TestConnectStopsRetryOnPermissionDenied verifies connectWithRetryRuns stops after a single login +// attempt on PermissionDenied, despite the fast retry config that would otherwise drive several. +func TestConnectStopsRetryOnPermissionDenied(t *testing.T) { + // Redirect profile paths to a temp dir so the test does not need root. + tempDir := t.TempDir() + origDefaultProfileDir := profilemanager.DefaultConfigPathDir + origActiveProfileStatePath := profilemanager.ActiveProfileStatePath + origDefaultConfigPath := profilemanager.DefaultConfigPath + profilemanager.ConfigDirOverride = tempDir + profilemanager.DefaultConfigPathDir = tempDir + profilemanager.ActiveProfileStatePath = filepath.Join(tempDir, "active_profile.json") + profilemanager.DefaultConfigPath = filepath.Join(tempDir, "default.json") + t.Cleanup(func() { + profilemanager.DefaultConfigPathDir = origDefaultProfileDir + profilemanager.ActiveProfileStatePath = origActiveProfileStatePath + profilemanager.DefaultConfigPath = origDefaultConfigPath + profilemanager.ConfigDirOverride = "" + }) + + // start the signal server + _, signalAddr, err := startSignal(t) + if err != nil { + t.Fatalf("failed to start signal server: %v", err) + } + + counter := 0 + // start the management server + _, mgmtAddr, err := startManagement(t, signalAddr, &counter) + if err != nil { + t.Fatalf("failed to start management server: %v", err) + } + + ctx := internal.CtxInitState(context.Background()) + + ctx, cancel := context.WithDeadline(ctx, time.Now().Add(30*time.Second)) + defer cancel() + // create new server + ic := profilemanager.ConfigInput{ + ManagementURL: "http://" + mgmtAddr, + ConfigPath: t.TempDir() + "/test-profile.json", + } + + config, err := profilemanager.UpdateOrCreateConfig(ic) + if err != nil { + t.Fatalf("failed to create config: %v", err) + } + + currUser, err := user.Current() + require.NoError(t, err) + + pm := profilemanager.ServiceManager{} + err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{ + ID: "test-profile", + Username: currUser.Username, + }) + if err != nil { + t.Fatalf("failed to set active profile state: %v", err) + } + + s := New(ctx, "debug", "", false, false, false, false) + + s.config = config + + s.statusRecorder = peer.NewRecorder(config.ManagementURL.String()) + t.Setenv(retryInitialIntervalVar, "1s") + t.Setenv(maxRetryIntervalVar, "2s") + t.Setenv(maxRetryTimeVar, "5s") + t.Setenv(retryMultiplierVar, "1") + + s.connectWithRetryRuns(ctx, config, s.statusRecorder, nil, nil) + if counter != 1 { + t.Fatalf("expected exactly 1 login attempt (PermissionDenied must stop the retry loop), got %d", counter) + } +} + +type mockServer struct { + mgmtProto.ManagementServiceServer + counter *int +} + +func (m *mockServer) Login(ctx context.Context, req *mgmtProto.EncryptedMessage) (*mgmtProto.EncryptedMessage, error) { + *m.counter++ + return m.ManagementServiceServer.Login(ctx, req) +} + +func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Server, string, error) { + t.Helper() + dataDir := t.TempDir() + + config := &config.Config{ + Stuns: []*config.Host{}, + TURNConfig: &config.TURNConfig{}, + Signal: &config.Host{ + Proto: "http", + URI: signalAddr, + }, + Datadir: dataDir, + HttpConfig: nil, + } + + lis, err := net.Listen("tcp", "localhost:0") + if err != nil { + return nil, "", err + } + s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp)) + store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", config.Datadir) + if err != nil { + return nil, "", err + } + t.Cleanup(cleanUp) + + eventStore := &activity.InMemoryEventStore{} + if err != nil { + return nil, "", err + } + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + permissionsManagerMock := permissions.NewMockManager(ctrl) + peersManager := peers.NewManager(store) + settingsManagerMock := settings.NewMockManager(ctrl) + + jobManager := job.NewJobManager(nil, store, peersManager) + + cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100) + if err != nil { + return nil, "", err + } + + ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, settingsManagerMock, eventStore, cacheStore) + + metrics, err := telemetry.NewDefaultAppMetrics(context.Background()) + require.NoError(t, err) + + settingsMockManager := settings.NewMockManager(ctrl) + groupsManager := groups.NewManagerMock() + + requestBuffer := server.NewAccountRequestBuffer(context.Background(), store) + peersUpdateManager := update_channel.NewPeersUpdateManager(metrics) + networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config, nil) + accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore) + if err != nil { + return nil, "", err + } + + secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(peersUpdateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager) + if err != nil { + return nil, "", err + } + mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil, nil) + if err != nil { + return nil, "", err + } + mock := &mockServer{ + ManagementServiceServer: mgmtServer, + counter: counter, + } + mgmtProto.RegisterManagementServiceServer(s, mock) + go func() { + if err = s.Serve(lis); err != nil { + log.Fatalf("failed to serve: %v", err) + } + }() + + return s, lis.Addr().String(), nil +} + +func startSignal(t *testing.T) (*grpc.Server, string, error) { + t.Helper() + + s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp)) + + lis, err := net.Listen("tcp", "localhost:0") + if err != nil { + return nil, "", err + } + + srv, err := signalServer.NewServer(context.Background(), otel.Meter("")) + require.NoError(t, err) + proto.RegisterSignalExchangeServer(s, srv) + + go func() { + if err = s.Serve(lis); err != nil { + log.Fatalf("failed to serve: %v", err) + } + }() + + return s, lis.Addr().String(), nil +} diff --git a/client/server/server_test.go b/client/server/server_test.go index 6627ba875..7717cfcf8 100644 --- a/client/server/server_test.go +++ b/client/server/server_test.go @@ -2,125 +2,22 @@ package server import ( "context" - "net" "net/url" "os/user" "path/filepath" "testing" "time" - "github.com/golang/mock/gomock" - "github.com/stretchr/testify/require" - "go.opentelemetry.io/otel" - - "github.com/netbirdio/management-integrations/integrations" - - "github.com/netbirdio/netbird/management/internals/modules/permissions" - - "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" - "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel" - "github.com/netbirdio/netbird/management/internals/modules/peers" - "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager" - nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" - "github.com/netbirdio/netbird/management/server/job" - - "github.com/netbirdio/netbird/management/internals/server/config" - "github.com/netbirdio/netbird/management/server/groups" - log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "google.golang.org/grpc" - "google.golang.org/grpc/keepalive" "github.com/netbirdio/netbird/client/internal" - "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" daemonProto "github.com/netbirdio/netbird/client/proto" - "github.com/netbirdio/netbird/management/server" - "github.com/netbirdio/netbird/management/server/activity" - nbcache "github.com/netbirdio/netbird/management/server/cache" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" - "github.com/netbirdio/netbird/management/server/settings" - "github.com/netbirdio/netbird/management/server/store" - "github.com/netbirdio/netbird/management/server/telemetry" - mgmtProto "github.com/netbirdio/netbird/shared/management/proto" - "github.com/netbirdio/netbird/shared/signal/proto" - signalServer "github.com/netbirdio/netbird/signal/server" ) -var ( - kaep = keepalive.EnforcementPolicy{ - MinTime: 15 * time.Second, - PermitWithoutStream: true, - } - - kasp = keepalive.ServerParameters{ - MaxConnectionIdle: 15 * time.Second, - MaxConnectionAgeGrace: 5 * time.Second, - Time: 5 * time.Second, - Timeout: 2 * time.Second, - } -) - -// TestConnectWithRetryRuns checks that the connectWithRetry function runs and runs the retries according to the times specified via environment variables -// we will use a management server started via to simulate the server and capture the number of retries -func TestConnectWithRetryRuns(t *testing.T) { - // start the signal server - _, signalAddr, err := startSignal(t) - if err != nil { - t.Fatalf("failed to start signal server: %v", err) - } - - counter := 0 - // start the management server - _, mgmtAddr, err := startManagement(t, signalAddr, &counter) - if err != nil { - t.Fatalf("failed to start management server: %v", err) - } - - ctx := internal.CtxInitState(context.Background()) - - ctx, cancel := context.WithDeadline(ctx, time.Now().Add(30*time.Second)) - defer cancel() - // create new server - ic := profilemanager.ConfigInput{ - ManagementURL: "http://" + mgmtAddr, - ConfigPath: t.TempDir() + "/test-profile.json", - } - - config, err := profilemanager.UpdateOrCreateConfig(ic) - if err != nil { - t.Fatalf("failed to create config: %v", err) - } - - currUser, err := user.Current() - require.NoError(t, err) - - pm := profilemanager.ServiceManager{} - err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: "test-profile", - Username: currUser.Username, - }) - if err != nil { - t.Fatalf("failed to set active profile state: %v", err) - } - - s := New(ctx, "debug", "", false, false, false) - - s.config = config - - s.statusRecorder = peer.NewRecorder(config.ManagementURL.String()) - t.Setenv(retryInitialIntervalVar, "1s") - t.Setenv(maxRetryIntervalVar, "2s") - t.Setenv(maxRetryTimeVar, "5s") - t.Setenv(retryMultiplierVar, "1") - - s.connectWithRetryRuns(ctx, config, s.statusRecorder, nil, nil) - if counter < 3 { - t.Fatalf("expected counter > 2, got %d", counter) - } -} - func TestServer_Up(t *testing.T) { tempDir := t.TempDir() origDefaultProfileDir := profilemanager.DefaultConfigPathDir @@ -159,14 +56,14 @@ func TestServer_Up(t *testing.T) { pm := profilemanager.ServiceManager{} err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: profName, + ID: profilemanager.ID(profName), Username: currUser.Username, }) if err != nil { t.Fatalf("failed to set active profile state: %v", err) } - s := New(ctx, "console", "", false, false, false) + s := New(ctx, "console", "", false, false, false, false) err = s.Start() require.NoError(t, err) @@ -229,14 +126,14 @@ func TestServer_SubcribeEvents(t *testing.T) { pm := profilemanager.ServiceManager{} err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: "default", + ID: "default", Username: currUser.Username, }) if err != nil { t.Fatalf("failed to set active profile state: %v", err) } - s := New(ctx, "console", "", false, false, false) + s := New(ctx, "console", "", false, false, false, false) err = s.Start() require.NoError(t, err) @@ -260,119 +157,3 @@ func TestServer_SubcribeEvents(t *testing.T) { assert.NoError(t, err) } - -type mockServer struct { - mgmtProto.ManagementServiceServer - counter *int -} - -func (m *mockServer) Login(ctx context.Context, req *mgmtProto.EncryptedMessage) (*mgmtProto.EncryptedMessage, error) { - *m.counter++ - return m.ManagementServiceServer.Login(ctx, req) -} - -func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Server, string, error) { - t.Helper() - dataDir := t.TempDir() - - config := &config.Config{ - Stuns: []*config.Host{}, - TURNConfig: &config.TURNConfig{}, - Signal: &config.Host{ - Proto: "http", - URI: signalAddr, - }, - Datadir: dataDir, - HttpConfig: nil, - } - - lis, err := net.Listen("tcp", "localhost:0") - if err != nil { - return nil, "", err - } - s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp)) - store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", config.Datadir) - if err != nil { - return nil, "", err - } - t.Cleanup(cleanUp) - - eventStore := &activity.InMemoryEventStore{} - if err != nil { - return nil, "", err - } - - ctrl := gomock.NewController(t) - t.Cleanup(ctrl.Finish) - - permissionsManagerMock := permissions.NewMockManager(ctrl) - peersManager := peers.NewManager(store) - settingsManagerMock := settings.NewMockManager(ctrl) - - jobManager := job.NewJobManager(nil, store, peersManager) - - cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100) - if err != nil { - return nil, "", err - } - - ia, _ := integrations.NewIntegratedValidator(context.Background(), peersManager, settingsManagerMock, eventStore, cacheStore) - - metrics, err := telemetry.NewDefaultAppMetrics(context.Background()) - require.NoError(t, err) - - settingsMockManager := settings.NewMockManager(ctrl) - groupsManager := groups.NewManagerMock() - - requestBuffer := server.NewAccountRequestBuffer(context.Background(), store) - peersUpdateManager := update_channel.NewPeersUpdateManager(metrics) - networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config) - accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore) - if err != nil { - return nil, "", err - } - - secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(peersUpdateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager) - if err != nil { - return nil, "", err - } - mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil) - if err != nil { - return nil, "", err - } - mock := &mockServer{ - ManagementServiceServer: mgmtServer, - counter: counter, - } - mgmtProto.RegisterManagementServiceServer(s, mock) - go func() { - if err = s.Serve(lis); err != nil { - log.Fatalf("failed to serve: %v", err) - } - }() - - return s, lis.Addr().String(), nil -} - -func startSignal(t *testing.T) (*grpc.Server, string, error) { - t.Helper() - - s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp)) - - lis, err := net.Listen("tcp", "localhost:0") - if err != nil { - log.Fatalf("failed to listen: %v", err) - } - - srv, err := signalServer.NewServer(context.Background(), otel.Meter("")) - require.NoError(t, err) - proto.RegisterSignalExchangeServer(s, srv) - - go func() { - if err = s.Serve(lis); err != nil { - log.Fatalf("failed to serve: %v", err) - } - }() - - return s, lis.Addr().String(), nil -} diff --git a/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go new file mode 100644 index 000000000..a392af6d3 --- /dev/null +++ b/client/server/setconfig_mdm_test.go @@ -0,0 +1,366 @@ +package server + +import ( + "context" + "os/user" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" + "github.com/netbirdio/netbird/client/proto" +) + +// fakeMDMFetcher implements mdm.PolicyFetcher returning a pre-set +// policy map. Tests build one per Server instance to inject a +// scripted MDM overlay via a Loader rather than via package-level state. +type fakeMDMFetcher struct{ values map[string]any } + +func (f *fakeMDMFetcher) Fetch() map[string]any { return f.values } + +// withMDMPolicy installs an mdm.Loader on the given Server whose +// loadPlatform returns the supplied Policy's underlying values. Use +// after setupServerWithProfile to inject the scripted policy the +// SetConfig / Login MDM gates will observe. +func withMDMPolicy(t *testing.T, s *Server, policy *mdm.Policy) { + t.Helper() + values := map[string]any{} + if policy != nil { + for _, k := range policy.ManagedKeys() { + if v, ok := policy.GetString(k); ok { + values[k] = v + continue + } + if v, ok := policy.GetInt(k); ok { + values[k] = v + continue + } + if v, ok := policy.GetBool(k); ok { + values[k] = v + continue + } + if v, ok := policy.GetStringSlice(k); ok { + values[k] = v + } + } + } + s.mdmLoader = mdm.NewLoader(&fakeMDMFetcher{values: values}) +} + +// setupServerWithProfile mirrors the boilerplate of TestSetConfig_AllFieldsSaved: +// overrides profilemanager paths to a temp dir, seeds a profile, sets it +// active, and constructs a Server instance. Returns the constructed server +// plus context + profile name + username + cfgPath for the seeded profile. +func setupServerWithProfile(t *testing.T) (s *Server, ctx context.Context, profName, username, cfgPath string) { + t.Helper() + tempDir := t.TempDir() + + origDefaultProfileDir := profilemanager.DefaultConfigPathDir + origDefaultConfigPath := profilemanager.DefaultConfigPath + origActiveProfileStatePath := profilemanager.ActiveProfileStatePath + profilemanager.ConfigDirOverride = tempDir + profilemanager.DefaultConfigPathDir = tempDir + profilemanager.ActiveProfileStatePath = tempDir + "/active_profile.json" + profilemanager.DefaultConfigPath = filepath.Join(tempDir, "default.json") + t.Cleanup(func() { + profilemanager.DefaultConfigPathDir = origDefaultProfileDir + profilemanager.ActiveProfileStatePath = origActiveProfileStatePath + profilemanager.DefaultConfigPath = origDefaultConfigPath + profilemanager.ConfigDirOverride = "" + }) + + currUser, err := user.Current() + require.NoError(t, err) + + profName = "test-profile-mdm" + cfgPath = filepath.Join(tempDir, profName+".json") + + _, err = profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: cfgPath, + ManagementURL: "https://api.netbird.io:443", + }) + require.NoError(t, err) + + pm := profilemanager.ServiceManager{} + require.NoError(t, pm.SetActiveProfileState(&profilemanager.ActiveProfileState{ + ID: profilemanager.ID(profName), + Username: currUser.Username, + })) + + // The privileged-change gate reads the caller's kernel identity from the + // context, which a real caller gets from the daemon's transport credentials. + // This test drives the handler directly, so it stands in for a root caller; + // without an identity the gate would (correctly) refuse the SSH fields. + ctx = privilegedTestCtx() + s = New(ctx, "console", "", false, false, false, false) + return s, ctx, profName, currUser.Username, cfgPath +} + +// extractViolation pulls the MDMManagedFieldsViolation detail from a +// FailedPrecondition error. Fails the test if absent or malformed. +func extractViolation(t *testing.T, err error) *proto.MDMManagedFieldsViolation { + t.Helper() + require.Error(t, err) + st, ok := gstatus.FromError(err) + require.True(t, ok, "error must be a gRPC status: %v", err) + require.Equal(t, codes.FailedPrecondition, st.Code(), "expected FailedPrecondition, got %s", st.Code()) + for _, d := range st.Details() { + if v, ok := d.(*proto.MDMManagedFieldsViolation); ok { + return v + } + } + t.Fatalf("MDMManagedFieldsViolation detail not found on status; details: %v", st.Details()) + return nil +} + +func TestSetConfig_MDMReject_SingleField(t *testing.T) { + s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ + mdm.KeyManagementURL: "https://mdm.example.com:443", + })) + + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ManagementUrl: "https://user.tried.this.com:443", + }) + + v := extractViolation(t, err) + assert.Equal(t, []string{mdm.KeyManagementURL}, v.GetFields()) +} + +func TestSetConfig_MDMReject_MultipleFields(t *testing.T) { + s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ + mdm.KeyManagementURL: "https://mdm.example.com:443", + mdm.KeyBlockInbound: true, + mdm.KeyRosenpassEnabled: true, + })) + + blockInbound := false + rosenpassEnabled := false + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ManagementUrl: "https://user.tried.this.com:443", + BlockInbound: &blockInbound, + RosenpassEnabled: &rosenpassEnabled, + }) + + v := extractViolation(t, err) + assert.ElementsMatch(t, []string{ + mdm.KeyManagementURL, + mdm.KeyBlockInbound, + mdm.KeyRosenpassEnabled, + }, v.GetFields()) +} + +func TestSetConfig_MDMReject_LocalMetrics(t *testing.T) { + s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ + mdm.KeyEnableLocalMetrics: true, + mdm.KeyLocalMetricsAddress: "127.0.0.1:9191", + })) + + enabled := false + addr := "0.0.0.0:9999" + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + EnableLocalMetrics: &enabled, + LocalMetricsAddress: &addr, + }) + + v := extractViolation(t, err) + assert.ElementsMatch(t, []string{ + mdm.KeyEnableLocalMetrics, + mdm.KeyLocalMetricsAddress, + }, v.GetFields()) +} + +// An explicitly empty address still changes the effective listen address +// (the manager falls back to the default), so presence must be honored +// rather than collapsed to "field not set". +func TestSetConfig_MDMReject_LocalMetricsEmptyAddress(t *testing.T) { + s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ + mdm.KeyLocalMetricsAddress: "127.0.0.1:9999", + })) + + addr := "" + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + LocalMetricsAddress: &addr, + }) + + v := extractViolation(t, err) + assert.ElementsMatch(t, []string{mdm.KeyLocalMetricsAddress}, v.GetFields()) +} + +func TestSetConfig_MDMReject_EmptyPreSharedKey(t *testing.T) { + s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ + mdm.KeyPreSharedKey: "mdm-enforced-psk", + })) + + psk := "" + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + OptionalPreSharedKey: &psk, + }) + + v := extractViolation(t, err) + assert.ElementsMatch(t, []string{mdm.KeyPreSharedKey}, v.GetFields()) +} + +func TestSetConfig_MDMAllow_PreSharedKeySentinelEcho(t *testing.T) { + s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ + mdm.KeyPreSharedKey: "mdm-enforced-psk", + })) + + psk := mdm.PreSharedKeyRedactedSentinel + resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + OptionalPreSharedKey: &psk, + }) + + require.NoError(t, err) + require.NotNil(t, resp) +} + +func TestLoginRequestMDMConflicts_PreSharedKey(t *testing.T) { + policy := mdm.NewPolicy(map[string]any{ + mdm.KeyPreSharedKey: "mdm-enforced-psk", + }) + empty := "" + sentinel := mdm.PreSharedKeyRedactedSentinel + same := "mdm-enforced-psk" + other := "user-psk" + + tests := []struct { + name string + msg *proto.LoginRequest + want []string + }{ + {name: "unset", msg: &proto.LoginRequest{}, want: nil}, + {name: "optional empty", msg: &proto.LoginRequest{OptionalPreSharedKey: &empty}, want: []string{mdm.KeyPreSharedKey}}, + {name: "optional sentinel echo", msg: &proto.LoginRequest{OptionalPreSharedKey: &sentinel}, want: nil}, + {name: "optional same value", msg: &proto.LoginRequest{OptionalPreSharedKey: &same}, want: nil}, + {name: "optional divergent", msg: &proto.LoginRequest{OptionalPreSharedKey: &other}, want: []string{mdm.KeyPreSharedKey}}, + {name: "legacy empty is unset", msg: &proto.LoginRequest{PreSharedKey: ""}, want: nil}, //nolint:staticcheck // SA1019: legacy proto field still accepted by Login + {name: "legacy sentinel echo", msg: &proto.LoginRequest{PreSharedKey: sentinel}, want: nil}, //nolint:staticcheck // SA1019: legacy proto field still accepted by Login + {name: "legacy divergent", msg: &proto.LoginRequest{PreSharedKey: other}, want: []string{mdm.KeyPreSharedKey}}, //nolint:staticcheck // SA1019: legacy proto field still accepted by Login + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, loginRequestMDMConflicts(tc.msg, policy)) + }) + } +} + +func TestSetConfig_MDMReject_AllOrNothing(t *testing.T) { + // MDM enforces ManagementURL only; user request touches both the + // enforced field AND a non-enforced field (RosenpassEnabled). + // The whole request must be rejected — non-conflicting fields are not + // applied either. + s, ctx, profName, username, cfgPath := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ + mdm.KeyManagementURL: "https://mdm.example.com:443", + })) + + rosenpassEnabled := true + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ManagementUrl: "https://user.tried.this.com:443", + RosenpassEnabled: &rosenpassEnabled, + }) + + v := extractViolation(t, err) + assert.Equal(t, []string{mdm.KeyManagementURL}, v.GetFields()) + + // Confirm RosenpassEnabled was NOT applied even though it was not + // in the conflict list: the request was rejected as a whole. + reloaded, err := profilemanager.GetConfig(cfgPath) + require.NoError(t, err) + assert.False(t, reloaded.RosenpassEnabled, "non-conflicting field must not be applied when request is rejected") +} + +func TestSetConfig_MDMAllow_NonManagedFields(t *testing.T) { + // MDM enforces ManagementURL but the user only writes RosenpassEnabled. + // Request must succeed. + s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ + mdm.KeyManagementURL: "https://mdm.example.com:443", + })) + + rosenpassEnabled := true + resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + RosenpassEnabled: &rosenpassEnabled, + }) + + require.NoError(t, err) + require.NotNil(t, resp) +} + +// TestSetConfig_MDMAllow_ManagementURLPortNormalized covers the +// regression from discussion #6483: MDM URL without explicit port vs +// UI echo with the parseURL-appended default port must be treated as +// a no-op echo, not a conflict. +func TestSetConfig_MDMAllow_ManagementURLPortNormalized(t *testing.T) { + tests := []struct { + name string + mdmURL string + submitURL string + }{ + {"policy_no_port_submit_with_443", "https://netbird.corp.example", "https://netbird.corp.example:443"}, + {"policy_with_443_submit_no_port", "https://netbird.corp.example:443", "https://netbird.corp.example"}, + {"http_policy_no_port_submit_with_80", "http://netbird.corp.example", "http://netbird.corp.example:80"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ + mdm.KeyManagementURL: tc.mdmURL, + })) + + rosenpassEnabled := true + resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ManagementUrl: tc.submitURL, + RosenpassEnabled: &rosenpassEnabled, + }) + + require.NoError(t, err, "port-normalized URL echo must not trip MDM conflict gate") + require.NotNil(t, resp) + }) + } +} + +func TestSetConfig_MDMEmpty_NoEnforcement(t *testing.T) { + // No MDM policy active: any field can be written. + s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(nil)) + + resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ManagementUrl: "https://user.changed.url.com:443", + }) + + require.NoError(t, err) + require.NotNil(t, resp) +} diff --git a/client/server/setconfig_test.go b/client/server/setconfig_test.go index 7f6847c43..7442b718e 100644 --- a/client/server/setconfig_test.go +++ b/client/server/setconfig_test.go @@ -1,7 +1,6 @@ package server import ( - "context" "os/user" "path/filepath" "reflect" @@ -47,17 +46,22 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { pm := profilemanager.ServiceManager{} err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: profName, + ID: profilemanager.ID(profName), Username: currUser.Username, }) require.NoError(t, err) - ctx := context.Background() - s := New(ctx, "console", "", false, false, false) + // The privileged-change gate reads the caller's kernel identity from the + // context, which a real caller gets from the daemon's transport credentials. + // This test drives the handler directly, so it stands in for a root caller; + // without an identity the gate would (correctly) refuse the SSH fields. + ctx := privilegedTestCtx() + s := New(ctx, "console", "", false, false, false, false) rosenpassEnabled := true rosenpassPermissive := true serverSSHAllowed := true + remoteJobsAllowed := true interfaceName := "utun100" wireguardPort := int64(51820) preSharedKey := "test-psk" @@ -69,48 +73,53 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { disableFirewall := true blockLANAccess := true disableNotifications := true - lazyConnectionEnabled := true blockInbound := true + disableIPv6 := true mtu := int64(1280) sshJWTCacheTTL := int32(300) + enableLocalMetrics := true + localMetricsAddress := "127.0.0.1:9292" req := &proto.SetConfigRequest{ - ProfileName: profName, - Username: currUser.Username, - ManagementUrl: "https://new-api.netbird.io:443", - AdminURL: "https://new-admin.netbird.io", - RosenpassEnabled: &rosenpassEnabled, - RosenpassPermissive: &rosenpassPermissive, - ServerSSHAllowed: &serverSSHAllowed, - InterfaceName: &interfaceName, - WireguardPort: &wireguardPort, - OptionalPreSharedKey: &preSharedKey, - DisableAutoConnect: &disableAutoConnect, - NetworkMonitor: &networkMonitor, - DisableClientRoutes: &disableClientRoutes, - DisableServerRoutes: &disableServerRoutes, - DisableDns: &disableDNS, - DisableFirewall: &disableFirewall, - BlockLanAccess: &blockLANAccess, - DisableNotifications: &disableNotifications, - LazyConnectionEnabled: &lazyConnectionEnabled, - BlockInbound: &blockInbound, - NatExternalIPs: []string{"1.2.3.4", "5.6.7.8"}, - CleanNATExternalIPs: false, - CustomDNSAddress: []byte("1.1.1.1:53"), - ExtraIFaceBlacklist: []string{"eth1", "eth2"}, - DnsLabels: []string{"label1", "label2"}, - CleanDNSLabels: false, - DnsRouteInterval: durationpb.New(2 * time.Minute), - Mtu: &mtu, - SshJWTCacheTTL: &sshJWTCacheTTL, + ProfileName: profName, + Username: currUser.Username, + ManagementUrl: "https://new-api.netbird.io:443", + AdminURL: "https://new-admin.netbird.io", + RosenpassEnabled: &rosenpassEnabled, + RosenpassPermissive: &rosenpassPermissive, + ServerSSHAllowed: &serverSSHAllowed, + RemoteJobsAllowed: &remoteJobsAllowed, + InterfaceName: &interfaceName, + WireguardPort: &wireguardPort, + OptionalPreSharedKey: &preSharedKey, + DisableAutoConnect: &disableAutoConnect, + NetworkMonitor: &networkMonitor, + DisableClientRoutes: &disableClientRoutes, + DisableServerRoutes: &disableServerRoutes, + DisableDns: &disableDNS, + DisableFirewall: &disableFirewall, + BlockLanAccess: &blockLANAccess, + DisableNotifications: &disableNotifications, + BlockInbound: &blockInbound, + DisableIpv6: &disableIPv6, + NatExternalIPs: []string{"1.2.3.4", "5.6.7.8"}, + CleanNATExternalIPs: false, + CustomDNSAddress: []byte("1.1.1.1:53"), + ExtraIFaceBlacklist: []string{"eth1", "eth2"}, + DnsLabels: []string{"label1", "label2"}, + CleanDNSLabels: false, + DnsRouteInterval: durationpb.New(2 * time.Minute), + Mtu: &mtu, + SshJWTCacheTTL: &sshJWTCacheTTL, + EnableLocalMetrics: &enableLocalMetrics, + LocalMetricsAddress: &localMetricsAddress, } _, err = s.SetConfig(ctx, req) require.NoError(t, err) profState := profilemanager.ActiveProfileState{ - Name: profName, + ID: profilemanager.ID(profName), Username: currUser.Username, } cfgPath, err := profState.FilePath() @@ -125,6 +134,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { require.Equal(t, rosenpassPermissive, cfg.RosenpassPermissive) require.NotNil(t, cfg.ServerSSHAllowed) require.Equal(t, serverSSHAllowed, *cfg.ServerSSHAllowed) + require.NotNil(t, cfg.RemoteJobsAllowed) + require.Equal(t, remoteJobsAllowed, *cfg.RemoteJobsAllowed) require.Equal(t, interfaceName, cfg.WgIface) require.Equal(t, int(wireguardPort), cfg.WgPort) require.Equal(t, preSharedKey, cfg.PreSharedKey) @@ -138,8 +149,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { require.Equal(t, blockLANAccess, cfg.BlockLANAccess) require.NotNil(t, cfg.DisableNotifications) require.Equal(t, disableNotifications, *cfg.DisableNotifications) - require.Equal(t, lazyConnectionEnabled, cfg.LazyConnectionEnabled) require.Equal(t, blockInbound, cfg.BlockInbound) + require.Equal(t, disableIPv6, cfg.DisableIPv6) require.Equal(t, []string{"1.2.3.4", "5.6.7.8"}, cfg.NATExternalIPs) require.Equal(t, "1.1.1.1:53", cfg.CustomDNSAddress) // IFaceBlackList contains defaults + extras @@ -150,6 +161,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { require.Equal(t, uint16(mtu), cfg.MTU) require.NotNil(t, cfg.SSHJWTCacheTTL) require.Equal(t, int(sshJWTCacheTTL), *cfg.SSHJWTCacheTTL) + require.Equal(t, enableLocalMetrics, cfg.LocalMetricsEnabled) + require.Equal(t, localMetricsAddress, cfg.LocalMetricsAddress) verifyAllFieldsCovered(t, req) } @@ -161,13 +174,14 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) { t.Helper() metadataFields := map[string]bool{ - "state": true, // protobuf internal - "sizeCache": true, // protobuf internal - "unknownFields": true, // protobuf internal - "Username": true, // metadata - "ProfileName": true, // metadata - "CleanNATExternalIPs": true, // control flag for clearing - "CleanDNSLabels": true, // control flag for clearing + "state": true, // protobuf internal + "sizeCache": true, // protobuf internal + "unknownFields": true, // protobuf internal + "Username": true, // metadata + "ProfileName": true, // metadata + "CleanNATExternalIPs": true, // control flag for clearing + "CleanDNSLabels": true, // control flag for clearing + "LazyConnectionEnabled": true, // deprecated: proto field retained for compat, no longer applied } expectedFields := map[string]bool{ @@ -176,6 +190,7 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) { "RosenpassEnabled": true, "RosenpassPermissive": true, "ServerSSHAllowed": true, + "RemoteJobsAllowed": true, "InterfaceName": true, "WireguardPort": true, "OptionalPreSharedKey": true, @@ -187,8 +202,8 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) { "DisableFirewall": true, "BlockLanAccess": true, "DisableNotifications": true, - "LazyConnectionEnabled": true, "BlockInbound": true, + "DisableIpv6": true, "NatExternalIPs": true, "CustomDNSAddress": true, "ExtraIFaceBlacklist": true, @@ -201,6 +216,8 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) { "EnableSSHRemotePortForwarding": true, "DisableSSHAuth": true, "SshJWTCacheTTL": true, + "EnableLocalMetrics": true, + "LocalMetricsAddress": true, } val := reflect.ValueOf(req).Elem() @@ -236,6 +253,7 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) { "enable-rosenpass": "RosenpassEnabled", "rosenpass-permissive": "RosenpassPermissive", "allow-server-ssh": "ServerSSHAllowed", + "allow-remote-jobs": "RemoteJobsAllowed", "interface-name": "InterfaceName", "wireguard-port": "WireguardPort", "preshared-key": "OptionalPreSharedKey", @@ -247,7 +265,7 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) { "disable-firewall": "DisableFirewall", "block-lan-access": "BlockLanAccess", "block-inbound": "BlockInbound", - "enable-lazy-connection": "LazyConnectionEnabled", + "disable-ipv6": "DisableIpv6", "external-ip-map": "NatExternalIPs", "dns-resolver-address": "CustomDNSAddress", "extra-iface-blacklist": "ExtraIFaceBlacklist", @@ -260,11 +278,14 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) { "enable-ssh-remote-port-forwarding": "EnableSSHRemotePortForwarding", "disable-ssh-auth": "DisableSSHAuth", "ssh-jwt-cache-ttl": "SshJWTCacheTTL", + "enable-local-metrics": "EnableLocalMetrics", + "local-metrics-address": "LocalMetricsAddress", } // SetConfigRequest fields that don't have CLI flags (settable only via UI or other means). fieldsWithoutCLIFlags := map[string]bool{ - "DisableNotifications": true, // Only settable via UI + "DisableNotifications": true, // Only settable via UI + "LazyConnectionEnabled": true, // deprecated: no longer settable (managed by server + NB_LAZY_CONN) } // Get all SetConfigRequest fields to verify our map is complete. diff --git a/client/server/sleep.go b/client/server/sleep.go index 7a83c75a6..877ad9690 100644 --- a/client/server/sleep.go +++ b/client/server/sleep.go @@ -2,13 +2,18 @@ package server import ( "context" + "os" + "strconv" log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/internal/sleep" "github.com/netbirdio/netbird/client/proto" ) +const envDisableSleepDetector = "NB_DISABLE_SLEEP_DETECTOR" + // serverAgent adapts Server to the handler.Agent and handler.StatusChecker interfaces type serverAgent struct { s *Server @@ -28,19 +33,61 @@ 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()) +// startSleepDetector starts the OS sleep/wake detector and forwards events to +// the sleep handler. On platforms without a supported detector the attempt +// logs a warning and returns. Setting NB_DISABLE_SLEEP_DETECTOR=true skips +// registration entirely. +func (s *Server) startSleepDetector() { + if sleepDetectorDisabled() { + log.Info("sleep detection disabled via " + envDisableSleepDetector) + return } - return &proto.OSLifecycleResponse{}, nil + + svc, err := sleep.New() + if err != nil { + log.Warnf("failed to initialize sleep detection: %v", err) + return + } + + err = svc.Register(func(event sleep.EventType) { + switch event { + case sleep.EventTypeSleep: + log.Info("handling sleep event") + if err := s.sleepHandler.HandleSleep(s.rootCtx); err != nil { + log.Errorf("failed to handle sleep event: %v", err) + } + case sleep.EventTypeWakeUp: + log.Info("handling wakeup event") + if err := s.sleepHandler.HandleWakeUp(s.rootCtx); err != nil { + log.Errorf("failed to handle wakeup event: %v", err) + } + } + }) + if err != nil { + log.Errorf("failed to register sleep detector: %v", err) + return + } + + log.Info("sleep detection service initialized") + + go func() { + <-s.rootCtx.Done() + log.Info("stopping sleep event listener") + if err := svc.Deregister(); err != nil { + log.Errorf("failed to deregister sleep detector: %v", err) + } + }() +} + +func sleepDetectorDisabled() bool { + val := os.Getenv(envDisableSleepDetector) + if val == "" { + return false + } + disabled, err := strconv.ParseBool(val) + if err != nil { + log.Warnf("failed to parse %s=%q: %v", envDisableSleepDetector, val, err) + return false + } + return disabled } diff --git a/client/server/ssh_gate.go b/client/server/ssh_gate.go new file mode 100644 index 000000000..01d24687e --- /dev/null +++ b/client/server/ssh_gate.go @@ -0,0 +1,351 @@ +package server + +import ( + "context" + "fmt" + "net/url" + "runtime" + "strings" + + log "github.com/sirupsen/logrus" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/daemonaddr" + "github.com/netbirdio/netbird/client/internal/ipcauth" + "github.com/netbirdio/netbird/client/internal/localmetrics" + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/util" +) + +// The daemon runs as root/LocalSystem, so a handful of config changes cross the +// user-to-root boundary and are restricted to privileged callers: +// +// - Enabling SSH root login, or disabling SSH authentication, turns the +// daemon's SSH server into a root (or unauthenticated) shell. +// - Enabling the SSH server at all is what makes the above reachable, and a +// profile the caller owns is not a privilege they hold. +// - While the SSH server is enabled, repointing the profile at another +// management identity hands SSH authorization decisions, including which +// keys and users are accepted, to whoever controls that identity. Changing +// the management URL and deregistering the peer are both ways to do that. +// - Binding the local metrics endpoint to a non-loopback address publishes +// peer names and connectivity state to the network without authentication. +// +// Everything else stays unauthenticated, so this is not an authorization model: +// it only refuses the changes that would let a local user become root. A caller +// whose identity cannot be established is refused as well. + +// privilegedConfigChange is the subset of a config request that crosses the +// user-to-root boundary. Fields are nil or empty when the request leaves them +// untouched. +type privilegedConfigChange struct { + managementURL string + serverSSHAllowed *bool + remoteJobsAllowed *bool + enableSSHRoot *bool + disableSSHAuth *bool + enableLocalMetrics *bool + localMetricsAddress *string +} + +func privilegedChangeFromSetConfig(msg *proto.SetConfigRequest) privilegedConfigChange { + return privilegedConfigChange{ + managementURL: msg.GetManagementUrl(), + serverSSHAllowed: msg.ServerSSHAllowed, + remoteJobsAllowed: msg.RemoteJobsAllowed, + enableSSHRoot: msg.EnableSSHRoot, + disableSSHAuth: msg.DisableSSHAuth, + enableLocalMetrics: msg.EnableLocalMetrics, + localMetricsAddress: msg.LocalMetricsAddress, + } +} + +func privilegedChangeFromLogin(msg *proto.LoginRequest) privilegedConfigChange { + return privilegedConfigChange{ + managementURL: msg.GetManagementUrl(), + serverSSHAllowed: msg.ServerSSHAllowed, + remoteJobsAllowed: msg.RemoteJobsAllowed, + enableSSHRoot: msg.EnableSSHRoot, + disableSSHAuth: msg.DisableSSHAuth, + enableLocalMetrics: msg.EnableLocalMetrics, + localMetricsAddress: msg.LocalMetricsAddress, + } +} + +// requirePrivilegeForConfigChange refuses the privileged parts of a config +// change when the caller is not root/administrator. stored is the profile's +// current config, or nil when it has none yet. +// +// Each check compares against the stored value so that a request restating a +// value it does not change is never refused: a UI that submits the whole +// settings form must not start failing once an administrator has enabled SSH. +func requirePrivilegeForConfigChange(ctx context.Context, stored *profilemanager.Config, change privilegedConfigChange) error { + if enables(storedFlag(stored, func(c *profilemanager.Config) *bool { return c.EnableSSHRoot }), change.enableSSHRoot) { + return denyPrivileged(ctx, "enabling SSH root login", ipcauth.UpCommand("--enable-ssh-root")) + } + + if enables(storedFlag(stored, func(c *profilemanager.Config) *bool { return c.DisableSSHAuth }), change.disableSSHAuth) { + return denyPrivileged(ctx, "disabling SSH authentication", ipcauth.UpCommand("--disable-ssh-auth")) + } + + if enables(sshServerCurrentlyAllowed(stored), change.serverSSHAllowed) { + return denyPrivileged(ctx, "enabling the NetBird SSH server", ipcauth.UpCommand("--allow-server-ssh")) + } + + // Enabling remote jobs lets the management server run jobs (e.g. debug + // bundles) on this host, so turning it on crosses the user-to-root + // boundary the same way enabling the SSH server does. The stored value + // defaults to off (nil = off), so a legacy config is correctly seen as + // off and turning it on requires privilege. + if enables(storedFlag(stored, func(c *profilemanager.Config) *bool { return c.RemoteJobsAllowed }), change.remoteJobsAllowed) { + return denyPrivileged(ctx, "enabling remote jobs", ipcauth.UpCommand("--allow-remote-jobs")) + } + + if addr, exposes := exposesLocalMetrics(stored, change); exposes { + return denyPrivileged(ctx, + "exposing the local metrics endpoint on a non-loopback address", + ipcauth.UpCommand("--enable-local-metrics --local-metrics-address "+addr)) + } + + // Only guard the management binding while the SSH server is enabled: that is + // when the management identity decides who may open a shell here. + if !sshServerEnabled(stored) { + return nil + } + + if change.managementURL != "" && !sameManagementURL(stored.ManagementURL, change.managementURL) { + return denyPrivileged(ctx, + "changing the management URL while the NetBird SSH server is enabled", + ipcauth.UpCommand("-m "+change.managementURL)) + } + + return nil +} + +// requirePrivilegeForDeregistration refuses to deregister the peer from the +// management server when the caller is not privileged and the profile has the +// SSH server enabled. Deregistering frees the peer's key to be registered +// against another management identity, which is the same handover the +// management URL check refuses. +// +// Callers that treat deregistration as best-effort (profile removal) continue +// without it; callers that were asked to deregister surface the error. +func requirePrivilegeForDeregistration(ctx context.Context, cfg *profilemanager.Config) error { + if !sshServerEnabled(cfg) { + return nil + } + + return denyPrivileged(ctx, + "deregistering this peer while the NetBird SSH server is enabled", + ipcauth.ElevatedCommand("netbird logout")) +} + +// denyPrivileged returns nil when the caller is privileged, and otherwise a +// PermissionDenied whose message names the action and the command that performs +// it with the privileges it needs. The same summary and command ride along as an +// ErrorInfo detail so the CLI and the UI can present them without parsing text. +// +// action reads as the subject of a sentence ("enabling SSH root login"), and +// command is the equivalent command, already elevated for the platform. +func denyPrivileged(ctx context.Context, action, command string) error { + id, ok := ipcauth.CallerIdentity(ctx) + if !ok { + log.Warnf("denying %s: the caller's identity cannot be verified on this control channel", action) + return privilegeError(unidentifiedSummary(action), reinstallCommand()) + } + + if ipcauth.IsPrivilegedCaller(id) { + log.Infof("allowing %s for privileged caller %s", action, id) + return nil + } + + log.Warnf("denying %s for unprivileged caller %s", action, id) + actor, command := requiredActor(command) + return privilegeError(privilegeSummary(action, actor), command) +} + +// requiredActor names who may perform the operation and adjusts the command to +// match. A daemon that is not itself privileged delegates to its own identity, so +// telling that host's user to become root is wrong twice over: root is not what the +// daemon checks for, and a rootless container has neither root nor sudo. +func requiredActor(command string) (string, string) { + self, delegates := ipcauth.SelfDelegatesTo() + if !delegates { + return ipcauth.PrivilegedActor(), command + } + return fmt.Sprintf("the user the daemon runs as (%s)", self), strings.ReplaceAll(command, "sudo ", "") +} + +// privilegeError builds the PermissionDenied carrying summary and command. +func privilegeError(summary, command string) error { + st := gstatus.New(codes.PermissionDenied, fmt.Sprintf("%s\n\n%s", summary, command)) + + detailed, err := st.WithDetails(&errdetails.ErrorInfo{ + Reason: ipcauth.ErrorReasonPrivilegeRequired, + Domain: ipcauth.ErrorDomain, + Metadata: map[string]string{ + ipcauth.ErrorMetaSummary: summary, + ipcauth.ErrorMetaCommand: command, + }, + }) + if err != nil { + log.Debugf("attach privilege error detail: %v", err) + return st.Err() + } + return detailed.Err() +} + +// privilegeSummary states what is refused and what it needs, in one sentence +// that reads the same in a dialog and in a terminal. +func privilegeSummary(action, actor string) string { + return fmt.Sprintf("%s requires %s.", capitalize(action), actor) +} + +// unidentifiedSummary covers a control channel that carries no caller identity. +// Elevating does not help there, so it points at the daemon's socket instead. +func unidentifiedSummary(action string) string { + return fmt.Sprintf("%s requires %s, and the daemon cannot verify who is calling over its current socket. "+ + "Reinstall the service on a socket that carries the caller's identity.", capitalize(action), ipcauth.PrivilegedActor()) +} + +// reinstallCommand is the command that moves the daemon onto a socket whose +// callers can be identified. +func reinstallCommand() string { + if runtime.GOOS == "windows" { + return fmt.Sprintf("netbird service install --daemon-addr %s", daemonaddr.WindowsPipeAddr) + } + return "sudo netbird service install --daemon-addr unix:///var/run/netbird.sock" +} + +func capitalize(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +// enables reports whether requested turns a flag on that is currently off. A +// request that restates the stored value, or turns the flag off, is not a +// privileged change. +func enables(stored, requested *bool) bool { + if requested == nil || !*requested { + return false + } + return stored == nil || !*stored +} + +// storedFlag reads a flag from the stored config, tolerating a config that does +// not exist yet. +func storedFlag(cfg *profilemanager.Config, get func(*profilemanager.Config) *bool) *bool { + if cfg == nil { + return nil + } + return get(cfg) +} + +// sshServerEnabled reports whether the profile currently runs the SSH server. +// +// A nil flag means ON, matching what the engine does with the same config +// (util.ReturnBoolWithDefaultTrue in internal/connect.go, kept for configs written +// before the flag existed). Reading it as OFF here would open the management-URL +// and deregistration guards on exactly those legacy hosts, whose SSH server is +// running. Configs loaded through profilemanager have already been materialised by +// apply(), so this is the same answer by a route that does not depend on that. +func sshServerEnabled(cfg *profilemanager.Config) bool { + if cfg == nil { + return false + } + return util.ReturnBoolWithDefaultTrue(cfg.ServerSSHAllowed) +} + +// sshServerCurrentlyAllowed is the value an enable request is compared against. It +// shares sshServerEnabled's nil-means-on default, so restating "on" for a legacy +// config is correctly seen as no change. +func sshServerCurrentlyAllowed(cfg *profilemanager.Config) *bool { + enabled := sshServerEnabled(cfg) + if cfg == nil { + return nil + } + return &enabled +} + +// exposesLocalMetrics reports whether the change would leave the metrics +// endpoint enabled on an address that is not confirmed loopback, and returns +// that address. A request that restates the stored state is not a change, so a +// settings form resubmitted after an administrator opened the endpoint is not +// refused. +func exposesLocalMetrics(stored *profilemanager.Config, change privilegedConfigChange) (string, bool) { + storedEnabled, storedAddr := storedLocalMetrics(stored) + + enabled := storedEnabled + if change.enableLocalMetrics != nil { + enabled = *change.enableLocalMetrics + } + addr := storedAddr + if change.localMetricsAddress != nil { + addr = metricsAddrOrDefault(*change.localMetricsAddress) + } + + if !enabled || localmetrics.IsLoopback(addr) { + return "", false + } + if storedEnabled && storedAddr == addr { + return "", false + } + return addr, true +} + +// storedLocalMetrics reads the metrics settings from the stored config, +// tolerating a config that does not exist yet. +func storedLocalMetrics(cfg *profilemanager.Config) (bool, string) { + if cfg == nil { + return false, localmetrics.DefaultListenAddress + } + return cfg.LocalMetricsEnabled, metricsAddrOrDefault(cfg.LocalMetricsAddress) +} + +func metricsAddrOrDefault(addr string) string { + if addr == "" { + return localmetrics.DefaultListenAddress + } + return addr +} + +// sameManagementURL reports whether requested addresses the same management +// server as stored, comparing scheme, host and effective port so that an +// equivalent spelling ("https://api.netbird.io" for a stored +// "https://api.netbird.io:443") is not treated as a change. It fails closed: +// anything unparseable counts as a change and therefore needs privilege. +func sameManagementURL(stored *url.URL, requested string) bool { + if stored == nil { + return false + } + + // Normalise the requested URL through the config layer's own parser, so the + // comparison cannot drift from how the value would actually be stored. + parsed, err := profilemanager.ParseServiceURL("Management URL", requested) + if err != nil { + return false + } + + return stored.Scheme == parsed.Scheme && + stored.Hostname() == parsed.Hostname() && + effectivePort(stored) == effectivePort(parsed) +} + +func effectivePort(u *url.URL) string { + if port := u.Port(); port != "" { + return port + } + switch u.Scheme { + case "https": + return "443" + case "http": + return "80" + default: + return "" + } +} diff --git a/client/server/ssh_gate_test.go b/client/server/ssh_gate_test.go new file mode 100644 index 000000000..b4712c64a --- /dev/null +++ b/client/server/ssh_gate_test.go @@ -0,0 +1,474 @@ +package server + +import ( + "context" + "net/url" + "os" + "runtime" + "strings" + "testing" + + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/peer" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/ipcauth" + "github.com/netbirdio/netbird/client/internal/profilemanager" +) + +// ctxWithIdentity builds a request context carrying the identity the transport +// credentials would have attached. +func ctxWithIdentity(id ipcauth.Identity) context.Context { + return peer.NewContext(context.Background(), &peer.Peer{ + AuthInfo: ipcauth.AuthInfo{ + CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity}, + Identity: id, + }, + }) +} + +// unprivUID is deliberately not this process's own uid. An unprivileged daemon +// treats a caller sharing its identity as privileged (rootless containers), and +// the test binary would otherwise stand in for both the daemon and the caller. +// os.Geteuid returns -1 on Windows, where identities are SIDs instead and this is +// unused. +var unprivUID = uint32(os.Geteuid() + 1) + +// The fabricated identities have to be shaped like the platform's: a uid says +// nothing on Windows, and a zero uid there would read as root and be privileged. +func rootCtx() context.Context { return ctxWithIdentity(privilegedIdentity()) } +func userCtx() context.Context { return ctxWithIdentity(unprivilegedIdentity()) } + +func privilegedIdentity() ipcauth.Identity { + if runtime.GOOS == "windows" { + // LocalSystem, which is what the Windows service account is. + return ipcauth.Identity{SID: "S-1-5-18"} + } + return ipcauth.Identity{UID: 0} +} + +func unprivilegedIdentity() ipcauth.Identity { + if runtime.GOOS == "windows" { + // A plain user SID: no groups, so no BUILTIN\Administrators, and not + // elevated. + return ipcauth.Identity{SID: "S-1-5-21-1-2-3-1001"} + } + return ipcauth.Identity{UID: unprivUID, GID: unprivUID} +} +func noIdentityCtx() context.Context { return context.Background() } + +func boolPtr(v bool) *bool { return &v } + +func strPtr(v string) *string { return &v } + +func mustURL(t *testing.T, raw string) *url.URL { + t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("parse %q: %v", raw, err) + } + return u +} + +func assertDenied(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Fatal("expected the change to be refused, got nil") + } + st := gstatus.Convert(err) + if st.Code() != codes.PermissionDenied { + t.Fatalf("code = %v, want PermissionDenied", st.Code()) + } + // The refusal must be machine-readable: the CLI and the UI render the + // summary and command from the detail rather than parsing the message. + var info *errdetails.ErrorInfo + for _, d := range st.Details() { + if got, ok := d.(*errdetails.ErrorInfo); ok { + info = got + } + } + if info == nil { + t.Fatal("refusal carries no ErrorInfo detail") + } + if info.GetReason() != ipcauth.ErrorReasonPrivilegeRequired || info.GetDomain() != ipcauth.ErrorDomain { + t.Fatalf("detail = %s/%s, want %s/%s", info.GetDomain(), info.GetReason(), ipcauth.ErrorDomain, ipcauth.ErrorReasonPrivilegeRequired) + } + if info.GetMetadata()[ipcauth.ErrorMetaSummary] == "" { + t.Error("detail carries no summary") + } + if info.GetMetadata()[ipcauth.ErrorMetaCommand] == "" { + t.Error("detail carries no command") + } +} + +func assertAllowed(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatalf("expected the change to be allowed, got %v", err) + } +} + +func TestRequirePrivilegeForConfigChange_SSHFlags(t *testing.T) { + tests := []struct { + name string + stored *profilemanager.Config + change privilegedConfigChange + privileged bool + wantDeny bool + }{ + { + name: "enabling the ssh server unprivileged is refused", + stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)}, + change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)}, + wantDeny: true, + }, + { + name: "enabling the ssh server as root is allowed", + stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)}, + change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)}, + privileged: true, + }, + { + name: "restating an already enabled ssh server is not a change", + stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)}, + change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)}, + }, + { + name: "turning the ssh server off is not guarded", + stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)}, + change: privilegedConfigChange{serverSSHAllowed: boolPtr(false)}, + }, + { + name: "a profile with no config yet counts as off, so enabling is refused", + stored: nil, + change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)}, + wantDeny: true, + }, + { + name: "enabling ssh root login unprivileged is refused", + stored: &profilemanager.Config{EnableSSHRoot: boolPtr(false)}, + change: privilegedConfigChange{enableSSHRoot: boolPtr(true)}, + wantDeny: true, + }, + { + name: "restating ssh root login is not a change", + stored: &profilemanager.Config{EnableSSHRoot: boolPtr(true)}, + change: privilegedConfigChange{enableSSHRoot: boolPtr(true)}, + }, + { + name: "turning ssh root login off is not guarded", + stored: &profilemanager.Config{EnableSSHRoot: boolPtr(true)}, + change: privilegedConfigChange{enableSSHRoot: boolPtr(false)}, + }, + { + name: "disabling ssh authentication unprivileged is refused", + stored: &profilemanager.Config{DisableSSHAuth: boolPtr(false)}, + change: privilegedConfigChange{disableSSHAuth: boolPtr(true)}, + wantDeny: true, + }, + { + name: "re-enabling ssh authentication is not guarded", + stored: &profilemanager.Config{DisableSSHAuth: boolPtr(true)}, + change: privilegedConfigChange{disableSSHAuth: boolPtr(false)}, + }, + { + name: "enabling remote jobs unprivileged is refused", + stored: &profilemanager.Config{RemoteJobsAllowed: boolPtr(false)}, + change: privilegedConfigChange{remoteJobsAllowed: boolPtr(true)}, + wantDeny: true, + }, + { + name: "enabling remote jobs as root is allowed", + stored: &profilemanager.Config{RemoteJobsAllowed: boolPtr(false)}, + change: privilegedConfigChange{remoteJobsAllowed: boolPtr(true)}, + privileged: true, + }, + { + name: "a profile with no config yet counts as off, so enabling remote jobs is refused", + stored: nil, + change: privilegedConfigChange{remoteJobsAllowed: boolPtr(true)}, + wantDeny: true, + }, + { + name: "restating already-enabled remote jobs is not a change", + stored: &profilemanager.Config{RemoteJobsAllowed: boolPtr(true)}, + change: privilegedConfigChange{remoteJobsAllowed: boolPtr(true)}, + }, + { + name: "turning remote jobs off is not guarded", + stored: &profilemanager.Config{RemoteJobsAllowed: boolPtr(true)}, + change: privilegedConfigChange{remoteJobsAllowed: boolPtr(false)}, + }, + { + name: "a request that touches none of the guarded fields is allowed", + stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)}, + change: privilegedConfigChange{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := userCtx() + if tt.privileged { + ctx = rootCtx() + } + err := requirePrivilegeForConfigChange(ctx, tt.stored, tt.change) + if tt.wantDeny { + assertDenied(t, err) + return + } + assertAllowed(t, err) + }) + } +} + +func TestRequirePrivilegeForConfigChange_LocalMetrics(t *testing.T) { + exposed := &profilemanager.Config{LocalMetricsEnabled: true, LocalMetricsAddress: "0.0.0.0:9191"} + + tests := []struct { + name string + stored *profilemanager.Config + change privilegedConfigChange + privileged bool + wantDeny bool + }{ + { + name: "binding a non-loopback address unprivileged is refused", + stored: &profilemanager.Config{}, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("0.0.0.0:9191")}, + wantDeny: true, + }, + { + name: "binding a non-loopback address as root is allowed", + stored: &profilemanager.Config{}, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("0.0.0.0:9191")}, + privileged: true, + }, + { + name: "enabling on the default loopback address is not guarded", + stored: &profilemanager.Config{}, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true)}, + }, + { + name: "enabling on an explicit loopback address is not guarded", + stored: &profilemanager.Config{}, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("127.0.0.1:9999")}, + }, + { + name: "enabling on the IPv6 loopback address is not guarded", + stored: &profilemanager.Config{}, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("[::1]:9191")}, + }, + { + // The address alone does nothing while the endpoint stays off. + name: "a non-loopback address without enabling is not guarded", + stored: &profilemanager.Config{}, + change: privilegedConfigChange{localMetricsAddress: strPtr("0.0.0.0:9191")}, + }, + { + name: "widening an already enabled loopback endpoint is refused", + stored: &profilemanager.Config{LocalMetricsEnabled: true, LocalMetricsAddress: "127.0.0.1:9191"}, + change: privilegedConfigChange{localMetricsAddress: strPtr("0.0.0.0:9191")}, + wantDeny: true, + }, + { + name: "restating an already exposed endpoint is not a change", + stored: exposed, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("0.0.0.0:9191")}, + }, + { + name: "turning an exposed endpoint off is not guarded", + stored: exposed, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(false)}, + }, + { + name: "re-enabling an exposed endpoint that was turned off is refused", + stored: &profilemanager.Config{LocalMetricsEnabled: false, LocalMetricsAddress: "0.0.0.0:9191"}, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true)}, + wantDeny: true, + }, + { + // Fail closed: an address that cannot be parsed is not confirmed loopback. + name: "an unparseable address is refused", + stored: &profilemanager.Config{}, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("not-an-address")}, + wantDeny: true, + }, + { + name: "a profile with no config yet counts as off, so exposing is refused", + stored: nil, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("0.0.0.0:9191")}, + wantDeny: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := userCtx() + if tt.privileged { + ctx = rootCtx() + } + err := requirePrivilegeForConfigChange(ctx, tt.stored, tt.change) + if tt.wantDeny { + assertDenied(t, err) + return + } + assertAllowed(t, err) + }) + } +} + +func TestRequirePrivilegeForConfigChange_ManagementURL(t *testing.T) { + sshOn := func(raw string) *profilemanager.Config { + return &profilemanager.Config{ServerSSHAllowed: boolPtr(true), ManagementURL: mustURL(t, raw)} + } + sshOff := func(raw string) *profilemanager.Config { + return &profilemanager.Config{ServerSSHAllowed: boolPtr(false), ManagementURL: mustURL(t, raw)} + } + + tests := []struct { + name string + stored *profilemanager.Config + requested string + privileged bool + wantDeny bool + }{ + { + name: "moving the binding while ssh is enabled is refused", + stored: sshOn("https://api.netbird.io:443"), + requested: "https://attacker.example.com:443", + wantDeny: true, + }, + { + name: "moving the binding as root is allowed", + stored: sshOn("https://api.netbird.io:443"), + requested: "https://selfhosted.example.com:443", + privileged: true, + }, + { + name: "the same url restated is not a change", + stored: sshOn("https://api.netbird.io:443"), + requested: "https://api.netbird.io:443", + }, + { + name: "an equivalent spelling of the same url is not a change", + stored: sshOn("https://api.netbird.io:443"), + requested: "https://api.netbird.io", + }, + { + name: "an equivalent spelling with an explicit http port is not a change", + stored: sshOn("http://mgmt.internal:80"), + requested: "http://mgmt.internal", + }, + { + name: "a different port on the same host is a change", + stored: sshOn("https://api.netbird.io:443"), + requested: "https://api.netbird.io:8443", + wantDeny: true, + }, + { + name: "a different scheme on the same host is a change", + stored: sshOn("https://mgmt.internal:443"), + requested: "http://mgmt.internal:443", + wantDeny: true, + }, + { + name: "with ssh disabled the binding is not guarded at all", + stored: sshOff("https://api.netbird.io:443"), + requested: "https://attacker.example.com:443", + }, + { + name: "an unparseable url fails closed", + stored: sshOn("https://api.netbird.io:443"), + requested: "ht tp://%zz", + wantDeny: true, + }, + { + name: "an empty url leaves the binding alone", + stored: sshOn("https://api.netbird.io:443"), + requested: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := userCtx() + if tt.privileged { + ctx = rootCtx() + } + err := requirePrivilegeForConfigChange(ctx, tt.stored, privilegedConfigChange{managementURL: tt.requested}) + if tt.wantDeny { + assertDenied(t, err) + return + } + assertAllowed(t, err) + }) + } +} + +// A caller the daemon cannot identify must be refused, not trusted: that is the +// state on a TCP daemon socket, where no peer credentials exist. +func TestRequirePrivilegeForConfigChange_UnidentifiedCallerIsRefused(t *testing.T) { + err := requirePrivilegeForConfigChange(noIdentityCtx(), + &profilemanager.Config{ServerSSHAllowed: boolPtr(false)}, + privilegedConfigChange{serverSSHAllowed: boolPtr(true)}) + assertDenied(t, err) + + // The guidance must point at the socket rather than at sudo, since elevating + // would not help. + st := gstatus.Convert(err) + if !strings.Contains(st.Message(), "service install") { + t.Errorf("message %q does not tell the operator how to fix the socket", st.Message()) + } +} + +func TestRequirePrivilegeForDeregistration(t *testing.T) { + tests := []struct { + name string + cfg *profilemanager.Config + privileged bool + wantDeny bool + }{ + { + name: "deregistering while ssh is enabled is refused", + cfg: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)}, + wantDeny: true, + }, + { + name: "deregistering while ssh is enabled is allowed for root", + cfg: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)}, + privileged: true, + }, + { + name: "deregistering with ssh disabled is not guarded", + cfg: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)}, + }, + { + name: "deregistering a profile with no config is not guarded", + cfg: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := userCtx() + if tt.privileged { + ctx = rootCtx() + } + err := requirePrivilegeForDeregistration(ctx, tt.cfg) + if tt.wantDeny { + assertDenied(t, err) + return + } + assertAllowed(t, err) + }) + } +} + +// privilegedTestCtx is the context a handler-level test should use when it is +// standing in for a root/administrator caller. Tests that drive the handlers +// directly have no transport credentials, and the privileged-change gate refuses +// a caller it cannot identify. +func privilegedTestCtx() context.Context { return rootCtx() } diff --git a/client/server/state.go b/client/server/state.go index f2d823465..a4e91468e 100644 --- a/client/server/state.go +++ b/client/server/state.go @@ -46,7 +46,7 @@ func (s *Server) CleanState(ctx context.Context, req *proto.CleanStateRequest) ( if req.All { // Reuse existing cleanup logic for all states - if err := restoreResidualState(ctx, statePath); err != nil { + if err := RestoreResidualState(ctx, statePath); err != nil { return nil, status.Errorf(codes.Internal, "failed to clean all states: %v", err) } @@ -113,9 +113,9 @@ func (s *Server) DeleteState(ctx context.Context, req *proto.DeleteStateRequest) }, nil } -// restoreResidualState checks if the client was not shut down in a clean way and restores residual if required. +// RestoreResidualState checks if the client was not shut down in a clean way and restores residual if required. // Otherwise, we might not be able to connect to the management server to retrieve new config. -func restoreResidualState(ctx context.Context, statePath string) error { +func RestoreResidualState(ctx context.Context, statePath string) error { if statePath == "" { return nil } diff --git a/client/server/status_stream.go b/client/server/status_stream.go new file mode 100644 index 000000000..c6ba547eb --- /dev/null +++ b/client/server/status_stream.go @@ -0,0 +1,57 @@ +package server + +import ( + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/proto" +) + +// SubscribeStatus pushes a fresh StatusResponse on every connection state +// change. The first message is the current snapshot, so a re-subscribing +// client doesn't need to also call Status. Subsequent messages fire when +// the peer recorder reports any of: connected/disconnected/connecting, +// management or signal flip, address change, or peers list change. +// +// The change channel coalesces bursts to a single tick. If the consumer +// is slow the daemon drops extras (not blocks), and the next snapshot +// the consumer pulls already reflects everything. +func (s *Server) SubscribeStatus(req *proto.StatusRequest, stream proto.DaemonService_SubscribeStatusServer) error { + subID, ch := s.statusRecorder.SubscribeToStateChanges() + defer func() { + s.statusRecorder.UnsubscribeFromStateChanges(subID) + log.Debug("client unsubscribed from status updates") + }() + + log.Debug("client subscribed to status updates") + + if err := s.sendStatusSnapshot(req, stream); err != nil { + return err + } + + for { + select { + case _, ok := <-ch: + if !ok { + return nil + } + if err := s.sendStatusSnapshot(req, stream); err != nil { + return err + } + case <-stream.Context().Done(): + return nil + } + } +} + +func (s *Server) sendStatusSnapshot(req *proto.StatusRequest, stream proto.DaemonService_SubscribeStatusServer) error { + resp, err := s.buildStatusResponse(stream.Context(), req) + if err != nil { + log.Warnf("build status snapshot for stream: %v", err) + return err + } + if err := stream.Send(resp); err != nil { + log.Warnf("send status snapshot to stream: %v", err) + return err + } + return nil +} diff --git a/client/server/trace.go b/client/server/trace.go index e4ac91487..7fea31c49 100644 --- a/client/server/trace.go +++ b/client/server/trace.go @@ -24,14 +24,9 @@ func (s *Server) TracePacket(_ context.Context, req *proto.TracePacketRequest) ( return nil, err } - srcAddr, err := s.parseAddress(req.GetSourceIp(), engine) + srcAddr, dstAddr, err := s.resolveTraceAddresses(req.GetSourceIp(), req.GetDestinationIp(), engine) if err != nil { - return nil, fmt.Errorf("invalid source IP address: %w", err) - } - - dstAddr, err := s.parseAddress(req.GetDestinationIp(), engine) - if err != nil { - return nil, fmt.Errorf("invalid destination IP address: %w", err) + return nil, err } protocol, err := s.parseProtocol(req.GetProtocol()) @@ -89,16 +84,73 @@ func (s *Server) getPacketTracer() (packetTracer, *internal.Engine, error) { return tracer, engine, nil } -func (s *Server) parseAddress(addr string, engine *internal.Engine) (netip.Addr, error) { - if addr == "self" { - return engine.GetWgAddr(), nil +// resolveTraceAddresses parses src/dst, resolving "self" to the local overlay +// address matching the peer's address family. +func (s *Server) resolveTraceAddresses(src, dst string, engine *internal.Engine) (netip.Addr, netip.Addr, error) { + srcSelf := src == "self" + dstSelf := dst == "self" + + if srcSelf && dstSelf { + return netip.Addr{}, netip.Addr{}, fmt.Errorf("both source and destination cannot be 'self'") } + var srcAddr, dstAddr netip.Addr + var err error + + // Parse the non-self address first so we know the family for self resolution. + if !srcSelf { + if srcAddr, err = parseAddr(src); err != nil { + return netip.Addr{}, netip.Addr{}, fmt.Errorf("invalid source IP: %w", err) + } + } + if !dstSelf { + if dstAddr, err = parseAddr(dst); err != nil { + return netip.Addr{}, netip.Addr{}, fmt.Errorf("invalid destination IP: %w", err) + } + } + + // Determine the peer address to pick the right self address. + peer := srcAddr + if srcSelf { + peer = dstAddr + } + + if srcSelf { + if srcAddr, err = selfAddr(engine, peer); err != nil { + return netip.Addr{}, netip.Addr{}, err + } + } + if dstSelf { + if dstAddr, err = selfAddr(engine, peer); err != nil { + return netip.Addr{}, netip.Addr{}, err + } + } + + return srcAddr, dstAddr, nil +} + +func selfAddr(engine *internal.Engine, peer netip.Addr) (netip.Addr, error) { + var addr netip.Addr + if peer.Is6() { + addr = engine.GetWgV6Addr() + } else { + addr = engine.GetWgAddr() + } + if !addr.IsValid() { + family := "IPv4" + if peer.Is6() { + family = "IPv6" + } + return netip.Addr{}, fmt.Errorf("no local %s overlay address configured", family) + } + return addr, nil +} + +func parseAddr(addr string) (netip.Addr, error) { a, err := netip.ParseAddr(addr) if err != nil { return netip.Addr{}, err } - return a.Unmap(), nil } diff --git a/client/ssh/auth/auth.go b/client/ssh/auth/auth.go index 079282fdc..92f517fac 100644 --- a/client/ssh/auth/auth.go +++ b/client/ssh/auth/auth.go @@ -3,6 +3,7 @@ package auth import ( "errors" "fmt" + "slices" "sync" log "github.com/sirupsen/logrus" @@ -155,6 +156,24 @@ func (a *Authorizer) GetUserIDClaim() string { return a.userIDClaim } +// Config returns the authorization currently in force. The user list and the +// machine-user map are copies; the originals stay in use here. +func (a *Authorizer) Config() *Config { + a.mu.RLock() + defer a.mu.RUnlock() + + machineUsers := make(map[string][]uint32, len(a.machineUsers)) + for osUser, indexes := range a.machineUsers { + machineUsers[osUser] = slices.Clone(indexes) + } + + return &Config{ + UserIDClaim: a.userIDClaim, + AuthorizedUsers: slices.Clone(a.authorizedUsers), + MachineUsers: machineUsers, + } +} + // findUserIndex finds the index of a hashed user ID in the authorized users list // Returns the index and true if found, 0 and false if not found func (a *Authorizer) findUserIndex(hashedUserID sshuserhash.UserIDHash) (int, bool) { diff --git a/client/ssh/client/client.go b/client/ssh/client/client.go index 7f72a72cf..31143a4f4 100644 --- a/client/ssh/client/client.go +++ b/client/ssh/client/client.go @@ -9,7 +9,6 @@ import ( "path/filepath" "runtime" "strconv" - "strings" "time" log "github.com/sirupsen/logrus" @@ -17,7 +16,6 @@ import ( "golang.org/x/crypto/ssh/knownhosts" "golang.org/x/term" "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" "github.com/netbirdio/netbird/client/internal/daemonaddr" "github.com/netbirdio/netbird/client/internal/profilemanager" @@ -25,13 +23,14 @@ import ( nbssh "github.com/netbirdio/netbird/client/ssh" "github.com/netbirdio/netbird/client/ssh/detection" "github.com/netbirdio/netbird/util" + "github.com/netbirdio/netbird/util/netrelay" ) const ( // DefaultDaemonAddr is the default address for the NetBird daemon DefaultDaemonAddr = "unix:///var/run/netbird.sock" // DefaultDaemonAddrWindows is the default address for the NetBird daemon on Windows - DefaultDaemonAddrWindows = "tcp://127.0.0.1:41731" + DefaultDaemonAddrWindows = daemonaddr.WindowsPipeAddr ) // Client wraps crypto/ssh Client for simplified SSH operations @@ -267,7 +266,7 @@ func getDefaultDaemonAddr() string { return addr } if runtime.GOOS == "windows" { - return DefaultDaemonAddrWindows + return daemonaddr.ResolveDaemonAddr(DefaultDaemonAddrWindows) } return daemonaddr.ResolveUnixDaemonAddr(DefaultDaemonAddr) } @@ -314,21 +313,23 @@ func Dial(ctx context.Context, addr, user string, opts DialOptions) (*Client, er // dialSSH establishes an SSH connection without JWT authentication func dialSSH(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*Client, error) { + if config.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, config.Timeout) + defer cancel() + } + dialer := &net.Dialer{} conn, err := dialer.DialContext(ctx, network, addr) if err != nil { return nil, fmt.Errorf("dial %s: %w", addr, err) } - clientConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config) + client, err := nbssh.Handshake(ctx, conn, addr, config) if err != nil { - if closeErr := conn.Close(); closeErr != nil { - log.Debugf("connection close after handshake failure: %v", closeErr) - } - return nil, fmt.Errorf("ssh handshake: %w", err) + return nil, err } - client := ssh.NewClient(clientConn, chans, reqs) return &Client{ client: client, }, nil @@ -409,12 +410,9 @@ func verifyHostKeyViaDaemon(hostname string, remote net.Addr, key ssh.PublicKey, } func connectToDaemon(daemonAddr string) (*grpc.ClientConn, error) { - addr := strings.TrimPrefix(daemonAddr, "tcp://") + target, opts := daemonaddr.DialTarget(daemonAddr) - conn, err := grpc.NewClient( - addr, - grpc.WithTransportCredentials(insecure.NewCredentials()), - ) + conn, err := grpc.NewClient(target, opts...) if err != nil { log.Debugf("failed to create gRPC client for NetBird daemon at %s: %v", daemonAddr, err) return nil, fmt.Errorf("failed to connect to NetBird daemon: %w", err) @@ -536,7 +534,7 @@ func (c *Client) LocalPortForward(ctx context.Context, localAddr, remoteAddr str continue } - go c.handleLocalForward(localConn, remoteAddr) + go c.handleLocalForward(ctx, localConn, remoteAddr) } }() @@ -548,7 +546,7 @@ func (c *Client) LocalPortForward(ctx context.Context, localAddr, remoteAddr str } // handleLocalForward handles a single local port forwarding connection -func (c *Client) handleLocalForward(localConn net.Conn, remoteAddr string) { +func (c *Client) handleLocalForward(ctx context.Context, localConn net.Conn, remoteAddr string) { defer func() { if err := localConn.Close(); err != nil { log.Debugf("local port forwarding: close local connection: %v", err) @@ -571,7 +569,7 @@ func (c *Client) handleLocalForward(localConn net.Conn, remoteAddr string) { } }() - nbssh.BidirectionalCopy(log.NewEntry(log.StandardLogger()), localConn, channel) + netrelay.Relay(ctx, localConn, channel, netrelay.Options{Logger: log.NewEntry(log.StandardLogger())}) } // RemotePortForward sets up remote port forwarding, binding on remote and forwarding to localAddr @@ -653,16 +651,19 @@ func (c *Client) handleRemoteForwardChannels(ctx context.Context, localAddr stri select { case <-ctx.Done(): return - case newChan := <-channelRequests: + case newChan, ok := <-channelRequests: + if !ok { + return + } if newChan != nil { - go c.handleRemoteForwardChannel(newChan, localAddr) + go c.handleRemoteForwardChannel(ctx, newChan, localAddr) } } } } // handleRemoteForwardChannel handles a single forwarded-tcpip channel -func (c *Client) handleRemoteForwardChannel(newChan ssh.NewChannel, localAddr string) { +func (c *Client) handleRemoteForwardChannel(ctx context.Context, newChan ssh.NewChannel, localAddr string) { channel, reqs, err := newChan.Accept() if err != nil { return @@ -675,8 +676,14 @@ func (c *Client) handleRemoteForwardChannel(newChan ssh.NewChannel, localAddr st go ssh.DiscardRequests(reqs) - localConn, err := net.Dial("tcp", localAddr) + // Bound the dial so a black-holed localAddr can't pin the accepted SSH + // channel open indefinitely; the relay itself runs under the outer ctx. + dialCtx, cancelDial := context.WithTimeout(ctx, 10*time.Second) + var dialer net.Dialer + localConn, err := dialer.DialContext(dialCtx, "tcp", localAddr) + cancelDial() if err != nil { + log.Debugf("remote port forwarding: dial %s: %v", localAddr, err) return } defer func() { @@ -685,7 +692,7 @@ func (c *Client) handleRemoteForwardChannel(newChan ssh.NewChannel, localAddr st } }() - nbssh.BidirectionalCopy(log.NewEntry(log.StandardLogger()), localConn, channel) + netrelay.Relay(ctx, localConn, channel, netrelay.Options{Logger: log.NewEntry(log.StandardLogger())}) } // tcpipForwardMsg represents the structure for tcpip-forward requests diff --git a/client/ssh/client/client_privileged_test.go b/client/ssh/client/client_privileged_test.go new file mode 100644 index 000000000..12edbbc06 --- /dev/null +++ b/client/ssh/client/client_privileged_test.go @@ -0,0 +1,118 @@ +//go:build privileged + +package client + +import ( + "context" + "errors" + "runtime" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + cryptossh "golang.org/x/crypto/ssh" + + "github.com/netbirdio/netbird/client/ssh/testutil" +) + +func TestSSHClient_CommandExecution(t *testing.T) { + if runtime.GOOS == "windows" && testutil.IsCI() { + t.Skip("Skipping Windows command execution tests in CI due to S4U authentication issues") + } + + server, _, client := setupTestSSHServerAndClient(t) + defer func() { + err := server.Stop() + require.NoError(t, err) + }() + defer func() { + err := client.Close() + assert.NoError(t, err) + }() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + t.Run("ExecuteCommand captures output", func(t *testing.T) { + output, err := client.ExecuteCommand(ctx, "echo hello") + assert.NoError(t, err) + assert.Contains(t, string(output), "hello") + }) + + t.Run("ExecuteCommandWithIO streams output", func(t *testing.T) { + err := client.ExecuteCommandWithIO(ctx, "echo world") + assert.NoError(t, err) + }) + + t.Run("commands with flags work", func(t *testing.T) { + output, err := client.ExecuteCommand(ctx, "echo -n test_flag") + assert.NoError(t, err) + assert.Equal(t, "test_flag", strings.TrimSpace(string(output))) + }) + + t.Run("non-zero exit codes don't return errors", func(t *testing.T) { + var testCmd string + if runtime.GOOS == "windows" { + testCmd = "echo hello | Select-String notfound" + } else { + testCmd = "echo 'hello' | grep 'notfound'" + } + _, err := client.ExecuteCommand(ctx, testCmd) + assert.NoError(t, err) + }) +} + +func TestSSHClient_ContextCancellation(t *testing.T) { + server, serverAddr, _ := setupTestSSHServerAndClient(t) + defer func() { + err := server.Stop() + require.NoError(t, err) + }() + + t.Run("connection with short timeout", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond) + defer cancel() + + currentUser := testutil.GetTestUsername(t) + _, err := Dial(ctx, serverAddr, currentUser, DialOptions{ + InsecureSkipVerify: true, + }) + if err != nil { + // Check for actual timeout-related errors rather than string matching + assert.True(t, + errors.Is(err, context.DeadlineExceeded) || + errors.Is(err, context.Canceled) || + strings.Contains(err.Error(), "timeout"), + "Expected timeout-related error, got: %v", err) + } + }) + + t.Run("command execution cancellation", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + currentUser := testutil.GetTestUsername(t) + client, err := Dial(ctx, serverAddr, currentUser, DialOptions{ + InsecureSkipVerify: true, + }) + require.NoError(t, err) + defer func() { + if err := client.Close(); err != nil { + t.Logf("client close error: %v", err) + } + }() + + cmdCtx, cmdCancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cmdCancel() + + err = client.ExecuteCommandWithPTY(cmdCtx, "sleep 10") + if err != nil { + var exitMissingErr *cryptossh.ExitMissingError + isValidCancellation := errors.Is(err, context.DeadlineExceeded) || + errors.Is(err, context.Canceled) || + errors.As(err, &exitMissingErr) + assert.True(t, isValidCancellation, "Should handle command cancellation properly") + } + }) +} diff --git a/client/ssh/client/client_test.go b/client/ssh/client/client_test.go index e38e02a86..191362940 100644 --- a/client/ssh/client/client_test.go +++ b/client/ssh/client/client_test.go @@ -15,7 +15,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - cryptossh "golang.org/x/crypto/ssh" "github.com/netbirdio/netbird/client/ssh" sshserver "github.com/netbirdio/netbird/client/ssh/server" @@ -78,53 +77,6 @@ func TestSSHClient_DialWithKey(t *testing.T) { assert.NotNil(t, client.client) } -func TestSSHClient_CommandExecution(t *testing.T) { - if runtime.GOOS == "windows" && testutil.IsCI() { - t.Skip("Skipping Windows command execution tests in CI due to S4U authentication issues") - } - - server, _, client := setupTestSSHServerAndClient(t) - defer func() { - err := server.Stop() - require.NoError(t, err) - }() - defer func() { - err := client.Close() - assert.NoError(t, err) - }() - - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - - t.Run("ExecuteCommand captures output", func(t *testing.T) { - output, err := client.ExecuteCommand(ctx, "echo hello") - assert.NoError(t, err) - assert.Contains(t, string(output), "hello") - }) - - t.Run("ExecuteCommandWithIO streams output", func(t *testing.T) { - err := client.ExecuteCommandWithIO(ctx, "echo world") - assert.NoError(t, err) - }) - - t.Run("commands with flags work", func(t *testing.T) { - output, err := client.ExecuteCommand(ctx, "echo -n test_flag") - assert.NoError(t, err) - assert.Equal(t, "test_flag", strings.TrimSpace(string(output))) - }) - - t.Run("non-zero exit codes don't return errors", func(t *testing.T) { - var testCmd string - if runtime.GOOS == "windows" { - testCmd = "echo hello | Select-String notfound" - } else { - testCmd = "echo 'hello' | grep 'notfound'" - } - _, err := client.ExecuteCommand(ctx, testCmd) - assert.NoError(t, err) - }) -} - func TestSSHClient_ConnectionHandling(t *testing.T) { server, serverAddr, _ := setupTestSSHServerAndClient(t) defer func() { @@ -154,59 +106,6 @@ func TestSSHClient_ConnectionHandling(t *testing.T) { } } -func TestSSHClient_ContextCancellation(t *testing.T) { - server, serverAddr, _ := setupTestSSHServerAndClient(t) - defer func() { - err := server.Stop() - require.NoError(t, err) - }() - - t.Run("connection with short timeout", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond) - defer cancel() - - currentUser := testutil.GetTestUsername(t) - _, err := Dial(ctx, serverAddr, currentUser, DialOptions{ - InsecureSkipVerify: true, - }) - if err != nil { - // Check for actual timeout-related errors rather than string matching - assert.True(t, - errors.Is(err, context.DeadlineExceeded) || - errors.Is(err, context.Canceled) || - strings.Contains(err.Error(), "timeout"), - "Expected timeout-related error, got: %v", err) - } - }) - - t.Run("command execution cancellation", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - currentUser := testutil.GetTestUsername(t) - client, err := Dial(ctx, serverAddr, currentUser, DialOptions{ - InsecureSkipVerify: true, - }) - require.NoError(t, err) - defer func() { - if err := client.Close(); err != nil { - t.Logf("client close error: %v", err) - } - }() - - cmdCtx, cmdCancel := context.WithTimeout(context.Background(), 100*time.Millisecond) - defer cmdCancel() - - err = client.ExecuteCommandWithPTY(cmdCtx, "sleep 10") - if err != nil { - var exitMissingErr *cryptossh.ExitMissingError - isValidCancellation := errors.Is(err, context.DeadlineExceeded) || - errors.Is(err, context.Canceled) || - errors.As(err, &exitMissingErr) - assert.True(t, isValidCancellation, "Should handle command cancellation properly") - } - }) -} - func TestSSHClient_NoAuthMode(t *testing.T) { hostKey, err := ssh.GeneratePrivateKey(ssh.ED25519) require.NoError(t, err) diff --git a/client/ssh/client/terminal_unix.go b/client/ssh/client/terminal_unix.go index aaa3418f9..a963dc8be 100644 --- a/client/ssh/client/terminal_unix.go +++ b/client/ssh/client/terminal_unix.go @@ -12,6 +12,8 @@ import ( log "github.com/sirupsen/logrus" "golang.org/x/crypto/ssh" "golang.org/x/term" + + nbssh "github.com/netbirdio/netbird/client/ssh" ) func (c *Client) setupTerminalMode(ctx context.Context, session *ssh.Session) error { @@ -82,37 +84,7 @@ func (c *Client) setupTerminal(session *ssh.Session, fd int) error { return fmt.Errorf("get terminal size: %w", err) } - modes := ssh.TerminalModes{ - ssh.ECHO: 1, - ssh.TTY_OP_ISPEED: 14400, - ssh.TTY_OP_OSPEED: 14400, - // Ctrl+C - ssh.VINTR: 3, - // Ctrl+\ - ssh.VQUIT: 28, - // Backspace - ssh.VERASE: 127, - // Ctrl+U - ssh.VKILL: 21, - // Ctrl+D - ssh.VEOF: 4, - ssh.VEOL: 0, - ssh.VEOL2: 0, - // Ctrl+Q - ssh.VSTART: 17, - // Ctrl+S - ssh.VSTOP: 19, - // Ctrl+Z - ssh.VSUSP: 26, - // Ctrl+O - ssh.VDISCARD: 15, - // Ctrl+R - ssh.VREPRINT: 18, - // Ctrl+W - ssh.VWERASE: 23, - // Ctrl+V - ssh.VLNEXT: 22, - } + modes := nbssh.DefaultTerminalModes terminal := os.Getenv("TERM") if terminal == "" { diff --git a/client/ssh/client/terminal_windows.go b/client/ssh/client/terminal_windows.go index 462438317..c6156fc26 100644 --- a/client/ssh/client/terminal_windows.go +++ b/client/ssh/client/terminal_windows.go @@ -10,6 +10,8 @@ import ( log "github.com/sirupsen/logrus" "golang.org/x/crypto/ssh" + + nbssh "github.com/netbirdio/netbird/client/ssh" ) const ( @@ -80,28 +82,14 @@ func (c *Client) setupTerminalMode(_ context.Context, session *ssh.Session) erro w, h := c.getWindowsConsoleSize() modes := ssh.TerminalModes{ - ssh.ECHO: 1, - ssh.TTY_OP_ISPEED: 14400, - ssh.TTY_OP_OSPEED: 14400, - ssh.ICRNL: 1, - ssh.OPOST: 1, - ssh.ONLCR: 1, - ssh.ISIG: 1, - ssh.ICANON: 1, - ssh.VINTR: 3, // Ctrl+C - ssh.VQUIT: 28, // Ctrl+\ - ssh.VERASE: 127, // Backspace - ssh.VKILL: 21, // Ctrl+U - ssh.VEOF: 4, // Ctrl+D - ssh.VEOL: 0, - ssh.VEOL2: 0, - ssh.VSTART: 17, // Ctrl+Q - ssh.VSTOP: 19, // Ctrl+S - ssh.VSUSP: 26, // Ctrl+Z - ssh.VDISCARD: 15, // Ctrl+O - ssh.VWERASE: 23, // Ctrl+W - ssh.VLNEXT: 22, // Ctrl+V - ssh.VREPRINT: 18, // Ctrl+R + ssh.ICRNL: 1, + ssh.OPOST: 1, + ssh.ONLCR: 1, + ssh.ISIG: 1, + ssh.ICANON: 1, + } + for mode, value := range nbssh.DefaultTerminalModes { + modes[mode] = value } if err := session.RequestPty("xterm-256color", h, w, modes); err != nil { diff --git a/client/ssh/common.go b/client/ssh/common.go index f6aec5f9c..4ebf8842a 100644 --- a/client/ssh/common.go +++ b/client/ssh/common.go @@ -13,6 +13,7 @@ import ( "golang.org/x/crypto/ssh" "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/util" ) const ( @@ -34,6 +35,19 @@ type HostKeyVerifier interface { VerifySSHHostKey(peerAddress string, key []byte) error } +// PeerKeyLookup returns the stored SSH host key for a peer address. +type PeerKeyLookup func(peerAddress string) ([]byte, bool) + +// VerifySSHHostKey implements HostKeyVerifier by looking up the stored key +// and comparing it against the presented key. +func (l PeerKeyLookup) VerifySSHHostKey(peerAddress string, presentedKey []byte) error { + storedKey, found := l(peerAddress) + if !found { + return ErrPeerNotFound + } + return VerifyHostKey(storedKey, presentedKey, peerAddress) +} + // DaemonHostKeyVerifier implements HostKeyVerifier using the NetBird daemon type DaemonHostKeyVerifier struct { client proto.DaemonServiceClient @@ -92,7 +106,8 @@ func printAuthInstructions(stderr io.Writer, authResponse *proto.RequestJWTAuthR // RequestJWTToken requests or retrieves a JWT token for SSH authentication func RequestJWTToken(ctx context.Context, client proto.DaemonServiceClient, stdout, stderr io.Writer, useCache bool, hint string, openBrowser func(string) error) (string, error) { - req := &proto.RequestJWTAuthRequest{} + // the ssh client runs in the user's session, the daemon does not: tell it what we can see + req := &proto.RequestJWTAuthRequest{HasGraphicalSession: util.HasGraphicalSession()} if hint != "" { req.Hint = &hint } @@ -193,64 +208,3 @@ func buildAddressList(hostname string, remote net.Addr) []string { } return addresses } - -// BidirectionalCopy copies data bidirectionally between two io.ReadWriter connections. -// It waits for both directions to complete before returning. -// The caller is responsible for closing the connections. -func BidirectionalCopy(logger *log.Entry, rw1, rw2 io.ReadWriter) { - done := make(chan struct{}, 2) - - go func() { - if _, err := io.Copy(rw2, rw1); err != nil && !isExpectedCopyError(err) { - logger.Debugf("copy error (1->2): %v", err) - } - done <- struct{}{} - }() - - go func() { - if _, err := io.Copy(rw1, rw2); err != nil && !isExpectedCopyError(err) { - logger.Debugf("copy error (2->1): %v", err) - } - done <- struct{}{} - }() - - <-done - <-done -} - -func isExpectedCopyError(err error) bool { - return errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) -} - -// BidirectionalCopyWithContext copies data bidirectionally between two io.ReadWriteCloser connections. -// It waits for both directions to complete or for context cancellation before returning. -// Both connections are closed when the function returns. -func BidirectionalCopyWithContext(logger *log.Entry, ctx context.Context, conn1, conn2 io.ReadWriteCloser) { - done := make(chan struct{}, 2) - - go func() { - if _, err := io.Copy(conn2, conn1); err != nil && !isExpectedCopyError(err) { - logger.Debugf("copy error (1->2): %v", err) - } - done <- struct{}{} - }() - - go func() { - if _, err := io.Copy(conn1, conn2); err != nil && !isExpectedCopyError(err) { - logger.Debugf("copy error (2->1): %v", err) - } - done <- struct{}{} - }() - - select { - case <-ctx.Done(): - case <-done: - select { - case <-ctx.Done(): - case <-done: - } - } - - _ = conn1.Close() - _ = conn2.Close() -} diff --git a/client/ssh/config/manager.go b/client/ssh/config/manager.go index 6e584b2c3..e15330739 100644 --- a/client/ssh/config/manager.go +++ b/client/ssh/config/manager.go @@ -3,6 +3,7 @@ package config import ( "context" "fmt" + "net/netip" "os" "path/filepath" "runtime" @@ -13,6 +14,7 @@ import ( log "github.com/sirupsen/logrus" nbssh "github.com/netbirdio/netbird/client/ssh" + "github.com/netbirdio/netbird/shared/management/domain" ) const ( @@ -91,7 +93,8 @@ type Manager struct { // PeerSSHInfo represents a peer's SSH configuration information type PeerSSHInfo struct { Hostname string - IP string + IP netip.Addr + IPv6 netip.Addr FQDN string } @@ -210,14 +213,26 @@ func (m *Manager) buildPeerConfig(allHostPatterns []string) (string, error) { func (m *Manager) buildHostPatterns(peer PeerSSHInfo) []string { var hostPatterns []string - if peer.IP != "" { - hostPatterns = append(hostPatterns, peer.IP) + if peer.IP.IsValid() { + hostPatterns = append(hostPatterns, peer.IP.String()) } - if peer.FQDN != "" { + if peer.IPv6.IsValid() { + hostPatterns = append(hostPatterns, peer.IPv6.String()) + } + // Peer FQDNs and hostnames originate from remote peers, so they must be + // validated as plain DNS names before being embedded in the ssh_config + // "Match host" pattern list. This prevents injection of arbitrary + // ssh_config directives via embedded quotes, whitespace, newlines, the + // comma pattern separator, or the "*"/"?" pattern metacharacters. + if domain.IsValidDomainNoWildcard(peer.FQDN) { hostPatterns = append(hostPatterns, peer.FQDN) + } else if peer.FQDN != "" { + log.Warnf("skipping peer FQDN with invalid characters in SSH config: %q", peer.FQDN) } - if peer.Hostname != "" && peer.Hostname != peer.FQDN { + if peer.Hostname != peer.FQDN && domain.IsValidDomainNoWildcard(peer.Hostname) { hostPatterns = append(hostPatterns, peer.Hostname) + } else if peer.Hostname != "" && peer.Hostname != peer.FQDN { + log.Warnf("skipping peer hostname with invalid characters in SSH config: %q", peer.Hostname) } return hostPatterns } @@ -229,8 +244,30 @@ func (m *Manager) writeSSHConfig(sshConfig string) error { return fmt.Errorf("create SSH config directory %s: %w", m.sshConfigDir, err) } - if err := writeFileWithTimeout(sshConfigPath, []byte(sshConfig), 0644); err != nil { - return fmt.Errorf("write SSH config file %s: %w", sshConfigPath, err) + tmp, err := os.CreateTemp(m.sshConfigDir, m.sshConfigFile+".*.tmp") + if err != nil { + return fmt.Errorf("create temp SSH config: %w", err) + } + tmpPath := tmp.Name() + defer func() { + if err := os.Remove(tmpPath); err != nil && !os.IsNotExist(err) { + log.Debugf("remove temp SSH config %s: %v", tmpPath, err) + } + }() + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp SSH config %s: %w", tmpPath, err) + } + + if err := writeFileWithTimeout(tmpPath, []byte(sshConfig), 0644); err != nil { + return fmt.Errorf("write SSH config file %s: %w", tmpPath, err) + } + + if err := os.Chmod(tmpPath, 0644); err != nil { + return fmt.Errorf("chmod SSH config file %s: %w", tmpPath, err) + } + + if err := os.Rename(tmpPath, sshConfigPath); err != nil { + return fmt.Errorf("rename SSH config %s -> %s: %w", tmpPath, sshConfigPath, err) } log.Infof("Created NetBird SSH client config: %s", sshConfigPath) diff --git a/client/ssh/config/manager_test.go b/client/ssh/config/manager_test.go index e7380c7f2..f65d0ba6d 100644 --- a/client/ssh/config/manager_test.go +++ b/client/ssh/config/manager_test.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "net/netip" "os" "path/filepath" "runtime" @@ -28,12 +29,12 @@ func TestManager_SetupSSHClientConfig(t *testing.T) { peers := []PeerSSHInfo{ { Hostname: "peer1", - IP: "100.125.1.1", + IP: netip.MustParseAddr("100.125.1.1"), FQDN: "peer1.nb.internal", }, { Hostname: "peer2", - IP: "100.125.1.2", + IP: netip.MustParseAddr("100.125.1.2"), FQDN: "peer2.nb.internal", }, } @@ -101,7 +102,7 @@ func TestManager_PeerLimit(t *testing.T) { for i := 0; i < MaxPeersForSSHConfig+10; i++ { peers = append(peers, PeerSSHInfo{ Hostname: fmt.Sprintf("peer%d", i), - IP: fmt.Sprintf("100.125.1.%d", i%254+1), + IP: netip.MustParseAddr(fmt.Sprintf("100.125.1.%d", i%254+1)), FQDN: fmt.Sprintf("peer%d.nb.internal", i), }) } @@ -127,8 +128,8 @@ func TestManager_MatchHostFormat(t *testing.T) { } peers := []PeerSSHInfo{ - {Hostname: "peer1", IP: "100.125.1.1", FQDN: "peer1.nb.internal"}, - {Hostname: "peer2", IP: "100.125.1.2", FQDN: "peer2.nb.internal"}, + {Hostname: "peer1", IP: netip.MustParseAddr("100.125.1.1"), FQDN: "peer1.nb.internal"}, + {Hostname: "peer2", IP: netip.MustParseAddr("100.125.1.2"), FQDN: "peer2.nb.internal"}, } err = manager.SetupSSHClientConfig(peers) @@ -147,6 +148,45 @@ func TestManager_MatchHostFormat(t *testing.T) { "should use Match host with comma-separated patterns") } +func TestManager_HostPatternInjection(t *testing.T) { + tempDir, err := os.MkdirTemp("", "netbird-ssh-config-test") + require.NoError(t, err) + defer func() { assert.NoError(t, os.RemoveAll(tempDir)) }() + + manager := &Manager{ + sshConfigDir: filepath.Join(tempDir, "ssh_config.d"), + sshConfigFile: "99-netbird.conf", + } + + // A malicious peer FQDN/hostname attempts to break out of the Match host + // directive and inject arbitrary ssh_config (a ProxyCommand executing a + // command). It must be rejected, not written to the config. + peers := []PeerSSHInfo{ + { + Hostname: "evil\"\n ProxyCommand touch /tmp/pwned\nHost x", + IP: netip.MustParseAddr("100.125.1.1"), + FQDN: "evil\"\n ProxyCommand touch /tmp/pwned\nHost x.nb.internal", + }, + {Hostname: "peer2", IP: netip.MustParseAddr("100.125.1.2"), FQDN: "peer2.nb.internal"}, + } + + err = manager.SetupSSHClientConfig(peers) + require.NoError(t, err) + + configPath := filepath.Join(manager.sshConfigDir, manager.sshConfigFile) + content, err := os.ReadFile(configPath) + require.NoError(t, err) + configStr := string(content) + + assert.NotContains(t, configStr, "ProxyCommand touch /tmp/pwned", + "injected directive must not appear in generated config") + assert.NotContains(t, configStr, "evil", + "malicious pattern must be dropped entirely") + // The valid peer must still be present, on a single Match host line. + assert.Contains(t, configStr, "Match host \"100.125.1.1,100.125.1.2,peer2.nb.internal,peer2\"", + "valid peers must survive, injected patterns dropped") +} + func TestManager_ForcedSSHConfig(t *testing.T) { // Set force environment variable t.Setenv(EnvForceSSHConfig, "true") @@ -167,7 +207,7 @@ func TestManager_ForcedSSHConfig(t *testing.T) { for i := 0; i < MaxPeersForSSHConfig+10; i++ { peers = append(peers, PeerSSHInfo{ Hostname: fmt.Sprintf("peer%d", i), - IP: fmt.Sprintf("100.125.1.%d", i%254+1), + IP: netip.MustParseAddr(fmt.Sprintf("100.125.1.%d", i%254+1)), FQDN: fmt.Sprintf("peer%d.nb.internal", i), }) } diff --git a/client/ssh/handshake.go b/client/ssh/handshake.go new file mode 100644 index 000000000..a718748df --- /dev/null +++ b/client/ssh/handshake.go @@ -0,0 +1,48 @@ +package ssh + +import ( + "context" + "fmt" + "io" + "net" + + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" +) + +// Handshake runs the SSH client handshake on an already dialed conn and +// returns the resulting client. Dialing bounds only the TCP establishment; +// a peer that accepts and then goes silent would block the handshake forever, +// so conn is closed as soon as ctx is done, which unblocks the handshake and +// surfaces the context error. conn is closed on any error. +func Handshake(ctx context.Context, conn net.Conn, addr string, config *ssh.ClientConfig) (*ssh.Client, error) { + stop := context.AfterFunc(ctx, func() { closeHandshake(conn, "conn on context done") }) + + sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config) + if err != nil { + if stop() { + closeHandshake(conn, "conn after handshake error") + } + return nil, handshakeError(ctx, err) + } + + if !stop() { + closeHandshake(sshConn, "ssh conn after context done") + return nil, fmt.Errorf("ssh handshake: %w", ctx.Err()) + } + + return ssh.NewClient(sshConn, chans, reqs), nil +} + +func closeHandshake(c io.Closer, label string) { + if err := c.Close(); err != nil { + log.Debugf("ssh: close %s: %v", label, err) + } +} + +func handshakeError(ctx context.Context, err error) error { + if ctxErr := ctx.Err(); ctxErr != nil { + return fmt.Errorf("ssh handshake: %w: %w", ctxErr, err) + } + return fmt.Errorf("ssh handshake: %w", err) +} diff --git a/client/ssh/handshake_test.go b/client/ssh/handshake_test.go new file mode 100644 index 000000000..77a6f916b --- /dev/null +++ b/client/ssh/handshake_test.go @@ -0,0 +1,90 @@ +package ssh + +import ( + "context" + "errors" + "net" + "testing" + "time" + + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" +) + +func TestHandshake_ContextDeadlineWrapped(t *testing.T) { + conn := dialSilentServer(t) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + _, err := Handshake(ctx, conn, conn.RemoteAddr().String(), testClientConfig()) + require.Error(t, err) + require.True(t, errors.Is(err, context.DeadlineExceeded), "expected context.DeadlineExceeded, got: %v", err) +} + +func TestHandshake_ContextCancelUnblocks(t *testing.T) { + conn := dialSilentServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + time.AfterFunc(50*time.Millisecond, cancel) + + errCh := make(chan error, 1) + go func() { + _, err := Handshake(ctx, conn, conn.RemoteAddr().String(), testClientConfig()) + errCh <- err + }() + + select { + case err := <-errCh: + require.Error(t, err) + require.True(t, errors.Is(err, context.Canceled), "expected context.Canceled, got: %v", err) + case <-time.After(5 * time.Second): + t.Fatal("handshake did not return after context cancellation") + } +} + +func TestHandshake_NonContextErrorNotWrapped(t *testing.T) { + conn := dialSilentServer(t) + require.NoError(t, conn.Close()) + + _, err := Handshake(context.Background(), conn, conn.RemoteAddr().String(), testClientConfig()) + require.Error(t, err) + require.False(t, errors.Is(err, context.Canceled)) + require.False(t, errors.Is(err, context.DeadlineExceeded)) +} + +func testClientConfig() *ssh.ClientConfig { + return &ssh.ClientConfig{ + User: "test", + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + } +} + +// dialSilentServer returns a client conn to a server that accepts and never +// sends anything, so the SSH handshake blocks until the context is done. +func dialSilentServer(t *testing.T) net.Conn { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = listener.Close() }) + + done := make(chan struct{}) + t.Cleanup(func() { close(done) }) + + go func() { + c, err := listener.Accept() + if err != nil { + return + } + defer func() { _ = c.Close() }() + <-done + }() + + conn, err := net.Dial("tcp", listener.Addr().String()) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + return conn +} diff --git a/client/ssh/proxy/proxy.go b/client/ssh/proxy/proxy.go index 59007f75c..070515b57 100644 --- a/client/ssh/proxy/proxy.go +++ b/client/ssh/proxy/proxy.go @@ -9,7 +9,6 @@ import ( "net" "os" "strconv" - "strings" "sync" "time" @@ -17,12 +16,13 @@ import ( log "github.com/sirupsen/logrus" cryptossh "golang.org/x/crypto/ssh" "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" + "github.com/netbirdio/netbird/client/internal/daemonaddr" "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/proto" nbssh "github.com/netbirdio/netbird/client/ssh" "github.com/netbirdio/netbird/client/ssh/detection" + "github.com/netbirdio/netbird/util/netrelay" "github.com/netbirdio/netbird/version" ) @@ -54,8 +54,8 @@ type SSHProxy struct { } func New(daemonAddr, targetHost string, targetPort int, stderr io.Writer, browserOpener func(string) error) (*SSHProxy, error) { - grpcAddr := strings.TrimPrefix(daemonAddr, "tcp://") - grpcConn, err := grpc.NewClient(grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + target, opts := daemonaddr.DialTarget(daemonAddr) + grpcConn, err := grpc.NewClient(target, opts...) if err != nil { return nil, fmt.Errorf("connect to daemon: %w", err) } @@ -321,7 +321,7 @@ func (p *SSHProxy) directTCPIPHandler(_ *ssh.Server, _ *cryptossh.ServerConn, ne return } - dest := fmt.Sprintf("%s:%d", payload.DestAddr, payload.DestPort) + dest := net.JoinHostPort(payload.DestAddr, strconv.Itoa(int(payload.DestPort))) log.Debugf("local port forwarding: %s", dest) backendClient, err := p.getOrCreateBackendClient(sshCtx, sshCtx.User()) @@ -352,7 +352,7 @@ func (p *SSHProxy) directTCPIPHandler(_ *ssh.Server, _ *cryptossh.ServerConn, ne } go cryptossh.DiscardRequests(clientReqs) - nbssh.BidirectionalCopyWithContext(log.NewEntry(log.StandardLogger()), sshCtx, clientChan, backendChan) + netrelay.Relay(sshCtx, clientChan, backendChan, netrelay.Options{Logger: log.NewEntry(log.StandardLogger())}) } func (p *SSHProxy) sftpSubsystemHandler(s ssh.Session, jwtToken string) { @@ -591,7 +591,7 @@ func (p *SSHProxy) handleForwardedChannel(sshCtx ssh.Context, sshConn *cryptossh } go cryptossh.DiscardRequests(clientReqs) - nbssh.BidirectionalCopyWithContext(log.NewEntry(log.StandardLogger()), sshCtx, clientChan, backendChan) + netrelay.Relay(sshCtx, clientChan, backendChan, netrelay.Options{Logger: log.NewEntry(log.StandardLogger())}) } func (p *SSHProxy) dialBackend(ctx context.Context, addr, user, jwtToken string) (*cryptossh.Client, error) { @@ -610,13 +610,10 @@ func (p *SSHProxy) dialBackend(ctx context.Context, addr, user, jwtToken string) return nil, fmt.Errorf("connect to server: %w", err) } - clientConn, chans, reqs, err := cryptossh.NewClientConn(conn, addr, config) - if err != nil { - _ = conn.Close() - return nil, fmt.Errorf("SSH handshake: %w", err) - } + handshakeCtx, cancel := context.WithTimeout(ctx, sshHandshakeTimeout) + defer cancel() - return cryptossh.NewClient(clientConn, chans, reqs), nil + return nbssh.Handshake(handshakeCtx, conn, addr, config) } func (p *SSHProxy) verifyHostKey(hostname string, remote net.Addr, key cryptossh.PublicKey) error { diff --git a/client/ssh/proxy/proxy_privileged_test.go b/client/ssh/proxy/proxy_privileged_test.go new file mode 100644 index 000000000..94495a3ae --- /dev/null +++ b/client/ssh/proxy/proxy_privileged_test.go @@ -0,0 +1,423 @@ +//go:build privileged + +package proxy + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "io" + "math/big" + "net" + "net/http" + "net/http/httptest" + "os" + "runtime" + "strconv" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + cryptossh "golang.org/x/crypto/ssh" + + nbssh "github.com/netbirdio/netbird/client/ssh" + sshauth "github.com/netbirdio/netbird/client/ssh/auth" + "github.com/netbirdio/netbird/client/ssh/server" + "github.com/netbirdio/netbird/client/ssh/testutil" + nbjwt "github.com/netbirdio/netbird/shared/auth/jwt" + sshuserhash "github.com/netbirdio/netbird/shared/sshauth" +) + +func (m *mockDaemon) setJWTToken(token string) { + m.impl.jwtToken = token +} + +func TestSSHProxy_Connect(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + // TODO: Windows test times out - user switching and command execution tested on Linux + if runtime.GOOS == "windows" { + t.Skip("Skipping on Windows - covered by Linux tests") + } + + const ( + issuer = "https://test-issuer.example.com" + audience = "test-audience" + ) + + jwksServer, privateKey, jwksURL := setupJWKSServer(t) + defer jwksServer.Close() + + hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + hostPubKey, err := nbssh.GeneratePublicKey(hostKey) + require.NoError(t, err) + + serverConfig := &server.Config{ + HostKeyPEM: hostKey, + JWT: &server.JWTConfig{ + Issuer: issuer, + Audiences: []string{audience}, + KeysLocation: jwksURL, + }, + } + sshServer := server.New(serverConfig) + sshServer.SetAllowRootLogin(true) + + // Configure SSH authorization for the test user + testUsername := testutil.GetTestUsername(t) + testJWTUser := "test-username" + testUserHash, err := sshuserhash.HashUserID(testJWTUser) + require.NoError(t, err) + + authConfig := &sshauth.Config{ + UserIDClaim: sshauth.DefaultUserIDClaim, + AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash}, + MachineUsers: map[string][]uint32{ + testUsername: {0}, // Index 0 in AuthorizedUsers + }, + } + sshServer.UpdateSSHAuth(authConfig) + + sshServerAddr := server.StartTestServer(t, sshServer) + defer func() { _ = sshServer.Stop() }() + + mockDaemon := startMockDaemon(t) + defer mockDaemon.stop() + + host, portStr, err := net.SplitHostPort(sshServerAddr) + require.NoError(t, err) + port, err := strconv.Atoi(portStr) + require.NoError(t, err) + + mockDaemon.setHostKey(host, hostPubKey) + + validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser) + mockDaemon.setJWTToken(validToken) + + proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil) + require.NoError(t, err) + + clientConn, proxyConn := net.Pipe() + defer func() { _ = clientConn.Close() }() + + origStdin := os.Stdin + origStdout := os.Stdout + defer func() { + os.Stdin = origStdin + os.Stdout = origStdout + }() + + stdinReader, stdinWriter, err := os.Pipe() + require.NoError(t, err) + stdoutReader, stdoutWriter, err := os.Pipe() + require.NoError(t, err) + + os.Stdin = stdinReader + os.Stdout = stdoutWriter + + go func() { + _, _ = io.Copy(stdinWriter, proxyConn) + }() + go func() { + _, _ = io.Copy(proxyConn, stdoutReader) + }() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + connectErrCh := make(chan error, 1) + go func() { + connectErrCh <- proxyInstance.Connect(ctx) + }() + + sshConfig := &cryptossh.ClientConfig{ + User: testutil.GetTestUsername(t), + Auth: []cryptossh.AuthMethod{}, + HostKeyCallback: cryptossh.InsecureIgnoreHostKey(), + Timeout: 3 * time.Second, + } + + sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig) + require.NoError(t, err, "Should connect to proxy server") + defer func() { _ = sshClientConn.Close() }() + + sshClient := cryptossh.NewClient(sshClientConn, chans, reqs) + + session, err := sshClient.NewSession() + require.NoError(t, err, "Should create session through full proxy to backend") + + outputCh := make(chan []byte, 1) + errCh := make(chan error, 1) + go func() { + output, err := session.Output("echo hello-from-proxy") + outputCh <- output + errCh <- err + }() + + select { + case output := <-outputCh: + err := <-errCh + require.NoError(t, err, "Command should execute successfully through proxy") + assert.Contains(t, string(output), "hello-from-proxy", "Should receive command output through proxy") + case <-time.After(3 * time.Second): + t.Fatal("Command execution timed out") + } + + _ = session.Close() + _ = sshClient.Close() + _ = clientConn.Close() + cancel() +} + +// TestSSHProxy_CommandQuoting verifies that the proxy preserves shell quoting +// when forwarding commands to the backend. This is critical for tools like +// Ansible that send commands such as: +// +// /bin/sh -c '( umask 77 && mkdir -p ... ) && sleep 0' +// +// The single quotes must be preserved so the backend shell receives the +// subshell expression as a single argument to -c. +func TestSSHProxy_CommandQuoting(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + sshClient, cleanup := setupProxySSHClient(t) + defer cleanup() + + // These commands simulate what the SSH protocol delivers as exec payloads. + // When a user types: ssh host '/bin/sh -c "( echo hello )"' + // the local shell strips the outer single quotes, and the SSH exec request + // contains the raw string: /bin/sh -c "( echo hello )" + // + // The proxy must forward this string verbatim. Using session.Command() + // (shlex.Split + strings.Join) strips the inner double quotes, breaking + // the command on the backend. + tests := []struct { + name string + command string + expect string + }{ + { + name: "subshell_in_double_quotes", + command: `/bin/sh -c "( echo from-subshell ) && echo outer"`, + expect: "from-subshell\nouter\n", + }, + { + name: "printf_with_special_chars", + command: `/bin/sh -c "printf '%s\n' 'hello world'"`, + expect: "hello world\n", + }, + { + name: "nested_command_substitution", + command: `/bin/sh -c "echo $(echo nested)"`, + expect: "nested\n", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + session, err := sshClient.NewSession() + require.NoError(t, err) + defer func() { _ = session.Close() }() + + var stderrBuf bytes.Buffer + session.Stderr = &stderrBuf + + outputCh := make(chan []byte, 1) + errCh := make(chan error, 1) + go func() { + output, err := session.Output(tc.command) + outputCh <- output + errCh <- err + }() + + select { + case output := <-outputCh: + err := <-errCh + if stderrBuf.Len() > 0 { + t.Logf("stderr: %s", stderrBuf.String()) + } + require.NoError(t, err, "command should succeed: %s", tc.command) + assert.Equal(t, tc.expect, string(output), "output mismatch for: %s", tc.command) + case <-time.After(5 * time.Second): + t.Fatalf("command timed out: %s", tc.command) + } + }) + } +} + +// setupProxySSHClient creates a full proxy test environment and returns +// an SSH client connected through the proxy to a backend NetBird SSH server. +func setupProxySSHClient(t *testing.T) (*cryptossh.Client, func()) { + t.Helper() + + const ( + issuer = "https://test-issuer.example.com" + audience = "test-audience" + ) + + jwksServer, privateKey, jwksURL := setupJWKSServer(t) + + hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + hostPubKey, err := nbssh.GeneratePublicKey(hostKey) + require.NoError(t, err) + + serverConfig := &server.Config{ + HostKeyPEM: hostKey, + JWT: &server.JWTConfig{ + Issuer: issuer, + Audiences: []string{audience}, + KeysLocation: jwksURL, + }, + } + sshServer := server.New(serverConfig) + sshServer.SetAllowRootLogin(true) + + testUsername := testutil.GetTestUsername(t) + testJWTUser := "test-username" + testUserHash, err := sshuserhash.HashUserID(testJWTUser) + require.NoError(t, err) + + authConfig := &sshauth.Config{ + UserIDClaim: sshauth.DefaultUserIDClaim, + AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash}, + MachineUsers: map[string][]uint32{ + testUsername: {0}, + }, + } + sshServer.UpdateSSHAuth(authConfig) + + sshServerAddr := server.StartTestServer(t, sshServer) + + mockDaemon := startMockDaemon(t) + + host, portStr, err := net.SplitHostPort(sshServerAddr) + require.NoError(t, err) + port, err := strconv.Atoi(portStr) + require.NoError(t, err) + + mockDaemon.setHostKey(host, hostPubKey) + + validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser) + mockDaemon.setJWTToken(validToken) + + proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil) + require.NoError(t, err) + + origStdin := os.Stdin + origStdout := os.Stdout + + stdinReader, stdinWriter, err := os.Pipe() + require.NoError(t, err) + stdoutReader, stdoutWriter, err := os.Pipe() + require.NoError(t, err) + + os.Stdin = stdinReader + os.Stdout = stdoutWriter + + clientConn, proxyConn := net.Pipe() + + go func() { _, _ = io.Copy(stdinWriter, proxyConn) }() + go func() { _, _ = io.Copy(proxyConn, stdoutReader) }() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + + go func() { + _ = proxyInstance.Connect(ctx) + }() + + sshConfig := &cryptossh.ClientConfig{ + User: testutil.GetTestUsername(t), + Auth: []cryptossh.AuthMethod{}, + HostKeyCallback: cryptossh.InsecureIgnoreHostKey(), + Timeout: 5 * time.Second, + } + + sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig) + require.NoError(t, err) + + client := cryptossh.NewClient(sshClientConn, chans, reqs) + + cleanupFn := func() { + _ = client.Close() + _ = clientConn.Close() + cancel() + os.Stdin = origStdin + os.Stdout = origStdout + _ = sshServer.Stop() + mockDaemon.stop() + jwksServer.Close() + } + + return client, cleanupFn +} + +func setupJWKSServer(t *testing.T) (*httptest.Server, *rsa.PrivateKey, string) { + t.Helper() + privateKey, jwksJSON := generateTestJWKS(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write(jwksJSON); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + })) + + return server, privateKey, server.URL +} + +func generateTestJWKS(t *testing.T) (*rsa.PrivateKey, []byte) { + t.Helper() + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + publicKey := &privateKey.PublicKey + n := publicKey.N.Bytes() + e := publicKey.E + + jwk := nbjwt.JSONWebKey{ + Kty: "RSA", + Kid: "test-key-id", + Use: "sig", + N: base64.RawURLEncoding.EncodeToString(n), + E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(e)).Bytes()), + } + + jwks := nbjwt.Jwks{ + Keys: []nbjwt.JSONWebKey{jwk}, + } + + jwksJSON, err := json.Marshal(jwks) + require.NoError(t, err) + + return privateKey, jwksJSON +} + +func generateValidJWT(t *testing.T, privateKey *rsa.PrivateKey, issuer, audience string, user string) string { + t.Helper() + claims := jwt.MapClaims{ + "iss": issuer, + "aud": audience, + "sub": user, + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header["kid"] = "test-key-id" + + tokenString, err := token.SignedString(privateKey) + require.NoError(t, err) + + return tokenString +} diff --git a/client/ssh/proxy/proxy_test.go b/client/ssh/proxy/proxy_test.go index b33d5f8f4..2795c786b 100644 --- a/client/ssh/proxy/proxy_test.go +++ b/client/ssh/proxy/proxy_test.go @@ -1,25 +1,12 @@ package proxy import ( - "bytes" "context" - "crypto/rand" - "crypto/rsa" - "encoding/base64" - "encoding/json" "fmt" - "io" - "math/big" "net" - "net/http" - "net/http/httptest" "os" - "runtime" - "strconv" "testing" - "time" - "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" cryptossh "golang.org/x/crypto/ssh" @@ -28,11 +15,7 @@ import ( "github.com/netbirdio/netbird/client/proto" nbssh "github.com/netbirdio/netbird/client/ssh" - sshauth "github.com/netbirdio/netbird/client/ssh/auth" - "github.com/netbirdio/netbird/client/ssh/server" "github.com/netbirdio/netbird/client/ssh/testutil" - nbjwt "github.com/netbirdio/netbird/shared/auth/jwt" - sshuserhash "github.com/netbirdio/netbird/shared/sshauth" ) func TestMain(m *testing.M) { @@ -106,331 +89,6 @@ func TestSSHProxy_verifyHostKey(t *testing.T) { }) } -func TestSSHProxy_Connect(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test in short mode") - } - - // TODO: Windows test times out - user switching and command execution tested on Linux - if runtime.GOOS == "windows" { - t.Skip("Skipping on Windows - covered by Linux tests") - } - - const ( - issuer = "https://test-issuer.example.com" - audience = "test-audience" - ) - - jwksServer, privateKey, jwksURL := setupJWKSServer(t) - defer jwksServer.Close() - - hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) - require.NoError(t, err) - hostPubKey, err := nbssh.GeneratePublicKey(hostKey) - require.NoError(t, err) - - serverConfig := &server.Config{ - HostKeyPEM: hostKey, - JWT: &server.JWTConfig{ - Issuer: issuer, - Audiences: []string{audience}, - KeysLocation: jwksURL, - }, - } - sshServer := server.New(serverConfig) - sshServer.SetAllowRootLogin(true) - - // Configure SSH authorization for the test user - testUsername := testutil.GetTestUsername(t) - testJWTUser := "test-username" - testUserHash, err := sshuserhash.HashUserID(testJWTUser) - require.NoError(t, err) - - authConfig := &sshauth.Config{ - UserIDClaim: sshauth.DefaultUserIDClaim, - AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash}, - MachineUsers: map[string][]uint32{ - testUsername: {0}, // Index 0 in AuthorizedUsers - }, - } - sshServer.UpdateSSHAuth(authConfig) - - sshServerAddr := server.StartTestServer(t, sshServer) - defer func() { _ = sshServer.Stop() }() - - mockDaemon := startMockDaemon(t) - defer mockDaemon.stop() - - host, portStr, err := net.SplitHostPort(sshServerAddr) - require.NoError(t, err) - port, err := strconv.Atoi(portStr) - require.NoError(t, err) - - mockDaemon.setHostKey(host, hostPubKey) - - validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser) - mockDaemon.setJWTToken(validToken) - - proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil) - require.NoError(t, err) - - clientConn, proxyConn := net.Pipe() - defer func() { _ = clientConn.Close() }() - - origStdin := os.Stdin - origStdout := os.Stdout - defer func() { - os.Stdin = origStdin - os.Stdout = origStdout - }() - - stdinReader, stdinWriter, err := os.Pipe() - require.NoError(t, err) - stdoutReader, stdoutWriter, err := os.Pipe() - require.NoError(t, err) - - os.Stdin = stdinReader - os.Stdout = stdoutWriter - - go func() { - _, _ = io.Copy(stdinWriter, proxyConn) - }() - go func() { - _, _ = io.Copy(proxyConn, stdoutReader) - }() - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - connectErrCh := make(chan error, 1) - go func() { - connectErrCh <- proxyInstance.Connect(ctx) - }() - - sshConfig := &cryptossh.ClientConfig{ - User: testutil.GetTestUsername(t), - Auth: []cryptossh.AuthMethod{}, - HostKeyCallback: cryptossh.InsecureIgnoreHostKey(), - Timeout: 3 * time.Second, - } - - sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig) - require.NoError(t, err, "Should connect to proxy server") - defer func() { _ = sshClientConn.Close() }() - - sshClient := cryptossh.NewClient(sshClientConn, chans, reqs) - - session, err := sshClient.NewSession() - require.NoError(t, err, "Should create session through full proxy to backend") - - outputCh := make(chan []byte, 1) - errCh := make(chan error, 1) - go func() { - output, err := session.Output("echo hello-from-proxy") - outputCh <- output - errCh <- err - }() - - select { - case output := <-outputCh: - err := <-errCh - require.NoError(t, err, "Command should execute successfully through proxy") - assert.Contains(t, string(output), "hello-from-proxy", "Should receive command output through proxy") - case <-time.After(3 * time.Second): - t.Fatal("Command execution timed out") - } - - _ = session.Close() - _ = sshClient.Close() - _ = clientConn.Close() - cancel() -} - -// TestSSHProxy_CommandQuoting verifies that the proxy preserves shell quoting -// when forwarding commands to the backend. This is critical for tools like -// Ansible that send commands such as: -// -// /bin/sh -c '( umask 77 && mkdir -p ... ) && sleep 0' -// -// The single quotes must be preserved so the backend shell receives the -// subshell expression as a single argument to -c. -func TestSSHProxy_CommandQuoting(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test in short mode") - } - - sshClient, cleanup := setupProxySSHClient(t) - defer cleanup() - - // These commands simulate what the SSH protocol delivers as exec payloads. - // When a user types: ssh host '/bin/sh -c "( echo hello )"' - // the local shell strips the outer single quotes, and the SSH exec request - // contains the raw string: /bin/sh -c "( echo hello )" - // - // The proxy must forward this string verbatim. Using session.Command() - // (shlex.Split + strings.Join) strips the inner double quotes, breaking - // the command on the backend. - tests := []struct { - name string - command string - expect string - }{ - { - name: "subshell_in_double_quotes", - command: `/bin/sh -c "( echo from-subshell ) && echo outer"`, - expect: "from-subshell\nouter\n", - }, - { - name: "printf_with_special_chars", - command: `/bin/sh -c "printf '%s\n' 'hello world'"`, - expect: "hello world\n", - }, - { - name: "nested_command_substitution", - command: `/bin/sh -c "echo $(echo nested)"`, - expect: "nested\n", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - session, err := sshClient.NewSession() - require.NoError(t, err) - defer func() { _ = session.Close() }() - - var stderrBuf bytes.Buffer - session.Stderr = &stderrBuf - - outputCh := make(chan []byte, 1) - errCh := make(chan error, 1) - go func() { - output, err := session.Output(tc.command) - outputCh <- output - errCh <- err - }() - - select { - case output := <-outputCh: - err := <-errCh - if stderrBuf.Len() > 0 { - t.Logf("stderr: %s", stderrBuf.String()) - } - require.NoError(t, err, "command should succeed: %s", tc.command) - assert.Equal(t, tc.expect, string(output), "output mismatch for: %s", tc.command) - case <-time.After(5 * time.Second): - t.Fatalf("command timed out: %s", tc.command) - } - }) - } -} - -// setupProxySSHClient creates a full proxy test environment and returns -// an SSH client connected through the proxy to a backend NetBird SSH server. -func setupProxySSHClient(t *testing.T) (*cryptossh.Client, func()) { - t.Helper() - - const ( - issuer = "https://test-issuer.example.com" - audience = "test-audience" - ) - - jwksServer, privateKey, jwksURL := setupJWKSServer(t) - - hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) - require.NoError(t, err) - hostPubKey, err := nbssh.GeneratePublicKey(hostKey) - require.NoError(t, err) - - serverConfig := &server.Config{ - HostKeyPEM: hostKey, - JWT: &server.JWTConfig{ - Issuer: issuer, - Audiences: []string{audience}, - KeysLocation: jwksURL, - }, - } - sshServer := server.New(serverConfig) - sshServer.SetAllowRootLogin(true) - - testUsername := testutil.GetTestUsername(t) - testJWTUser := "test-username" - testUserHash, err := sshuserhash.HashUserID(testJWTUser) - require.NoError(t, err) - - authConfig := &sshauth.Config{ - UserIDClaim: sshauth.DefaultUserIDClaim, - AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash}, - MachineUsers: map[string][]uint32{ - testUsername: {0}, - }, - } - sshServer.UpdateSSHAuth(authConfig) - - sshServerAddr := server.StartTestServer(t, sshServer) - - mockDaemon := startMockDaemon(t) - - host, portStr, err := net.SplitHostPort(sshServerAddr) - require.NoError(t, err) - port, err := strconv.Atoi(portStr) - require.NoError(t, err) - - mockDaemon.setHostKey(host, hostPubKey) - - validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser) - mockDaemon.setJWTToken(validToken) - - proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil) - require.NoError(t, err) - - origStdin := os.Stdin - origStdout := os.Stdout - - stdinReader, stdinWriter, err := os.Pipe() - require.NoError(t, err) - stdoutReader, stdoutWriter, err := os.Pipe() - require.NoError(t, err) - - os.Stdin = stdinReader - os.Stdout = stdoutWriter - - clientConn, proxyConn := net.Pipe() - - go func() { _, _ = io.Copy(stdinWriter, proxyConn) }() - go func() { _, _ = io.Copy(proxyConn, stdoutReader) }() - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - - go func() { - _ = proxyInstance.Connect(ctx) - }() - - sshConfig := &cryptossh.ClientConfig{ - User: testutil.GetTestUsername(t), - Auth: []cryptossh.AuthMethod{}, - HostKeyCallback: cryptossh.InsecureIgnoreHostKey(), - Timeout: 5 * time.Second, - } - - sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig) - require.NoError(t, err) - - client := cryptossh.NewClient(sshClientConn, chans, reqs) - - cleanupFn := func() { - _ = client.Close() - _ = clientConn.Close() - cancel() - os.Stdin = origStdin - os.Stdout = origStdout - _ = sshServer.Stop() - mockDaemon.stop() - jwksServer.Close() - } - - return client, cleanupFn -} - type mockDaemonServer struct { proto.UnimplementedDaemonServiceServer hostKeys map[string][]byte @@ -492,10 +150,6 @@ func (m *mockDaemon) setHostKey(addr string, pubKey []byte) { m.impl.hostKeys[addr] = pubKey } -func (m *mockDaemon) setJWTToken(token string) { - m.impl.jwtToken = token -} - func (m *mockDaemon) stop() { if m.server != nil { m.server.Stop() @@ -508,63 +162,3 @@ func mustParsePublicKey(t *testing.T, pubKeyBytes []byte) cryptossh.PublicKey { require.NoError(t, err) return pubKey } - -func setupJWKSServer(t *testing.T) (*httptest.Server, *rsa.PrivateKey, string) { - t.Helper() - privateKey, jwksJSON := generateTestJWKS(t) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if _, err := w.Write(jwksJSON); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } - })) - - return server, privateKey, server.URL -} - -func generateTestJWKS(t *testing.T) (*rsa.PrivateKey, []byte) { - t.Helper() - privateKey, err := rsa.GenerateKey(rand.Reader, 2048) - require.NoError(t, err) - - publicKey := &privateKey.PublicKey - n := publicKey.N.Bytes() - e := publicKey.E - - jwk := nbjwt.JSONWebKey{ - Kty: "RSA", - Kid: "test-key-id", - Use: "sig", - N: base64.RawURLEncoding.EncodeToString(n), - E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(e)).Bytes()), - } - - jwks := nbjwt.Jwks{ - Keys: []nbjwt.JSONWebKey{jwk}, - } - - jwksJSON, err := json.Marshal(jwks) - require.NoError(t, err) - - return privateKey, jwksJSON -} - -func generateValidJWT(t *testing.T, privateKey *rsa.PrivateKey, issuer, audience string, user string) string { - t.Helper() - claims := jwt.MapClaims{ - "iss": issuer, - "aud": audience, - "sub": user, - "exp": time.Now().Add(time.Hour).Unix(), - "iat": time.Now().Unix(), - } - - token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) - token.Header["kid"] = "test-key-id" - - tokenString, err := token.SignedString(privateKey) - require.NoError(t, err) - - return tokenString -} diff --git a/client/ssh/server/command_execution.go b/client/ssh/server/command_execution.go index b0a85fe4b..c8b3240d0 100644 --- a/client/ssh/server/command_execution.go +++ b/client/ssh/server/command_execution.go @@ -75,8 +75,8 @@ func (s *Server) createCommand(logger *log.Entry, privilegeResult PrivilegeCheck } // Try su first for system integration (PAM/audit) when privileged - cmd, err := s.createSuCommand(logger, session, localUser, hasPty) - if err != nil || privilegeResult.UsedFallback { + cmd, err := s.createSuCommand(logger, session, localUser, hasPty) //nolint:staticcheck + if err != nil || privilegeResult.UsedFallback { //nolint:staticcheck // always errors on platforms without su logger.Debugf("su command failed, falling back to executor: %v", err) cmd, cleanup, err := s.createExecutorCommand(logger, session, localUser, hasPty) if err != nil { diff --git a/client/ssh/server/command_execution_windows.go b/client/ssh/server/command_execution_windows.go index e1ba777f6..feb8daa26 100644 --- a/client/ssh/server/command_execution_windows.go +++ b/client/ssh/server/command_execution_windows.go @@ -243,7 +243,7 @@ func (s *Server) setUserEnvironmentVariables(envMap map[string]string, userProfi // prepareCommandEnv prepares environment variables for command execution on Windows func (s *Server) prepareCommandEnv(logger *log.Entry, localUser *user.User, session ssh.Session) []string { - username, domain := s.parseUsername(localUser.Username) + username, domain := parseUsername(localUser.Username) userEnv, err := s.getUserEnvironment(logger, username, domain) if err != nil { log.Debugf("failed to get user environment for %s\\%s, using fallback: %v", domain, username, err) @@ -383,7 +383,7 @@ func (s *Server) executeCommandWithPty(logger *log.Entry, session ssh.Session, _ return false } - username, domain := s.parseUsername(localUser.Username) + username, domain := parseUsername(localUser.Username) shell := getUserShell(localUser.Uid) req := PtyExecutionRequest{ diff --git a/client/ssh/server/executor_unix_privileged_test.go b/client/ssh/server/executor_unix_privileged_test.go new file mode 100644 index 000000000..f1b0805d9 --- /dev/null +++ b/client/ssh/server/executor_unix_privileged_test.go @@ -0,0 +1,66 @@ +//go:build unix && privileged + +package server + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPrivilegeDropper_CreateExecutorCommand(t *testing.T) { + pd := NewPrivilegeDropper() + + config := ExecutorConfig{ + UID: 1000, + GID: 1000, + Groups: []uint32{1000, 1001}, + WorkingDir: "/home/testuser", + Shell: "/bin/bash", + Command: "ls -la", + } + + cmd, err := pd.CreateExecutorCommand(context.Background(), config) + require.NoError(t, err) + require.NotNil(t, cmd) + + // Verify the command is calling netbird ssh exec + assert.Contains(t, cmd.Args, "ssh") + assert.Contains(t, cmd.Args, "exec") + assert.Contains(t, cmd.Args, "--uid") + assert.Contains(t, cmd.Args, "1000") + assert.Contains(t, cmd.Args, "--gid") + assert.Contains(t, cmd.Args, "1000") + assert.Contains(t, cmd.Args, "--groups") + assert.Contains(t, cmd.Args, "1000") + assert.Contains(t, cmd.Args, "1001") + assert.Contains(t, cmd.Args, "--working-dir") + assert.Contains(t, cmd.Args, "/home/testuser") + assert.Contains(t, cmd.Args, "--shell") + assert.Contains(t, cmd.Args, "/bin/bash") + assert.Contains(t, cmd.Args, "--cmd") + assert.Contains(t, cmd.Args, "ls -la") +} + +func TestPrivilegeDropper_CreateExecutorCommandInteractive(t *testing.T) { + pd := NewPrivilegeDropper() + + config := ExecutorConfig{ + UID: 1000, + GID: 1000, + Groups: []uint32{1000}, + WorkingDir: "/home/testuser", + Shell: "/bin/bash", + Command: "", + } + + cmd, err := pd.CreateExecutorCommand(context.Background(), config) + require.NoError(t, err) + require.NotNil(t, cmd) + + // Verify no command mode (command is empty so no --cmd flag) + assert.NotContains(t, cmd.Args, "--cmd") + assert.NotContains(t, cmd.Args, "--interactive") +} diff --git a/client/ssh/server/executor_unix_test.go b/client/ssh/server/executor_unix_test.go index 0c5108f57..171e78b83 100644 --- a/client/ssh/server/executor_unix_test.go +++ b/client/ssh/server/executor_unix_test.go @@ -73,61 +73,6 @@ func TestPrivilegeDropper_ValidatePrivileges(t *testing.T) { } } -func TestPrivilegeDropper_CreateExecutorCommand(t *testing.T) { - pd := NewPrivilegeDropper() - - config := ExecutorConfig{ - UID: 1000, - GID: 1000, - Groups: []uint32{1000, 1001}, - WorkingDir: "/home/testuser", - Shell: "/bin/bash", - Command: "ls -la", - } - - cmd, err := pd.CreateExecutorCommand(context.Background(), config) - require.NoError(t, err) - require.NotNil(t, cmd) - - // Verify the command is calling netbird ssh exec - assert.Contains(t, cmd.Args, "ssh") - assert.Contains(t, cmd.Args, "exec") - assert.Contains(t, cmd.Args, "--uid") - assert.Contains(t, cmd.Args, "1000") - assert.Contains(t, cmd.Args, "--gid") - assert.Contains(t, cmd.Args, "1000") - assert.Contains(t, cmd.Args, "--groups") - assert.Contains(t, cmd.Args, "1000") - assert.Contains(t, cmd.Args, "1001") - assert.Contains(t, cmd.Args, "--working-dir") - assert.Contains(t, cmd.Args, "/home/testuser") - assert.Contains(t, cmd.Args, "--shell") - assert.Contains(t, cmd.Args, "/bin/bash") - assert.Contains(t, cmd.Args, "--cmd") - assert.Contains(t, cmd.Args, "ls -la") -} - -func TestPrivilegeDropper_CreateExecutorCommandInteractive(t *testing.T) { - pd := NewPrivilegeDropper() - - config := ExecutorConfig{ - UID: 1000, - GID: 1000, - Groups: []uint32{1000}, - WorkingDir: "/home/testuser", - Shell: "/bin/bash", - Command: "", - } - - cmd, err := pd.CreateExecutorCommand(context.Background(), config) - require.NoError(t, err) - require.NotNil(t, cmd) - - // Verify no command mode (command is empty so no --cmd flag) - assert.NotContains(t, cmd.Args, "--cmd") - assert.NotContains(t, cmd.Args, "--interactive") -} - // TestPrivilegeDropper_ActualPrivilegeDrop tests actual privilege dropping // This test requires root privileges and will be skipped if not running as root func TestPrivilegeDropper_ActualPrivilegeDrop(t *testing.T) { diff --git a/client/ssh/server/getent_cgo_unix.go b/client/ssh/server/getent_cgo_unix.go deleted file mode 100644 index 4afbfc627..000000000 --- a/client/ssh/server/getent_cgo_unix.go +++ /dev/null @@ -1,24 +0,0 @@ -//go:build cgo && !osusergo && !windows - -package server - -import "os/user" - -// lookupWithGetent with CGO delegates directly to os/user.Lookup. -// When CGO is enabled, os/user uses libc (getpwnam_r) which goes through -// the NSS stack natively. If it fails, the user truly doesn't exist and -// getent would also fail. -func lookupWithGetent(username string) (*user.User, error) { - return user.Lookup(username) -} - -// currentUserWithGetent with CGO delegates directly to os/user.Current. -func currentUserWithGetent() (*user.User, error) { - return user.Current() -} - -// groupIdsWithFallback with CGO delegates directly to user.GroupIds. -// libc's getgrouplist handles NSS groups natively. -func groupIdsWithFallback(u *user.User) ([]string, error) { - return u.GroupIds() -} diff --git a/client/ssh/server/getent_nocgo_unix.go b/client/ssh/server/getent_nocgo_unix.go deleted file mode 100644 index 314daae4c..000000000 --- a/client/ssh/server/getent_nocgo_unix.go +++ /dev/null @@ -1,74 +0,0 @@ -//go:build (!cgo || osusergo) && !windows - -package server - -import ( - "os" - "os/user" - "strconv" - - log "github.com/sirupsen/logrus" -) - -// lookupWithGetent looks up a user by name, falling back to getent if os/user fails. -// Without CGO, os/user only reads /etc/passwd and misses NSS-provided users. -// getent goes through the host's NSS stack. -func lookupWithGetent(username string) (*user.User, error) { - u, err := user.Lookup(username) - if err == nil { - return u, nil - } - - stdErr := err - log.Debugf("os/user.Lookup(%q) failed, trying getent: %v", username, err) - - u, _, getentErr := runGetent(username) - if getentErr != nil { - log.Debugf("getent fallback for %q also failed: %v", username, getentErr) - return nil, stdErr - } - - return u, nil -} - -// currentUserWithGetent gets the current user, falling back to getent if os/user fails. -func currentUserWithGetent() (*user.User, error) { - u, err := user.Current() - if err == nil { - return u, nil - } - - stdErr := err - uid := strconv.Itoa(os.Getuid()) - log.Debugf("os/user.Current() failed, trying getent with UID %s: %v", uid, err) - - u, _, getentErr := runGetent(uid) - if getentErr != nil { - return nil, stdErr - } - - return u, nil -} - -// groupIdsWithFallback gets group IDs for a user via the id command first, -// falling back to user.GroupIds(). -// NOTE: unlike lookupWithGetent/currentUserWithGetent which try stdlib first, -// this intentionally tries `id -G` first because without CGO, user.GroupIds() -// only reads /etc/group and silently returns incomplete results for NSS users -// (no error, just missing groups). The id command goes through NSS and returns -// the full set. -func groupIdsWithFallback(u *user.User) ([]string, error) { - ids, err := runIdGroups(u.Username) - if err == nil { - return ids, nil - } - - log.Debugf("id -G %q failed, falling back to user.GroupIds(): %v", u.Username, err) - - ids, stdErr := u.GroupIds() - if stdErr != nil { - return nil, stdErr - } - - return ids, nil -} diff --git a/client/ssh/server/getent_unix.go b/client/ssh/server/getent_unix.go deleted file mode 100644 index 18edb2fdf..000000000 --- a/client/ssh/server/getent_unix.go +++ /dev/null @@ -1,122 +0,0 @@ -//go:build !windows - -package server - -import ( - "context" - "fmt" - "os/exec" - "os/user" - "runtime" - "strings" - "time" -) - -const getentTimeout = 5 * time.Second - -// getShellFromGetent gets a user's login shell via getent by UID. -// This is needed even with CGO because getShellFromPasswd reads /etc/passwd -// directly and won't find NSS-provided users there. -func getShellFromGetent(userID string) string { - _, shell, err := runGetent(userID) - if err != nil { - return "" - } - return shell -} - -// runGetent executes `getent passwd ` and returns the user and login shell. -func runGetent(query string) (*user.User, string, error) { - if !validateGetentInput(query) { - return nil, "", fmt.Errorf("invalid getent input: %q", query) - } - - ctx, cancel := context.WithTimeout(context.Background(), getentTimeout) - defer cancel() - - out, err := exec.CommandContext(ctx, "getent", "passwd", query).Output() - if err != nil { - return nil, "", fmt.Errorf("getent passwd %s: %w", query, err) - } - - return parseGetentPasswd(string(out)) -} - -// parseGetentPasswd parses getent passwd output: "name:x:uid:gid:gecos:home:shell" -func parseGetentPasswd(output string) (*user.User, string, error) { - fields := strings.SplitN(strings.TrimSpace(output), ":", 8) - if len(fields) < 6 { - return nil, "", fmt.Errorf("unexpected getent output (need 6+ fields): %q", output) - } - - if fields[0] == "" || fields[2] == "" || fields[3] == "" { - return nil, "", fmt.Errorf("missing required fields in getent output: %q", output) - } - - var shell string - if len(fields) >= 7 { - shell = fields[6] - } - - return &user.User{ - Username: fields[0], - Uid: fields[2], - Gid: fields[3], - Name: fields[4], - HomeDir: fields[5], - }, shell, nil -} - -// validateGetentInput checks that the input is safe to pass to getent or id. -// Allows POSIX usernames, numeric UIDs, and common NSS extensions -// (@ for Kerberos, $ for Samba, + for NIS compat). -func validateGetentInput(input string) bool { - maxLen := 32 - if runtime.GOOS == "linux" { - maxLen = 256 - } - - if len(input) == 0 || len(input) > maxLen { - return false - } - - for _, r := range input { - if isAllowedGetentChar(r) { - continue - } - return false - } - return true -} - -func isAllowedGetentChar(r rune) bool { - if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' { - return true - } - switch r { - case '.', '_', '-', '@', '+', '$': - return true - } - return false -} - -// runIdGroups runs `id -G ` and returns the space-separated group IDs. -func runIdGroups(username string) ([]string, error) { - if !validateGetentInput(username) { - return nil, fmt.Errorf("invalid username for id command: %q", username) - } - - ctx, cancel := context.WithTimeout(context.Background(), getentTimeout) - defer cancel() - - out, err := exec.CommandContext(ctx, "id", "-G", username).Output() - if err != nil { - return nil, fmt.Errorf("id -G %s: %w", username, err) - } - - trimmed := strings.TrimSpace(string(out)) - if trimmed == "" { - return nil, fmt.Errorf("id -G %s: empty output", username) - } - return strings.Fields(trimmed), nil -} diff --git a/client/ssh/server/getent_windows.go b/client/ssh/server/getent_windows.go deleted file mode 100644 index 3e76b3e8e..000000000 --- a/client/ssh/server/getent_windows.go +++ /dev/null @@ -1,26 +0,0 @@ -//go:build windows - -package server - -import "os/user" - -// lookupWithGetent on Windows just delegates to os/user.Lookup. -// Windows does not use NSS/getent; its user lookup works without CGO. -func lookupWithGetent(username string) (*user.User, error) { - return user.Lookup(username) -} - -// currentUserWithGetent on Windows just delegates to os/user.Current. -func currentUserWithGetent() (*user.User, error) { - return user.Current() -} - -// getShellFromGetent is a no-op on Windows; shell resolution uses PowerShell detection. -func getShellFromGetent(_ string) string { - return "" -} - -// groupIdsWithFallback on Windows just delegates to u.GroupIds(). -func groupIdsWithFallback(u *user.User) ([]string, error) { - return u.GroupIds() -} diff --git a/client/ssh/server/port_forwarding.go b/client/ssh/server/port_forwarding.go index e16ff5d46..81d3b9173 100644 --- a/client/ssh/server/port_forwarding.go +++ b/client/ssh/server/port_forwarding.go @@ -17,7 +17,7 @@ import ( log "github.com/sirupsen/logrus" cryptossh "golang.org/x/crypto/ssh" - nbssh "github.com/netbirdio/netbird/client/ssh" + "github.com/netbirdio/netbird/util/netrelay" ) const privilegedPortThreshold = 1024 @@ -56,12 +56,12 @@ func (s *Server) configurePortForwarding(server *ssh.Server) { server.LocalPortForwardingCallback = func(ctx ssh.Context, dstHost string, dstPort uint32) bool { logger := s.getRequestLogger(ctx) if !allowLocal { - logger.Warnf("local port forwarding denied for %s:%d: disabled", dstHost, dstPort) + logger.Warnf("local port forwarding denied for %s: disabled", net.JoinHostPort(dstHost, strconv.Itoa(int(dstPort)))) return false } if err := s.checkPortForwardingPrivileges(ctx, "local", dstPort); err != nil { - logger.Warnf("local port forwarding denied for %s:%d: %v", dstHost, dstPort, err) + logger.Warnf("local port forwarding denied for %s: %v", net.JoinHostPort(dstHost, strconv.Itoa(int(dstPort))), err) return false } @@ -71,12 +71,12 @@ func (s *Server) configurePortForwarding(server *ssh.Server) { server.ReversePortForwardingCallback = func(ctx ssh.Context, bindHost string, bindPort uint32) bool { logger := s.getRequestLogger(ctx) if !allowRemote { - logger.Warnf("remote port forwarding denied for %s:%d: disabled", bindHost, bindPort) + logger.Warnf("remote port forwarding denied for %s: disabled", net.JoinHostPort(bindHost, strconv.Itoa(int(bindPort)))) return false } if err := s.checkPortForwardingPrivileges(ctx, "remote", bindPort); err != nil { - logger.Warnf("remote port forwarding denied for %s:%d: %v", bindHost, bindPort, err) + logger.Warnf("remote port forwarding denied for %s: %v", net.JoinHostPort(bindHost, strconv.Itoa(int(bindPort))), err) return false } @@ -133,7 +133,12 @@ func (s *Server) checkPrivilegedPortAccess(forwardType string, port uint32, resu return nil } - if result.User != nil && isPrivilegedUsername(result.User.Username) { + // Only uid 0 may bind below the threshold, which is the kernel's own rule and + // is asked directly rather than through isPrivilegedOrUnknown: that helper + // reports an account it cannot evaluate as privileged, which is safe for a + // refusal and unsafe for a grant such as this one. Windows has returned + // above, so Uid here is a Unix uid and never a SID. + if result.User != nil && result.User.Uid == "0" { return nil } @@ -183,15 +188,16 @@ func (s *Server) cancelTcpipForwardHandler(ctx ssh.Context, _ *ssh.Server, req * return false, nil } - key := forwardKey(fmt.Sprintf("%s:%d", payload.Host, payload.Port)) + hostPort := net.JoinHostPort(payload.Host, strconv.Itoa(int(payload.Port))) + key := forwardKey(hostPort) if s.removeRemoteForwardListener(key) { - forwardAddr := fmt.Sprintf("-R %s:%d", payload.Host, payload.Port) + forwardAddr := "-R " + hostPort s.removeConnectionPortForward(ctx.RemoteAddr(), forwardAddr) - logger.Infof("remote port forwarding cancelled: %s:%d", payload.Host, payload.Port) + logger.Infof("remote port forwarding cancelled: %s", hostPort) return true, nil } - logger.Warnf("cancel-tcpip-forward failed: no listener found for %s:%d", payload.Host, payload.Port) + logger.Warnf("cancel-tcpip-forward failed: no listener found for %s", net.JoinHostPort(payload.Host, strconv.Itoa(int(payload.Port)))) return false, nil } @@ -201,7 +207,7 @@ func (s *Server) handleRemoteForwardListener(ctx ssh.Context, ln net.Listener, h defer func() { if err := ln.Close(); err != nil { - logger.Debugf("remote forward listener close error for %s:%d: %v", host, port, err) + logger.Debugf("remote forward listener close error for %s: %v", net.JoinHostPort(host, strconv.Itoa(int(port))), err) } }() @@ -230,7 +236,7 @@ func (s *Server) handleRemoteForwardListener(ctx ssh.Context, ln net.Listener, h } go s.handleRemoteForwardConnection(ctx, result.conn, host, port) case <-ctx.Done(): - logger.Debugf("remote forward listener shutting down for %s:%d", host, port) + logger.Debugf("remote forward listener shutting down for %s", net.JoinHostPort(host, strconv.Itoa(int(port)))) return } } @@ -311,17 +317,17 @@ func (s *Server) setupDirectForward(ctx ssh.Context, logger *log.Entry, sshConn logger.Debugf("tcpip-forward allocated port %d for %s", actualPort, payload.Host) } - key := forwardKey(fmt.Sprintf("%s:%d", payload.Host, payload.Port)) + key := forwardKey(net.JoinHostPort(payload.Host, strconv.Itoa(int(payload.Port)))) s.storeRemoteForwardListener(key, ln) - forwardAddr := fmt.Sprintf("-R %s:%d", payload.Host, actualPort) + forwardAddr := "-R " + net.JoinHostPort(payload.Host, strconv.Itoa(int(actualPort))) s.addConnectionPortForward(ctx.User(), ctx.RemoteAddr(), forwardAddr) go s.handleRemoteForwardListener(ctx, ln, payload.Host, actualPort) response := make([]byte, 4) binary.BigEndian.PutUint32(response, actualPort) - logger.Infof("remote port forwarding established: %s:%d", payload.Host, actualPort) + logger.Infof("remote port forwarding established: %s", net.JoinHostPort(payload.Host, strconv.Itoa(int(actualPort)))) return true, response } @@ -351,12 +357,12 @@ func (s *Server) handleRemoteForwardConnection(ctx ssh.Context, conn net.Conn, h channel, err := s.openForwardChannel(sshConn, host, port, remoteAddr) if err != nil { - logger.Debugf("open forward channel for %s:%d: %v", host, port, err) + logger.Debugf("open forward channel for %s: %v", net.JoinHostPort(host, strconv.Itoa(int(port))), err) _ = conn.Close() return } - nbssh.BidirectionalCopyWithContext(logger, ctx, conn, channel) + netrelay.Relay(ctx, conn, channel, netrelay.Options{Logger: logger}) } // openForwardChannel creates an SSH forwarded-tcpip channel diff --git a/client/ssh/server/privileges_other.go b/client/ssh/server/privileges_other.go new file mode 100644 index 000000000..89440dea8 --- /dev/null +++ b/client/ssh/server/privileges_other.go @@ -0,0 +1,16 @@ +//go:build !windows + +package server + +// isProcessElevated is only meaningful on Windows; other platforms use the +// effective UID check in isCurrentProcessPrivileged. +func isProcessElevated() bool { + return false +} + +// isWindowsAccountPrivilegedOrUnknown is only reachable on Windows. Report +// privileged on other platforms so a caller refusing privileged accounts fails +// closed. +func isWindowsAccountPrivilegedOrUnknown(string) bool { + return true +} diff --git a/client/ssh/server/privileges_windows.go b/client/ssh/server/privileges_windows.go new file mode 100644 index 000000000..41ea00fd7 --- /dev/null +++ b/client/ssh/server/privileges_windows.go @@ -0,0 +1,228 @@ +//go:build windows + +package server + +import ( + "fmt" + "strings" + "unsafe" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" +) + +var ( + netapi32 = windows.NewLazySystemDLL("netapi32.dll") + procNetUserGetLocalGroups = netapi32.NewProc("NetUserGetLocalGroups") +) + +const ( + // lgIncludeIndirect makes NetUserGetLocalGroups also return local groups + // the user belongs to through a global group. + lgIncludeIndirect = 0x1 + maxPreferredLength = 0xFFFFFFFF +) + +// localGroupUsersInfo0 mirrors LOCALGROUP_USERS_INFO_0. +type localGroupUsersInfo0 struct { + name *uint16 +} + +// isProcessElevated reports whether the current process token is elevated +// (TokenElevation): true for elevated administrators, the built-in +// Administrator, administrators with UAC disabled, and SYSTEM; false for +// standard users and administrators running with a UAC-filtered token. +func isProcessElevated() bool { + return windows.GetCurrentProcessToken().IsElevated() +} + +// isWindowsAccountPrivilegedOrUnknown reports whether the account is privileged +// on this machine: a well-known service account, a built-in Administrator +// (RID 500), or a member of the local Administrators group, directly or through +// nested groups. +// +// An account whose privilege cannot be determined counts as privileged, which +// is why the name says "or unknown". That is fail-closed for a caller that +// refuses privileged accounts, and fail-open for a caller that grants something +// to them, so only the former may use this. +func isWindowsAccountPrivilegedOrUnknown(username string) bool { + sid, _, _, err := windows.LookupSID("", username) + if err != nil { + log.Warnf("privilege check: SID lookup for %q failed, treating as privileged: %v", username, err) + return true + } + + if isPrivilegedUserSID(sid) { + return true + } + + member, err := isLocalAdminsMember(username) + if err != nil { + log.Warnf("privilege check: cannot determine Administrators membership for %q, treating as privileged: %v", username, err) + return true + } + return member +} + +// isPrivilegedUserSID reports whether the SID itself identifies a privileged +// principal, without consulting group membership. +func isPrivilegedUserSID(sid *windows.SID) bool { + wellKnown := []windows.WELL_KNOWN_SID_TYPE{ + windows.WinLocalSystemSid, + windows.WinLocalServiceSid, + windows.WinNetworkServiceSid, + windows.WinBuiltinAdministratorsSid, + } + for _, sidType := range wellKnown { + if sid.IsWellKnown(sidType) { + return true + } + } + return isBuiltinAdministratorSID(sid) +} + +// isBuiltinAdministratorSID reports whether the SID is a machine or domain +// built-in Administrator account (S-1-5-21-...-500). RID 500 is reserved for +// that account; it can be renamed but cannot be removed from the +// Administrators group. +func isBuiltinAdministratorSID(sid *windows.SID) bool { + if sid.IdentifierAuthority() != windows.SECURITY_NT_AUTHORITY { + return false + } + count := sid.SubAuthorityCount() + if count < 2 || sid.SubAuthority(0) != 21 { + return false + } + return sid.SubAuthority(uint32(count-1)) == 500 +} + +// isLocalAdminsMember reports whether the account is a member of the local +// Administrators group. +// +// Local accounts are checked against the local SAM, which is authoritative for +// them and, unlike a token, cannot under-report: UAC filters the tokens of +// local administrators, and a filtered token carries Administrators as +// deny-only, which a membership check on the token would read as "not a +// member". Domain accounts are exempt from that filtering, so for them an S4U +// token is preferred because its group list is LSA's transitive expansion and +// therefore covers nested and universal groups plus the machine's own local +// groups. NetUserGetLocalGroups expands only one global-group hop but needs no +// logon, so it serves as the fallback when no token can be obtained. +func isLocalAdminsMember(username string) (bool, error) { + adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + if err != nil { + return false, fmt.Errorf("create Administrators SID: %w", err) + } + + account, domain := parseUsername(username) + if NewPrivilegeDropper().isLocalUser(domain) { + return localGroupsContainSID(account, adminSid) + } + + member, s4uErr := s4uTokenIsMember(account, domain, adminSid) + if s4uErr == nil { + return member, nil + } + log.Debugf("privilege check: S4U membership check for %q failed, falling back to local group enumeration: %v", username, s4uErr) + + member, err = localGroupsContainSID(buildUserCpn(account, domain), adminSid) + if err != nil { + return false, fmt.Errorf("S4U check: %w; local group enumeration: %w", s4uErr, err) + } + return member, nil +} + +// s4uTokenIsMember obtains an S4U token for the account and checks whether the +// given SID is enabled in it. +func s4uTokenIsMember(account, domain string, sid *windows.SID) (bool, error) { + token, err := generateS4UUserToken(log.NewEntry(log.StandardLogger()), account, domain) + if err != nil { + return false, err + } + defer func() { + if err := windows.CloseHandle(token); err != nil { + log.Debugf("close S4U token: %v", err) + } + }() + return windows.Token(token).IsMember(sid) +} + +// localGroupsContainSID reports whether the wanted group is among the local +// groups the account belongs to, directly or through a global group. +// +// The wanted SID is resolved to its group name once and compared against the +// enumerated names. Well-known SIDs resolve from a static table, so that lookup +// needs no domain controller, and it keeps the comparison correct for a renamed +// or localized group because both sides then carry the new name. Resolving each +// enumerated name back to a SID instead would add a lookup per group that can +// block until it times out while a domain controller is unreachable, and cannot +// change the outcome: the names enumerated here are local groups of this +// machine, whose names are unique, so a name match identifies the group. +// +// A failure to resolve the wanted SID is returned rather than reported as +// "not a member", so a privilege check built on this fails closed. +func localGroupsContainSID(username string, want *windows.SID) (bool, error) { + wantName, _, _, err := want.LookupAccount("") + if err != nil { + return false, fmt.Errorf("resolve group SID %s to a name: %w", want, err) + } + + groups, err := netUserGetLocalGroups(username) + if err != nil { + return false, err + } + + for _, group := range groups { + if strings.EqualFold(group, wantName) { + return true, nil + } + } + return false, nil +} + +// netUserGetLocalGroups returns the names of the local groups the account is a +// member of, including indirect membership through global groups. +func netUserGetLocalGroups(username string) ([]string, error) { + name16, err := windows.UTF16PtrFromString(username) + if err != nil { + return nil, fmt.Errorf("convert username: %w", err) + } + + var buf *byte + var entriesRead, totalEntries uint32 + status, _, _ := procNetUserGetLocalGroups.Call( + 0, // local server + uintptr(unsafe.Pointer(name16)), + 0, // level 0: LOCALGROUP_USERS_INFO_0 + lgIncludeIndirect, + uintptr(unsafe.Pointer(&buf)), + maxPreferredLength, + uintptr(unsafe.Pointer(&entriesRead)), + uintptr(unsafe.Pointer(&totalEntries)), + ) + if status != 0 { + return nil, fmt.Errorf("NetUserGetLocalGroups for %q: status %d", username, status) + } + if buf == nil { + return nil, nil + } + defer func() { + if err := windows.NetApiBufferFree(buf); err != nil { + log.Debugf("free NetApi buffer: %v", err) + } + }() + + // MAX_PREFERRED_LENGTH makes the API allocate as much as it needs, so a + // short read is not expected. Report it rather than silently returning a + // subset of the account's groups. + if entriesRead != totalEntries { + return nil, fmt.Errorf("NetUserGetLocalGroups for %q returned %d of %d groups", username, entriesRead, totalEntries) + } + + entries := unsafe.Slice((*localGroupUsersInfo0)(unsafe.Pointer(buf)), entriesRead) + groups := make([]string, 0, entriesRead) + for _, entry := range entries { + groups = append(groups, windows.UTF16PtrToString(entry.name)) + } + return groups, nil +} diff --git a/client/ssh/server/privileges_windows_test.go b/client/ssh/server/privileges_windows_test.go new file mode 100644 index 000000000..983fccdf7 --- /dev/null +++ b/client/ssh/server/privileges_windows_test.go @@ -0,0 +1,293 @@ +//go:build windows + +package server + +import ( + "os/user" + "testing" + "unsafe" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +// filterNormalAccount limits NetUserEnum to normal user accounts. +const filterNormalAccount = 0x2 + +// TOKEN_ELEVATION_TYPE values. +const ( + tokenElevationTypeDefault = 1 + tokenElevationTypeFull = 2 + tokenElevationTypeLimited = 3 +) + +// tokenElevationType reads TokenElevationType from a token. +func tokenElevationType(token windows.Token) (uint32, error) { + var elevationType, returnedLen uint32 + err := windows.GetTokenInformation(token, windows.TokenElevationType, + (*byte)(unsafe.Pointer(&elevationType)), uint32(unsafe.Sizeof(elevationType)), &returnedLen) + if err != nil { + return 0, err + } + return elevationType, nil +} + +// userInfo0 mirrors USER_INFO_0. +type userInfo0 struct { + name *uint16 +} + +func mustParseSID(t *testing.T, s string) *windows.SID { + t.Helper() + sid, err := windows.StringToSid(s) + require.NoError(t, err, "parse SID %s", s) + return sid +} + +// localAccountNames returns the names of the local user accounts. +func localAccountNames(t *testing.T) []string { + t.Helper() + + var buf *byte + var entriesRead, totalEntries, resume uint32 + err := windows.NetUserEnum(nil, 0, filterNormalAccount, &buf, maxPreferredLength, + &entriesRead, &totalEntries, &resume) + require.NoError(t, err, "enumerate local users") + t.Cleanup(func() { + require.NoError(t, windows.NetApiBufferFree(buf), "free NetApi buffer") + }) + + entries := unsafe.Slice((*userInfo0)(unsafe.Pointer(buf)), entriesRead) + names := make([]string, 0, entriesRead) + for _, entry := range entries { + names = append(names, windows.UTF16PtrToString(entry.name)) + } + return names +} + +// localAccountNameByRID returns the name of the local account carrying the +// given RID. Accounts such as Administrator and Guest can be renamed and are +// localized, so tests must not name them literally. +func localAccountNameByRID(t *testing.T, rid uint32) string { + t.Helper() + + for _, name := range localAccountNames(t) { + sid, _, _, err := windows.LookupSID("", name) + if err != nil { + continue + } + if sid.IdentifierAuthority() != windows.SECURITY_NT_AUTHORITY { + continue + } + count := sid.SubAuthorityCount() + if count < 2 || sid.SubAuthority(0) != 21 { + continue + } + if sid.SubAuthority(uint32(count-1)) == rid { + return name + } + } + + t.Fatalf("no local account with RID %d", rid) + return "" +} + +// wellKnownAccountName resolves a well-known SID to the qualified account name +// the local system uses for it, which is localized. +func wellKnownAccountName(t *testing.T, sidType windows.WELL_KNOWN_SID_TYPE) string { + t.Helper() + + sid, err := windows.CreateWellKnownSid(sidType) + require.NoError(t, err, "create well-known SID") + name, domain, _, err := sid.LookupAccount("") + require.NoError(t, err, "resolve %s to an account name", sid) + if domain == "" { + return name + } + return domain + `\` + name +} + +func TestIsBuiltinAdministratorSID(t *testing.T) { + tests := []struct { + name string + sid string + want bool + }{ + {"machine_administrator", "S-1-5-21-1111111111-2222222222-3333333333-500", true}, + {"domain_administrator", "S-1-5-21-3390233681-4087452608-412898826-500", true}, + {"regular_user", "S-1-5-21-1111111111-2222222222-3333333333-1001", false}, + {"guest_account", "S-1-5-21-1111111111-2222222222-3333333333-501", false}, + {"domain_admins_group", "S-1-5-21-1111111111-2222222222-3333333333-512", false}, + {"system", "S-1-5-18", false}, + {"administrators_group", "S-1-5-32-544", false}, + {"non_nt_authority", "S-1-1-0", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isBuiltinAdministratorSID(mustParseSID(t, tt.sid)) + assert.Equal(t, tt.want, result, "RID 500 detection for %s", tt.sid) + }) + } +} + +func TestIsPrivilegedUserSID(t *testing.T) { + tests := []struct { + name string + sid string + want bool + }{ + {"local_system", "S-1-5-18", true}, + {"local_service", "S-1-5-19", true}, + {"network_service", "S-1-5-20", true}, + {"administrators_group", "S-1-5-32-544", true}, + {"builtin_administrator", "S-1-5-21-1111111111-2222222222-3333333333-500", true}, + {"regular_user", "S-1-5-21-1111111111-2222222222-3333333333-1001", false}, + {"users_group", "S-1-5-32-545", false}, + {"everyone", "S-1-1-0", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isPrivilegedUserSID(mustParseSID(t, tt.sid)) + assert.Equal(t, tt.want, result, "SID privilege classification for %s", tt.sid) + }) + } +} + +func TestIsWindowsAccountPrivilegedOrUnknown(t *testing.T) { + tests := []struct { + name string + username string + want bool + }{ + {"system", wellKnownAccountName(t, windows.WinLocalSystemSid), true}, + {"local_service", wellKnownAccountName(t, windows.WinLocalServiceSid), true}, + {"network_service", wellKnownAccountName(t, windows.WinNetworkServiceSid), true}, + {"administrators_group", wellKnownAccountName(t, windows.WinBuiltinAdministratorsSid), true}, + // The built-in Administrator (RID 500) and Guest (RID 501) accounts + // exist on every Windows installation, though they may be disabled. + {"builtin_administrator", localAccountNameByRID(t, 500), true}, + {"guest", localAccountNameByRID(t, 501), false}, + // Unresolvable accounts fail closed. + {"nonexistent_user", "netbird-no-such-user", true}, + {"empty_username", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isWindowsAccountPrivilegedOrUnknown(tt.username) + assert.Equal(t, tt.want, result, "account privilege classification for %q", tt.username) + }) + } +} + +func TestIsProcessElevated(t *testing.T) { + elevated := isProcessElevated() + + // TokenElevationType is a second, independent view of the same token: + // Full means elevated and Limited means a filtered administrator, while + // Default covers both a standard user and an administrator with no linked + // token (UAC off, the built-in Administrator, SYSTEM), so it implies nothing. + elevationType, err := tokenElevationType(windows.GetCurrentProcessToken()) + require.NoError(t, err, "read token elevation type") + + adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + require.NoError(t, err, "create Administrators SID") + + // Token(0) makes CheckTokenMembership evaluate the caller's own token. It + // counts only enabled SIDs, so a filtered administrator reports false here. + member, err := windows.Token(0).IsMember(adminSid) + require.NoError(t, err, "check own Administrators membership") + + t.Logf("elevated=%v elevationType=%d memberOfAdministrators=%v", elevated, elevationType, member) + + switch elevationType { + case tokenElevationTypeFull: + assert.True(t, elevated, "a token of elevation type Full must report elevated") + case tokenElevationTypeLimited: + assert.False(t, elevated, "a filtered administrator token must not report elevated") + } + + // Administrators enabled in the token means the token wields administrative + // rights, which is what elevation reports. + if member { + assert.True(t, elevated, "token with enabled Administrators membership must report elevated") + } +} + +// TestS4UMembershipAgreesWithLocalGroups exercises the S4U token path used +// for domain accounts. S4U logons need the TCB privilege, so the test runs +// only as SYSTEM (which is how CI executes the suite). For local accounts the +// token's Administrators membership must agree with the SAM enumeration. +func TestS4UMembershipAgreesWithLocalGroups(t *testing.T) { + system, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + require.NoError(t, err, "create SYSTEM SID") + current, err := user.Current() + require.NoError(t, err, "get current user") + if current.Uid != system.String() { + t.Skipf("S4U logon requires SYSTEM (running as %s)", current.Username) + } + + adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + require.NoError(t, err, "create Administrators SID") + + checked := 0 + for _, name := range localAccountNames(t) { + viaToken, err := s4uTokenIsMember(name, ".", adminSid) + if err != nil { + // Disabled or logon-restricted accounts cannot get an S4U logon. + t.Logf("skipping %s: %v", name, err) + continue + } + viaSAM, err := localGroupsContainSID(name, adminSid) + require.NoError(t, err, "enumerate local groups for %s", name) + + assert.Equal(t, viaSAM, viaToken, "S4U token and SAM enumeration must agree on Administrators membership for %s", name) + checked++ + } + // Ineligible accounts are skipped, so without this the test could report + // success while comparing nothing at all. + require.Positive(t, checked, "no local account completed an S4U logon, so nothing was compared") + t.Logf("checked %d local accounts via S4U", checked) +} + +// TestLocalGroupsContainSID_Administrator checks the positive case against the +// built-in Administrator, a member of Administrators on every installation. +func TestLocalGroupsContainSID_Administrator(t *testing.T) { + adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + require.NoError(t, err, "create Administrators SID") + + administrator := localAccountNameByRID(t, 500) + member, err := localGroupsContainSID(administrator, adminSid) + require.NoError(t, err, "enumerate local groups for %s", administrator) + assert.True(t, member, "%s is a member of the Administrators group", administrator) +} + +// TestLocalGroupsContainSID_UnresolvableGroupFailsClosed covers a wanted SID +// that resolves to no group: the error must surface rather than being reported +// as "not a member", so the privilege check treats the account as privileged. +func TestLocalGroupsContainSID_UnresolvableGroupFailsClosed(t *testing.T) { + unknown := mustParseSID(t, "S-1-5-21-1111111111-2222222222-3333333333-4444") + + _, err := localGroupsContainSID(localAccountNameByRID(t, 500), unknown) + require.Error(t, err, "must report an error when the wanted group cannot be identified") +} + +func TestLocalGroupsContainSID_Guest(t *testing.T) { + guestsSid, err := windows.CreateWellKnownSid(windows.WinBuiltinGuestsSid) + require.NoError(t, err, "create Guests SID") + adminsSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + require.NoError(t, err, "create Administrators SID") + + guest := localAccountNameByRID(t, 501) + + inGuests, err := localGroupsContainSID(guest, guestsSid) + require.NoError(t, err, "enumerate local groups for %s", guest) + assert.True(t, inGuests, "%s is a member of the Guests group", guest) + + inAdmins, err := localGroupsContainSID(guest, adminsSid) + require.NoError(t, err, "enumerate local groups for %s", guest) + assert.False(t, inAdmins, "%s is not a member of the Administrators group", guest) +} diff --git a/client/ssh/server/server.go b/client/ssh/server/server.go index 82d3b700f..b32da796e 100644 --- a/client/ssh/server/server.go +++ b/client/ssh/server/server.go @@ -10,6 +10,7 @@ import ( "net" "net/netip" "slices" + "strconv" "strings" "sync" "time" @@ -26,6 +27,7 @@ import ( "github.com/netbirdio/netbird/client/ssh/detection" "github.com/netbirdio/netbird/shared/auth" "github.com/netbirdio/netbird/shared/auth/jwt" + "github.com/netbirdio/netbird/util/netrelay" "github.com/netbirdio/netbird/version" ) @@ -52,6 +54,10 @@ const ( DefaultJWTMaxTokenAge = 10 * 60 ) +// directTCPIPDialTimeout bounds how long relayDirectTCPIP waits on a dial to +// the forwarded destination before rejecting the SSH channel. +const directTCPIPDialTimeout = 30 * time.Second + var ( ErrPrivilegedUserDisabled = errors.New(msgPrivilegedUserDisabled) ErrUserNotFound = errors.New("user not found") @@ -137,10 +143,11 @@ type sessionState struct { } type Server struct { - sshServer *ssh.Server - listener net.Listener - mu sync.RWMutex - hostKeyPEM []byte + sshServer *ssh.Server + listener net.Listener + extraListeners []net.Listener + mu sync.RWMutex + hostKeyPEM []byte // sessions tracks active SSH sessions (shell, command, SFTP). // These are created when a client opens a session channel and requests shell/exec/subsystem. @@ -190,6 +197,12 @@ type Config struct { // HostKey is the SSH server host key in PEM format HostKeyPEM []byte + + // Auth is the fine-grained authorization to open with. Nil starts with an + // empty authorizer, which authorizes nobody until UpdateSSHAuth is called. + // Setting it here rather than afterwards means the server never accepts a + // login before it knows who is allowed. + Auth *sshauth.Config } // SessionInfo contains information about an active SSH session @@ -213,7 +226,11 @@ func New(config *Config) *Server { connections: make(map[connKey]*connState), jwtEnabled: config.JWT != nil, jwtConfig: config.JWT, - authorizer: sshauth.NewAuthorizer(), // Initialize with empty config + authorizer: sshauth.NewAuthorizer(), + } + + if config.Auth != nil { + s.authorizer.Update(config.Auth) } return s @@ -254,6 +271,35 @@ func (s *Server) Start(ctx context.Context, addr netip.AddrPort) error { return nil } +// AddListener starts serving SSH on an additional address (e.g. IPv6). +// Must be called after Start. +func (s *Server) AddListener(ctx context.Context, addr netip.AddrPort) error { + s.mu.Lock() + srv := s.sshServer + if srv == nil { + s.mu.Unlock() + return errors.New("SSH server is not running") + } + + ln, addrDesc, err := s.createListener(ctx, addr) + if err != nil { + s.mu.Unlock() + return fmt.Errorf("create listener: %w", err) + } + + s.extraListeners = append(s.extraListeners, ln) + s.mu.Unlock() + + log.Infof("SSH server also listening on %s", addrDesc) + + go func() { + if err := srv.Serve(ln); err != nil && !errors.Is(err, ssh.ErrServerClosed) { + log.Errorf("SSH server error on %s: %v", addrDesc, err) + } + }() + return nil +} + func (s *Server) createListener(ctx context.Context, addr netip.AddrPort) (net.Listener, string, error) { if s.netstackNet != nil { ln, err := s.netstackNet.ListenTCPAddrPort(addr) @@ -291,6 +337,8 @@ func (s *Server) Stop() error { } s.sshServer = nil s.listener = nil + extraListeners := s.extraListeners + s.extraListeners = nil s.mu.Unlock() // Close outside the lock: session handlers need s.mu for unregisterSession. @@ -298,6 +346,12 @@ func (s *Server) Stop() error { log.Debugf("close SSH server: %v", err) } + for _, ln := range extraListeners { + if err := ln.Close(); err != nil { + log.Debugf("close extra SSH listener: %v", err) + } + } + s.mu.Lock() maps.Clear(s.sessions) maps.Clear(s.pendingAuthJWT) @@ -417,6 +471,27 @@ func (s *Server) UpdateSSHAuth(config *sshauth.Config) { s.authorizer.Update(config) } +// JWTConfig returns the JWT authentication this server was built with, or nil +// when JWT authentication is disabled. +func (s *Server) JWTConfig() *JWTConfig { + s.mu.RLock() + defer s.mu.RUnlock() + return s.jwtConfig +} + +// AuthConfig returns the fine-grained authorization currently in force, or nil +// when the server has no authorizer. +func (s *Server) AuthConfig() *sshauth.Config { + s.mu.RLock() + authorizer := s.authorizer + s.mu.RUnlock() + + if authorizer == nil { + return nil + } + return authorizer.Config() +} + // ensureJWTValidator initializes the JWT validator and extractor if not already initialized func (s *Server) ensureJWTValidator() error { s.mu.RLock() @@ -749,11 +824,10 @@ func (s *Server) findSessionKeyByContext(ctx ssh.Context) sessionKey { func (s *Server) connectionValidator(_ ssh.Context, conn net.Conn) net.Conn { s.mu.RLock() - netbirdNetwork := s.wgAddress.Network - localIP := s.wgAddress.IP + wgAddr := s.wgAddress s.mu.RUnlock() - if !netbirdNetwork.IsValid() || !localIP.IsValid() { + if !wgAddr.Network.IsValid() || !wgAddr.IP.IsValid() { return conn } @@ -769,14 +843,17 @@ func (s *Server) connectionValidator(_ ssh.Context, conn net.Conn) net.Conn { log.Warnf("SSH connection rejected: invalid remote IP %s", tcpAddr.IP) return nil } + remoteIP = remoteIP.Unmap() // Block connections from our own IP (prevent local apps from connecting to ourselves) - if remoteIP == localIP { + if remoteIP == wgAddr.IP || wgAddr.IPv6.IsValid() && remoteIP == wgAddr.IPv6 { log.Warnf("SSH connection rejected from own IP %s", remoteIP) return nil } - if !netbirdNetwork.Contains(remoteIP) { + inV4 := wgAddr.Network.Contains(remoteIP) + inV6 := wgAddr.IPv6Net.IsValid() && wgAddr.IPv6Net.Contains(remoteIP) + if !inV4 && !inV6 { log.Warnf("SSH connection rejected from non-NetBird IP %s", remoteIP) return nil } @@ -876,20 +953,45 @@ func (s *Server) directTCPIPHandler(srv *ssh.Server, conn *cryptossh.ServerConn, s.mu.RUnlock() if !allowLocal { - logger.Warnf("local port forwarding denied for %s:%d: disabled", payload.Host, payload.Port) + logger.Warnf("local port forwarding denied for %s: disabled", net.JoinHostPort(payload.Host, strconv.Itoa(int(payload.Port)))) _ = newChan.Reject(cryptossh.Prohibited, "local port forwarding disabled") return } if err := s.checkPortForwardingPrivileges(ctx, "local", payload.Port); err != nil { - logger.Warnf("local port forwarding denied for %s:%d: %v", payload.Host, payload.Port, err) + logger.Warnf("local port forwarding denied for %s: %v", net.JoinHostPort(payload.Host, strconv.Itoa(int(payload.Port))), err) _ = newChan.Reject(cryptossh.Prohibited, "insufficient privileges") return } - forwardAddr := fmt.Sprintf("-L %s:%d", payload.Host, payload.Port) + hostPort := net.JoinHostPort(payload.Host, strconv.Itoa(int(payload.Port))) + forwardAddr := "-L " + hostPort s.addConnectionPortForward(ctx.User(), ctx.RemoteAddr(), forwardAddr) - logger.Infof("local port forwarding: %s:%d", payload.Host, payload.Port) + logger.Infof("local port forwarding: %s", hostPort) - ssh.DirectTCPIPHandler(srv, conn, newChan, ctx) + s.relayDirectTCPIP(ctx, newChan, payload.Host, int(payload.Port), logger) +} + +// relayDirectTCPIP is a netrelay-based replacement for gliderlabs' +// DirectTCPIPHandler. The upstream handler closes both sides on the first +// EOF; netrelay.Relay propagates CloseWrite so each direction drains on its +// own terms. +func (s *Server) relayDirectTCPIP(ctx ssh.Context, newChan cryptossh.NewChannel, host string, port int, logger *log.Entry) { + dest := net.JoinHostPort(host, strconv.Itoa(port)) + + dialer := net.Dialer{Timeout: directTCPIPDialTimeout} + dconn, err := dialer.DialContext(ctx, "tcp", dest) + if err != nil { + _ = newChan.Reject(cryptossh.ConnectionFailed, err.Error()) + return + } + + ch, reqs, err := newChan.Accept() + if err != nil { + _ = dconn.Close() + return + } + go cryptossh.DiscardRequests(reqs) + + netrelay.Relay(ctx, dconn, ch, netrelay.Options{Logger: logger}) } diff --git a/client/ssh/server/server_config_test.go b/client/ssh/server/server_config_test.go index f70e29963..983bd7a43 100644 --- a/client/ssh/server/server_config_test.go +++ b/client/ssh/server/server_config_test.go @@ -239,6 +239,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) { forwardType string port uint32 username string + uid string expectError bool errorMsg string skipOnWindows bool @@ -248,6 +249,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) { forwardType: "remote", port: 80, username: "testuser", + uid: "1000", expectError: true, errorMsg: "cannot bind to privileged port", skipOnWindows: true, @@ -257,6 +259,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) { forwardType: "tcpip-forward", port: 443, username: "testuser", + uid: "1000", expectError: true, errorMsg: "cannot bind to privileged port", skipOnWindows: true, @@ -266,6 +269,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) { forwardType: "remote", port: 8080, username: "testuser", + uid: "1000", expectError: false, }, { @@ -273,6 +277,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) { forwardType: "remote", port: 0, username: "testuser", + uid: "1000", expectError: false, }, { @@ -280,13 +285,35 @@ func TestServer_PrivilegedPortAccess(t *testing.T) { forwardType: "remote", port: 22, username: "root", + uid: "0", expectError: false, }, + { + // Only uid 0 is privileged, whatever the account is called. + name: "uid 0 under another name may bind a privileged port", + forwardType: "remote", + port: 22, + username: "toor", + uid: "0", + expectError: false, + skipOnWindows: true, + }, + { + name: "account named root without uid 0 may not", + forwardType: "remote", + port: 22, + username: "root", + uid: "1000", + expectError: true, + errorMsg: "cannot bind to privileged port", + skipOnWindows: true, + }, { name: "local forward privileged port allowed for non-root", forwardType: "local", port: 80, username: "testuser", + uid: "1000", expectError: false, }, } @@ -299,7 +326,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) { result := PrivilegeCheckResult{ Allowed: true, - User: &user.User{Username: tt.username}, + User: &user.User{Username: tt.username, Uid: tt.uid}, } err := server.checkPrivilegedPortAccess(tt.forwardType, tt.port, result) @@ -420,6 +447,13 @@ func TestServer_PortConflictHandling(t *testing.T) { func TestServer_IsPrivilegedUser(t *testing.T) { + // Windows classification depends on account SIDs and group membership, and + // the accounts involved carry localized, renameable names. It is covered by + // TestIsWindowsAccountPrivileged, which resolves them from well-known SIDs. + if runtime.GOOS == "windows" { + t.Skip("covered by TestIsWindowsAccountPrivileged") + } + tests := []struct { username string expected bool @@ -440,44 +474,16 @@ func TestServer_IsPrivilegedUser(t *testing.T) { expected: false, description: "empty username should not be privileged", }, - } - - // Add Windows-specific tests - if runtime.GOOS == "windows" { - tests = append(tests, []struct { - username string - expected bool - description string - }{ - { - username: "Administrator", - expected: true, - description: "Administrator should be considered privileged on Windows", - }, - { - username: "administrator", - expected: true, - description: "administrator should be considered privileged on Windows (case insensitive)", - }, - }...) - } else { - // On non-Windows systems, Administrator should not be privileged - tests = append(tests, []struct { - username string - expected bool - description string - }{ - { - username: "Administrator", - expected: false, - description: "Administrator should not be privileged on non-Windows systems", - }, - }...) + { + username: "Administrator", + expected: false, + description: "Administrator should not be privileged on non-Windows systems", + }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - result := isPrivilegedUsername(tt.username) + result := isPrivilegedOrUnknown(tt.username) assert.Equal(t, tt.expected, result, tt.description) }) } diff --git a/client/ssh/server/sftp_windows.go b/client/ssh/server/sftp_windows.go index dc532b9e7..25cd17298 100644 --- a/client/ssh/server/sftp_windows.go +++ b/client/ssh/server/sftp_windows.go @@ -17,7 +17,7 @@ import ( // createSftpCommand creates a Windows SFTP command with user switching. // The caller must close the returned token handle after starting the process. func (s *Server) createSftpCommand(targetUser *user.User, sess ssh.Session) (*exec.Cmd, windows.Token, error) { - username, domain := s.parseUsername(targetUser.Username) + username, domain := parseUsername(targetUser.Username) netbirdPath, err := os.Executable() if err != nil { diff --git a/client/ssh/server/shell.go b/client/ssh/server/shell.go index 1e8ff5e31..7b356b2a0 100644 --- a/client/ssh/server/shell.go +++ b/client/ssh/server/shell.go @@ -13,6 +13,8 @@ import ( "github.com/gliderlabs/ssh" log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/getent" ) const ( @@ -56,7 +58,11 @@ func getUnixUserShell(userID string) string { return shell } - if shell := getShellFromGetent(userID); shell != "" { + shell, err := getent.UserShell(userID) + if err != nil { + log.Debugf("look up the shell for uid %s through getent: %v", userID, err) + } + if shell != "" { return shell } diff --git a/client/ssh/server/shell_unix_test.go b/client/ssh/server/shell_unix_test.go new file mode 100644 index 000000000..c5e65e535 --- /dev/null +++ b/client/ssh/server/shell_unix_test.go @@ -0,0 +1,94 @@ +//go:build !windows + +package server + +import ( + "os/exec" + "os/user" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/getent" +) + +// TestGetShellFromPasswd_CurrentUser verifies that getShellFromPasswd correctly +// reads the current user's shell from /etc/passwd by comparing it against what +// getent reports (which goes through NSS). +func TestGetShellFromPasswd_CurrentUser(t *testing.T) { + current, err := user.Current() + require.NoError(t, err) + + shell := getShellFromPasswd(current.Uid) + if shell == "" { + t.Skip("current user not found in /etc/passwd (may be an NSS-only user)") + } + + assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell) + + if _, err := exec.LookPath("getent"); err == nil { + getentShell, getentErr := getent.UserShell(current.Uid) + if getentErr == nil && getentShell != "" { + assert.Equal(t, getentShell, shell, "shell from /etc/passwd should match getent") + } + } +} + +// TestGetShellFromPasswd_RootUser verifies that getShellFromPasswd can read +// root's shell from /etc/passwd. Root is guaranteed to be in /etc/passwd on +// any standard Unix system. +func TestGetShellFromPasswd_RootUser(t *testing.T) { + shell := getShellFromPasswd("0") + require.NotEmpty(t, shell, "root (UID 0) must be in /etc/passwd") + assert.True(t, shell[0] == '/', "root shell should be an absolute path, got %q", shell) +} + +// TestGetShellFromPasswd_NonexistentUID verifies that getShellFromPasswd +// returns empty for a UID that doesn't exist in /etc/passwd. +func TestGetShellFromPasswd_NonexistentUID(t *testing.T) { + shell := getShellFromPasswd("4294967294") + assert.Empty(t, shell, "nonexistent UID should return empty shell") +} + +// TestGetShellFromPasswd_MatchesGetentForKnownUsers reads /etc/passwd directly +// and cross-validates every entry against getent to ensure the two shell +// sources agree. +func TestGetShellFromPasswd_MatchesGetentForKnownUsers(t *testing.T) { + if _, err := exec.LookPath("getent"); err != nil { + t.Skip("getent not available") + } + + // Pick a few well-known system UIDs that are virtually always in /etc/passwd. + uids := []string{"0"} // root + + current, err := user.Current() + require.NoError(t, err) + uids = append(uids, current.Uid) + + for _, uid := range uids { + passwdShell := getShellFromPasswd(uid) + if passwdShell == "" { + continue + } + + getentShell, err := getent.UserShell(uid) + if err != nil { + continue + } + + assert.Equal(t, getentShell, passwdShell, "shell mismatch for UID %s", uid) + } +} + +// TestIntegration_ShellLookupChain tests the full shell resolution chain +// (getShellFromPasswd -> getent -> $SHELL -> default). +func TestIntegration_ShellLookupChain(t *testing.T) { + current, err := user.Current() + require.NoError(t, err) + + // getUserShell is the top-level function used by the SSH server. + shell := getUserShell(current.Uid) + require.NotEmpty(t, shell, "getUserShell must always return a shell") + assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell) +} diff --git a/client/ssh/server/test.go b/client/ssh/server/test.go index 454d3afa3..7ca28f034 100644 --- a/client/ssh/server/test.go +++ b/client/ssh/server/test.go @@ -1,3 +1,11 @@ +// This file is intentionally named test.go (not test_test.go) so the exported +// StartTestServer helper is visible to the ssh/proxy and ssh/client external +// test packages, not just this package's own tests. The //go:build !js tag +// keeps its "testing" import, along with the whole testing/flag/regexp +// transitive chain it drags in, out of the wasm client, which links +// ssh/server through the engine but never runs Go tests under GOOS=js. +//go:build !js + package server import ( diff --git a/client/ssh/server/user_utils.go b/client/ssh/server/user_utils.go index bc2aa2d7d..f2f33b3d7 100644 --- a/client/ssh/server/user_utils.go +++ b/client/ssh/server/user_utils.go @@ -9,6 +9,8 @@ import ( "strings" log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/getent" ) var ( @@ -16,19 +18,17 @@ var ( ErrPrivilegedUserSwitch = errors.New("cannot switch to privileged user - current user lacks required privileges") ) -// isPlatformUnix returns true for Unix-like platforms (Linux, macOS, etc.) -func isPlatformUnix() bool { - return getCurrentOS() != "windows" -} - // Dependency injection variables for testing - allows mocking dynamic runtime checks var ( - getCurrentUser = currentUserWithGetent - lookupUser = lookupWithGetent + getCurrentUser = getent.CurrentUser + lookupUser = getent.LookupUser getCurrentOS = func() string { return runtime.GOOS } getIsProcessPrivileged = isCurrentProcessPrivileged getEuid = os.Geteuid + + getProcessElevated = isProcessElevated + getWindowsAccountPrivilegedOrUnknown = isWindowsAccountPrivilegedOrUnknown ) const ( @@ -65,6 +65,13 @@ type PrivilegeCheckResult struct { RequiresUserSwitching bool } +// privilegeCheckContext holds all context needed for privilege checking +type privilegeCheckContext struct { + currentUser *user.User + currentUserPrivileged bool + allowRoot bool +} + // CheckPrivileges performs comprehensive privilege checking for all SSH features. // This is the single source of truth for privilege decisions across the SSH server. func (s *Server) CheckPrivileges(req PrivilegeCheckRequest) PrivilegeCheckResult { @@ -75,7 +82,7 @@ func (s *Server) CheckPrivileges(req PrivilegeCheckRequest) PrivilegeCheckResult // Handle empty username case - but still check root access controls if req.RequestedUsername == "" { - if isPrivilegedUsername(context.currentUser.Username) && !context.allowRoot { + if isPrivilegedOrUnknown(context.currentUser.Username) && !context.allowRoot { return PrivilegeCheckResult{ Allowed: false, Error: &PrivilegedUserError{Username: context.currentUser.Username}, @@ -135,7 +142,7 @@ func (s *Server) checkUserRequest(ctx *privilegeCheckContext, req PrivilegeCheck needsUserSwitching := !isSameResolvedUser(resolvedUser, ctx.currentUser) - if isPrivilegedUsername(resolvedUser.Username) && !ctx.allowRoot { + if isPrivilegedOrUnknown(resolvedUser.Username) && !ctx.allowRoot { return PrivilegeCheckResult{ Allowed: false, Error: &PrivilegedUserError{Username: resolvedUser.Username}, @@ -175,6 +182,42 @@ func (s *Server) resolveRequestedUser(requestedUsername string) (*user.User, err return u, nil } +// SetAllowRootLogin configures root login access +func (s *Server) SetAllowRootLogin(allow bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.allowRootLogin = allow +} + +// userNameLookup performs user lookup with root login permission check +func (s *Server) userNameLookup(username string) (*user.User, error) { + result, err := s.userPrivilegeCheck(username) + if err != nil { + return nil, err + } + return result.User, nil +} + +// userPrivilegeCheck performs user lookup with full privilege check result +func (s *Server) userPrivilegeCheck(username string) (PrivilegeCheckResult, error) { + result := s.CheckPrivileges(PrivilegeCheckRequest{ + RequestedUsername: username, + FeatureSupportsUserSwitch: true, + FeatureName: FeatureSSHLogin, + }) + + if !result.Allowed { + return result, result.Error + } + + return result, nil +} + +// isPlatformUnix returns true for Unix-like platforms (Linux, macOS, etc.) +func isPlatformUnix() bool { + return getCurrentOS() != "windows" +} + // isSameResolvedUser compares two resolved user identities func isSameResolvedUser(user1, user2 *user.User) bool { if user1 == nil || user2 == nil { @@ -183,13 +226,6 @@ func isSameResolvedUser(user1, user2 *user.User) bool { return user1.Uid == user2.Uid } -// privilegeCheckContext holds all context needed for privilege checking -type privilegeCheckContext struct { - currentUser *user.User - currentUserPrivileged bool - allowRoot bool -} - // isSameUser checks if two usernames refer to the same user // SECURITY: This function must be conservative - it should only return true // when we're certain both usernames refer to the exact same user identity @@ -253,159 +289,30 @@ func isWindowsSameUser(requestedUsername, currentUsername string) bool { return strings.EqualFold(reqDomain, curDomain) } -// SetAllowRootLogin configures root login access -func (s *Server) SetAllowRootLogin(allow bool) { - s.mu.Lock() - defer s.mu.Unlock() - s.allowRootLogin = allow -} - -// userNameLookup performs user lookup with root login permission check -func (s *Server) userNameLookup(username string) (*user.User, error) { - result := s.CheckPrivileges(PrivilegeCheckRequest{ - RequestedUsername: username, - FeatureSupportsUserSwitch: true, - FeatureName: FeatureSSHLogin, - }) - - if !result.Allowed { - return nil, result.Error - } - - return result.User, nil -} - -// userPrivilegeCheck performs user lookup with full privilege check result -func (s *Server) userPrivilegeCheck(username string) (PrivilegeCheckResult, error) { - result := s.CheckPrivileges(PrivilegeCheckRequest{ - RequestedUsername: username, - FeatureSupportsUserSwitch: true, - FeatureName: FeatureSSHLogin, - }) - - if !result.Allowed { - return result, result.Error - } - - return result, nil -} - -// isPrivilegedUsername checks if the given username represents a privileged user across platforms. -// On Unix: root -// On Windows: Administrator, SYSTEM (case-insensitive) -// Handles domain-qualified usernames like "DOMAIN\Administrator" or "user@domain.com" -func isPrivilegedUsername(username string) bool { +// isPrivilegedOrUnknown reports whether the given username represents a +// privileged user, or on Windows an account whose privilege could not be +// determined. +// On Unix: root. +// On Windows: well-known service accounts, built-in Administrator accounts, +// and members of the local Administrators group; handles domain-qualified +// usernames like "DOMAIN\user" or "user@domain.com". An account that cannot be +// resolved or evaluated is reported as privileged. +// +// Use this to refuse privileged accounts, never to grant them anything: the +// undetermined case is safe for a refusal and unsafe for a grant. +func isPrivilegedOrUnknown(username string) bool { if getCurrentOS() != "windows" { return username == "root" } - - bareUsername := username - // Handle Windows domain format: DOMAIN\username - if idx := strings.LastIndex(username, `\`); idx != -1 { - bareUsername = username[idx+1:] - } - // Handle email-style format: username@domain.com - if idx := strings.Index(bareUsername, "@"); idx != -1 { - bareUsername = bareUsername[:idx] - } - - return isWindowsPrivilegedUser(bareUsername) -} - -// isWindowsPrivilegedUser checks if a bare username (domain already stripped) represents a Windows privileged account -func isWindowsPrivilegedUser(bareUsername string) bool { - // common privileged usernames (case insensitive) - privilegedNames := []string{ - "administrator", - "admin", - "root", - "system", - "localsystem", - "networkservice", - "localservice", - } - - usernameLower := strings.ToLower(bareUsername) - for _, privilegedName := range privilegedNames { - if usernameLower == privilegedName { - return true - } - } - - // computer accounts (ending with $) are not privileged by themselves - // They only gain privileges through group membership or specific SIDs - - if targetUser, err := lookupUser(bareUsername); err == nil { - return isWindowsPrivilegedSID(targetUser.Uid) - } - - return false -} - -// isWindowsPrivilegedSID checks if a Windows SID represents a privileged account -func isWindowsPrivilegedSID(sid string) bool { - privilegedSIDs := []string{ - "S-1-5-18", // Local System (SYSTEM) - "S-1-5-19", // Local Service (NT AUTHORITY\LOCAL SERVICE) - "S-1-5-20", // Network Service (NT AUTHORITY\NETWORK SERVICE) - "S-1-5-32-544", // Administrators group (BUILTIN\Administrators) - "S-1-5-500", // Built-in Administrator account (local machine RID 500) - } - - for _, privilegedSID := range privilegedSIDs { - if sid == privilegedSID { - return true - } - } - - // Check for domain administrator accounts (RID 500 in any domain) - // Format: S-1-5-21-domain-domain-domain-500 - // This is reliable as RID 500 is reserved for the domain Administrator account - if strings.HasPrefix(sid, "S-1-5-21-") && strings.HasSuffix(sid, "-500") { - return true - } - - // Check for other well-known privileged RIDs in domain contexts - // RID 512 = Domain Admins group, RID 516 = Domain Controllers group - if strings.HasPrefix(sid, "S-1-5-21-") { - if strings.HasSuffix(sid, "-512") || // Domain Admins group - strings.HasSuffix(sid, "-516") || // Domain Controllers group - strings.HasSuffix(sid, "-519") { // Enterprise Admins group - return true - } - } - - return false + return getWindowsAccountPrivilegedOrUnknown(username) } // isCurrentProcessPrivileged checks if the current process is running with elevated privileges. // On Unix systems, this means running as root (UID 0). -// On Windows, this means running as Administrator or SYSTEM. +// On Windows, this means the process token is elevated (administrators, SYSTEM). func isCurrentProcessPrivileged() bool { if getCurrentOS() == "windows" { - return isWindowsElevated() + return getProcessElevated() } return getEuid() == 0 } - -// isWindowsElevated checks if the current process is running with elevated privileges on Windows -func isWindowsElevated() bool { - currentUser, err := getCurrentUser() - if err != nil { - log.Errorf("failed to get current user for privilege check, assuming non-privileged: %v", err) - return false - } - - if isWindowsPrivilegedSID(currentUser.Uid) { - log.Debugf("Windows user switching supported: running as privileged SID %s", currentUser.Uid) - return true - } - - if isPrivilegedUsername(currentUser.Username) { - log.Debugf("Windows user switching supported: running as privileged username %s", currentUser.Username) - return true - } - - log.Debugf("Windows user switching not supported: not running as privileged user (current: %s)", currentUser.Uid) - return false -} diff --git a/client/ssh/server/user_utils_test.go b/client/ssh/server/user_utils_test.go index 637dc10d0..2fa9b68ee 100644 --- a/client/ssh/server/user_utils_test.go +++ b/client/ssh/server/user_utils_test.go @@ -4,6 +4,7 @@ import ( "errors" "os/user" "runtime" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -27,8 +28,8 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri originalLookupUser := lookupUser originalGetCurrentOS := getCurrentOS originalGetEuid := getEuid - - // Reset caches to ensure clean test state + originalGetProcessElevated := getProcessElevated + originalGetWindowsAccountPrivilegedOrUnknown := getWindowsAccountPrivilegedOrUnknown // Set test values - inject platform dependencies getCurrentUser = func() (*user.User, error) { @@ -53,16 +54,31 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri return euid } - // Mock privilege detection based on the test user - getIsProcessPrivileged = func() bool { + // Simulate the Windows token elevation check based on the fixture user: + // the built-in Administrator (RID 500) and SYSTEM run elevated. + getProcessElevated = func() bool { if currentUser == nil { return false } - // Check both username and SID for Windows systems - if os == "windows" && isWindowsPrivilegedSID(currentUser.Uid) { + return currentUser.Uid == "S-1-5-18" || strings.HasSuffix(currentUser.Uid, "-500") + } + + // Simulate the Windows account classifier for the fixture accounts. + // "root" does not exist on Windows; the real classifier fails closed on + // unresolvable accounts, so it counts as privileged here too. + getWindowsAccountPrivilegedOrUnknown = func(username string) bool { + bare := username + if idx := strings.LastIndex(bare, `\`); idx != -1 { + bare = bare[idx+1:] + } + if idx := strings.Index(bare, "@"); idx != -1 { + bare = bare[:idx] + } + switch strings.ToLower(bare) { + case "administrator", "system", "root": return true } - return isPrivilegedUsername(currentUser.Username) + return false } // Return cleanup function @@ -71,10 +87,8 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri lookupUser = originalLookupUser getCurrentOS = originalGetCurrentOS getEuid = originalGetEuid - - getIsProcessPrivileged = isCurrentProcessPrivileged - - // Reset caches after test + getProcessElevated = originalGetProcessElevated + getWindowsAccountPrivilegedOrUnknown = originalGetWindowsAccountPrivilegedOrUnknown } } @@ -421,6 +435,9 @@ func TestUsedFallback_MeansNoPrivilegeDropping(t *testing.T) { } func TestPrivilegedUsernameDetection(t *testing.T) { + // Windows classification is syscall-backed (SID resolution, group + // membership) and is covered by privileges_windows_test.go; here only the + // Unix logic and the platform dispatch are exercised. tests := []struct { name string username string @@ -432,25 +449,9 @@ func TestPrivilegedUsernameDetection(t *testing.T) { {"unix_regular_user", "alice", "linux", false}, {"unix_root_capital", "Root", "linux", false}, // Case-sensitive - // Windows tests + // Windows dispatch to the (mocked) account classifier {"windows_administrator", "Administrator", "windows", true}, - {"windows_system", "SYSTEM", "windows", true}, - {"windows_admin", "admin", "windows", true}, - {"windows_admin_lowercase", "administrator", "windows", true}, // Case-insensitive - {"windows_domain_admin", "DOMAIN\\Administrator", "windows", true}, - {"windows_email_admin", "admin@domain.com", "windows", true}, {"windows_regular_user", "alice", "windows", false}, - {"windows_domain_user", "DOMAIN\\alice", "windows", false}, - {"windows_localsystem", "localsystem", "windows", true}, - {"windows_networkservice", "networkservice", "windows", true}, - {"windows_localservice", "localservice", "windows", true}, - - // Computer accounts (these depend on current user context in real implementation) - {"windows_computer_account", "WIN2K19-C2$", "windows", false}, // Computer account by itself not privileged - {"windows_domain_computer", "DOMAIN\\COMPUTER$", "windows", false}, // Domain computer account - - // Cross-platform - {"root_on_windows", "root", "windows", true}, // Root should be privileged everywhere } for _, tt := range tests { @@ -459,50 +460,8 @@ func TestPrivilegedUsernameDetection(t *testing.T) { cleanup := setupTestDependencies(nil, nil, tt.platform, 1000, nil, nil) defer cleanup() - result := isPrivilegedUsername(tt.username) - assert.Equal(t, tt.privileged, result) - }) - } -} - -func TestWindowsPrivilegedSIDDetection(t *testing.T) { - tests := []struct { - name string - sid string - privileged bool - description string - }{ - // Well-known system accounts - {"system_account", "S-1-5-18", true, "Local System (SYSTEM)"}, - {"local_service", "S-1-5-19", true, "Local Service"}, - {"network_service", "S-1-5-20", true, "Network Service"}, - {"administrators_group", "S-1-5-32-544", true, "Administrators group"}, - {"builtin_administrator", "S-1-5-500", true, "Built-in Administrator"}, - - // Domain accounts - {"domain_administrator", "S-1-5-21-1234567890-1234567890-1234567890-500", true, "Domain Administrator (RID 500)"}, - {"domain_admins_group", "S-1-5-21-1234567890-1234567890-1234567890-512", true, "Domain Admins group"}, - {"domain_controllers_group", "S-1-5-21-1234567890-1234567890-1234567890-516", true, "Domain Controllers group"}, - {"enterprise_admins_group", "S-1-5-21-1234567890-1234567890-1234567890-519", true, "Enterprise Admins group"}, - - // Regular users - {"regular_user", "S-1-5-21-1234567890-1234567890-1234567890-1001", false, "Regular domain user"}, - {"another_regular_user", "S-1-5-21-1234567890-1234567890-1234567890-1234", false, "Another regular user"}, - {"local_user", "S-1-5-21-1234567890-1234567890-1234567890-1000", false, "Local regular user"}, - - // Groups that are not privileged - {"domain_users", "S-1-5-21-1234567890-1234567890-1234567890-513", false, "Domain Users group"}, - {"power_users", "S-1-5-32-547", false, "Power Users group"}, - - // Invalid SIDs - {"malformed_sid", "S-1-5-invalid", false, "Malformed SID"}, - {"empty_sid", "", false, "Empty SID"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := isWindowsPrivilegedSID(tt.sid) - assert.Equal(t, tt.privileged, result, "Failed for %s: %s", tt.description, tt.sid) + result := isPrivilegedOrUnknown(tt.username) + assert.Equal(t, tt.privileged, result, "privilege classification for %s on %s", tt.username, tt.platform) }) } } diff --git a/client/ssh/server/userswitching_unix.go b/client/ssh/server/userswitching_unix.go index 220e2240f..ae60ec64c 100644 --- a/client/ssh/server/userswitching_unix.go +++ b/client/ssh/server/userswitching_unix.go @@ -16,6 +16,8 @@ import ( "github.com/gliderlabs/ssh" log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/getent" ) // POSIX portable filename character set regex: [a-zA-Z0-9._-] @@ -160,7 +162,7 @@ func (s *Server) parseUserCredentials(localUser *user.User) (uint32, uint32, []u // getSupplementaryGroups retrieves supplementary group IDs for a user. // Uses id/getent fallback for NSS users in CGO_ENABLED=0 builds. func (s *Server) getSupplementaryGroups(u *user.User) ([]uint32, error) { - groupIDStrings, err := groupIdsWithFallback(u) + groupIDStrings, err := getent.GroupIDs(u) if err != nil { return nil, fmt.Errorf("get group IDs for user %s: %w", u.Username, err) } diff --git a/client/ssh/server/userswitching_windows.go b/client/ssh/server/userswitching_windows.go index 260e1301e..9e8cd5b30 100644 --- a/client/ssh/server/userswitching_windows.go +++ b/client/ssh/server/userswitching_windows.go @@ -91,7 +91,7 @@ func validateUsernameFormat(username string) error { func (s *Server) createExecutorCommand(logger *log.Entry, session ssh.Session, localUser *user.User, hasPty bool) (*exec.Cmd, func(), error) { logger.Debugf("creating Windows executor command for user %s (Pty: %v)", localUser.Username, hasPty) - username, _ := s.parseUsername(localUser.Username) + username, _ := parseUsername(localUser.Username) if err := validateUsername(username); err != nil { return nil, nil, fmt.Errorf("invalid username %q: %w", username, err) } @@ -102,7 +102,7 @@ func (s *Server) createExecutorCommand(logger *log.Entry, session ssh.Session, l // createUserSwitchCommand creates a command with Windows user switching. // Returns the command and a cleanup function that must be called after starting the process. func (s *Server) createUserSwitchCommand(logger *log.Entry, session ssh.Session, localUser *user.User) (*exec.Cmd, func(), error) { - username, domain := s.parseUsername(localUser.Username) + username, domain := parseUsername(localUser.Username) shell := getUserShell(localUser.Uid) @@ -138,7 +138,7 @@ func (s *Server) createUserSwitchCommand(logger *log.Entry, session ssh.Session, } // parseUsername extracts username and domain from a Windows username -func (s *Server) parseUsername(fullUsername string) (username, domain string) { +func parseUsername(fullUsername string) (username, domain string) { // Handle DOMAIN\username format if idx := strings.LastIndex(fullUsername, `\`); idx != -1 { domain = fullUsername[:idx] diff --git a/client/ssh/session.go b/client/ssh/session.go new file mode 100644 index 000000000..999b6f251 --- /dev/null +++ b/client/ssh/session.go @@ -0,0 +1,84 @@ +package ssh + +import ( + "fmt" + "io" + + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" +) + +// DefaultTerminalModes are the PTY modes used by the interactive terminal clients. +var DefaultTerminalModes = ssh.TerminalModes{ + ssh.ECHO: 1, + ssh.TTY_OP_ISPEED: 14400, + ssh.TTY_OP_OSPEED: 14400, + ssh.VINTR: 3, // Ctrl+C + ssh.VQUIT: 28, // Ctrl+\ + ssh.VERASE: 127, // Backspace + ssh.VKILL: 21, // Ctrl+U + ssh.VEOF: 4, // Ctrl+D + ssh.VEOL: 0, + ssh.VEOL2: 0, + ssh.VSTART: 17, // Ctrl+Q + ssh.VSTOP: 19, // Ctrl+S + ssh.VSUSP: 26, // Ctrl+Z + ssh.VDISCARD: 15, // Ctrl+O + ssh.VREPRINT: 18, // Ctrl+R + ssh.VWERASE: 23, // Ctrl+W + ssh.VLNEXT: 22, // Ctrl+V +} + +// PTYSession is an interactive shell session with a PTY and its I/O pipes. +type PTYSession struct { + Session *ssh.Session + Stdin io.WriteCloser + Stdout io.Reader + Stderr io.Reader +} + +// StartPTYSession opens a session on the client, requests an xterm-256color PTY +// with the default terminal modes, wires up the I/O pipes and starts a shell. +// The session is closed on any error. +func StartPTYSession(client *ssh.Client, cols, rows int) (*PTYSession, error) { + session, err := client.NewSession() + if err != nil { + return nil, fmt.Errorf("new session: %w", err) + } + + pty, err := setupPTYSession(session, cols, rows) + if err != nil { + if closeErr := session.Close(); closeErr != nil { + log.Debugf("ssh: session close after setup error: %v", closeErr) + } + return nil, err + } + return pty, nil +} + +// setupPTYSession requests the PTY, opens the pipes and starts the shell on an +// already created session. +func setupPTYSession(session *ssh.Session, cols, rows int) (*PTYSession, error) { + if err := session.RequestPty("xterm-256color", rows, cols, DefaultTerminalModes); err != nil { + return nil, fmt.Errorf("request pty: %w", err) + } + + stdin, err := session.StdinPipe() + if err != nil { + return nil, fmt.Errorf("stdin pipe: %w", err) + } + stdout, err := session.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("stdout pipe: %w", err) + } + stderr, err := session.StderrPipe() + if err != nil { + return nil, fmt.Errorf("stderr pipe: %w", err) + } + + if err := session.Shell(); err != nil { + return nil, fmt.Errorf("start shell: %w", err) + } + + return &PTYSession{Session: session, Stdin: stdin, Stdout: stdout, Stderr: stderr}, nil +} diff --git a/client/status/status.go b/client/status/status.go index 8c932bbab..1c204cdb1 100644 --- a/client/status/status.go +++ b/client/status/status.go @@ -46,7 +46,10 @@ func ParseDaemonStatus(s string) DaemonStatus { // ConvertOptions holds parameters for ConvertToStatusOutputOverview. type ConvertOptions struct { - Anonymize bool + Anonymize bool + // AnonymizeLevel selects how much the anonymizer redacts. Only + // meaningful when Anonymize is set. + AnonymizeLevel anonymize.Level DaemonVersion string DaemonStatus DaemonStatus StatusFilter string @@ -55,11 +58,16 @@ type ConvertOptions struct { IPsFilter map[string]struct{} ConnectionTypeFilter string ProfileName string + // SessionExpiresAt is the absolute UTC instant at which the peer's SSO + // session expires. Zero when the peer is not SSO-tracked or login + // expiration is disabled. Sourced from StatusResponse.SessionExpiresAt. + SessionExpiresAt time.Time } type PeerStateDetailOutput struct { FQDN string `json:"fqdn" yaml:"fqdn"` IP string `json:"netbirdIp" yaml:"netbirdIp"` + IPv6 string `json:"netbirdIpv6,omitempty" yaml:"netbirdIpv6,omitempty"` PubKey string `json:"publicKey" yaml:"publicKey"` Status string `json:"status" yaml:"status"` LastStatusUpdate time.Time `json:"lastStatusUpdate" yaml:"lastStatusUpdate"` @@ -97,6 +105,7 @@ type RelayStateOutputDetail struct { URI string `json:"uri" yaml:"uri"` Available bool `json:"available" yaml:"available"` Error string `json:"error" yaml:"error"` + Transport string `json:"transport,omitempty" yaml:"transport,omitempty"` } type RelayStateOutput struct { @@ -139,8 +148,10 @@ type OutputOverview struct { SignalState SignalStateOutput `json:"signal" yaml:"signal"` Relays RelayStateOutput `json:"relays" yaml:"relays"` IP string `json:"netbirdIp" yaml:"netbirdIp"` + IPv6 string `json:"netbirdIpv6,omitempty" yaml:"netbirdIpv6,omitempty"` PubKey string `json:"publicKey" yaml:"publicKey"` KernelInterface bool `json:"usesKernelInterface" yaml:"usesKernelInterface"` + WgPort int `json:"wireguardPort" yaml:"wireguardPort"` FQDN string `json:"fqdn" yaml:"fqdn"` RosenpassEnabled bool `json:"quantumResistance" yaml:"quantumResistance"` RosenpassPermissive bool `json:"quantumResistancePermissive" yaml:"quantumResistancePermissive"` @@ -151,6 +162,11 @@ type OutputOverview struct { LazyConnectionEnabled bool `json:"lazyConnectionEnabled" yaml:"lazyConnectionEnabled"` ProfileName string `json:"profileName" yaml:"profileName"` SSHServerState SSHServerStateOutput `json:"sshServer" yaml:"sshServer"` + // SessionExpiresAt is the absolute UTC instant at which the peer's SSO + // session expires. nil when the peer is not SSO-tracked or login + // expiration is disabled. Pointer (rather than zero-value time.Time) so + // JSON / YAML omit the field entirely with `,omitempty`. + SessionExpiresAt *time.Time `json:"sessionExpiresAt,omitempty" yaml:"sessionExpiresAt,omitempty"` } // ConvertToStatusOutputOverview converts protobuf status to the output overview. @@ -182,8 +198,10 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO SignalState: signalOverview, Relays: relayOverview, IP: pbFullStatus.GetLocalPeerState().GetIP(), + IPv6: pbFullStatus.GetLocalPeerState().GetIpv6(), PubKey: pbFullStatus.GetLocalPeerState().GetPubKey(), KernelInterface: pbFullStatus.GetLocalPeerState().GetKernelInterface(), + WgPort: int(pbFullStatus.GetLocalPeerState().GetWgPort()), FQDN: pbFullStatus.GetLocalPeerState().GetFqdn(), RosenpassEnabled: pbFullStatus.GetLocalPeerState().GetRosenpassEnabled(), RosenpassPermissive: pbFullStatus.GetLocalPeerState().GetRosenpassPermissive(), @@ -195,9 +213,14 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO ProfileName: opts.ProfileName, SSHServerState: sshServerOverview, } + if !opts.SessionExpiresAt.IsZero() { + t := opts.SessionExpiresAt + overview.SessionExpiresAt = &t + } if opts.Anonymize { anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + anonymizer.SetLevel(opts.AnonymizeLevel) anonymizeOverview(anonymizer, &overview) } @@ -214,7 +237,8 @@ func mapRelays(relays []*proto.RelayState) RelayStateOutput { RelayStateOutputDetail{ URI: relay.URI, Available: available, - Error: relay.GetError(), + Error: relayErrorString(relay.GetError()), + Transport: relay.GetTransport(), }, ) @@ -230,6 +254,12 @@ func mapRelays(relays []*proto.RelayState) RelayStateOutput { } } +// relayErrorString flattens a newline-joined aggregated relay error onto a +// single line for status output. +func relayErrorString(s string) string { + return strings.ReplaceAll(s, "\n", "; ") +} + func mapNSGroups(servers []*proto.NSGroupState) []NsServerGroupStateOutput { mappedNSGroups := make([]NsServerGroupStateOutput, 0, len(servers)) for _, pbNsGroupServer := range servers { @@ -317,6 +347,7 @@ func mapPeers( timeLocal := pbPeerState.GetConnStatusUpdate().AsTime().Local() peerState := PeerStateDetailOutput{ IP: pbPeerState.GetIP(), + IPv6: pbPeerState.GetIpv6(), PubKey: pbPeerState.GetPubKey(), Status: pbPeerState.GetConnStatus(), LastStatusUpdate: timeLocal, @@ -417,6 +448,11 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS interfaceIP = "N/A" } + ipv6Line := "" + if o.IPv6 != "" { + ipv6Line = fmt.Sprintf("NetBird IPv6: %s\n", o.IPv6) + } + var relaysString string if showRelays { for _, relay := range o.Relays.Details { @@ -430,6 +466,8 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS available = "Unavailable" reason = fmt.Sprintf(", reason: %s", relay.Error) } + } else if relay.Transport != "" { + available = fmt.Sprintf("%s via %s", available, relay.Transport) } relaysString += fmt.Sprintf("\n [%s] is %s%s", relay.URI, available, reason) @@ -526,6 +564,15 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS peersCountString := fmt.Sprintf("%d/%d Connected", o.Peers.Connected, o.Peers.Total) + var sessionExpiryString string + if o.SessionExpiresAt != nil && !o.SessionExpiresAt.IsZero() { + sessionExpiryString = fmt.Sprintf( + "Session expires: %s (in %s)\n", + o.SessionExpiresAt.Format(time.RFC3339), + FormatRemainingDuration(time.Until(*o.SessionExpiresAt)), + ) + } + var forwardingRulesString string if o.NumberOfForwardingRules > 0 { forwardingRulesString = fmt.Sprintf("Forwarding rules: %d\n", o.NumberOfForwardingRules) @@ -538,6 +585,21 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS goarm = fmt.Sprintf(" (ARMv%s)", os.Getenv("GOARM")) } + daemonVersion := "N/A" + if o.DaemonVersion != "" { + daemonVersion = o.DaemonVersion + } + + cliVersion := version.NetbirdVersion() + if o.CliVersion != "" { + cliVersion = o.CliVersion + } + + wgPortString := "N/A" + if o.WgPort > 0 { + wgPortString = fmt.Sprintf("%d", o.WgPort) + } + summary := fmt.Sprintf( "OS: %s\n"+ "Daemon version: %s\n"+ @@ -549,16 +611,19 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS "Nameservers: %s\n"+ "FQDN: %s\n"+ "NetBird IP: %s\n"+ + "%s"+ "Interface type: %s\n"+ + "Wireguard port: %s\n"+ "Quantum resistance: %s\n"+ "Lazy connection: %s\n"+ "SSH Server: %s\n"+ "Networks: %s\n"+ "%s"+ + "%s"+ "Peers count: %s\n", fmt.Sprintf("%s/%s%s", goos, goarch, goarm), - o.DaemonVersion, - version.NetbirdVersion(), + daemonVersion, + cliVersion, o.ProfileName, managementConnString, signalConnString, @@ -566,12 +631,15 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS dnsServersString, domain.Domain(o.FQDN).SafeString(), interfaceIP, + ipv6Line, interfaceTypeString, + wgPortString, rosenpassEnabledStatus, lazyConnectionEnabledStatus, sshServerStatus, networks, forwardingRulesString, + sessionExpiryString, peersCountString, ) return summary @@ -616,6 +684,7 @@ func ToProtoFullStatus(fullStatus peer.FullStatus) *proto.FullStatus { } pbFullStatus.LocalPeerState.IP = fullStatus.LocalPeerState.IP + pbFullStatus.LocalPeerState.Ipv6 = fullStatus.LocalPeerState.IPv6 pbFullStatus.LocalPeerState.PubKey = fullStatus.LocalPeerState.PubKey pbFullStatus.LocalPeerState.KernelInterface = fullStatus.LocalPeerState.KernelInterface pbFullStatus.LocalPeerState.Fqdn = fullStatus.LocalPeerState.FQDN @@ -628,6 +697,7 @@ func ToProtoFullStatus(fullStatus peer.FullStatus) *proto.FullStatus { for _, peerState := range fullStatus.Peers { pbPeerState := &proto.PeerState{ IP: peerState.IP, + Ipv6: peerState.IPv6, PubKey: peerState.PubKey, ConnStatus: peerState.ConnStatus.String(), ConnStatusUpdate: timestamppb.New(peerState.ConnStatusUpdate), @@ -680,6 +750,8 @@ func ToProtoFullStatus(fullStatus peer.FullStatus) *proto.FullStatus { pbFullStatus.DnsServers = append(pbFullStatus.DnsServers, pbDnsState) } + pbFullStatus.Events = fullStatus.Events + return &pbFullStatus } @@ -733,9 +805,15 @@ func parsePeers(peers PeersStateOutput, rosenpassEnabled, rosenpassPermissive bo networks = strings.Join(peerState.Networks, ", ") } + ipv6Line := "" + if peerState.IPv6 != "" { + ipv6Line = fmt.Sprintf(" NetBird IPv6: %s\n", peerState.IPv6) + } + peerString := fmt.Sprintf( "\n %s:\n"+ " NetBird IP: %s\n"+ + "%s"+ " Public key: %s\n"+ " Status: %s\n"+ " -- detail --\n"+ @@ -751,6 +829,7 @@ func parsePeers(peers PeersStateOutput, rosenpassEnabled, rosenpassPermissive bo " Latency: %s\n", domain.Domain(peerState.FQDN).SafeString(), peerState.IP, + ipv6Line, peerState.PubKey, peerState.Status, peerState.ConnType, @@ -787,6 +866,9 @@ func skipDetailByFilters(peerState *proto.PeerState, peerStatus string, statusFi if len(ipsFilter) > 0 { _, ok := ipsFilter[peerState.IP] + if !ok { + _, ok = ipsFilter[peerState.Ipv6] + } if !ok { ipEval = true } @@ -898,6 +980,7 @@ func timeAgo(t time.Time) string { func anonymizePeerDetail(a *anonymize.Anonymizer, peer *PeerStateDetailOutput) { peer.FQDN = a.AnonymizeDomain(peer.FQDN) + peer.PubKey = a.AnonymizeWGKey(peer.PubKey) if localIP, port, err := net.SplitHostPort(peer.IceCandidateEndpoint.Local); err == nil { peer.IceCandidateEndpoint.Local = fmt.Sprintf("%s:%s", a.AnonymizeIPString(localIP), port) } @@ -905,6 +988,7 @@ func anonymizePeerDetail(a *anonymize.Anonymizer, peer *PeerStateDetailOutput) { peer.IceCandidateEndpoint.Remote = fmt.Sprintf("%s:%s", a.AnonymizeIPString(remoteIP), port) } + peer.IPv6 = a.AnonymizeIPString(peer.IPv6) peer.RelayAddress = a.AnonymizeURI(peer.RelayAddress) for i, route := range peer.Networks { @@ -928,7 +1012,9 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) { overview.SignalState.URL = a.AnonymizeURI(overview.SignalState.URL) overview.SignalState.Error = a.AnonymizeString(overview.SignalState.Error) + overview.PubKey = a.AnonymizeWGKey(overview.PubKey) overview.IP = a.AnonymizeIPString(overview.IP) + overview.IPv6 = a.AnonymizeIPString(overview.IPv6) for i, detail := range overview.Relays.Details { detail.URI = a.AnonymizeURI(detail.URI) detail.Error = a.AnonymizeString(detail.Error) @@ -971,3 +1057,57 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) { overview.SSHServerState.Sessions[i].Command = a.AnonymizeString(session.Command) } } + +// FormatRemainingDuration renders a time.Duration for the "Session expires" +// line. Examples: "2h 15m", "47m 12s", "8s", "expired 3m ago". +// +// Granularity drops to seconds only under a minute, otherwise minutes are +// the smallest unit shown — sub-minute precision is noise for a deadline +// that's hours or days out. +func FormatRemainingDuration(d time.Duration) string { + if d <= 0 { + return "expired " + HumaniseDuration(-d) + " ago" + } + return HumaniseDuration(d) +} + +// HumaniseDuration renders a positive duration in compact form (e.g. +// "2h 15m", "47m", "8s"). Exposed alongside FormatRemainingDuration so +// callers that don't need the "expired … ago" wording can format +// positive durations directly. +func HumaniseDuration(d time.Duration) string { + if d < time.Minute { + s := int(d.Round(time.Second).Seconds()) + if s < 1 { + s = 1 + } + return fmt.Sprintf("%ds", s) + } + + const ( + day = 24 * time.Hour + hour = time.Hour + minute = time.Minute + ) + + days := int64(d / day) + d -= time.Duration(days) * day + hours := int64(d / hour) + d -= time.Duration(hours) * hour + minutes := int64(d / minute) + + switch { + case days > 0: + if hours == 0 { + return fmt.Sprintf("%dd", days) + } + return fmt.Sprintf("%dd %dh", days, hours) + case hours > 0: + if minutes == 0 { + return fmt.Sprintf("%dh", hours) + } + return fmt.Sprintf("%dh %dm", hours, minutes) + default: + return fmt.Sprintf("%dm", minutes) + } +} diff --git a/client/status/status_test.go b/client/status/status_test.go index 7754eebae..2babd9342 100644 --- a/client/status/status_test.go +++ b/client/status/status_test.go @@ -32,6 +32,7 @@ var resp = &proto.StatusResponse{ Peers: []*proto.PeerState{ { IP: "192.168.178.101", + Ipv6: "fd00::1", PubKey: "Pubkey1", Fqdn: "peer-1.awesome-domain.com", ConnStatus: "Connected", @@ -90,8 +91,10 @@ var resp = &proto.StatusResponse{ }, LocalPeerState: &proto.LocalPeerState{ IP: "192.168.178.100/16", + Ipv6: "fd00::100", PubKey: "Some-Pub-Key", KernelInterface: true, + WgPort: 51820, Fqdn: "some-localhost.awesome-domain.com", Networks: []string{ "10.10.0.0/24", @@ -130,6 +133,7 @@ var overview = OutputOverview{ Details: []PeerStateDetailOutput{ { IP: "192.168.178.101", + IPv6: "fd00::1", PubKey: "Pubkey1", FQDN: "peer-1.awesome-domain.com", Status: "Connected", @@ -204,8 +208,10 @@ var overview = OutputOverview{ }, }, IP: "192.168.178.100/16", + IPv6: "fd00::100", PubKey: "Some-Pub-Key", KernelInterface: true, + WgPort: 51820, FQDN: "some-localhost.awesome-domain.com", NSServerGroups: []NsServerGroupStateOutput{ { @@ -284,6 +290,7 @@ func TestParsingToJSON(t *testing.T) { { "fqdn": "peer-1.awesome-domain.com", "netbirdIp": "192.168.178.101", + "netbirdIpv6": "fd00::1", "publicKey": "Pubkey1", "status": "Connected", "lastStatusUpdate": "2001-01-01T01:01:01Z", @@ -361,8 +368,10 @@ func TestParsingToJSON(t *testing.T) { ] }, "netbirdIp": "192.168.178.100/16", + "netbirdIpv6": "fd00::100", "publicKey": "Some-Pub-Key", "usesKernelInterface": true, + "wireguardPort": 51820, "fqdn": "some-localhost.awesome-domain.com", "quantumResistance": false, "quantumResistancePermissive": false, @@ -418,6 +427,7 @@ func TestParsingToYAML(t *testing.T) { details: - fqdn: peer-1.awesome-domain.com netbirdIp: 192.168.178.101 + netbirdIpv6: fd00::1 publicKey: Pubkey1 status: Connected lastStatusUpdate: 2001-01-01T01:01:01Z @@ -477,8 +487,10 @@ relays: available: false error: 'context: deadline exceeded' netbirdIp: 192.168.178.100/16 +netbirdIpv6: fd00::100 publicKey: Some-Pub-Key usesKernelInterface: true +wireguardPort: 51820 fqdn: some-localhost.awesome-domain.com quantumResistance: false quantumResistancePermissive: false @@ -523,6 +535,7 @@ func TestParsingToDetail(t *testing.T) { `Peers detail: peer-1.awesome-domain.com: NetBird IP: 192.168.178.101 + NetBird IPv6: fd00::1 Public key: Pubkey1 Status: Connected -- detail -- @@ -568,13 +581,15 @@ Nameservers: [1.1.1.1:53, 2.2.2.2:53] for [example.com, example.net] is Unavailable, reason: timeout FQDN: some-localhost.awesome-domain.com NetBird IP: 192.168.178.100/16 +NetBird IPv6: fd00::100 Interface type: Kernel +Wireguard port: %d Quantum resistance: false Lazy connection: false SSH Server: Disabled Networks: 10.10.0.0/24 Peers count: 2/2 Connected -`, lastConnectionUpdate1, lastHandshake1, lastConnectionUpdate2, lastHandshake2, runtime.GOOS, runtime.GOARCH, overview.CliVersion) +`, lastConnectionUpdate1, lastHandshake1, lastConnectionUpdate2, lastHandshake2, runtime.GOOS, runtime.GOARCH, overview.CliVersion, overview.WgPort) assert.Equal(t, expectedDetail, detail) } @@ -592,7 +607,9 @@ Relays: 1/2 Available Nameservers: 1/2 Available FQDN: some-localhost.awesome-domain.com NetBird IP: 192.168.178.100/16 +NetBird IPv6: fd00::100 Interface type: Kernel +Wireguard port: 51820 Quantum resistance: false Lazy connection: false SSH Server: Disabled @@ -630,3 +647,60 @@ func TestTimeAgo(t *testing.T) { }) } } + +func TestHumaniseDuration(t *testing.T) { + cases := []struct { + in time.Duration + want string + }{ + {0, "1s"}, + {500 * time.Millisecond, "1s"}, + {8 * time.Second, "8s"}, + {59 * time.Second, "59s"}, + {time.Minute, "1m"}, + {47*time.Minute + 12*time.Second, "47m"}, + {time.Hour, "1h"}, + {2*time.Hour + 15*time.Minute, "2h 15m"}, + {2 * time.Hour, "2h"}, + {24 * time.Hour, "1d"}, + {2*24*time.Hour + 3*time.Hour, "2d 3h"}, + } + for _, tc := range cases { + got := HumaniseDuration(tc.in) + assert.Equal(t, tc.want, got, "input %s", tc.in) + } +} + +func TestFormatRemainingDuration_Expired(t *testing.T) { + assert.Equal(t, "expired 3m ago", FormatRemainingDuration(-3*time.Minute)) + assert.Equal(t, "expired 1s ago", FormatRemainingDuration(-500*time.Millisecond)) +} + +func TestSessionExpiresLineRendered(t *testing.T) { + in := overview // copy of the package-level fixture + deadline := time.Now().Add(2*time.Hour + 30*time.Minute).UTC() + in.SessionExpiresAt = &deadline + + out := in.GeneralSummary(false, false, false, false) + assert.Contains(t, out, "Session expires: ") + assert.Contains(t, out, deadline.Format(time.RFC3339)) + // 2h 30m drifts to "2h 29m" within 60s — match the family prefix. + assert.Contains(t, out, "(in 2h ") +} + +func TestSessionExpiresLineOmittedWhenNil(t *testing.T) { + in := overview + in.SessionExpiresAt = nil + out := in.GeneralSummary(false, false, false, false) + assert.NotContains(t, out, "Session expires") +} + +func TestMapRelaysTransport(t *testing.T) { + out := mapRelays([]*proto.RelayState{ + {URI: "rels://relay.example:443", Available: true, Transport: "quic"}, + {URI: "rels://relay2.example:443", Available: true, Transport: "ws"}, + }) + require.Len(t, out.Details, 2) + assert.Equal(t, "quic", out.Details[0].Transport) + assert.Equal(t, "ws", out.Details[1].Transport) +} diff --git a/client/system/info.go b/client/system/info.go index 175d1f07f..273c7a533 100644 --- a/client/system/info.go +++ b/client/system/info.go @@ -2,8 +2,11 @@ package system import ( "context" + "errors" "net/netip" + "slices" "strings" + "time" log "github.com/sirupsen/logrus" "google.golang.org/grpc/metadata" @@ -62,6 +65,7 @@ type Info struct { RosenpassEnabled bool RosenpassPermissive bool ServerSSHAllowed bool + RemoteJobsAllowed bool DisableClientRoutes bool DisableServerRoutes bool @@ -69,29 +73,34 @@ type Info struct { DisableFirewall bool BlockLANAccess bool BlockInbound bool - - LazyConnectionEnabled bool + DisableIPv6 bool EnableSSHRoot bool EnableSSHSFTP bool EnableSSHLocalPortForwarding bool EnableSSHRemotePortForwarding bool DisableSSHAuth bool + + SyncMessageVersion *int } func (i *Info) SetFlags( rosenpassEnabled, rosenpassPermissive bool, serverSSHAllowed *bool, disableClientRoutes, disableServerRoutes, - disableDNS, disableFirewall, blockLANAccess, blockInbound, lazyConnectionEnabled bool, + disableDNS, disableFirewall, blockLANAccess, blockInbound, disableIPv6 bool, syncMessageVersion *int, enableSSHRoot, enableSSHSFTP, enableSSHLocalPortForwarding, enableSSHRemotePortForwarding *bool, disableSSHAuth *bool, + remoteJobsAllowed *bool, ) { i.RosenpassEnabled = rosenpassEnabled i.RosenpassPermissive = rosenpassPermissive if serverSSHAllowed != nil { i.ServerSSHAllowed = *serverSSHAllowed } + if remoteJobsAllowed != nil { + i.RemoteJobsAllowed = *remoteJobsAllowed + } i.DisableClientRoutes = disableClientRoutes i.DisableServerRoutes = disableServerRoutes @@ -99,8 +108,9 @@ func (i *Info) SetFlags( i.DisableFirewall = disableFirewall i.BlockLANAccess = blockLANAccess i.BlockInbound = blockInbound + i.DisableIPv6 = disableIPv6 - i.LazyConnectionEnabled = lazyConnectionEnabled + i.SyncMessageVersion = syncMessageVersion if enableSSHRoot != nil { i.EnableSSHRoot = *enableSSHRoot @@ -119,6 +129,23 @@ func (i *Info) SetFlags( } } +// removeAddresses drops network addresses whose IP matches any of the given +// addresses, regardless of prefix length. Used to exclude the NetBird overlay +// address, which otherwise churns the meta as the interface comes and goes. +func (i *Info) removeAddresses(ips ...netip.Addr) { + if len(ips) == 0 { + return + } + filtered := i.NetworkAddresses[:0] + for _, addr := range i.NetworkAddresses { + if slices.Contains(ips, addr.NetIP.Addr()) { + continue + } + filtered = append(filtered, addr) + } + i.NetworkAddresses = filtered +} + // extractUserAgent extracts Netbird's agent (client) name and version from the outgoing context func extractUserAgent(ctx context.Context) string { md, hasMeta := metadata.FromOutgoingContext(ctx) @@ -145,14 +172,16 @@ func extractDeviceName(ctx context.Context, defaultName string) string { } // GetInfoWithChecks retrieves and parses the system information with applied checks. -func GetInfoWithChecks(ctx context.Context, checks []*proto.Checks) (*Info, error) { +// excludeIPs are dropped from the reported network addresses (e.g. our own +// WireGuard overlay address, which otherwise churns the peer meta). +func GetInfoWithChecks(ctx context.Context, checks []*proto.Checks, excludeIPs ...netip.Addr) (*Info, error) { log.Debugf("gathering system information with checks: %d", len(checks)) processCheckPaths := make([]string, 0) for _, check := range checks { processCheckPaths = append(processCheckPaths, check.GetFiles()...) } - files, err := checkFileAndProcess(processCheckPaths) + files, err := checkFileAndProcess(ctx, processCheckPaths) if err != nil { return nil, err } @@ -160,7 +189,48 @@ func GetInfoWithChecks(ctx context.Context, checks []*proto.Checks) (*Info, erro info := GetInfo(ctx) info.Files = files + info.removeAddresses(excludeIPs...) log.Debugf("all system information gathered successfully") return info, nil } + +// GetInfoWithChecksTimeout is GetInfoWithChecks bounded by timeout. Posture-check gathering +// runs uncancellable system calls (process enumeration, os.Stat), so calling it inline can +// block the caller for as long as such a call hangs. It runs in a goroutine instead: if it +// does not return within timeout the caller gets (nil, false) and should proceed with +// degraded behavior rather than block. On a gathering error it falls back to base GetInfo. +// +// The buffered channel lets the abandoned goroutine finish and exit once its blocking call +// returns, so it does not leak beyond the duration of that call. +func GetInfoWithChecksTimeout(ctx context.Context, timeout time.Duration, checks []*proto.Checks, excludeIPs ...netip.Addr) (*Info, bool) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + infoCh := make(chan *Info, 1) + go func() { + info, err := GetInfoWithChecks(ctx, checks, excludeIPs...) + if err != nil { + if ctx.Err() != nil { + return + } + log.Warnf("failed to get system info with checks: %v", err) + info = GetInfo(ctx) + info.removeAddresses(excludeIPs...) + } + infoCh <- info + }() + + select { + case info := <-infoCh: + return info, true + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + log.Warnf("gathering system info with checks timed out after %s", timeout) + } else { + // Parent context canceled (e.g. shutdown), not a timeout. + log.Warnf("gathering system info with checks canceled: %v", ctx.Err()) + } + return nil, false + } +} diff --git a/client/system/info_android.go b/client/system/info_android.go index 794ff15ed..d4f479386 100644 --- a/client/system/info_android.go +++ b/client/system/info_android.go @@ -30,6 +30,11 @@ func GetInfo(ctx context.Context) *Info { kernelVersion = osInfo[2] } + addrs, err := networkAddresses() + if err != nil { + log.Warnf("discover network addresses: %s", err) + } + gio := &Info{ GoOS: runtime.GOOS, Kernel: kernel, @@ -41,6 +46,7 @@ func GetInfo(ctx context.Context) *Info { NetbirdVersion: version.NetbirdVersion(), UIVersion: extractUIVersion(ctx), KernelVersion: kernelVersion, + NetworkAddresses: addrs, SystemSerialNumber: serial(), SystemProductName: productModel(), SystemManufacturer: productManufacturer(), @@ -50,7 +56,7 @@ func GetInfo(ctx context.Context) *Info { } // checkFileAndProcess checks if the file path exists and if a process is running at that path. -func checkFileAndProcess(paths []string) ([]File, error) { +func checkFileAndProcess(_ context.Context, _ []string) ([]File, error) { return []File{}, nil } diff --git a/client/system/info_darwin.go b/client/system/info_darwin.go index 4a31920ec..e7bf367f6 100644 --- a/client/system/info_darwin.go +++ b/client/system/info_darwin.go @@ -32,7 +32,7 @@ func GetInfo(ctx context.Context) *Info { sysName := string(bytes.Split(utsname.Sysname[:], []byte{0})[0]) machine := string(bytes.Split(utsname.Machine[:], []byte{0})[0]) release := string(bytes.Split(utsname.Release[:], []byte{0})[0]) - swVersion, err := exec.Command("sw_vers", "-productVersion").Output() + swVersion, err := exec.CommandContext(ctx, "sw_vers", "-productVersion").Output() if err != nil { log.Warnf("got an error while retrieving macOS version with sw_vers, error: %s. Using darwin version instead.\n", err) swVersion = []byte(release) diff --git a/client/system/info_ios.go b/client/system/info_ios.go index ad42b1edf..1b0c084b3 100644 --- a/client/system/info_ios.go +++ b/client/system/info_ios.go @@ -105,7 +105,7 @@ func isDuplicated(addresses []NetworkAddress, addr NetworkAddress) bool { } // checkFileAndProcess checks if the file path exists and if a process is running at that path. -func checkFileAndProcess(paths []string) ([]File, error) { +func checkFileAndProcess(_ context.Context, _ []string) ([]File, error) { return []File{}, nil } diff --git a/client/system/info_js.go b/client/system/info_js.go index 994d439a7..3323fb542 100644 --- a/client/system/info_js.go +++ b/client/system/info_js.go @@ -15,7 +15,7 @@ func UpdateStaticInfoAsync() { } // GetInfo retrieves system information for WASM environment -func GetInfo(_ context.Context) *Info { +func GetInfo(ctx context.Context) *Info { info := &Info{ GoOS: runtime.GOOS, Kernel: runtime.GOARCH, @@ -30,6 +30,13 @@ func GetInfo(_ context.Context) *Info { collectBrowserInfo(info) collectLocationInfo(info) collectSystemInfo(info) + + // A caller-provided device name wins, as on the other platforms. A peer + // registered over an API keeps reporting the name it was registered with, + // so its meta does not change on the first sync. + if name := extractDeviceName(ctx, info.Hostname); name != "" { + info.Hostname = name + } return info } @@ -103,7 +110,7 @@ func collectLocationInfo(info *Info) { } } -func checkFileAndProcess(_ []string) ([]File, error) { +func checkFileAndProcess(_ context.Context, _ []string) ([]File, error) { return []File{}, nil } diff --git a/client/system/info_js_test.go b/client/system/info_js_test.go new file mode 100644 index 000000000..e2a33ada0 --- /dev/null +++ b/client/system/info_js_test.go @@ -0,0 +1,27 @@ +//go:build js + +package system + +import ( + "context" + "testing" +) + +// TestGetInfoHonorsDeviceName covers a caller-provided device name reaching the +// reported hostname, so a peer registered over an API keeps reporting the name +// it was registered with instead of renaming itself on its first sync. +func TestGetInfoHonorsDeviceName(t *testing.T) { + ctx := context.WithValue(context.Background(), DeviceNameCtxKey, "session-name") + if got := GetInfo(ctx).Hostname; got != "session-name" { + t.Errorf("hostname should carry the caller's device name, got %q", got) + } +} + +// TestGetInfoWithoutDeviceNameKeepsFallback covers the embed layer's habit of +// always setting the context value: an empty name must not blank the hostname. +func TestGetInfoWithoutDeviceNameKeepsFallback(t *testing.T) { + ctx := context.WithValue(context.Background(), DeviceNameCtxKey, "") + if got := GetInfo(ctx).Hostname; got == "" { + t.Error("an empty device name must not blank the hostname") + } +} diff --git a/client/system/info_linux.go b/client/system/info_linux.go index 6c7a23b95..de37a9f5b 100644 --- a/client/system/info_linux.go +++ b/client/system/info_linux.go @@ -3,15 +3,14 @@ package system import ( - "bytes" "context" "os" - "os/exec" "regexp" "runtime" - "strings" "time" + "golang.org/x/sys/unix" + log "github.com/sirupsen/logrus" "github.com/zcalusic/sysinfo" @@ -29,19 +28,11 @@ func UpdateStaticInfoAsync() { // GetInfo retrieves and parses the system information func GetInfo(ctx context.Context) *Info { - info := _getInfo() - for strings.Contains(info, "broken pipe") { - info = _getInfo() - time.Sleep(500 * time.Millisecond) - } - - osStr := strings.ReplaceAll(info, "\n", "") - osStr = strings.ReplaceAll(osStr, "\r\n", "") - osInfo := strings.Split(osStr, " ") + kernelName, kernelVersion, kernelPlatform := kernelInfo() osName, osVersion := readOsReleaseFile() if osName == "" { - osName = osInfo[3] + osName = kernelName } systemHostname, _ := os.Hostname() @@ -58,8 +49,8 @@ func GetInfo(ctx context.Context) *Info { } gio := &Info{ - Kernel: osInfo[0], - Platform: osInfo[2], + Kernel: kernelName, + Platform: kernelPlatform, OS: osName, OSVersion: osVersion, Hostname: extractDeviceName(ctx, systemHostname), @@ -67,7 +58,7 @@ func GetInfo(ctx context.Context) *Info { CPUs: runtime.NumCPU(), NetbirdVersion: version.NetbirdVersion(), UIVersion: extractUserAgent(ctx), - KernelVersion: osInfo[1], + KernelVersion: kernelVersion, NetworkAddresses: addrs, SystemSerialNumber: si.SystemSerialNumber, SystemProductName: si.SystemProductName, @@ -78,18 +69,12 @@ func GetInfo(ctx context.Context) *Info { return gio } -func _getInfo() string { - cmd := exec.Command("uname", "-srio") - cmd.Stdin = strings.NewReader("some") - var out bytes.Buffer - var stderr bytes.Buffer - cmd.Stdout = &out - cmd.Stderr = &stderr - err := cmd.Run() - if err != nil { - log.Warnf("getInfo: %s", err) +func kernelInfo() (string, string, string) { + var uts unix.Utsname + if err := unix.Uname(&uts); err != nil { + return "", "", "" } - return out.String() + return unix.ByteSliceToString(uts.Sysname[:]), unix.ByteSliceToString(uts.Release[:]), unix.ByteSliceToString(uts.Machine[:]) } func sysInfo() (string, string, string) { diff --git a/client/system/info_source.go b/client/system/info_source.go new file mode 100644 index 000000000..050e094e4 --- /dev/null +++ b/client/system/info_source.go @@ -0,0 +1,38 @@ +package system + +import ( + "context" + "net/netip" + "slices" + "sync/atomic" + "time" + + "github.com/netbirdio/netbird/shared/management/proto" +) + +// InfoSource gathers the system info sent to management, keeping the posture +// check results from the last Refresh for the cheap Current snapshots. +type InfoSource struct { + files atomic.Pointer[[]File] +} + +// Refresh gathers the info with the posture checks evaluated, bounded by timeout. +func (s *InfoSource) Refresh(ctx context.Context, timeout time.Duration, checks []*proto.Checks, excludeIPs ...netip.Addr) (*Info, bool) { + info, ok := GetInfoWithChecksTimeout(ctx, timeout, checks, excludeIPs...) + if !ok { + return nil, false + } + files := slices.Clone(info.Files) + s.files.Store(&files) + return info, true +} + +// Current gathers the info without evaluating the checks, reusing the last Refresh results. +func (s *InfoSource) Current(ctx context.Context, excludeIPs ...netip.Addr) *Info { + info := GetInfo(ctx) + info.removeAddresses(excludeIPs...) + if files := s.files.Load(); files != nil { + info.Files = *files + } + return info +} diff --git a/client/system/info_source_test.go b/client/system/info_source_test.go new file mode 100644 index 000000000..1c86806af --- /dev/null +++ b/client/system/info_source_test.go @@ -0,0 +1,59 @@ +package system + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/shared/management/proto" +) + +func TestInfoSource_CurrentBeforeRefresh(t *testing.T) { + var src InfoSource + + info := src.Current(context.Background()) + + assert.Empty(t, info.Files) +} + +func TestInfoSource_CurrentReusesRefreshedFiles(t *testing.T) { + path := filepath.Join(t.TempDir(), "agent") + require.NoError(t, os.WriteFile(path, nil, 0o600)) + checks := []*proto.Checks{{Files: []string{path}}} + + var src InfoSource + refreshed, ok := src.Refresh(context.Background(), 15*time.Second, checks) + require.True(t, ok) + require.Equal(t, []File{{Path: path, Exist: true}}, refreshed.Files) + + info := src.Current(context.Background()) + + assert.Equal(t, refreshed.Files, info.Files) +} + +func TestInfoSource_CurrentExcludesAddresses(t *testing.T) { + addrs := GetInfo(context.Background()).NetworkAddresses + if len(addrs) == 0 { + t.Skip("no network addresses on this host") + } + excluded := addrs[0].NetIP.Addr() + matching := 0 + for _, addr := range addrs { + if addr.NetIP.Addr() == excluded { + matching++ + } + } + + var src InfoSource + info := src.Current(context.Background(), excluded) + + assert.Len(t, info.NetworkAddresses, len(addrs)-matching) + for _, addr := range info.NetworkAddresses { + assert.NotEqual(t, excluded, addr.NetIP.Addr()) + } +} diff --git a/client/system/info_test.go b/client/system/info_test.go index 27821f3c5..a7fa02197 100644 --- a/client/system/info_test.go +++ b/client/system/info_test.go @@ -2,7 +2,9 @@ package system import ( "context" + "net/netip" "testing" + "time" "github.com/stretchr/testify/assert" "google.golang.org/grpc/metadata" @@ -34,6 +36,20 @@ func Test_CustomHostname(t *testing.T) { assert.Equal(t, want, got.Hostname) } +func TestGetInfoWithChecksTimeout_Success(t *testing.T) { + info, ok := GetInfoWithChecksTimeout(context.Background(), 30*time.Second, nil) + assert.True(t, ok, "expected gathering to complete within the timeout") + assert.NotNil(t, info) +} + +func TestGetInfoWithChecksTimeout_Timeout(t *testing.T) { + // A 1ns budget expires before the (real) system-info gathering can finish, so the + // caller must get (nil, false) instead of blocking on the in-flight goroutine. + info, ok := GetInfoWithChecksTimeout(context.Background(), time.Nanosecond, nil) + assert.False(t, ok, "expected timeout to be reported") + assert.Nil(t, info) +} + func Test_NetAddresses(t *testing.T) { addr, err := networkAddresses() if err != nil { @@ -43,3 +59,42 @@ func Test_NetAddresses(t *testing.T) { t.Errorf("no network addresses found") } } + +func TestInfo_RemoveAddresses(t *testing.T) { + addr := func(cidr string) NetworkAddress { + return NetworkAddress{NetIP: netip.MustParsePrefix(cidr)} + } + + info := &Info{ + NetworkAddresses: []NetworkAddress{ + addr("192.168.1.7/24"), + addr("100.76.70.97/32"), // overlay v4 (host mask /32) + addr("2001:818:c51b:4800:845:a65d:ae6f:623f/64"), // real global v6 + addr("fd00:1234::1/64"), // overlay v6 + }, + } + + // Overlay addresses as the engine knows them, with a different mask (/16, /64). + info.removeAddresses( + netip.MustParseAddr("100.76.70.97"), + netip.MustParseAddr("fd00:1234::1"), + ) + + want := []string{"192.168.1.7/24", "2001:818:c51b:4800:845:a65d:ae6f:623f/64"} + if len(info.NetworkAddresses) != len(want) { + t.Fatalf("got %d addresses, want %d: %v", len(info.NetworkAddresses), len(want), info.NetworkAddresses) + } + for i, w := range want { + if got := info.NetworkAddresses[i].NetIP.String(); got != w { + t.Errorf("address[%d] = %s, want %s", i, got, w) + } + } +} + +func TestInfo_RemoveAddresses_NoOp(t *testing.T) { + info := &Info{NetworkAddresses: []NetworkAddress{{NetIP: netip.MustParsePrefix("10.0.0.1/24")}}} + info.removeAddresses() + if len(info.NetworkAddresses) != 1 { + t.Errorf("expected no change with empty input, got %v", info.NetworkAddresses) + } +} diff --git a/client/system/network_addr.go b/client/system/network_addr.go index 5423cf8ad..505a6f0ea 100644 --- a/client/system/network_addr.go +++ b/client/system/network_addr.go @@ -1,4 +1,4 @@ -//go:build !ios +//go:build !ios && !android package system @@ -46,7 +46,9 @@ func toNetworkAddress(address net.Addr, mac string) (NetworkAddress, bool) { if !ok { return NetworkAddress{}, false } - if ipNet.IP.IsLoopback() { + // Skip link-local and multicast: they carry no routable peer info and the + // IPv6 link-local of a flapping NIC churns the meta on every up/down. + if ipNet.IP.IsLoopback() || ipNet.IP.IsLinkLocalUnicast() || ipNet.IP.IsMulticast() { return NetworkAddress{}, false } prefix, err := netip.ParsePrefix(ipNet.String()) diff --git a/client/system/network_addr_android.go b/client/system/network_addr_android.go new file mode 100644 index 000000000..99a71e105 --- /dev/null +++ b/client/system/network_addr_android.go @@ -0,0 +1,89 @@ +package system + +import ( + "net/netip" + "strings" +) + +var iFaceDiscover IFaceDiscover + +type IFaceDiscover interface { + IFaces() (string, error) +} + +// SetIFaceDiscover configures the Android interface discovery provider. +func SetIFaceDiscover(discover IFaceDiscover) { + iFaceDiscover = discover +} + +func networkAddresses() ([]NetworkAddress, error) { + if iFaceDiscover == nil { + return nil, nil + } + ifaces, err := iFaceDiscover.IFaces() + if err != nil { + return nil, err + } + + var netAddresses []NetworkAddress + for _, line := range strings.Split(ifaces, "\n") { + addresses, ok := interfaceAddresses(line) + if !ok { + continue + } + for _, address := range addresses { + netAddr, ok := toNetworkAddress(address) + if !ok { + continue + } + if isDuplicated(netAddresses, netAddr) { + continue + } + netAddresses = append(netAddresses, netAddr) + } + } + return netAddresses, nil +} + +func interfaceAddresses(line string) ([]string, bool) { + parts := strings.Split(line, "|") + if len(parts) != 2 { + return nil, false + } + flags := strings.Fields(parts[0]) + if len(flags) != 8 { + return nil, false + } + up, loopback := flags[3], flags[5] + if up != "true" || loopback == "true" { + return nil, false + } + return strings.Fields(parts[1]), true +} + +func toNetworkAddress(address string) (NetworkAddress, bool) { + prefix, err := netip.ParsePrefix(address) + if err != nil { + return NetworkAddress{}, false + } + if prefix.Addr().Is4In6() { + if prefix.Bits() < 96 { + return NetworkAddress{}, false + } + prefix = netip.PrefixFrom(prefix.Addr().Unmap(), prefix.Bits()-96) + } + ip := prefix.Addr() + if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsMulticast() { + return NetworkAddress{}, false + } + return NetworkAddress{NetIP: prefix}, true +} + +func isDuplicated(addresses []NetworkAddress, addr NetworkAddress) bool { + for _, duplicated := range addresses { + if duplicated.NetIP == addr.NetIP { + return true + } + } + return false +} diff --git a/client/system/network_addr_test.go b/client/system/network_addr_test.go new file mode 100644 index 000000000..b0be40f0a --- /dev/null +++ b/client/system/network_addr_test.go @@ -0,0 +1,45 @@ +//go:build !ios && !android + +package system + +import ( + "net" + "testing" +) + +func mustIPNet(t *testing.T, cidr string) *net.IPNet { + t.Helper() + ip, ipNet, err := net.ParseCIDR(cidr) + if err != nil { + t.Fatalf("parse %q: %v", cidr, err) + } + ipNet.IP = ip + return ipNet +} + +func TestToNetworkAddress_Filtering(t *testing.T) { + const mac = "c8:4b:d6:b6:04:ac" + + tests := []struct { + name string + cidr string + want bool + }{ + {"ipv4 global", "10.65.16.181/23", true}, + {"ipv6 global", "2620:52:0:4110:102d:6a98:ee75:8b92/64", true}, + {"ipv4 loopback", "127.0.0.1/8", false}, + {"ipv6 loopback", "::1/128", false}, + {"ipv6 link-local", "fe80::871:4c25:23d7:2529/64", false}, + {"ipv4 link-local", "169.254.1.2/16", false}, + {"ipv6 multicast", "ff02::1/128", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, got := toNetworkAddress(mustIPNet(t, tt.cidr), mac) + if got != tt.want { + t.Errorf("toNetworkAddress(%s) ok = %v, want %v", tt.cidr, got, tt.want) + } + }) + } +} diff --git a/client/system/process.go b/client/system/process.go index 87e21eb9d..fefa7d913 100644 --- a/client/system/process.go +++ b/client/system/process.go @@ -3,24 +3,30 @@ package system import ( + "context" "os" "slices" - "github.com/shirou/gopsutil/v3/process" + "github.com/shirou/gopsutil/v4/process" ) -// getRunningProcesses returns a list of running process paths. -func getRunningProcesses() ([]string, error) { - processIDs, err := process.Pids() +// getRunningProcesses returns a list of running process paths. The context bounds the work: +// the per-PID loop bails as soon as ctx is done, and the gopsutil calls honor it where they +// can, so a stuck enumeration cannot run unbounded. +func getRunningProcesses(ctx context.Context) ([]string, error) { + processIDs, err := process.PidsWithContext(ctx) if err != nil { return nil, err } processMap := make(map[string]bool) for _, pID := range processIDs { + if err := ctx.Err(); err != nil { + return nil, err + } p := &process.Process{Pid: pID} - path, _ := p.Exe() + path, _ := p.ExeWithContext(ctx) if path != "" { processMap[path] = false } @@ -35,18 +41,21 @@ func getRunningProcesses() ([]string, error) { } // checkFileAndProcess checks if the file path exists and if a process is running at that path. -func checkFileAndProcess(paths []string) ([]File, error) { +func checkFileAndProcess(ctx context.Context, paths []string) ([]File, error) { files := make([]File, len(paths)) if len(paths) == 0 { return files, nil } - runningProcesses, err := getRunningProcesses() + runningProcesses, err := getRunningProcesses(ctx) if err != nil { return nil, err } for i, path := range paths { + if err := ctx.Err(); err != nil { + return nil, err + } file := File{Path: path} _, err := os.Stat(path) diff --git a/client/system/process_test.go b/client/system/process_test.go index 505808a9e..de1cfc1db 100644 --- a/client/system/process_test.go +++ b/client/system/process_test.go @@ -1,15 +1,18 @@ +//go:build windows || (linux && !android) || (darwin && !ios) || freebsd + package system import ( + "context" "testing" - "github.com/shirou/gopsutil/v3/process" + "github.com/shirou/gopsutil/v4/process" ) func Benchmark_getRunningProcesses(b *testing.B) { b.Run("getRunningProcesses new", func(b *testing.B) { for i := 0; i < b.N; i++ { - ps, err := getRunningProcesses() + ps, err := getRunningProcesses(context.Background()) if err != nil { b.Fatalf("unexpected error: %v", err) } @@ -29,12 +32,38 @@ func Benchmark_getRunningProcesses(b *testing.B) { } } }) - s, _ := getRunningProcesses() + s, _ := getRunningProcesses(context.Background()) b.Logf("getRunningProcesses returned %d processes", len(s)) s, _ = getRunningProcessesOld() b.Logf("getRunningProcessesOld returned %d processes", len(s)) } +func TestCheckFileAndProcess_ContextCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + // With a canceled context and non-empty paths the gathering must bail with an error + // instead of running the (potentially blocking) process scan / stat loop. + if _, err := checkFileAndProcess(ctx, []string{"/does/not/exist"}); err == nil { + t.Fatal("expected error on canceled context, got nil") + } +} + +func TestCheckFileAndProcess_EmptyPaths(t *testing.T) { + // No check paths means no work to do: it must return immediately with no error, + // even on a canceled context (nothing to scan or stat). + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + files, err := checkFileAndProcess(ctx, nil) + if err != nil { + t.Fatalf("unexpected error for empty paths: %v", err) + } + if len(files) != 0 { + t.Fatalf("expected no files, got %d", len(files)) + } +} + func getRunningProcessesOld() ([]string, error) { processes, err := process.Processes() if err != nil { diff --git a/client/test/json-socket-docker.sh b/client/test/json-socket-docker.sh new file mode 100755 index 000000000..a878f13d6 --- /dev/null +++ b/client/test/json-socket-docker.sh @@ -0,0 +1,223 @@ +#!/usr/bin/env bash +set -eEuo pipefail + +usage() { + cat <<'EOF' +Usage: client/test/json-socket-docker.sh [tcp|unix|both] + +Builds the NetBird client Docker image from the local source tree, starts +`netbird service run` in a container with --enable-json-socket, and verifies +that the HTTP/JSON daemon gateway responds to Status requests. + +Modes: + tcp Validate tcp://0.0.0.0:8080 via a published localhost port (default) + unix Validate unix:///sock/netbird-http.sock via a bind-mounted socket dir + both Run both validations + +Environment: + CONTAINER_RUNTIME docker or podman. Auto-detected if unset. + IMAGE Image tag to build. Default: netbird-json-socket-test:local + TARGETARCH Go/Docker target arch. Default: `go env GOARCH` + PLATFORM Docker platform. Default: linux/$TARGETARCH + WAIT_TIMEOUT Seconds to wait for the JSON socket. Default: 30 +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +MODE="${1:-tcp}" +case "${MODE}" in + tcp|unix|both) ;; + *) + usage >&2 + echo "invalid mode: ${MODE}" >&2 + exit 2 + ;; +esac + +RUNTIME="${CONTAINER_RUNTIME:-}" +if [[ -z "${RUNTIME}" ]]; then + if command -v docker >/dev/null 2>&1; then + RUNTIME=docker + elif command -v podman >/dev/null 2>&1; then + RUNTIME=podman + else + echo "docker or podman is required" >&2 + exit 127 + fi +fi +if ! command -v "${RUNTIME}" >/dev/null 2>&1; then + echo "container runtime not found: ${RUNTIME}" >&2 + exit 127 +fi + +if ! command -v curl >/dev/null 2>&1; then + echo "curl is required" >&2 + exit 127 +fi + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +IMAGE="${IMAGE:-netbird-json-socket-test:local}" +TARGETARCH="${TARGETARCH:-$(go env GOARCH)}" +PLATFORM="${PLATFORM:-linux/${TARGETARCH}}" +WAIT_TIMEOUT="${WAIT_TIMEOUT:-30}" +TMP_DIR="$(mktemp -d)" +CONTAINERS=() + +cleanup() { + local status=$? + for container in "${CONTAINERS[@]:-}"; do + "${RUNTIME}" rm -f "${container}" >/dev/null 2>&1 || true + done + rm -rf "${TMP_DIR}" + exit "${status}" +} +trap cleanup EXIT + +build_image() { + echo "==> Building Linux ${TARGETARCH} netbird binary" + mkdir -p "${TMP_DIR}/context/client" + cp "${ROOT_DIR}/client/Dockerfile" "${TMP_DIR}/context/Dockerfile" + cp "${ROOT_DIR}/client/netbird-entrypoint.sh" "${TMP_DIR}/context/client/netbird-entrypoint.sh" + + (cd "${ROOT_DIR}" && CGO_ENABLED=0 GOOS=linux GOARCH="${TARGETARCH}" go build -o "${TMP_DIR}/context/netbird" ./client) + + echo "==> Building ${IMAGE} for ${PLATFORM}" + "${RUNTIME}" build \ + --platform "${PLATFORM}" \ + --build-arg NETBIRD_BINARY=netbird \ + -t "${IMAGE}" \ + -f "${TMP_DIR}/context/Dockerfile" \ + "${TMP_DIR}/context" +} + +pick_port() { + python3 - <<'PY' +import socket +sock = socket.socket() +sock.bind(("127.0.0.1", 0)) +print(sock.getsockname()[1]) +sock.close() +PY +} + +assert_status_json() { + local response_file="$1" + if command -v python3 >/dev/null 2>&1; then + python3 - "${response_file}" <<'PY' +import json +import sys +with open(sys.argv[1], encoding="utf-8") as fh: + data = json.load(fh) +if not data.get("status"): + raise SystemExit("missing non-empty status field") +if "daemonVersion" not in data: + raise SystemExit("missing daemonVersion field") +print(f"status={data['status']} daemonVersion={data['daemonVersion']}") +PY + else + grep -q '"status"' "${response_file}" + grep -q '"daemonVersion"' "${response_file}" + cat "${response_file}" + fi +} + +container_logs() { + local container="$1" + echo "---- ${container} logs ----" >&2 + "${RUNTIME}" logs "${container}" >&2 || true + echo "--------------------------" >&2 +} + +wait_for_http_status() { + local container="$1" + local response="${TMP_DIR}/${container}.json" + local curl_err="${TMP_DIR}/${container}.curl.err" + shift + local deadline=$((SECONDS + WAIT_TIMEOUT)) + + while (( SECONDS < deadline )); do + if curl -fsS "$@" \ + -X POST \ + -H 'Content-Type: application/json' \ + -d '{}' \ + -o "${response}" \ + 2>"${curl_err}"; then + assert_status_json "${response}" + return 0 + fi + + if ! "${RUNTIME}" ps --format '{{.Names}}' | grep -Fxq "${container}"; then + echo "container exited before JSON socket became ready" >&2 + container_logs "${container}" + return 1 + fi + sleep 1 + done + + echo "timed out waiting for JSON socket after ${WAIT_TIMEOUT}s" >&2 + cat "${curl_err}" >&2 || true + container_logs "${container}" + return 1 +} + +run_netbird_container() { + local container="$1" + local json_socket="$2" + shift 2 + + CONTAINERS+=("${container}") + "${RUNTIME}" run --rm -d \ + --name "${container}" \ + -e NB_STATE_DIR=/tmp/netbird-state \ + --entrypoint /usr/local/bin/netbird \ + "$@" \ + "${IMAGE}" \ + --log-file console \ + --daemon-addr unix:///tmp/netbird.sock \ + service run \ + --enable-json-socket \ + --json-socket "${json_socket}" >/dev/null +} + +run_tcp_test() { + local port container + port="$(pick_port)" + container="nb-json-socket-tcp-$RANDOM-$RANDOM" + + echo "==> Validating TCP JSON socket on 127.0.0.1:${port}" + run_netbird_container "${container}" "tcp://0.0.0.0:8080" -p "127.0.0.1:${port}:8080" + wait_for_http_status "${container}" "http://127.0.0.1:${port}/daemon.DaemonService/Status" +} + +run_unix_test() { + local sock_dir sock_path container + sock_dir="${TMP_DIR}/sock" + sock_path="${sock_dir}/netbird-http.sock" + container="nb-json-socket-unix-$RANDOM-$RANDOM" + mkdir -p "${sock_dir}" + + echo "==> Validating Unix JSON socket at ${sock_path}" + run_netbird_container "${container}" "unix:///sock/netbird-http.sock" -v "${sock_dir}:/sock" + wait_for_http_status "${container}" --unix-socket "${sock_path}" "http://unix/daemon.DaemonService/Status" +} + +build_image + +case "${MODE}" in + tcp) + run_tcp_test + ;; + unix) + run_unix_test + ;; + both) + run_tcp_test + run_unix_test + ;; +esac + +echo "==> Docker JSON socket validation passed (${MODE})" diff --git a/client/testutil/privileged/runner_test.go b/client/testutil/privileged/runner_test.go new file mode 100644 index 000000000..157005d3e --- /dev/null +++ b/client/testutil/privileged/runner_test.go @@ -0,0 +1,196 @@ +//go:build privileged && (linux || darwin) + +// Package privileged provides a self-hosting harness that runs the repo's +// privileged-tagged test suite inside a --privileged --cap-add=NET_ADMIN +// container, so developers can exercise the root/system-mutating tests on a +// non-root host with a single `go test` invocation. +package privileged + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/moby/moby/api/types/container" + "github.com/ory/dockertest/v4" +) + +// containerImage / containerTag match the image used by the CI privileged job +// (.github/workflows/golang-test-linux.yml, test_client_on_docker). +const ( + containerImage = "golang" + containerTag = "1.26.7-alpine" +) + +const ( + containerWorkdir = "/app" + containerGoCache = "/root/.cache/go-build" + containerGoModCache = "/go/pkg/mod" +) + +// alpinePackages are the build/runtime deps the privileged tests need, mirroring +// the CI container setup. +const alpinePackages = "ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base" + +// privilegedTestPackages is the package list the suite runs, excluding the +// server-side trees and UI/upload helpers, matching the CI Docker job's filter. +const privilegedTestPackages = `go list -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server` + +// testWriter forwards container output to the test log line by line. +type testWriter struct{ t *testing.T } + +func (w testWriter) Write(p []byte) (int, error) { + for _, line := range strings.Split(strings.TrimRight(string(p), "\n"), "\n") { + w.t.Log(line) + } + return len(p), nil +} + +// TestRunPrivilegedSuiteInDocker spins up a privileged container, mounts the repo, +// and runs `go test -tags 'devcert privileged'` inside it. When already running +// inside that container (DOCKER_CI=true) it returns immediately so the real +// privileged tests in the suite execute in place instead of recursing. +func TestRunPrivilegedSuiteInDocker(t *testing.T) { + if os.Getenv("DOCKER_CI") == "true" { + t.Skip("inside privileged container, skipping container spawn; privileged tests run in place") + } + + repoRoot, err := findRepoRoot() + if err != nil { + t.Fatalf("locate repo root: %v", err) + } + goCache, goModCache := hostGoCaches(t) + + // dockertest reads DOCKER_HOST; point it at the active context's socket when + // the default one is absent (macOS Docker Desktop, Colima, OrbStack). + if host := dockerHost(); host != "" { + t.Setenv("DOCKER_HOST", host) + } + + // NewPoolT registers container cleanup via t.Cleanup automatically. + pool := dockertest.NewPoolT(t, "", dockertest.WithMaxWait(30*time.Minute)) + + // Keep the container alive so the suite runs via Exec, which yields a clean + // exit code (the v4 Resource API exposes no container wait/exit-code). + resource := pool.RunT(t, containerImage, + dockertest.WithTag(containerTag), + dockertest.WithWorkingDir(containerWorkdir), + dockertest.WithMounts([]string{ + repoRoot + ":" + containerWorkdir, + goCache + ":" + containerGoCache, + goModCache + ":" + containerGoModCache, + }), + dockertest.WithEnv([]string{ + "CGO_ENABLED=1", + "CI=true", + "DOCKER_CI=true", + "CONTAINER=true", + "GOCACHE=" + containerGoCache, + "GOMODCACHE=" + containerGoModCache, + }), + dockertest.WithCmd([]string{"sleep", "infinity"}), + dockertest.WithHostConfig(func(hc *container.HostConfig) { + hc.Privileged = true + hc.CapAdd = []string{"NET_ADMIN"} + }), + dockertest.WithoutReuse(), + ) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + + result, err := resource.Exec(ctx, []string{"sh", "-c", buildTestScript()}) + if err != nil { + t.Fatalf("run privileged suite in container: %v", err) + } + + w := testWriter{t} + _, _ = w.Write([]byte(result.StdOut)) + _, _ = w.Write([]byte(result.StdErr)) + + if result.ExitCode != 0 { + t.Fatalf("privileged test suite failed in container (exit code %d)", result.ExitCode) + } +} + +// findRepoRoot walks up from the test's working directory to the module root. +func findRepoRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + for { + if _, statErr := os.Stat(filepath.Join(dir, "go.mod")); statErr == nil { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf("go.mod not found above %s", dir) + } + dir = parent + } +} + +// dockerHost returns a DOCKER_HOST override when the default socket is missing. +// An empty result means the caller should leave DOCKER_HOST untouched (it is +// already set, or the default unix socket exists). When neither is present +// (common on macOS Docker Desktop, Colima and OrbStack, which use a per-user +// socket), it resolves the active docker context's endpoint. +func dockerHost() string { + if os.Getenv("DOCKER_HOST") != "" { + return "" + } + if _, err := os.Stat("/var/run/docker.sock"); err == nil { + return "" + } + + out, err := exec.Command("docker", "context", "inspect", "-f", "{{.Endpoints.docker.Host}}").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// hostGoCaches resolves the host GOCACHE/GOMODCACHE so the container reuses the +// existing build/module cache for speed. +func hostGoCaches(t *testing.T) (string, string) { + t.Helper() + return goEnv(t, "GOCACHE"), goEnv(t, "GOMODCACHE") +} + +func goEnv(t *testing.T, key string) string { + t.Helper() + var out bytes.Buffer + cmd := exec.Command("go", "env", key) + cmd.Stdout = &out + if err := cmd.Run(); err != nil { + t.Fatalf("go env %s: %v", key, err) + } + return strings.TrimSpace(out.String()) +} + +// buildTestScript builds the in-container command. PRIV_PKGS overrides the package +// list (default: the full filtered set); PRIV_RUN adds a -run test-name filter. +// Both empty reproduces the full privileged suite. +func buildTestScript() string { + pkgs := privilegedTestPackages + " | xargs" + if p := os.Getenv("PRIV_PKGS"); p != "" { + pkgs = "echo " + p + " | xargs" + } + + runFilter := "" + if r := os.Getenv("PRIV_RUN"); r != "" { + runFilter = "-run '" + r + "' " + } + + return fmt.Sprintf( + "apk update >/dev/null && apk add --no-cache %s >/dev/null && %s go test -buildvcs=false -tags 'devcert privileged' %s-v -timeout 20m -p 1", + alpinePackages, pkgs, runFilter, + ) +} diff --git a/client/ui/.gitignore b/client/ui/.gitignore new file mode 100644 index 000000000..9f233d8b6 --- /dev/null +++ b/client/ui/.gitignore @@ -0,0 +1,8 @@ +.task +bin +frontend/dist +frontend/node_modules +frontend/bindings +frontend/.vite +build/linux/appimage/build +build/windows/nsis/MicrosoftEdgeWebview2Setup.exe diff --git a/client/ui/Netbird.icns b/client/ui/Netbird.icns deleted file mode 100644 index 20af72825..000000000 Binary files a/client/ui/Netbird.icns and /dev/null differ diff --git a/client/ui/Taskfile.yml b/client/ui/Taskfile.yml new file mode 100644 index 000000000..2d0af9018 --- /dev/null +++ b/client/ui/Taskfile.yml @@ -0,0 +1,58 @@ +version: '3' + +includes: + common: ./build/Taskfile.yml + windows: ./build/windows/Taskfile.yml + darwin: ./build/darwin/Taskfile.yml + linux: ./build/linux/Taskfile.yml + +vars: + APP_NAME: "netbird-ui" + BIN_DIR: "bin" + VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}' + +tasks: + build: + summary: Builds the application + cmds: + - task: "{{OS}}:build" + + package: + summary: Packages a production build of the application + cmds: + - task: "{{OS}}:package" + + run: + summary: Runs the application + cmds: + - task: "{{OS}}:run" + + dev: + summary: Runs the application in development mode + cmds: + - wails3 dev -config ./build/config.yml -port {{.VITE_PORT}} + + setup:docker: + summary: Builds Docker image for cross-compilation (~800MB download) + cmds: + - task: common:setup:docker + + build:server: + summary: Builds the application in server mode (no GUI, HTTP server only) + cmds: + - task: common:build:server + + run:server: + summary: Runs the application in server mode + cmds: + - task: common:run:server + + build:docker: + summary: Builds a Docker image for server mode deployment + cmds: + - task: common:build:docker + + run:docker: + summary: Builds and runs the Docker image + cmds: + - task: common:run:docker diff --git a/client/ui/assets/connected.png b/client/ui/assets/connected.png deleted file mode 100644 index 7dd2ab01a..000000000 Binary files a/client/ui/assets/connected.png and /dev/null differ diff --git a/client/ui/assets/disconnected.png b/client/ui/assets/disconnected.png deleted file mode 100644 index 421632b52..000000000 Binary files a/client/ui/assets/disconnected.png and /dev/null differ diff --git a/client/ui/assets/netbird-disconnected.ico b/client/ui/assets/netbird-disconnected.ico deleted file mode 100644 index 812e9d283..000000000 Binary files a/client/ui/assets/netbird-disconnected.ico and /dev/null differ diff --git a/client/ui/assets/netbird-disconnected.png b/client/ui/assets/netbird-disconnected.png deleted file mode 100644 index 79d4775ea..000000000 Binary files a/client/ui/assets/netbird-disconnected.png and /dev/null differ diff --git a/client/ui/assets/netbird-menu-16.png b/client/ui/assets/netbird-menu-16.png new file mode 100644 index 000000000..d5dcab446 Binary files /dev/null and b/client/ui/assets/netbird-menu-16.png differ diff --git a/client/ui/assets/netbird-menu-24.png b/client/ui/assets/netbird-menu-24.png new file mode 100644 index 000000000..087c1c2ae Binary files /dev/null and b/client/ui/assets/netbird-menu-24.png differ diff --git a/client/ui/assets/netbird-menu-about-18.png b/client/ui/assets/netbird-menu-about-18.png new file mode 100644 index 000000000..bb12c6367 Binary files /dev/null and b/client/ui/assets/netbird-menu-about-18.png differ diff --git a/client/ui/assets/netbird-menu-dot-connected-16.png b/client/ui/assets/netbird-menu-dot-connected-16.png new file mode 100644 index 000000000..3a7fa31a4 Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-connected-16.png differ diff --git a/client/ui/assets/netbird-menu-dot-connected-22.png b/client/ui/assets/netbird-menu-dot-connected-22.png new file mode 100644 index 000000000..78b068748 Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-connected-22.png differ diff --git a/client/ui/assets/netbird-menu-dot-connected.png b/client/ui/assets/netbird-menu-dot-connected.png new file mode 100644 index 000000000..fc8ce4d85 Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-connected.png differ diff --git a/client/ui/assets/netbird-menu-dot-connecting-16.png b/client/ui/assets/netbird-menu-dot-connecting-16.png new file mode 100644 index 000000000..f874706b5 Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-connecting-16.png differ diff --git a/client/ui/assets/netbird-menu-dot-connecting-22.png b/client/ui/assets/netbird-menu-dot-connecting-22.png new file mode 100644 index 000000000..d8e5970f5 Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-connecting-22.png differ diff --git a/client/ui/assets/netbird-menu-dot-connecting.png b/client/ui/assets/netbird-menu-dot-connecting.png new file mode 100644 index 000000000..3f8bc29d8 Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-connecting.png differ diff --git a/client/ui/assets/netbird-menu-dot-error-16.png b/client/ui/assets/netbird-menu-dot-error-16.png new file mode 100644 index 000000000..cdc6254da Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-error-16.png differ diff --git a/client/ui/assets/netbird-menu-dot-error-22.png b/client/ui/assets/netbird-menu-dot-error-22.png new file mode 100644 index 000000000..d9bd013d6 Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-error-22.png differ diff --git a/client/ui/assets/netbird-menu-dot-error.png b/client/ui/assets/netbird-menu-dot-error.png new file mode 100644 index 000000000..ce5d0e8ef Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-error.png differ diff --git a/client/ui/assets/netbird-menu-dot-idle-16.png b/client/ui/assets/netbird-menu-dot-idle-16.png new file mode 100644 index 000000000..354b5b860 Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-idle-16.png differ diff --git a/client/ui/assets/netbird-menu-dot-idle-22.png b/client/ui/assets/netbird-menu-dot-idle-22.png new file mode 100644 index 000000000..675cf1ffe Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-idle-22.png differ diff --git a/client/ui/assets/netbird-menu-dot-idle.png b/client/ui/assets/netbird-menu-dot-idle.png new file mode 100644 index 000000000..79e7bbbf8 Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-idle.png differ diff --git a/client/ui/assets/netbird-menu-dot-offline-16.png b/client/ui/assets/netbird-menu-dot-offline-16.png new file mode 100644 index 000000000..f9aa5c3e9 Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-offline-16.png differ diff --git a/client/ui/assets/netbird-menu-dot-offline-22.png b/client/ui/assets/netbird-menu-dot-offline-22.png new file mode 100644 index 000000000..5202c8baa Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-offline-22.png differ diff --git a/client/ui/assets/netbird-menu-dot-offline.png b/client/ui/assets/netbird-menu-dot-offline.png new file mode 100644 index 000000000..7aec5d01d Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-offline.png differ diff --git a/client/ui/assets/netbird-systemtray-connected-dark.ico b/client/ui/assets/netbird-systemtray-connected-dark.ico deleted file mode 100644 index 0db8a0862..000000000 Binary files a/client/ui/assets/netbird-systemtray-connected-dark.ico and /dev/null differ diff --git a/client/ui/assets/netbird-systemtray-connected-macos.png b/client/ui/assets/netbird-systemtray-connected-macos.png index ead210250..d29a7ade8 100644 Binary files a/client/ui/assets/netbird-systemtray-connected-macos.png and b/client/ui/assets/netbird-systemtray-connected-macos.png differ diff --git a/client/ui/assets/netbird-systemtray-connected-mono-dark.png b/client/ui/assets/netbird-systemtray-connected-mono-dark.png new file mode 100644 index 000000000..1f7d40121 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-connected-mono-dark.png differ diff --git a/client/ui/assets/netbird-systemtray-connected-mono.png b/client/ui/assets/netbird-systemtray-connected-mono.png new file mode 100644 index 000000000..8a8710746 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-connected-mono.png differ diff --git a/client/ui/assets/netbird-systemtray-connected.ico b/client/ui/assets/netbird-systemtray-connected.ico deleted file mode 100644 index c16bec3f5..000000000 Binary files a/client/ui/assets/netbird-systemtray-connected.ico and /dev/null differ diff --git a/client/ui/assets/netbird-systemtray-connecting-dark.ico b/client/ui/assets/netbird-systemtray-connecting-dark.ico deleted file mode 100644 index 615d40f07..000000000 Binary files a/client/ui/assets/netbird-systemtray-connecting-dark.ico and /dev/null differ diff --git a/client/ui/assets/netbird-systemtray-connecting-macos.png b/client/ui/assets/netbird-systemtray-connecting-macos.png index 0fe7fa0db..306c6ddf5 100644 Binary files a/client/ui/assets/netbird-systemtray-connecting-macos.png and b/client/ui/assets/netbird-systemtray-connecting-macos.png differ diff --git a/client/ui/assets/netbird-systemtray-connecting-mono-dark.png b/client/ui/assets/netbird-systemtray-connecting-mono-dark.png new file mode 100644 index 000000000..f208cb6bf Binary files /dev/null and b/client/ui/assets/netbird-systemtray-connecting-mono-dark.png differ diff --git a/client/ui/assets/netbird-systemtray-connecting-mono.png b/client/ui/assets/netbird-systemtray-connecting-mono.png new file mode 100644 index 000000000..e254321fd Binary files /dev/null and b/client/ui/assets/netbird-systemtray-connecting-mono.png differ diff --git a/client/ui/assets/netbird-systemtray-connecting.ico b/client/ui/assets/netbird-systemtray-connecting.ico deleted file mode 100644 index 4e4c3a9b1..000000000 Binary files a/client/ui/assets/netbird-systemtray-connecting.ico and /dev/null differ diff --git a/client/ui/assets/netbird-systemtray-disconnected-macos.png b/client/ui/assets/netbird-systemtray-disconnected-macos.png index 36b9a488f..48cfa7c60 100644 Binary files a/client/ui/assets/netbird-systemtray-disconnected-macos.png and b/client/ui/assets/netbird-systemtray-disconnected-macos.png differ diff --git a/client/ui/assets/netbird-systemtray-disconnected-mono-dark.png b/client/ui/assets/netbird-systemtray-disconnected-mono-dark.png new file mode 100644 index 000000000..035e71ba7 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-disconnected-mono-dark.png differ diff --git a/client/ui/assets/netbird-systemtray-disconnected-mono.png b/client/ui/assets/netbird-systemtray-disconnected-mono.png new file mode 100644 index 000000000..d68c4dc5b Binary files /dev/null and b/client/ui/assets/netbird-systemtray-disconnected-mono.png differ diff --git a/client/ui/assets/netbird-systemtray-disconnected.ico b/client/ui/assets/netbird-systemtray-disconnected.ico deleted file mode 100644 index dcb9f4bf8..000000000 Binary files a/client/ui/assets/netbird-systemtray-disconnected.ico and /dev/null differ diff --git a/client/ui/assets/netbird-systemtray-error-dark.ico b/client/ui/assets/netbird-systemtray-error-dark.ico deleted file mode 100644 index 083816188..000000000 Binary files a/client/ui/assets/netbird-systemtray-error-dark.ico and /dev/null differ diff --git a/client/ui/assets/netbird-systemtray-error-macos.png b/client/ui/assets/netbird-systemtray-error-macos.png index 9a9998bcf..580fe647c 100644 Binary files a/client/ui/assets/netbird-systemtray-error-macos.png and b/client/ui/assets/netbird-systemtray-error-macos.png differ diff --git a/client/ui/assets/netbird-systemtray-error-mono-dark.png b/client/ui/assets/netbird-systemtray-error-mono-dark.png new file mode 100644 index 000000000..6bcdacd44 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-error-mono-dark.png differ diff --git a/client/ui/assets/netbird-systemtray-error-mono.png b/client/ui/assets/netbird-systemtray-error-mono.png new file mode 100644 index 000000000..164d65a4f Binary files /dev/null and b/client/ui/assets/netbird-systemtray-error-mono.png differ diff --git a/client/ui/assets/netbird-systemtray-error.ico b/client/ui/assets/netbird-systemtray-error.ico deleted file mode 100644 index 1abc45c2a..000000000 Binary files a/client/ui/assets/netbird-systemtray-error.ico and /dev/null differ diff --git a/client/ui/assets/netbird-systemtray-needs-login-macos.png b/client/ui/assets/netbird-systemtray-needs-login-macos.png new file mode 100644 index 000000000..580fe647c Binary files /dev/null and b/client/ui/assets/netbird-systemtray-needs-login-macos.png differ diff --git a/client/ui/assets/netbird-systemtray-needs-login-mono-dark.png b/client/ui/assets/netbird-systemtray-needs-login-mono-dark.png new file mode 100644 index 000000000..6bcdacd44 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-needs-login-mono-dark.png differ diff --git a/client/ui/assets/netbird-systemtray-needs-login-mono.png b/client/ui/assets/netbird-systemtray-needs-login-mono.png new file mode 100644 index 000000000..164d65a4f Binary files /dev/null and b/client/ui/assets/netbird-systemtray-needs-login-mono.png differ diff --git a/client/ui/assets/netbird-systemtray-needs-login.png b/client/ui/assets/netbird-systemtray-needs-login.png new file mode 100644 index 000000000..722342989 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-needs-login.png differ diff --git a/client/ui/assets/netbird-systemtray-update-connected-dark.ico b/client/ui/assets/netbird-systemtray-update-connected-dark.ico deleted file mode 100644 index b11bb5492..000000000 Binary files a/client/ui/assets/netbird-systemtray-update-connected-dark.ico and /dev/null differ diff --git a/client/ui/assets/netbird-systemtray-update-connected-macos.png b/client/ui/assets/netbird-systemtray-update-connected-macos.png index 8a6b2f2db..8b7b9f131 100644 Binary files a/client/ui/assets/netbird-systemtray-update-connected-macos.png and b/client/ui/assets/netbird-systemtray-update-connected-macos.png differ diff --git a/client/ui/assets/netbird-systemtray-update-connected-mono-dark.png b/client/ui/assets/netbird-systemtray-update-connected-mono-dark.png new file mode 100644 index 000000000..284efa880 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-update-connected-mono-dark.png differ diff --git a/client/ui/assets/netbird-systemtray-update-connected-mono.png b/client/ui/assets/netbird-systemtray-update-connected-mono.png new file mode 100644 index 000000000..ed9ceb8a2 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-update-connected-mono.png differ diff --git a/client/ui/assets/netbird-systemtray-update-connected.ico b/client/ui/assets/netbird-systemtray-update-connected.ico deleted file mode 100644 index d3ce2f0f3..000000000 Binary files a/client/ui/assets/netbird-systemtray-update-connected.ico and /dev/null differ diff --git a/client/ui/assets/netbird-systemtray-update-disconnected-dark.ico b/client/ui/assets/netbird-systemtray-update-disconnected-dark.ico deleted file mode 100644 index 123237f66..000000000 Binary files a/client/ui/assets/netbird-systemtray-update-disconnected-dark.ico and /dev/null differ diff --git a/client/ui/assets/netbird-systemtray-update-disconnected-macos.png b/client/ui/assets/netbird-systemtray-update-disconnected-macos.png index 8b190034e..b6afa3937 100644 Binary files a/client/ui/assets/netbird-systemtray-update-disconnected-macos.png and b/client/ui/assets/netbird-systemtray-update-disconnected-macos.png differ diff --git a/client/ui/assets/netbird-systemtray-update-disconnected-mono-dark.png b/client/ui/assets/netbird-systemtray-update-disconnected-mono-dark.png new file mode 100644 index 000000000..eb0c4bcf5 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-update-disconnected-mono-dark.png differ diff --git a/client/ui/assets/netbird-systemtray-update-disconnected-mono.png b/client/ui/assets/netbird-systemtray-update-disconnected-mono.png new file mode 100644 index 000000000..519ea014a Binary files /dev/null and b/client/ui/assets/netbird-systemtray-update-disconnected-mono.png differ diff --git a/client/ui/assets/netbird-systemtray-update-disconnected.ico b/client/ui/assets/netbird-systemtray-update-disconnected.ico deleted file mode 100644 index 968dc4105..000000000 Binary files a/client/ui/assets/netbird-systemtray-update-disconnected.ico and /dev/null differ diff --git a/client/ui/assets/netbird.ico b/client/ui/assets/netbird.ico deleted file mode 100644 index 2bab8a503..000000000 Binary files a/client/ui/assets/netbird.ico and /dev/null differ diff --git a/client/ui/assets/svg/needs-login.svg b/client/ui/assets/svg/needs-login.svg new file mode 100644 index 000000000..5c01b48d4 --- /dev/null +++ b/client/ui/assets/svg/needs-login.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/client/ui/assets/svg/netbird-menu.svg b/client/ui/assets/svg/netbird-menu.svg new file mode 100644 index 000000000..bd4e9d65d --- /dev/null +++ b/client/ui/assets/svg/netbird-menu.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/client/ui/authsession/service.go b/client/ui/authsession/service.go new file mode 100644 index 000000000..9c094c2a1 --- /dev/null +++ b/client/ui/authsession/service.go @@ -0,0 +1,129 @@ +//go:build !android && !ios && !freebsd && !js + +package authsession + +import ( + "context" + "time" + + log "github.com/sirupsen/logrus" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/proto" +) + +type ExtendStartParams struct { + // Hint is the OIDC login_hint, typically the user's email. + Hint string `json:"hint"` +} + +type ExtendStartResult struct { + VerificationURI string `json:"verificationUri"` + VerificationURIComplete string `json:"verificationUriComplete"` + UserCode string `json:"userCode"` + DeviceCode string `json:"deviceCode"` + ExpiresIn int64 `json:"expiresIn"` +} + +type ExtendWaitParams struct { + DeviceCode string `json:"deviceCode"` + UserCode string `json:"userCode"` +} + +// ExtendResult: ExpiresAt is nil when the peer is ineligible for extension. +// Preempted means a newer WaitExtend took over the IdP poll — a no-op, not a failure. +type ExtendResult struct { + ExpiresAt *time.Time `json:"sessionExpiresAt,omitempty"` + Preempted bool `json:"preempted,omitempty"` +} + +// DaemonConn duplicates services.DaemonConn to avoid an import cycle. +type DaemonConn interface { + Client() (proto.DaemonServiceClient, error) +} + +// Session bundles the session-auth daemon RPCs the UI drives. +type Session struct { + conn DaemonConn +} + +func NewSession(conn DaemonConn) *Session { + return &Session{conn: conn} +} + +// RequestExtend starts the SSO session-extension flow on the daemon. +func (s *Session) RequestExtend(ctx context.Context, p ExtendStartParams) (ExtendStartResult, error) { + cli, err := s.conn.Client() + if err != nil { + return ExtendStartResult{}, err + } + + // a request from the UI implies a graphical session, which the daemon cannot detect itself + req := &proto.RequestExtendAuthSessionRequest{HasGraphicalSession: true} + hint := p.Hint + if hint == "" { + pm := profilemanager.NewProfileManager() + if active, perr := pm.GetActiveProfile(); perr != nil { + log.Debugf("failed to get active profile for login hint: %v", perr) + } else if state, serr := pm.GetProfileState(active.ID); serr != nil { + log.Debugf("failed to get profile state for login hint: %v", serr) + } else { + hint = state.Email + } + } + if hint != "" { + req.Hint = &hint + } + + resp, err := cli.RequestExtendAuthSession(ctx, req) + if err != nil { + return ExtendStartResult{}, err + } + + return ExtendStartResult{ + VerificationURI: resp.GetVerificationURI(), + VerificationURIComplete: resp.GetVerificationURIComplete(), + UserCode: resp.GetUserCode(), + DeviceCode: resp.GetDeviceCode(), + ExpiresIn: resp.GetExpiresIn(), + }, nil +} + +// WaitExtend blocks until the user completes the SSO flow started by RequestExtend. +func (s *Session) WaitExtend(ctx context.Context, p ExtendWaitParams) (ExtendResult, error) { + cli, err := s.conn.Client() + if err != nil { + return ExtendResult{}, err + } + + resp, err := cli.WaitExtendAuthSession(ctx, &proto.WaitExtendAuthSessionRequest{ + DeviceCode: p.DeviceCode, + UserCode: p.UserCode, + }) + if err != nil { + if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Canceled { + return ExtendResult{Preempted: true}, nil + } + return ExtendResult{}, err + } + + out := ExtendResult{} + if ts := resp.GetSessionExpiresAt(); ts.IsValid() && !ts.AsTime().IsZero() { + t := ts.AsTime().UTC() + out.ExpiresAt = &t + } + return out, nil +} + +// DismissWarning suppresses the daemon's T-FinalWarningLead fallback dialog for +// the current deadline. Best-effort: a stale call is silently swallowed daemon-side. +func (s *Session) DismissWarning(ctx context.Context) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + _, err = cli.DismissSessionWarning(ctx, &proto.DismissSessionWarningRequest{}) + return err +} diff --git a/client/ui/authsession/warning.go b/client/ui/authsession/warning.go new file mode 100644 index 000000000..91ae7f101 --- /dev/null +++ b/client/ui/authsession/warning.go @@ -0,0 +1,64 @@ +//go:build !android && !ios && !freebsd && !js + +// Package authsession holds the UI-side domain logic for the SSO +// session-extend feature. The Wails facades in client/ui/services/session*.go +// are thin adapters over these types. +package authsession + +import ( + "time" + + "github.com/netbirdio/netbird/client/internal/auth/sessionwatch" +) + +// Re-exported from sessionwatch so UI-side consumers don't import the +// daemon-internal package directly. +const ( + MetaWarning = sessionwatch.MetaSessionWarning + MetaFinal = sessionwatch.MetaSessionFinal + MetaExpiresAt = sessionwatch.MetaSessionExpiresAt + MetaLeadMinutes = sessionwatch.MetaSessionLeadMinutes + MetaDeadlineRejected = sessionwatch.MetaSessionDeadlineRejected +) + +// Warning is the typed payload emitted on the session-warning Wails events. +type Warning struct { + // Absolute UTC deadline; best-effort, stays zero when metadata is + // missing or malformed (e.g. an older daemon) and the UI falls back + // to the Status snapshot. + ExpiresAt time.Time `json:"sessionExpiresAt"` + // Configured lead time, so the UI need not hardcode the constant. + LeadMinutes int `json:"leadMinutes"` + // True on the final-warning fallback event. + Final bool `json:"final"` +} + +// WarningFromMetadata parses SystemEvent metadata into a Warning, or returns +// (nil, false) when the event is not a session-warning. A field that fails to +// parse stays zero; the event is still surfaced. +func WarningFromMetadata(meta map[string]string) (*Warning, bool) { + if meta == nil || meta[MetaWarning] != "true" { + return nil, false + } + + out := &Warning{ + Final: meta[MetaFinal] == "true", + } + if raw := meta[MetaExpiresAt]; raw != "" { + if t, err := sessionwatch.ParseExpiresAt(raw); err == nil { + out.ExpiresAt = t + } + } + if raw := meta[MetaLeadMinutes]; raw != "" { + if n, err := sessionwatch.ParseLeadMinutes(raw); err == nil { + out.LeadMinutes = n + } + } + return out, true +} + +// ParseExpiresAt re-exports sessionwatch.ParseExpiresAt so UI-side call sites +// don't import the daemon-internal package. +func ParseExpiresAt(s string) (time.Time, error) { + return sessionwatch.ParseExpiresAt(s) +} diff --git a/client/ui/authsession/warning_test.go b/client/ui/authsession/warning_test.go new file mode 100644 index 000000000..297073ded --- /dev/null +++ b/client/ui/authsession/warning_test.go @@ -0,0 +1,82 @@ +//go:build !android && !ios && !freebsd && !js + +package authsession + +import ( + "testing" + "time" +) + +func TestWarningFromMetadata_NotASessionWarning(t *testing.T) { + cases := []struct { + name string + meta map[string]string + }{ + {"nil metadata", nil}, + {"empty map", map[string]string{}}, + {"unrelated event", map[string]string{"new_version_available": "0.65.0"}}, + {"flag not 'true'", map[string]string{"session_warning": "1"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if w, ok := WarningFromMetadata(tc.meta); ok { + t.Fatalf("expected (nil, false), got (%+v, %v)", w, ok) + } + }) + } +} + +func TestWarningFromMetadata_FullPayload(t *testing.T) { + ts := "2026-05-18T13:30:00Z" + meta := map[string]string{ + "session_warning": "true", + "session_expires_at": ts, + "lead_minutes": "10", + } + + got, ok := WarningFromMetadata(meta) + if !ok { + t.Fatalf("expected the warning to be recognised, got ok=false") + } + want, _ := time.Parse(time.RFC3339, ts) + if !got.ExpiresAt.Equal(want.UTC()) { + t.Errorf("ExpiresAt = %v, want %v", got.ExpiresAt, want.UTC()) + } + if got.LeadMinutes != 10 { + t.Errorf("LeadMinutes = %d, want 10", got.LeadMinutes) + } +} + +func TestWarningFromMetadata_BadFieldsStillEmits(t *testing.T) { + // Older or buggy daemon: the flag is set but the timestamp/lead are + // missing or malformed. The UI should still get a warning so it can + // at least surface "session expires soon"; field zero-values are fine. + meta := map[string]string{ + "session_warning": "true", + "session_expires_at": "not-a-timestamp", + "lead_minutes": "abc", + } + + got, ok := WarningFromMetadata(meta) + if !ok { + t.Fatalf("warning should still be recognised even with malformed fields") + } + if !got.ExpiresAt.IsZero() { + t.Errorf("malformed timestamp should leave field zero, got %v", got.ExpiresAt) + } + if got.LeadMinutes != 0 { + t.Errorf("malformed lead_minutes should leave field 0, got %d", got.LeadMinutes) + } +} + +func TestWarningFromMetadata_MissingFieldsStillEmits(t *testing.T) { + // Only the flag is present (e.g. future-trimmed event). Still emit. + meta := map[string]string{"session_warning": "true"} + got, ok := WarningFromMetadata(meta) + if !ok { + t.Fatalf("warning should still be recognised when only flag is present") + } + if got.ExpiresAt.IsZero() != true || got.LeadMinutes != 0 { + t.Errorf("missing fields should be zero-valued, got %+v", got) + } +} diff --git a/client/ui/autostart_default.go b/client/ui/autostart_default.go new file mode 100644 index 000000000..0c67667dd --- /dev/null +++ b/client/ui/autostart_default.go @@ -0,0 +1,121 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + "os" + "path/filepath" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" + "github.com/netbirdio/netbird/client/ui/preferences" + "github.com/netbirdio/netbird/client/ui/services" +) + +// autostartDefaultState carries the guard inputs of the one-time autostart +// default decision so the decision itself stays a pure, testable function. +type autostartDefaultState struct { + supported bool + mdmDisabled bool + priorInstall bool +} + +// shouldEnableAutostartDefault applies the first-run guards in order and +// returns whether autostart may be enabled, plus the reason when it may not. +func shouldEnableAutostartDefault(s autostartDefaultState) (bool, string) { + switch { + case !s.supported: + return false, "autostart not supported on this platform" + case s.mdmDisabled: + return false, "autostart disabled by MDM policy" + case s.priorInstall: + return false, "existing NetBird installation" + } + return true, "" +} + +// autostartDisabledByMDM reports whether the MDM policy manages the +// disableAutostart key in a way that must suppress the default. An +// unparseable managed value is treated as disabled to stay on the safe side. +func autostartDisabledByMDM(policy *mdm.Policy) bool { + if !policy.HasKey(mdm.KeyDisableAutostart) { + return false + } + disabled, ok := policy.GetBool(mdm.KeyDisableAutostart) + return !ok || disabled +} + +// netbirdFootprintExists reports whether the machine already carries NetBird +// daemon config or state, meaning this is not a genuinely fresh install. It is +// the update-safety gate for the autostart default: upgrading users always +// have a footprint, so an update can never trigger a autostart entry write. +func netbirdFootprintExists() bool { + candidates := []string{ + profilemanager.DefaultConfigPath, + filepath.Join(profilemanager.DefaultConfigPathDir, "config.json"), + filepath.Join(profilemanager.DefaultConfigPathDir, "state.json"), + } + for _, path := range candidates { + if path != "" && fileExists(path) { + return true + } + } + return false +} + +// applyAutostartDefault runs the one-time launch-on-login default for genuinely +// fresh installs. The autostartInitialized marker is persisted before any +// enable attempt so a crash mid-flow degrades to "never enabled" instead of +// retrying autostart entry writes on every launch. A user's later disable in +// Settings is never overridden: the marker guarantees at-most-once, ever. +func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, prefs *preferences.Store, prefsFileExisted bool) { + mdmDisabled := autostartDisabledByMDM(mdm.NewLoader(nil).Load()) + + if mdmDisabled { + if enabled, err := autostart.IsEnabled(ctx); err != nil { + log.Warnf("MDM disableAutostart: read autostart state: %v", err) + } else if enabled { + if err := autostart.SetEnabled(ctx, false); err != nil { + log.Warnf("MDM disableAutostart: force off failed: %v", err) + } else { + log.Info("MDM disableAutostart enforced: autostart turned off") + } + } + } + + priorFootprint := netbirdFootprintExists() || prefsFileExisted + + if prefs.Get().AutostartInitialized { + return + } + if err := prefs.SetAutostartInitialized(true); err != nil { + log.Warnf("persist autostart marker, skipping autostart default: %v", err) + return + } + + state := autostartDefaultState{ + supported: autostart.Supported(ctx), + mdmDisabled: mdmDisabled, + priorInstall: priorFootprint, + } + enable, reason := shouldEnableAutostartDefault(state) + if !enable { + log.Debugf("skipping autostart default: %s", reason) + return + } + + if err := autostart.SetEnabled(ctx, true); err != nil { + log.Warnf("enable autostart on fresh install: %v", err) + return + } + log.Info("autostart enabled by default on fresh install") +} + +// fileExists reports whether path exists. +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/client/ui/autostart_default_test.go b/client/ui/autostart_default_test.go new file mode 100644 index 000000000..b7bdf9f2a --- /dev/null +++ b/client/ui/autostart_default_test.go @@ -0,0 +1,125 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/client/mdm" +) + +func TestShouldEnableAutostartDefault(t *testing.T) { + allPass := autostartDefaultState{ + supported: true, + mdmDisabled: false, + priorInstall: false, + } + + tests := []struct { + name string + mutate func(*autostartDefaultState) + wantEnable bool + wantReason string + }{ + { + name: "fresh install with all guards passing enables", + mutate: func(*autostartDefaultState) {}, + wantEnable: true, + }, + { + name: "unsupported platform skips", + mutate: func(s *autostartDefaultState) { s.supported = false }, + wantReason: "autostart not supported on this platform", + }, + { + name: "MDM disable skips", + mutate: func(s *autostartDefaultState) { s.mdmDisabled = true }, + wantReason: "autostart disabled by MDM policy", + }, + { + name: "existing installation (upgrade) skips", + mutate: func(s *autostartDefaultState) { s.priorInstall = true }, + wantReason: "existing NetBird installation", + }, + { + name: "unsupported wins over every other guard", + mutate: func(s *autostartDefaultState) { + s.supported = false + s.mdmDisabled = true + s.priorInstall = true + }, + wantReason: "autostart not supported on this platform", + }, + { + name: "MDM disable wins over prior install", + mutate: func(s *autostartDefaultState) { + s.mdmDisabled = true + s.priorInstall = true + }, + wantReason: "autostart disabled by MDM policy", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + state := allPass + tc.mutate(&state) + enable, reason := shouldEnableAutostartDefault(state) + assert.Equal(t, tc.wantEnable, enable, "enable decision should match for state %+v", state) + assert.Equal(t, tc.wantReason, reason, "skip reason should identify the failing guard") + }) + } +} + +func TestAutostartDisabledByMDM(t *testing.T) { + tests := []struct { + name string + values map[string]any + want bool + }{ + { + name: "empty policy does not disable", + values: nil, + want: false, + }, + { + name: "unrelated managed keys do not disable", + values: map[string]any{mdm.KeyDisableAutoConnect: true}, + want: false, + }, + { + name: "disableAutostart true disables", + values: map[string]any{mdm.KeyDisableAutostart: true}, + want: true, + }, + { + name: "disableAutostart registry DWORD 1 disables", + values: map[string]any{mdm.KeyDisableAutostart: int64(1)}, + want: true, + }, + { + name: "disableAutostart string true disables", + values: map[string]any{mdm.KeyDisableAutostart: "true"}, + want: true, + }, + { + name: "disableAutostart explicit false allows", + values: map[string]any{mdm.KeyDisableAutostart: false}, + want: false, + }, + { + name: "unparseable managed value is treated as disabled", + values: map[string]any{mdm.KeyDisableAutostart: "not-a-bool"}, + want: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := autostartDisabledByMDM(mdm.NewPolicy(tc.values)) + assert.Equal(t, tc.want, got, "MDM disable decision should match for values %v", tc.values) + }) + } +} diff --git a/client/ui/build/Taskfile.yml b/client/ui/build/Taskfile.yml new file mode 100644 index 000000000..590d4791b --- /dev/null +++ b/client/ui/build/Taskfile.yml @@ -0,0 +1,295 @@ +version: '3' + +tasks: + go:mod:tidy: + summary: Runs `go mod tidy` + internal: true + cmds: + - go mod tidy + + install:frontend:deps: + summary: Install frontend dependencies + dir: frontend + sources: + - package.json + - pnpm-lock.yaml + generates: + - node_modules + preconditions: + - sh: pnpm --version + msg: "Looks like pnpm isn't installed. Install with: corepack enable && corepack prepare pnpm@latest --activate" + cmds: + - pnpm install + + build:frontend: + label: build:frontend (DEV={{.DEV}}) + summary: Build the frontend project + dir: frontend + sources: + - "**/*" + - exclude: node_modules/**/* + generates: + - dist/**/* + deps: + - task: install:frontend:deps + - task: generate:bindings + vars: + BUILD_FLAGS: + ref: .BUILD_FLAGS + cmds: + - pnpm run {{.BUILD_COMMAND}} + env: + PRODUCTION: '{{if eq .DEV "true"}}false{{else}}true{{end}}' + vars: + BUILD_COMMAND: '{{if eq .DEV "true"}}build:dev{{else}}build{{end}}' + + + frontend:vendor:puppertino: + summary: Fetches Puppertino CSS into frontend/public for consistent mobile styling + sources: + - frontend/public/puppertino/puppertino.css + generates: + - frontend/public/puppertino/puppertino.css + cmds: + - | + set -euo pipefail + mkdir -p frontend/public/puppertino + # If bundled Puppertino exists, prefer it. Otherwise, try to fetch, but don't fail build on error. + if [ ! -f frontend/public/puppertino/puppertino.css ]; then + echo "No bundled Puppertino found. Attempting to fetch from GitHub..." + if curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/dist/css/full.css -o frontend/public/puppertino/puppertino.css; then + curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/LICENSE -o frontend/public/puppertino/LICENSE || true + echo "Puppertino CSS downloaded to frontend/public/puppertino/puppertino.css" + else + echo "Warning: Could not fetch Puppertino CSS. Proceeding without download since template may bundle it." + fi + else + echo "Using bundled Puppertino at frontend/public/puppertino/puppertino.css" + fi + # Ensure index.html includes Puppertino CSS and button classes + INDEX_HTML=frontend/index.html + if [ -f "$INDEX_HTML" ]; then + if ! grep -q 'href="/puppertino/puppertino.css"' "$INDEX_HTML"; then + # Insert Puppertino link tag after style.css link + awk ' + /href="\/style.css"\/?/ && !x { print; print " "; x=1; next }1 + ' "$INDEX_HTML" > "$INDEX_HTML.tmp" && mv "$INDEX_HTML.tmp" "$INDEX_HTML" + fi + # Replace default .btn with Puppertino primary button classes if present + sed -E -i'' 's/class=\"btn\"/class=\"p-btn p-prim-col\"/g' "$INDEX_HTML" || true + fi + + + generate:bindings: + label: generate:bindings (BUILD_FLAGS={{.BUILD_FLAGS}}) + summary: Generates bindings for the frontend + deps: + - task: go:mod:tidy + sources: + - "**/*.[jt]s" + - exclude: frontend/**/* + - frontend/bindings/**/* # Rerun when switching between dev/production mode causes changes in output + - "**/*.go" + - go.mod + - go.sum + generates: + - frontend/bindings/**/* + cmds: + - wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true -ts + + generate:icons: + summary: Generates Windows `.ico` and Mac `.icns` from an image; on macOS, `-iconcomposerinput appicon.icon -macassetdir darwin` also produces `Assets.car` from a `.icon` file (skipped on other platforms). + dir: build + sources: + - "appicon.png" + - "appicon.icon" + generates: + - "darwin/icons.icns" + - "windows/icon.ico" + cmds: + - wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico -iconcomposerinput appicon.icon -macassetdir darwin + + generate:tray:icons: + summary: Rebuild Windows multi-res .ico files from the per-state PNGs. + desc: | + The colored tray PNGs (assets/netbird-systemtray-.png) and the + macOS template variants are committed to the repo as the canonical + source. This task only regenerates the Windows multi-resolution .ico + files from those PNGs by downscaling each to 16/24/32/48 px and + packing them with icotool, so Shell_NotifyIcon picks the frame + matching the user's DPI instead of downscaling a single large PNG. + + Run after replacing any of the colored PNGs (e.g. when copying a new + version of the icons from client/ui/assets). The SVG sources in + assets/svg/ are kept for reference but are not built by default. + dir: assets + sources: + - "netbird-systemtray-connected.png" + - "netbird-systemtray-disconnected.png" + - "netbird-systemtray-connecting.png" + - "netbird-systemtray-error.png" + - "netbird-systemtray-update-connected.png" + - "netbird-systemtray-update-disconnected.png" + generates: + - "netbird-systemtray-*.ico" + preconditions: + - sh: command -v magick >/dev/null 2>&1 || command -v convert >/dev/null 2>&1 + msg: "ImageMagick is required to downscale PNGs (apt install imagemagick)" + - sh: command -v icotool >/dev/null 2>&1 + msg: "icotool is required to pack tray .ico files (apt install icoutils)" + cmds: + - | + set -euo pipefail + tmp=$(mktemp -d) + trap 'rm -rf "$tmp"' EXIT + resize=$(command -v magick || echo convert) + for state in connected disconnected connecting error update-connected update-disconnected; do + for sz in 16 24 32 48; do + "$resize" "netbird-systemtray-$state.png" -resize ${sz}x${sz} "$tmp/$state-$sz.png" + done + icotool -c -o "netbird-systemtray-$state.ico" \ + "$tmp/$state-16.png" "$tmp/$state-24.png" "$tmp/$state-32.png" "$tmp/$state-48.png" + done + + dev:frontend: + summary: Runs the frontend in development mode + dir: frontend + deps: + - task: install:frontend:deps + cmds: + - pnpm exec vite --port {{.VITE_PORT}} --strictPort + + update:build-assets: + summary: Updates the build assets + dir: build + cmds: + - wails3 update build-assets -name "{{.APP_NAME}}" -binaryname "{{.APP_NAME}}" -config config.yml -dir . + + build:server: + summary: Builds the application in server mode (no GUI, HTTP server only) + desc: | + Builds the application with the server build tag enabled. + Server mode runs as a pure HTTP server without native GUI dependencies. + Usage: task build:server + deps: + - task: build:frontend + vars: + BUILD_FLAGS: + ref: .BUILD_FLAGS + cmds: + - go build -tags server {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}} + vars: + BUILD_FLAGS: "{{.BUILD_FLAGS}}" + + run:server: + summary: Builds and runs the application in server mode + deps: + - task: build:server + cmds: + - ./{{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}} + + build:docker: + summary: Builds a Docker image for server mode deployment + desc: | + Creates a minimal Docker image containing the server mode binary. + The image is based on distroless for security and small size. + Usage: task build:docker [TAG=myapp:latest] + cmds: + - docker build -t {{.TAG | default (printf "%s:latest" .APP_NAME)}} -f build/docker/Dockerfile.server . + vars: + TAG: "{{.TAG}}" + preconditions: + - sh: docker info > /dev/null 2>&1 + msg: "Docker is required. Please install Docker first." + - sh: test -f build/docker/Dockerfile.server + msg: "Dockerfile.server not found. Run 'wails3 update build-assets' to generate it." + + run:docker: + summary: Builds and runs the Docker image + desc: | + Builds the Docker image and runs it, exposing port 8080. + Usage: task run:docker [TAG=myapp:latest] [PORT=8080] + Note: The internal container port is always 8080. The PORT variable + only changes the host port mapping. Ensure your app uses port 8080 + or modify the Dockerfile to match your ServerOptions.Port setting. + deps: + - task: build:docker + vars: + TAG: + ref: .TAG + cmds: + - docker run --rm -p {{.PORT | default "8080"}}:8080 {{.TAG | default (printf "%s:latest" .APP_NAME)}} + vars: + TAG: "{{.TAG}}" + PORT: "{{.PORT}}" + + setup:docker: + summary: Builds Docker image for cross-compilation (~800MB download) + desc: | + Builds the Docker image needed for cross-compiling to any platform. + Run this once to enable cross-platform builds from any OS. + cmds: + - docker build -t wails-cross -f build/docker/Dockerfile.cross build/docker/ + preconditions: + - sh: docker info > /dev/null 2>&1 + msg: "Docker is required. Please install Docker first." + + ios:device:list: + summary: Lists connected iOS devices (UDIDs) + cmds: + - xcrun xcdevice list + + ios:run:device: + summary: Build, install, and launch on a physical iPhone using Apple tools (xcodebuild/devicectl) + vars: + PROJECT: '{{.PROJECT}}' # e.g., build/ios/xcode/.xcodeproj + SCHEME: '{{.SCHEME}}' # e.g., ios.dev + CONFIG: '{{.CONFIG | default "Debug"}}' + DERIVED: '{{.DERIVED | default "build/ios/DerivedData"}}' + UDID: '{{.UDID}}' # from `task ios:device:list` + BUNDLE_ID: '{{.BUNDLE_ID}}' # e.g., com.yourco.wails.ios.dev + TEAM_ID: '{{.TEAM_ID}}' # optional, if your project is not already set up for signing + preconditions: + - sh: xcrun -f xcodebuild + msg: "xcodebuild not found. Please install Xcode." + - sh: xcrun -f devicectl + msg: "devicectl not found. Please update to Xcode 15+ (which includes devicectl)." + - sh: test -n '{{.PROJECT}}' + msg: "Set PROJECT to your .xcodeproj path (e.g., PROJECT=build/ios/xcode/App.xcodeproj)." + - sh: test -n '{{.SCHEME}}' + msg: "Set SCHEME to your app scheme (e.g., SCHEME=ios.dev)." + - sh: test -n '{{.UDID}}' + msg: "Set UDID to your device UDID (see: task ios:device:list)." + - sh: test -n '{{.BUNDLE_ID}}' + msg: "Set BUNDLE_ID to your app's bundle identifier (e.g., com.yourco.wails.ios.dev)." + cmds: + - | + set -euo pipefail + echo "Building for device: UDID={{.UDID}} SCHEME={{.SCHEME}} PROJECT={{.PROJECT}}" + XCB_ARGS=( + -project "{{.PROJECT}}" + -scheme "{{.SCHEME}}" + -configuration "{{.CONFIG}}" + -destination "id={{.UDID}}" + -derivedDataPath "{{.DERIVED}}" + -allowProvisioningUpdates + -allowProvisioningDeviceRegistration + ) + # Optionally inject signing identifiers if provided + if [ -n '{{.TEAM_ID}}' ]; then XCB_ARGS+=(DEVELOPMENT_TEAM={{.TEAM_ID}}); fi + if [ -n '{{.BUNDLE_ID}}' ]; then XCB_ARGS+=(PRODUCT_BUNDLE_IDENTIFIER={{.BUNDLE_ID}}); fi + xcodebuild "${XCB_ARGS[@]}" build | xcpretty || true + # If xcpretty isn't installed, run without it + if [ "${PIPESTATUS[0]}" -ne 0 ]; then + xcodebuild "${XCB_ARGS[@]}" build + fi + # Find built .app + APP_PATH=$(find "{{.DERIVED}}/Build/Products" -type d -name "*.app" -maxdepth 3 | head -n 1) + if [ -z "$APP_PATH" ]; then + echo "Could not locate built .app under {{.DERIVED}}/Build/Products" >&2 + exit 1 + fi + echo "Installing: $APP_PATH" + xcrun devicectl device install app --device "{{.UDID}}" "$APP_PATH" + echo "Launching: {{.BUNDLE_ID}}" + xcrun devicectl device process launch --device "{{.UDID}}" --stderr console --stdout console "{{.BUNDLE_ID}}" diff --git a/client/ui/build/appicon.icon/Assets/wails_icon_vector.svg b/client/ui/build/appicon.icon/Assets/wails_icon_vector.svg new file mode 100644 index 000000000..83c4c22a9 --- /dev/null +++ b/client/ui/build/appicon.icon/Assets/wails_icon_vector.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/client/ui/build/appicon.icon/icon.json b/client/ui/build/appicon.icon/icon.json new file mode 100644 index 000000000..4a0371af3 --- /dev/null +++ b/client/ui/build/appicon.icon/icon.json @@ -0,0 +1,26 @@ +{ + "fill" : { + "solid" : "srgb:1.00000,1.00000,1.00000,1.00000" + }, + "groups" : [ + { + "layers" : [ + { + "image-name" : "wails_icon_vector.svg", + "name" : "wails_icon_vector" + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "specular" : true + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} diff --git a/client/ui/build/appicon.png b/client/ui/build/appicon.png new file mode 100644 index 000000000..977d2400a Binary files /dev/null and b/client/ui/build/appicon.png differ diff --git a/client/ui/build/build-ui-linux.sh b/client/ui/build/build-ui-linux.sh deleted file mode 100644 index eab08214d..000000000 --- a/client/ui/build/build-ui-linux.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash -sudo apt update -sudo apt remove gir1.2-appindicator3-0.1 -sudo apt install -y libayatana-appindicator3-dev -go build \ No newline at end of file diff --git a/client/ui/build/config.yml b/client/ui/build/config.yml new file mode 100644 index 000000000..08b95b6bd --- /dev/null +++ b/client/ui/build/config.yml @@ -0,0 +1,78 @@ +# This file contains the configuration for this project. +# When you update `info` or `fileAssociations`, run `wails3 task common:update:build-assets` to update the assets. +# Note that this will overwrite any changes you have made to the assets. +version: '3' + +# This information is used to generate the build assets. +info: + companyName: "NetBird GmbH" # The name of the company + productName: "NetBird" # The name of the application + productIdentifier: "io.netbird.client" # The unique product identifier + description: "NetBird desktop client" # The application description + copyright: "NetBird GmbH" # Copyright text + comments: "Some Product Comments" # Comments + version: "0.0.1" # The application version + # cfBundleIconName: "appicon" # The macOS icon name in Assets.car icon bundles (optional) + # # Should match the name of your .icon file without the extension + # # If not set and Assets.car exists, defaults to "appicon" + +# iOS build configuration (uncomment to customise iOS project generation) +# Note: Keys under `ios` OVERRIDE values under `info` when set. +# ios: +# # The iOS bundle identifier used in the generated Xcode project (CFBundleIdentifier) +# bundleID: "com.mycompany.myproduct" +# # The display name shown under the app icon (CFBundleDisplayName/CFBundleName) +# displayName: "My Product" +# # The app version to embed in Info.plist (CFBundleShortVersionString/CFBundleVersion) +# version: "0.0.1" +# # The company/organisation name for templates and project settings +# company: "My Company" +# # Additional comments to embed in Info.plist metadata +# comments: "Some Product Comments" + +# Dev mode configuration +dev_mode: + root_path: . + log_level: warn + debounce: 1000 + ignore: + dir: + - .git + - node_modules + - frontend + - bin + file: + - .DS_Store + - .gitignore + - .gitkeep + watched_extension: + - "*.go" + - "*.js" # Watch for changes to JS/TS files included using the //wails:include directive. + - "*.ts" # The frontend directory will be excluded entirely by the setting above. + git_ignore: true + executes: + - cmd: wails3 build DEV=true + type: blocking + - cmd: wails3 task common:dev:frontend + type: background + - cmd: wails3 task run + type: primary + +# File Associations +# More information at: https://v3.wails.io/noit/done/yet +fileAssociations: +# - ext: wails +# name: Wails +# description: Wails Application File +# iconName: wailsFileIcon +# role: Editor +# - ext: jpg +# name: JPEG +# description: Image File +# iconName: jpegFileIcon +# role: Editor +# mimeType: image/jpeg # (optional) + +# Other data +other: + - name: My Other Data \ No newline at end of file diff --git a/client/ui/build/darwin/Info.dev.plist b/client/ui/build/darwin/Info.dev.plist new file mode 100644 index 000000000..78f5a7b1c --- /dev/null +++ b/client/ui/build/darwin/Info.dev.plist @@ -0,0 +1,38 @@ + + + + CFBundlePackageType + APPL + CFBundleName + NetBird + CFBundleDisplayName + NetBird + CFBundleExecutable + netbird-ui + CFBundleIdentifier + io.netbird.client + CFBundleVersion + 0.0.1 + CFBundleGetInfoString + This is a comment + CFBundleShortVersionString + 0.0.1 + CFBundleIconFile + icons + CFBundleIconName + appicon + LSMinimumSystemVersion + 10.15.0 + NSHighResolutionCapable + true + LSUIElement + 1 + NSHumanReadableCopyright + NetBird GmbH + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + + \ No newline at end of file diff --git a/client/ui/build/darwin/Info.plist b/client/ui/build/darwin/Info.plist new file mode 100644 index 000000000..1e12b049b --- /dev/null +++ b/client/ui/build/darwin/Info.plist @@ -0,0 +1,36 @@ + + + + CFBundlePackageType + APPL + CFBundleName + NetBird + CFBundleDisplayName + NetBird + CFBundleExecutable + netbird-ui + CFBundleIdentifier + io.netbird.client + CFBundleVersion + 0.0.1 + CFBundleGetInfoString + This is a comment + CFBundleShortVersionString + 0.0.1 + CFBundleIconFile + icons + CFBundleIconName + appicon + LSMinimumSystemVersion + 10.15.0 + NSHighResolutionCapable + true + + LSUIElement + 1 + NSHumanReadableCopyright + NetBird GmbH + + \ No newline at end of file diff --git a/client/ui/build/darwin/Taskfile.yml b/client/ui/build/darwin/Taskfile.yml new file mode 100644 index 000000000..8a5c27bdc --- /dev/null +++ b/client/ui/build/darwin/Taskfile.yml @@ -0,0 +1,210 @@ +version: '3' + +includes: + common: ../Taskfile.yml + +vars: + # Signing configuration - edit these values for your project + # SIGN_IDENTITY: "Developer ID Application: Your Company (TEAMID)" + # KEYCHAIN_PROFILE: "my-notarize-profile" + # ENTITLEMENTS: "build/darwin/entitlements.plist" + + # Docker image for cross-compilation (used when building on non-macOS) + CROSS_IMAGE: wails-cross + +tasks: + build: + summary: Builds the application + cmds: + - task: '{{if eq OS "darwin"}}build:native{{else}}build:docker{{end}}' + vars: + ARCH: '{{.ARCH}}' + DEV: '{{.DEV}}' + OUTPUT: '{{.OUTPUT}}' + EXTRA_TAGS: '{{.EXTRA_TAGS}}' + vars: + DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}' + OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}' + + build:native: + summary: Builds the application natively on macOS + internal: true + deps: + - task: common:go:mod:tidy + - task: common:build:frontend + vars: + BUILD_FLAGS: + ref: .BUILD_FLAGS + DEV: + ref: .DEV + - task: common:generate:icons + cmds: + - go build {{.BUILD_FLAGS}} -o {{.OUTPUT}} + vars: + BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-gcflags=all="-l"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -ldflags="-w -s"{{end}}' + DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}' + OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}' + env: + GOOS: darwin + CGO_ENABLED: 1 + GOARCH: '{{.ARCH | default ARCH}}' + CGO_CFLAGS: "-mmacosx-version-min=10.15" + CGO_LDFLAGS: "-mmacosx-version-min=10.15" + MACOSX_DEPLOYMENT_TARGET: "10.15" + + build:docker: + summary: Cross-compiles for macOS using Docker (for Linux/Windows hosts) + internal: true + deps: + - task: common:build:frontend + - task: common:generate:icons + preconditions: + - sh: docker info > /dev/null 2>&1 + msg: "Docker is required for cross-compilation. Please install Docker." + - sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1 + msg: | + Docker image '{{.CROSS_IMAGE}}' not found. + Build it first: wails3 task setup:docker + cmds: + - docker run --rm -v "{{.ROOT_DIR}}:/app" {{.GO_CACHE_MOUNT}} {{.REPLACE_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} {{.CROSS_IMAGE}} darwin {{.DOCKER_ARCH}} + - docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin + - mkdir -p {{.BIN_DIR}} + - mv "bin/{{.APP_NAME}}-darwin-{{.DOCKER_ARCH}}" "{{.OUTPUT}}" + vars: + DOCKER_ARCH: '{{if eq .ARCH "arm64"}}arm64{{else if eq .ARCH "amd64"}}amd64{{else}}arm64{{end}}' + DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}' + OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}' + # Mount Go module cache for faster builds + GO_CACHE_MOUNT: + sh: 'echo "-v ${GOPATH:-$HOME/go}/pkg/mod:/go/pkg/mod"' + # Extract replace directives from go.mod and create -v mounts for each + # Handles both relative (=> ../) and absolute (=> /) paths + REPLACE_MOUNTS: + sh: | + grep -E '^replace .* => ' go.mod 2>/dev/null | while read -r line; do + path=$(echo "$line" | sed -E 's/^replace .* => //' | tr -d '\r') + # Convert relative paths to absolute + if [ "${path#/}" = "$path" ]; then + path="$(cd "$(dirname "$path")" 2>/dev/null && pwd)/$(basename "$path")" + fi + # Only mount if directory exists + if [ -d "$path" ]; then + echo "-v $path:$path:ro" + fi + done | tr '\n' ' ' + + build:universal: + summary: Builds darwin universal binary (arm64 + amd64) + deps: + - task: build + vars: + ARCH: amd64 + OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" + - task: build + vars: + ARCH: arm64 + OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-arm64" + cmds: + - task: '{{if eq OS "darwin"}}build:universal:lipo:native{{else}}build:universal:lipo:go{{end}}' + + build:universal:lipo:native: + summary: Creates universal binary using native lipo (macOS) + internal: true + cmds: + - lipo -create -output "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64" + - rm "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64" + + build:universal:lipo:go: + summary: Creates universal binary using wails3 tool lipo (Linux/Windows) + internal: true + cmds: + - wails3 tool lipo -output "{{.BIN_DIR}}/{{.APP_NAME}}" -input "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" -input "{{.BIN_DIR}}/{{.APP_NAME}}-arm64" + - rm -f "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64" + + package: + summary: Packages the application into a `.app` bundle + deps: + - task: build + cmds: + - task: create:app:bundle + + package:universal: + summary: Packages darwin universal binary (arm64 + amd64) + deps: + - task: build:universal + cmds: + - task: create:app:bundle + + + create:app:bundle: + summary: Creates an `.app` bundle + cmds: + - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS" + - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources" + - cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources" + - | + if [ -f build/darwin/Assets.car ]; then + cp build/darwin/Assets.car "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources" + fi + - cp "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS" + - cp build/darwin/Info.plist "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents" + - task: '{{if eq OS "darwin"}}codesign:adhoc{{else}}codesign:skip{{end}}' + + codesign:adhoc: + summary: Ad-hoc signs the app bundle (macOS only) + internal: true + cmds: + - codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.app" + + codesign:skip: + summary: Skips codesigning when cross-compiling + internal: true + cmds: + - 'echo "Skipping codesign (not available on {{OS}}). Sign the .app on macOS before distribution."' + + run: + deps: + - task: common:generate:icons + cmds: + - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS" + - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources" + - cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources" + - | + if [ -f build/darwin/Assets.car ]; then + cp build/darwin/Assets.car "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources" + fi + - cp "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS" + - cp "build/darwin/Info.dev.plist" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Info.plist" + - codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app" + - '{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS/{{.APP_NAME}}' + + sign: + summary: Signs the application bundle with Developer ID + desc: | + Signs the .app bundle for distribution. + Configure SIGN_IDENTITY in the vars section at the top of this file. + deps: + - task: package + cmds: + - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.app" --identity "{{.SIGN_IDENTITY}}" {{if .ENTITLEMENTS}}--entitlements {{.ENTITLEMENTS}}{{end}} + preconditions: + - sh: '[ -n "{{.SIGN_IDENTITY}}" ]' + msg: "SIGN_IDENTITY is required. Set it in the vars section at the top of build/darwin/Taskfile.yml" + + sign:notarize: + summary: Signs and notarizes the application bundle + desc: | + Signs the .app bundle and submits it for notarization. + Configure SIGN_IDENTITY and KEYCHAIN_PROFILE in the vars section at the top of this file. + + Setup (one-time): + wails3 signing credentials --apple-id "you@email.com" --team-id "TEAMID" --password "app-specific-password" --profile "my-profile" + deps: + - task: package + cmds: + - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.app" --identity "{{.SIGN_IDENTITY}}" {{if .ENTITLEMENTS}}--entitlements {{.ENTITLEMENTS}}{{end}} --notarize --keychain-profile {{.KEYCHAIN_PROFILE}} + preconditions: + - sh: '[ -n "{{.SIGN_IDENTITY}}" ]' + msg: "SIGN_IDENTITY is required. Set it in the vars section at the top of build/darwin/Taskfile.yml" + - sh: '[ -n "{{.KEYCHAIN_PROFILE}}" ]' + msg: "KEYCHAIN_PROFILE is required. Set it in the vars section at the top of build/darwin/Taskfile.yml" diff --git a/client/ui/build/darwin/icons.icns b/client/ui/build/darwin/icons.icns new file mode 100644 index 000000000..fb78a18a9 Binary files /dev/null and b/client/ui/build/darwin/icons.icns differ diff --git a/client/ui/build/docker/Dockerfile.cross b/client/ui/build/docker/Dockerfile.cross new file mode 100644 index 000000000..55c0d69e1 --- /dev/null +++ b/client/ui/build/docker/Dockerfile.cross @@ -0,0 +1,203 @@ +# Cross-compile Wails v3 apps to any platform +# +# Darwin: Zig + macOS SDK +# Linux: Native GCC when host matches target, Zig for cross-arch +# Windows: Zig + bundled mingw +# +# Usage: +# docker build -t wails-cross -f Dockerfile.cross . +# docker run --rm -v $(pwd):/app wails-cross darwin arm64 +# docker run --rm -v $(pwd):/app wails-cross darwin amd64 +# docker run --rm -v $(pwd):/app wails-cross linux amd64 +# docker run --rm -v $(pwd):/app wails-cross linux arm64 +# docker run --rm -v $(pwd):/app wails-cross windows amd64 +# docker run --rm -v $(pwd):/app wails-cross windows arm64 + +FROM golang:1.26.7-bookworm + +ARG TARGETARCH + +# Install base tools, GCC, and GTK/WebKit dev packages +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl xz-utils nodejs npm pkg-config gcc libc6-dev \ + libgtk-3-dev libwebkit2gtk-4.1-dev \ + libgtk-4-dev libwebkitgtk-6.0-dev \ + && rm -rf /var/lib/apt/lists/* + +# Install Zig - automatically selects correct binary for host architecture +ARG ZIG_VERSION=0.14.0 +RUN ZIG_ARCH=$(case "${TARGETARCH}" in arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \ + curl -L "https://ziglang.org/download/${ZIG_VERSION}/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}.tar.xz" \ + | tar -xJ -C /opt \ + && ln -s /opt/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}/zig /usr/local/bin/zig + +# Download macOS SDK (required for darwin targets) +ARG MACOS_SDK_VERSION=14.5 +RUN curl -L "https://github.com/joseluisq/macosx-sdks/releases/download/${MACOS_SDK_VERSION}/MacOSX${MACOS_SDK_VERSION}.sdk.tar.xz" \ + | tar -xJ -C /opt \ + && mv /opt/MacOSX${MACOS_SDK_VERSION}.sdk /opt/macos-sdk + +ENV MACOS_SDK_PATH=/opt/macos-sdk + +# Create Zig CC wrappers for cross-compilation targets +# Darwin and Windows use Zig; Linux uses native GCC (run with --platform for cross-arch) + +# Darwin arm64 +COPY <<'ZIGWRAP' /usr/local/bin/zcc-darwin-arm64 +#!/bin/sh +ARGS="" +SKIP_NEXT=0 +for arg in "$@"; do + if [ $SKIP_NEXT -eq 1 ]; then + SKIP_NEXT=0 + continue + fi + case "$arg" in + -target) SKIP_NEXT=1 ;; + -mmacosx-version-min=*) ;; + *) ARGS="$ARGS $arg" ;; + esac +done +exec zig cc -fno-sanitize=all -target aarch64-macos-none -isysroot /opt/macos-sdk -I/opt/macos-sdk/usr/include -L/opt/macos-sdk/usr/lib -F/opt/macos-sdk/System/Library/Frameworks -w $ARGS +ZIGWRAP +RUN chmod +x /usr/local/bin/zcc-darwin-arm64 + +# Darwin amd64 +COPY <<'ZIGWRAP' /usr/local/bin/zcc-darwin-amd64 +#!/bin/sh +ARGS="" +SKIP_NEXT=0 +for arg in "$@"; do + if [ $SKIP_NEXT -eq 1 ]; then + SKIP_NEXT=0 + continue + fi + case "$arg" in + -target) SKIP_NEXT=1 ;; + -mmacosx-version-min=*) ;; + *) ARGS="$ARGS $arg" ;; + esac +done +exec zig cc -fno-sanitize=all -target x86_64-macos-none -isysroot /opt/macos-sdk -I/opt/macos-sdk/usr/include -L/opt/macos-sdk/usr/lib -F/opt/macos-sdk/System/Library/Frameworks -w $ARGS +ZIGWRAP +RUN chmod +x /usr/local/bin/zcc-darwin-amd64 + +# Windows amd64 - uses Zig's bundled mingw +COPY <<'ZIGWRAP' /usr/local/bin/zcc-windows-amd64 +#!/bin/sh +ARGS="" +SKIP_NEXT=0 +for arg in "$@"; do + if [ $SKIP_NEXT -eq 1 ]; then + SKIP_NEXT=0 + continue + fi + case "$arg" in + -target) SKIP_NEXT=1 ;; + -Wl,*) ;; + *) ARGS="$ARGS $arg" ;; + esac +done +exec zig cc -target x86_64-windows-gnu $ARGS +ZIGWRAP +RUN chmod +x /usr/local/bin/zcc-windows-amd64 + +# Windows arm64 - uses Zig's bundled mingw +COPY <<'ZIGWRAP' /usr/local/bin/zcc-windows-arm64 +#!/bin/sh +ARGS="" +SKIP_NEXT=0 +for arg in "$@"; do + if [ $SKIP_NEXT -eq 1 ]; then + SKIP_NEXT=0 + continue + fi + case "$arg" in + -target) SKIP_NEXT=1 ;; + -Wl,*) ;; + *) ARGS="$ARGS $arg" ;; + esac +done +exec zig cc -target aarch64-windows-gnu $ARGS +ZIGWRAP +RUN chmod +x /usr/local/bin/zcc-windows-arm64 + +# Build script +COPY <<'SCRIPT' /usr/local/bin/build.sh +#!/bin/sh +set -e + +OS=${1:-darwin} +ARCH=${2:-arm64} + +case "${OS}-${ARCH}" in + darwin-arm64|darwin-aarch64) + export CC=zcc-darwin-arm64 + export GOARCH=arm64 + export GOOS=darwin + ;; + darwin-amd64|darwin-x86_64) + export CC=zcc-darwin-amd64 + export GOARCH=amd64 + export GOOS=darwin + ;; + linux-arm64|linux-aarch64) + export CC=gcc + export GOARCH=arm64 + export GOOS=linux + ;; + linux-amd64|linux-x86_64) + export CC=gcc + export GOARCH=amd64 + export GOOS=linux + ;; + windows-arm64|windows-aarch64) + export CC=zcc-windows-arm64 + export GOARCH=arm64 + export GOOS=windows + ;; + windows-amd64|windows-x86_64) + export CC=zcc-windows-amd64 + export GOARCH=amd64 + export GOOS=windows + ;; + *) + echo "Usage: " + echo " os: darwin, linux, windows" + echo " arch: amd64, arm64" + exit 1 + ;; +esac + +export CGO_ENABLED=1 +export CGO_CFLAGS="-w" + +# Build frontend if exists and not already built (host may have built it) +if [ -d "frontend" ] && [ -f "frontend/package.json" ] && [ ! -d "frontend/dist" ]; then + (cd frontend && npm install --silent && npm run build --silent) +fi + +# Build +APP=${APP_NAME:-$(basename $(pwd))} +mkdir -p bin + +EXT="" +LDFLAGS="-s -w" +if [ "$GOOS" = "windows" ]; then + EXT=".exe" + LDFLAGS="-s -w -H windowsgui" +fi + +TAGS="production" +if [ -n "$EXTRA_TAGS" ]; then + TAGS="${TAGS},${EXTRA_TAGS}" +fi + +go build -tags "$TAGS" -trimpath -ldflags="$LDFLAGS" -o bin/${APP}-${GOOS}-${GOARCH}${EXT} . +echo "Built: bin/${APP}-${GOOS}-${GOARCH}${EXT}" +SCRIPT +RUN chmod +x /usr/local/bin/build.sh + +WORKDIR /app +ENTRYPOINT ["/usr/local/bin/build.sh"] +CMD ["darwin", "arm64"] diff --git a/client/ui/build/docker/Dockerfile.server b/client/ui/build/docker/Dockerfile.server new file mode 100644 index 000000000..57183f1d2 --- /dev/null +++ b/client/ui/build/docker/Dockerfile.server @@ -0,0 +1,41 @@ +# Wails Server Mode Dockerfile +# Multi-stage build for minimal image size + +# Build stage +FROM golang:1.26.7-alpine AS builder + +WORKDIR /app + +# Install build dependencies +RUN apk add --no-cache git + +# Copy source code +COPY . . + +# Remove local replace directive if present (for production builds) +RUN sed -i '/^replace/d' go.mod || true + +# Download dependencies +RUN go mod tidy + +# Build the server binary +RUN go build -tags server -ldflags="-s -w" -o server . + +# Runtime stage - minimal image +FROM gcr.io/distroless/static-debian12 + +# Copy the binary +COPY --from=builder /app/server /server + +# Copy frontend assets +COPY --from=builder /app/frontend/dist /frontend/dist + +# Expose the default port +EXPOSE 8080 + +# Bind to all interfaces (required for Docker) +# Can be overridden at runtime with -e WAILS_SERVER_HOST=... +ENV WAILS_SERVER_HOST=0.0.0.0 + +# Run the server +ENTRYPOINT ["/server"] diff --git a/client/ui/build/linux/Taskfile.yml b/client/ui/build/linux/Taskfile.yml new file mode 100644 index 000000000..94d041375 --- /dev/null +++ b/client/ui/build/linux/Taskfile.yml @@ -0,0 +1,235 @@ +version: '3' + +includes: + common: ../Taskfile.yml + +vars: + # Signing configuration - edit these values for your project + # PGP_KEY: "path/to/signing-key.asc" + # SIGN_ROLE: "builder" # Options: origin, maint, archive, builder + # + # Password is stored securely in system keychain. Run: wails3 setup signing + + # Docker image for cross-compilation (used when building on non-Linux or no CC available) + CROSS_IMAGE: wails-cross + +tasks: + build: + summary: Builds the application for Linux + cmds: + # Linux requires CGO - use Docker when: + # 1. Cross-compiling from non-Linux, OR + # 2. No C compiler is available, OR + # 3. Target architecture differs from host architecture (cross-arch compilation) + - task: '{{if and (eq OS "linux") (eq .HAS_CC "true") (eq .TARGET_ARCH ARCH)}}build:native{{else}}build:docker{{end}}' + vars: + ARCH: '{{.ARCH}}' + DEV: '{{.DEV}}' + OUTPUT: '{{.OUTPUT}}' + EXTRA_TAGS: '{{.EXTRA_TAGS}}' + vars: + DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}' + OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}' + # Determine target architecture (defaults to host ARCH if not specified) + TARGET_ARCH: '{{.ARCH | default ARCH}}' + # Check if a C compiler is available (gcc or clang) + HAS_CC: + sh: '(command -v gcc >/dev/null 2>&1 || command -v clang >/dev/null 2>&1) && echo "true" || echo "false"' + + build:native: + summary: Builds the application natively on Linux + internal: true + deps: + - task: common:go:mod:tidy + - task: common:build:frontend + vars: + BUILD_FLAGS: + ref: .BUILD_FLAGS + DEV: + ref: .DEV + - task: common:generate:icons + - task: generate:dotdesktop + cmds: + - go build {{.BUILD_FLAGS}} -o {{.OUTPUT}} + vars: + BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-gcflags=all="-l"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -ldflags="-w -s"{{end}}' + DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}' + OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}' + env: + GOOS: linux + CGO_ENABLED: 1 + GOARCH: '{{.ARCH | default ARCH}}' + + build:docker: + summary: Builds for Linux using Docker (for non-Linux hosts or when no C compiler available) + internal: true + deps: + - task: common:build:frontend + - task: common:generate:icons + - task: generate:dotdesktop + preconditions: + - sh: docker info > /dev/null 2>&1 + msg: "Docker is required for cross-compilation to Linux. Please install Docker." + - sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1 + msg: | + Docker image '{{.CROSS_IMAGE}}' not found. + Build it first: wails3 task setup:docker + cmds: + - docker run --rm -v "{{.ROOT_DIR}}:/app" {{.GO_CACHE_MOUNT}} {{.REPLACE_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} "{{.CROSS_IMAGE}}" linux {{.DOCKER_ARCH}} + - docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin + - mkdir -p {{.BIN_DIR}} + - mv "bin/{{.APP_NAME}}-linux-{{.DOCKER_ARCH}}" "{{.OUTPUT}}" + vars: + DOCKER_ARCH: '{{.ARCH | default "amd64"}}' + DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}' + OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}' + # Mount Go module cache for faster builds + GO_CACHE_MOUNT: + sh: 'echo "-v ${GOPATH:-$HOME/go}/pkg/mod:/go/pkg/mod"' + # Extract replace directives from go.mod and create -v mounts for each + REPLACE_MOUNTS: + sh: | + grep -E '^replace .* => ' go.mod 2>/dev/null | while read -r line; do + path=$(echo "$line" | sed -E 's/^replace .* => //' | tr -d '\r') + # Convert relative paths to absolute + if [ "${path#/}" = "$path" ]; then + path="$(cd "$(dirname "$path")" 2>/dev/null && pwd)/$(basename "$path")" + fi + # Only mount if directory exists + if [ -d "$path" ]; then + echo "-v $path:$path:ro" + fi + done | tr '\n' ' ' + + package: + summary: Packages the application for Linux + deps: + - task: build + cmds: + - task: create:appimage + - task: create:deb + - task: create:rpm + - task: create:aur + + create:appimage: + summary: Creates an AppImage + dir: build/linux/appimage + deps: + - task: build + - task: generate:dotdesktop + cmds: + - cp "{{.APP_BINARY}}" "{{.APP_NAME}}" + - cp ../../appicon.png "{{.APP_NAME}}.png" + - wails3 generate appimage -binary "{{.APP_NAME}}" -icon {{.ICON}} -desktopfile {{.DESKTOP_FILE}} -outputdir {{.OUTPUT_DIR}} -builddir {{.ROOT_DIR}}/build/linux/appimage/build + vars: + APP_NAME: '{{.APP_NAME}}' + APP_BINARY: '../../../bin/{{.APP_NAME}}' + ICON: '{{.APP_NAME}}.png' + DESKTOP_FILE: '../{{.APP_NAME}}.desktop' + OUTPUT_DIR: '../../../bin' + + create:deb: + summary: Creates a deb package + deps: + - task: build + cmds: + - task: generate:dotdesktop + - task: generate:deb + + create:rpm: + summary: Creates a rpm package + deps: + - task: build + cmds: + - task: generate:dotdesktop + - task: generate:rpm + + create:aur: + summary: Creates a arch linux packager package + deps: + - task: build + cmds: + - task: generate:dotdesktop + - task: generate:aur + + generate:deb: + summary: Creates a deb package + cmds: + - wails3 tool package -name "{{.APP_NAME}}" -format deb -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin + + generate:rpm: + summary: Creates a rpm package + cmds: + - wails3 tool package -name "{{.APP_NAME}}" -format rpm -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin + + generate:aur: + summary: Creates a arch linux packager package + cmds: + - wails3 tool package -name "{{.APP_NAME}}" -format archlinux -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin + + generate:dotdesktop: + summary: Generates a `.desktop` file + dir: build + cmds: + - mkdir -p {{.ROOT_DIR}}/build/linux/appimage + - wails3 generate .desktop -name "{{.APP_NAME}}" -exec "{{.EXEC}}" -icon "{{.ICON}}" -outputfile "{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop" -categories "{{.CATEGORIES}}" + # Wrap Exec= with `env WEBKIT_DISABLE_DMABUF_RENDERER=1 ...` so launches + # from any desktop environment use the working renderer. See build/linux/Taskfile.yml :run for the matching dev-mode env block. + - sed -i -E 's|^Exec=([^ ]+)(.*)$|Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 \1\2|' {{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop + vars: + APP_NAME: '{{.APP_NAME}}' + EXEC: '{{.APP_NAME}}' + ICON: '{{.APP_NAME}}' + CATEGORIES: 'Development;' + OUTPUTFILE: '{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop' + + run: + cmds: + - '{{.BIN_DIR}}/{{.APP_NAME}}' + env: + # WebKitGTK 2.50's default DMA-BUF renderer fails on RDP, VirtualBox/QEMU, + # and some bare WMs (Fluxbox, dwm) where DRM dumb-buffer access is + # restricted. Disabling it falls back to the GLES2/cairo path which works + # everywhere. Production launchers must set this too. + WEBKIT_DISABLE_DMABUF_RENDERER: "1" + + sign:deb: + summary: Signs the DEB package + desc: | + Signs the .deb package with a PGP key. + Configure PGP_KEY in the vars section at the top of this file. + Password is retrieved from system keychain (run: wails3 setup signing) + deps: + - task: create:deb + cmds: + - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}*.deb" --pgp-key {{.PGP_KEY}} {{if .SIGN_ROLE}}--role {{.SIGN_ROLE}}{{end}} + preconditions: + - sh: '[ -n "{{.PGP_KEY}}" ]' + msg: "PGP_KEY is required. Set it in the vars section at the top of build/linux/Taskfile.yml" + + sign:rpm: + summary: Signs the RPM package + desc: | + Signs the .rpm package with a PGP key. + Configure PGP_KEY in the vars section at the top of this file. + Password is retrieved from system keychain (run: wails3 setup signing) + deps: + - task: create:rpm + cmds: + - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}*.rpm" --pgp-key {{.PGP_KEY}} + preconditions: + - sh: '[ -n "{{.PGP_KEY}}" ]' + msg: "PGP_KEY is required. Set it in the vars section at the top of build/linux/Taskfile.yml" + + sign:packages: + summary: Signs all Linux packages (DEB and RPM) + desc: | + Signs both .deb and .rpm packages with a PGP key. + Configure PGP_KEY in the vars section at the top of this file. + Password is retrieved from system keychain (run: wails3 setup signing) + cmds: + - task: sign:deb + - task: sign:rpm + preconditions: + - sh: '[ -n "{{.PGP_KEY}}" ]' + msg: "PGP_KEY is required. Set it in the vars section at the top of build/linux/Taskfile.yml" diff --git a/client/ui/build/linux/appimage/build.sh b/client/ui/build/linux/appimage/build.sh new file mode 100644 index 000000000..85901c34e --- /dev/null +++ b/client/ui/build/linux/appimage/build.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Copyright (c) 2018-Present Lea Anthony +# SPDX-License-Identifier: MIT + +# Fail script on any error +set -euxo pipefail + +# Define variables +APP_DIR="${APP_NAME}.AppDir" + +# Create AppDir structure +mkdir -p "${APP_DIR}/usr/bin" +cp -r "${APP_BINARY}" "${APP_DIR}/usr/bin/" +cp "${ICON_PATH}" "${APP_DIR}/" +cp "${DESKTOP_FILE}" "${APP_DIR}/" + +if [[ $(uname -m) == *x86_64* ]]; then + # Download linuxdeploy and make it executable + wget -q -4 -N https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage + chmod +x linuxdeploy-x86_64.AppImage + + # Run linuxdeploy to bundle the application + ./linuxdeploy-x86_64.AppImage --appdir "${APP_DIR}" --output appimage +else + # Download linuxdeploy and make it executable (arm64) + wget -q -4 -N https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-aarch64.AppImage + chmod +x linuxdeploy-aarch64.AppImage + + # Run linuxdeploy to bundle the application (arm64) + ./linuxdeploy-aarch64.AppImage --appdir "${APP_DIR}" --output appimage +fi + +# Rename the generated AppImage +mv "${APP_NAME}*.AppImage" "${APP_NAME}.AppImage" + diff --git a/client/ui/build/linux/desktop b/client/ui/build/linux/desktop new file mode 100644 index 000000000..deadfe9f4 --- /dev/null +++ b/client/ui/build/linux/desktop @@ -0,0 +1,13 @@ +[Desktop Entry] +Version=1.0 +Name=NetBird +Comment=NetBird desktop client +# The Exec line includes %u to pass the URL to the application +Exec=/usr/local/bin/netbird-ui %u +Terminal=false +Type=Application +Icon=netbird-ui +Categories=Utility; +StartupWMClass=netbird-ui + + diff --git a/client/ui/build/linux/netbird-ui.desktop b/client/ui/build/linux/netbird-ui.desktop new file mode 100755 index 000000000..6b6ed42a5 --- /dev/null +++ b/client/ui/build/linux/netbird-ui.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=netbird-ui +Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 netbird-ui +Icon=netbird-ui +Categories=Development; +Terminal=false +Keywords=wails +Version=1.0 +StartupNotify=false diff --git a/client/ui/build/linux/netbird.desktop b/client/ui/build/linux/netbird.desktop new file mode 100644 index 000000000..0d43b62a2 --- /dev/null +++ b/client/ui/build/linux/netbird.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Name=NetBird +Comment=NetBird desktop client +Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 /usr/bin/netbird-ui +Icon=netbird +Type=Application +Terminal=false +Categories=Utility; +Keywords=netbird; +StartupWMClass=org.wails.netbird \ No newline at end of file diff --git a/client/ui/build/linux/nfpm/nfpm.yaml b/client/ui/build/linux/nfpm/nfpm.yaml new file mode 100644 index 000000000..764855a63 --- /dev/null +++ b/client/ui/build/linux/nfpm/nfpm.yaml @@ -0,0 +1,70 @@ +# Feel free to remove those if you don't want/need to use them. +# Make sure to check the documentation at https://nfpm.goreleaser.com +# +# The lines below are called `modelines`. See `:help modeline` + +name: "netbird-ui" +arch: ${GOARCH} +platform: "linux" +version: "0.0.1" +section: "default" +priority: "extra" +maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}> +description: "NetBird desktop client" +vendor: "NetBird" +homepage: "https://wails.io" +license: "MIT" +release: "1" + +contents: + - src: "./bin/netbird-ui" + dst: "/usr/local/bin/netbird-ui" + - src: "./build/appicon.png" + dst: "/usr/share/icons/hicolor/128x128/apps/netbird-ui.png" + - src: "./build/linux/netbird-ui.desktop" + dst: "/usr/share/applications/netbird-ui.desktop" + +# Default dependencies for the GTK4 + WebKitGTK 6.0 stack (Ubuntu 24.04+ / Debian 13+) +depends: + - libgtk-4-1 (>= 4.14) + - libwebkitgtk-6.0-4 + - xdg-utils + +# Distribution-specific overrides for different package formats +overrides: + # RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux / openSUSE + rpm: + depends: + - (gtk4 >= 4.14 or libgtk-4-1 >= 4.14) + - (webkitgtk6.0 or libwebkitgtk-6_0-4) + - xdg-utils + + # Arch Linux packages + archlinux: + depends: + - gtk4 + - webkitgtk-6.0 + - xdg-utils + +# scripts section to ensure desktop database is updated after install +scripts: + postinstall: "./build/linux/nfpm/scripts/postinstall.sh" + # You can also add preremove, postremove if needed + # preremove: "./build/linux/nfpm/scripts/preremove.sh" + # postremove: "./build/linux/nfpm/scripts/postremove.sh" + +# replaces: +# - foobar +# provides: +# - bar +# depends: +# - gtk3 +# - libwebkit2gtk +# recommends: +# - whatever +# suggests: +# - something-else +# conflicts: +# - not-foo +# - not-bar +# changelog: "changelog.yaml" diff --git a/client/ui/build/linux/nfpm/scripts/postinstall.sh b/client/ui/build/linux/nfpm/scripts/postinstall.sh new file mode 100644 index 000000000..4bbb815a3 --- /dev/null +++ b/client/ui/build/linux/nfpm/scripts/postinstall.sh @@ -0,0 +1,21 @@ +#!/bin/sh + +# Update desktop database for .desktop file changes +# This makes the application appear in application menus and registers its capabilities. +if command -v update-desktop-database >/dev/null 2>&1; then + echo "Updating desktop database..." + update-desktop-database -q /usr/share/applications +else + echo "Warning: update-desktop-database command not found. Desktop file may not be immediately recognized." >&2 +fi + +# Update MIME database for custom URL schemes (x-scheme-handler) +# This ensures the system knows how to handle your custom protocols. +if command -v update-mime-database >/dev/null 2>&1; then + echo "Updating MIME database..." + update-mime-database -n /usr/share/mime +else + echo "Warning: update-mime-database command not found. Custom URL schemes may not be immediately recognized." >&2 +fi + +exit 0 diff --git a/client/ui/build/linux/nfpm/scripts/postremove.sh b/client/ui/build/linux/nfpm/scripts/postremove.sh new file mode 100644 index 000000000..a9bf588e2 --- /dev/null +++ b/client/ui/build/linux/nfpm/scripts/postremove.sh @@ -0,0 +1 @@ +#!/bin/bash diff --git a/client/ui/build/linux/nfpm/scripts/preinstall.sh b/client/ui/build/linux/nfpm/scripts/preinstall.sh new file mode 100644 index 000000000..a9bf588e2 --- /dev/null +++ b/client/ui/build/linux/nfpm/scripts/preinstall.sh @@ -0,0 +1 @@ +#!/bin/bash diff --git a/client/ui/build/linux/nfpm/scripts/preremove.sh b/client/ui/build/linux/nfpm/scripts/preremove.sh new file mode 100644 index 000000000..a9bf588e2 --- /dev/null +++ b/client/ui/build/linux/nfpm/scripts/preremove.sh @@ -0,0 +1 @@ +#!/bin/bash diff --git a/client/ui/build/linux/polkit/io.netbird.settings.policy b/client/ui/build/linux/polkit/io.netbird.settings.policy new file mode 100644 index 000000000..e12f1ddc7 --- /dev/null +++ b/client/ui/build/linux/polkit/io.netbird.settings.policy @@ -0,0 +1,47 @@ + + + + + + NetBird + https://netbird.io + + + Change privileged NetBird settings + Authentication is required to change NetBird settings that grant SSH access to this computer. + netbird + + auth_admin + auth_admin + auth_admin + + /usr/bin/netbird-ui + --apply-privileged-settings + + + + Change privileged NetBird settings + Authentication is required to change NetBird settings that grant SSH access to this computer. + netbird + + auth_admin + auth_admin + auth_admin + + /usr/local/bin/netbird-ui + --apply-privileged-settings + + diff --git a/client/ui/build/netbird.desktop b/client/ui/build/netbird.desktop deleted file mode 100644 index b3a1b92dc..000000000 --- a/client/ui/build/netbird.desktop +++ /dev/null @@ -1,8 +0,0 @@ -[Desktop Entry] -Name=Netbird -Exec=/usr/bin/netbird-ui -Icon=netbird -Type=Application -Terminal=false -Categories=Utility; -Keywords=netbird; diff --git a/client/ui/build/windows/Taskfile.yml b/client/ui/build/windows/Taskfile.yml new file mode 100644 index 000000000..f51f7fbee --- /dev/null +++ b/client/ui/build/windows/Taskfile.yml @@ -0,0 +1,243 @@ +version: '3' + +includes: + common: ../Taskfile.yml + +vars: + # Signing configuration - edit these values for your project + # SIGN_CERTIFICATE: "path/to/certificate.pfx" + # SIGN_THUMBPRINT: "certificate-thumbprint" # Alternative to SIGN_CERTIFICATE + # TIMESTAMP_SERVER: "http://timestamp.digicert.com" + # + # Password is stored securely in system keychain. Run: wails3 setup signing + + # Docker image for cross-compilation with CGO (used when CGO_ENABLED=1 on non-Windows) + CROSS_IMAGE: wails-cross + +tasks: + build: + summary: Builds the application for Windows + cmds: + # CGO Windows builds from Linux use mingw-w64 (lighter than docker). + # Docker is only needed if mingw-w64 is unavailable. + - task: build:native + vars: + ARCH: '{{.ARCH}}' + DEV: '{{.DEV}}' + EXTRA_TAGS: '{{.EXTRA_TAGS}}' + vars: + CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}' + + build:console: + summary: Builds a console-attached Windows binary so logs go to the terminal. + desc: | + Same as `windows:build` but links against the console PE subsystem + instead of windowsgui, so stdout/stderr (logrus, panics) print to the + terminal that launched the .exe. Useful for chasing tray, event-stream, + or daemon-RPC bugs that have no other feedback channel on Windows. + + Output is bin/netbird-ui-console.exe — kept distinct so the production + binary built by `windows:build` isn't shadowed. + + Cross-compile from Linux works the same way: + CGO_ENABLED=1 task windows:build:console + + Pass DEV=true to drop the `production` build tag so the WebKit/WebView2 + DevTools inspector (right-click → Inspect, or F12) stays enabled and the + frontend JS console is reachable — same DEV handling as windows:build: + CGO_ENABLED=1 task windows:build:console DEV=true + deps: + - task: common:go:mod:tidy + - task: common:build:frontend + vars: + BUILD_FLAGS: + ref: .BUILD_FLAGS + DEV: + ref: .DEV + - task: common:generate:icons + preconditions: + - sh: '[ "{{OS}}" = "windows" ] || [ "{{.CGO_ENABLED}}" != "1" ] || command -v {{.CC}}' + msg: "{{.CC}} not found. Install with: sudo apt-get install gcc-mingw-w64-x86-64 (Debian/Ubuntu) / sudo dnf install mingw64-gcc (Fedora)" + cmds: + - task: generate:syso + - go build {{.BUILD_FLAGS}} -o "{{.BIN_DIR}}/{{.APP_NAME}}-console.exe" + - cmd: powershell Remove-item *.syso + platforms: [windows] + - cmd: rm -f *.syso + platforms: [linux, darwin] + vars: + # Identical to build:native's flags (including DEV handling) except no + # -H windowsgui, so the binary attaches to the launching console. With + # DEV=true the `production` tag is dropped, keeping the WebKit/WebView2 + # DevTools inspector enabled so the frontend JS console is reachable. + BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-gcflags=all="-l"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -ldflags="-w -s"{{end}}' + CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}' + CC: '{{.CC | default "x86_64-w64-mingw32-gcc"}}' + env: + GOOS: windows + CGO_ENABLED: '{{.CGO_ENABLED}}' + GOARCH: '{{.ARCH | default ARCH}}' + CC: '{{.CC}}' + + build:native: + summary: Builds for Windows natively, or cross-compiles from Linux/macOS via mingw-w64. + internal: true + deps: + - task: common:go:mod:tidy + - task: common:build:frontend + vars: + BUILD_FLAGS: + ref: .BUILD_FLAGS + DEV: + ref: .DEV + - task: common:generate:icons + preconditions: + # When cross-compiling with CGO from a non-Windows host, the mingw-w64 + # cross-gcc must be present. Native Windows builds skip this check. + - sh: '[ "{{OS}}" = "windows" ] || [ "{{.CGO_ENABLED}}" != "1" ] || command -v {{.CC}}' + msg: "{{.CC}} not found. Install with: sudo apt-get install gcc-mingw-w64-x86-64 (Debian/Ubuntu) / sudo dnf install mingw64-gcc (Fedora)" + cmds: + - task: generate:syso + - go build {{.BUILD_FLAGS}} -o "{{.BIN_DIR}}/{{.APP_NAME}}.exe" + - cmd: powershell Remove-item *.syso + platforms: [windows] + - cmd: rm -f *.syso + platforms: [linux, darwin] + vars: + BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-gcflags=all="-l"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -ldflags="-w -s -H windowsgui"{{end}}' + CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}' + CC: '{{.CC | default "x86_64-w64-mingw32-gcc"}}' + env: + GOOS: windows + CGO_ENABLED: '{{.CGO_ENABLED}}' + GOARCH: '{{.ARCH | default ARCH}}' + CC: '{{.CC}}' + + build:docker: + summary: Cross-compiles for Windows using Docker with Zig (for CGO builds on non-Windows) + internal: true + deps: + - task: common:build:frontend + - task: common:generate:icons + preconditions: + - sh: docker info > /dev/null 2>&1 + msg: "Docker is required for CGO cross-compilation. Please install Docker." + - sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1 + msg: | + Docker image '{{.CROSS_IMAGE}}' not found. + Build it first: wails3 task setup:docker + cmds: + - task: generate:syso + - docker run --rm -v "{{.ROOT_DIR}}:/app" {{.GO_CACHE_MOUNT}} {{.REPLACE_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} {{.CROSS_IMAGE}} windows {{.DOCKER_ARCH}} + - docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin + - rm -f *.syso + vars: + DOCKER_ARCH: '{{.ARCH | default "amd64"}}' + # Mount Go module cache for faster builds + GO_CACHE_MOUNT: + sh: 'echo "-v ${GOPATH:-$HOME/go}/pkg/mod:/go/pkg/mod"' + # Extract replace directives from go.mod and create -v mounts for each + REPLACE_MOUNTS: + sh: | + grep -E '^replace .* => ' go.mod 2>/dev/null | while read -r line; do + path=$(echo "$line" | sed -E 's/^replace .* => //' | tr -d '\r') + # Convert relative paths to absolute + if [ "${path#/}" = "$path" ]; then + path="$(cd "$(dirname "$path")" 2>/dev/null && pwd)/$(basename "$path")" + fi + # Only mount if directory exists + if [ -d "$path" ]; then + echo "-v $path:$path:ro" + fi + done | tr '\n' ' ' + + package: + summary: Packages the application + cmds: + - task: '{{if eq (.FORMAT | default "nsis") "msix"}}create:msix:package{{else}}create:nsis:installer{{end}}' + vars: + FORMAT: '{{.FORMAT | default "nsis"}}' + + generate:syso: + summary: Generates Windows `.syso` file + dir: build + cmds: + - wails3 generate syso -arch {{.ARCH}} -icon windows/icon.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_{{.ARCH}}.syso + vars: + ARCH: '{{.ARCH | default ARCH}}' + + create:nsis:installer: + summary: Creates an NSIS installer + dir: build/windows/nsis + deps: + - task: build + cmds: + # Create the Microsoft WebView2 bootstrapper if it doesn't exist + - wails3 generate webview2bootstrapper -dir "{{.ROOT_DIR}}/build/windows/nsis" + - | + {{if eq OS "windows"}} + makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}\{{.BIN_DIR}}\{{.APP_NAME}}.exe" project.nsi + {{else}} + makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" project.nsi + {{end}} + vars: + ARCH: '{{.ARCH | default ARCH}}' + ARG_FLAG: '{{if eq .ARCH "amd64"}}AMD64{{else}}ARM64{{end}}' + + create:msix:package: + summary: Creates an MSIX package + deps: + - task: build + cmds: + - |- + wails3 tool msix \ + --config "{{.ROOT_DIR}}/wails.json" \ + --name "{{.APP_NAME}}" \ + --executable "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" \ + --arch "{{.ARCH}}" \ + --out "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}-{{.ARCH}}.msix" \ + {{if .CERT_PATH}}--cert "{{.CERT_PATH}}"{{end}} \ + {{if .PUBLISHER}}--publisher "{{.PUBLISHER}}"{{end}} \ + {{if .USE_MSIX_TOOL}}--use-msix-tool{{else}}--use-makeappx{{end}} + vars: + ARCH: '{{.ARCH | default ARCH}}' + CERT_PATH: '{{.CERT_PATH | default ""}}' + PUBLISHER: '{{.PUBLISHER | default ""}}' + USE_MSIX_TOOL: '{{.USE_MSIX_TOOL | default "false"}}' + + install:msix:tools: + summary: Installs tools required for MSIX packaging + cmds: + - wails3 tool msix-install-tools + + run: + cmds: + - '{{.BIN_DIR}}/{{.APP_NAME}}.exe' + + sign: + summary: Signs the Windows executable + desc: | + Signs the .exe with an Authenticode certificate. + Configure SIGN_CERTIFICATE or SIGN_THUMBPRINT in the vars section at the top of this file. + Password is retrieved from system keychain (run: wails3 setup signing) + deps: + - task: build + cmds: + - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.exe" {{if .SIGN_CERTIFICATE}}--certificate {{.SIGN_CERTIFICATE}}{{end}} {{if .SIGN_THUMBPRINT}}--thumbprint {{.SIGN_THUMBPRINT}}{{end}} {{if .TIMESTAMP_SERVER}}--timestamp {{.TIMESTAMP_SERVER}}{{end}} + preconditions: + - sh: '[ -n "{{.SIGN_CERTIFICATE}}" ] || [ -n "{{.SIGN_THUMBPRINT}}" ]' + msg: "Either SIGN_CERTIFICATE or SIGN_THUMBPRINT is required. Set it in the vars section at the top of build/windows/Taskfile.yml" + + sign:installer: + summary: Signs the NSIS installer + desc: | + Creates and signs the NSIS installer. + Configure SIGN_CERTIFICATE or SIGN_THUMBPRINT in the vars section at the top of this file. + Password is retrieved from system keychain (run: wails3 setup signing) + deps: + - task: create:nsis:installer + cmds: + - wails3 tool sign --input "build/windows/nsis/{{.APP_NAME}}-installer.exe" {{if .SIGN_CERTIFICATE}}--certificate {{.SIGN_CERTIFICATE}}{{end}} {{if .SIGN_THUMBPRINT}}--thumbprint {{.SIGN_THUMBPRINT}}{{end}} {{if .TIMESTAMP_SERVER}}--timestamp {{.TIMESTAMP_SERVER}}{{end}} + preconditions: + - sh: '[ -n "{{.SIGN_CERTIFICATE}}" ] || [ -n "{{.SIGN_THUMBPRINT}}" ]' + msg: "Either SIGN_CERTIFICATE or SIGN_THUMBPRINT is required. Set it in the vars section at the top of build/windows/Taskfile.yml" diff --git a/client/ui/build/windows/icon.ico b/client/ui/build/windows/icon.ico new file mode 100644 index 000000000..7abbfa5a3 Binary files /dev/null and b/client/ui/build/windows/icon.ico differ diff --git a/client/ui/build/windows/info.json b/client/ui/build/windows/info.json new file mode 100644 index 000000000..a67c8fd81 --- /dev/null +++ b/client/ui/build/windows/info.json @@ -0,0 +1,15 @@ +{ + "fixed": { + "file_version": "0.0.1" + }, + "info": { + "0000": { + "ProductVersion": "0.0.1", + "CompanyName": "NetBird", + "FileDescription": "NetBird desktop client", + "LegalCopyright": "NetBird GmbH", + "ProductName": "NetBird", + "Comments": "This is a comment" + } + } +} \ No newline at end of file diff --git a/client/ui/build/windows/msix/app_manifest.xml b/client/ui/build/windows/msix/app_manifest.xml new file mode 100644 index 000000000..0ae55ce77 --- /dev/null +++ b/client/ui/build/windows/msix/app_manifest.xml @@ -0,0 +1,55 @@ + + + + + + + NetBird + NetBird + NetBird desktop client + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/ui/build/windows/msix/template.xml b/client/ui/build/windows/msix/template.xml new file mode 100644 index 000000000..437a68097 --- /dev/null +++ b/client/ui/build/windows/msix/template.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + false + NetBird + NetBird + NetBird desktop client + Assets\AppIcon.png + + + + + + + diff --git a/client/ui/build/windows/nsis/project.nsi b/client/ui/build/windows/nsis/project.nsi new file mode 100644 index 000000000..8d2530972 --- /dev/null +++ b/client/ui/build/windows/nsis/project.nsi @@ -0,0 +1,114 @@ +Unicode true + +#### +## Please note: Template replacements don't work in this file. They are provided with default defines like +## mentioned underneath. +## If the keyword is not defined, "wails_tools.nsh" will populate them. +## If they are defined here, "wails_tools.nsh" will not touch them. This allows you to use this project.nsi manually +## from outside of Wails for debugging and development of the installer. +## +## For development first make a wails nsis build to populate the "wails_tools.nsh": +## > wails build --target windows/amd64 --nsis +## Then you can call makensis on this file with specifying the path to your binary: +## For a AMD64 only installer: +## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe +## For a ARM64 only installer: +## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe +## For a installer with both architectures: +## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe +#### +## The following information is taken from the wails_tools.nsh file, but they can be overwritten here. +#### +## !define INFO_PROJECTNAME "my-project" # Default "netbird-ui" +## !define INFO_COMPANYNAME "My Company" # Default "NetBird" +## !define INFO_PRODUCTNAME "My Product Name" # Default "NetBird" +## !define INFO_PRODUCTVERSION "1.0.0" # Default "0.0.1" +## !define INFO_COPYRIGHT "(c) Now, My Company" # Default "© 2026, My Company" +### +## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe" +## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}" +#### +## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html +#### +## Include the wails tools +#### +!include "wails_tools.nsh" + +# The version information for this two must consist of 4 parts +VIProductVersion "${INFO_PRODUCTVERSION}.0" +VIFileVersion "${INFO_PRODUCTVERSION}.0" + +VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}" +VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer" +VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}" +VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}" +VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}" +VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}" + +# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware +ManifestDPIAware true + +!include "MUI.nsh" + +!define MUI_ICON "..\icon.ico" +!define MUI_UNICON "..\icon.ico" +# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314 +!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps +!define MUI_ABORTWARNING # This will warn the user if they exit from the installer. + +!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page. +# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer +!insertmacro MUI_PAGE_DIRECTORY # In which folder install page. +!insertmacro MUI_PAGE_INSTFILES # Installing page. +!insertmacro MUI_PAGE_FINISH # Finished installation page. + +!insertmacro MUI_UNPAGE_INSTFILES # Uninstalling page + +!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer + +## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1 +#!uninstfinalize 'signtool --file "%1"' +#!finalize 'signtool --file "%1"' + +Name "${INFO_PRODUCTNAME}" +OutFile "..\..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file. +InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder). +ShowInstDetails show # This will always show the installation details. + +Function .onInit + !insertmacro wails.checkArchitecture +FunctionEnd + +Section + !insertmacro wails.setShellContext + + !insertmacro wails.webview2runtime + + SetOutPath $INSTDIR + + !insertmacro wails.files + + CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" + CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" + + !insertmacro wails.associateFiles + !insertmacro wails.associateCustomProtocols + + !insertmacro wails.writeUninstaller +SectionEnd + +Section "uninstall" + !insertmacro wails.setShellContext + + RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath + + RMDir /r $INSTDIR + + Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" + Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk" + + !insertmacro wails.unassociateFiles + !insertmacro wails.unassociateCustomProtocols + + !insertmacro wails.deleteUninstaller +SectionEnd diff --git a/client/ui/build/windows/nsis/wails_tools.nsh b/client/ui/build/windows/nsis/wails_tools.nsh new file mode 100644 index 000000000..b63101b32 --- /dev/null +++ b/client/ui/build/windows/nsis/wails_tools.nsh @@ -0,0 +1,236 @@ +# DO NOT EDIT - Generated automatically by `wails build` + +!include "x64.nsh" +!include "WinVer.nsh" +!include "FileFunc.nsh" + +!ifndef INFO_PROJECTNAME + !define INFO_PROJECTNAME "netbird-ui" +!endif +!ifndef INFO_COMPANYNAME + !define INFO_COMPANYNAME "NetBird" +!endif +!ifndef INFO_PRODUCTNAME + !define INFO_PRODUCTNAME "NetBird" +!endif +!ifndef INFO_PRODUCTVERSION + !define INFO_PRODUCTVERSION "0.0.1" +!endif +!ifndef INFO_COPYRIGHT + !define INFO_COPYRIGHT "NetBird GmbH" +!endif +!ifndef PRODUCT_EXECUTABLE + !define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe" +!endif +!ifndef UNINST_KEY_NAME + !define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}" +!endif +!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}" + +!ifndef REQUEST_EXECUTION_LEVEL + !define REQUEST_EXECUTION_LEVEL "admin" +!endif + +RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" + +!ifdef ARG_WAILS_AMD64_BINARY + !define SUPPORTS_AMD64 +!endif + +!ifdef ARG_WAILS_ARM64_BINARY + !define SUPPORTS_ARM64 +!endif + +!ifdef SUPPORTS_AMD64 + !ifdef SUPPORTS_ARM64 + !define ARCH "amd64_arm64" + !else + !define ARCH "amd64" + !endif +!else + !ifdef SUPPORTS_ARM64 + !define ARCH "arm64" + !else + !error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY" + !endif +!endif + +!macro wails.checkArchitecture + !ifndef WAILS_WIN10_REQUIRED + !define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later." + !endif + + !ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED + !define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}" + !endif + + ${If} ${AtLeastWin10} + !ifdef SUPPORTS_AMD64 + ${if} ${IsNativeAMD64} + Goto ok + ${EndIf} + !endif + + !ifdef SUPPORTS_ARM64 + ${if} ${IsNativeARM64} + Goto ok + ${EndIf} + !endif + + IfSilent silentArch notSilentArch + silentArch: + SetErrorLevel 65 + Abort + notSilentArch: + MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}" + Quit + ${else} + IfSilent silentWin notSilentWin + silentWin: + SetErrorLevel 64 + Abort + notSilentWin: + MessageBox MB_OK "${WAILS_WIN10_REQUIRED}" + Quit + ${EndIf} + + ok: +!macroend + +!macro wails.files + !ifdef SUPPORTS_AMD64 + ${if} ${IsNativeAMD64} + File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}" + ${EndIf} + !endif + + !ifdef SUPPORTS_ARM64 + ${if} ${IsNativeARM64} + File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}" + ${EndIf} + !endif +!macroend + +!macro wails.writeUninstaller + WriteUninstaller "$INSTDIR\uninstall.exe" + + SetRegView 64 + WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}" + WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\"" + WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S" + + ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2 + IntFmt $0 "0x%08X" $0 + WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0" +!macroend + +!macro wails.deleteUninstaller + Delete "$INSTDIR\uninstall.exe" + + SetRegView 64 + DeleteRegKey HKLM "${UNINST_KEY}" +!macroend + +!macro wails.setShellContext + ${If} ${REQUEST_EXECUTION_LEVEL} == "admin" + SetShellVarContext all + ${else} + SetShellVarContext current + ${EndIf} +!macroend + +# Install webview2 by launching the bootstrapper +# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment +!macro wails.webview2runtime + !ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT + !define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime" + !endif + + SetRegView 64 + # If the admin key exists and is not empty then webview2 is already installed + ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + ${If} $0 != "" + Goto ok + ${EndIf} + + ${If} ${REQUEST_EXECUTION_LEVEL} == "user" + # If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed + ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + ${If} $0 != "" + Goto ok + ${EndIf} + ${EndIf} + + SetDetailsPrint both + DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}" + SetDetailsPrint listonly + + InitPluginsDir + CreateDirectory "$pluginsdir\webview2bootstrapper" + SetOutPath "$pluginsdir\webview2bootstrapper" + File "MicrosoftEdgeWebview2Setup.exe" + ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install' + + SetDetailsPrint both + ok: +!macroend + +# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b +!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND + ; Backup the previously associated file class + ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0" + + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}" + + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open" + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}` +!macroend + +!macro APP_UNASSOCIATE EXT FILECLASS + ; Backup the previously associated file class + ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup` + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0" + + DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}` +!macroend + +!macro wails.associateFiles + ; Create file associations + +!macroend + +!macro wails.unassociateFiles + ; Delete app associations + +!macroend + +!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND + DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}" +!macroend + +!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL + DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}" +!macroend + +!macro wails.associateCustomProtocols + ; Create custom protocols associations + +!macroend + +!macro wails.unassociateCustomProtocols + ; Delete app custom protocol associations + +!macroend \ No newline at end of file diff --git a/client/ui/build/windows/wails.exe.manifest b/client/ui/build/windows/wails.exe.manifest new file mode 100644 index 000000000..f8b7b8e14 --- /dev/null +++ b/client/ui/build/windows/wails.exe.manifest @@ -0,0 +1,22 @@ + + + + + + + + + + + true/pm + permonitorv2,permonitor + + + + + + + + + + diff --git a/client/ui/client_ui.go b/client/ui/client_ui.go deleted file mode 100644 index c149b2152..000000000 --- a/client/ui/client_ui.go +++ /dev/null @@ -1,1830 +0,0 @@ -//go:build !(linux && 386) - -package main - -import ( - "context" - _ "embed" - "errors" - "flag" - "fmt" - "net/url" - "os" - "os/exec" - "os/user" - "path" - "runtime" - "strconv" - "strings" - "sync" - "time" - "unicode" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/app" - "fyne.io/fyne/v2/canvas" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/dialog" - "fyne.io/fyne/v2/layout" - "fyne.io/fyne/v2/theme" - "fyne.io/fyne/v2/widget" - "fyne.io/systray" - "github.com/cenkalti/backoff/v4" - log "github.com/sirupsen/logrus" - "golang.zx2c4.com/wireguard/wgctrl/wgtypes" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" - - "github.com/netbirdio/netbird/client/iface" - "github.com/netbirdio/netbird/client/internal" - "github.com/netbirdio/netbird/client/internal/profilemanager" - "github.com/netbirdio/netbird/client/internal/sleep" - "github.com/netbirdio/netbird/client/proto" - "github.com/netbirdio/netbird/client/ui/desktop" - "github.com/netbirdio/netbird/client/ui/event" - "github.com/netbirdio/netbird/client/ui/process" - "github.com/netbirdio/netbird/util" - - "github.com/netbirdio/netbird/version" -) - -const ( - defaultFailTimeout = 3 * time.Second - failFastTimeout = time.Second -) - -const ( - censoredPreSharedKey = "**********" - maxSSHJWTCacheTTL = 86_400 // 24 hours in seconds -) - -func main() { - flags := parseFlags() - - // Initialize file logging if needed. - var logFile string - if flags.saveLogsInFile { - file, err := initLogFile() - if err != nil { - log.Errorf("error while initializing log: %v", err) - return - } - logFile = file - } else { - _ = util.InitLog("trace", util.LogConsole) - } - - // Create the Fyne application. - a := app.NewWithID("NetBird") - a.SetIcon(fyne.NewStaticResource("netbird", iconDisconnected)) - - // Show error message window if needed. - if flags.errorMsg != "" { - showErrorMessage(flags.errorMsg) - return - } - - // Create the service client (this also builds the settings or networks UI if requested). - client := newServiceClient(&newServiceClientArgs{ - addr: flags.daemonAddr, - logFile: logFile, - app: a, - showSettings: flags.showSettings, - showNetworks: flags.showNetworks, - showLoginURL: flags.showLoginURL, - showDebug: flags.showDebug, - showProfiles: flags.showProfiles, - showQuickActions: flags.showQuickActions, - showUpdate: flags.showUpdate, - showUpdateVersion: flags.showUpdateVersion, - }) - - // Watch for theme/settings changes to update the icon. - go watchSettingsChanges(a, client) - - // Run in window mode if any UI flag was set. - if flags.showSettings || flags.showNetworks || flags.showDebug || flags.showLoginURL || flags.showProfiles || flags.showQuickActions || flags.showUpdate { - a.Run() - return - } - - // Check for another running process. - pid, running, err := process.IsAnotherProcessRunning() - if err != nil { - log.Errorf("error while checking process: %v", err) - return - } - if running { - log.Infof("another process is running with pid %d, sending signal to show window", pid) - if err := sendShowWindowSignal(pid); err != nil { - log.Errorf("send signal to running instance: %v", err) - } - return - } - - client.setupSignalHandler(client.ctx) - - client.setDefaultFonts() - systray.Run(client.onTrayReady, client.onTrayExit) -} - -type cliFlags struct { - daemonAddr string - showSettings bool - showNetworks bool - showProfiles bool - showDebug bool - showLoginURL bool - showQuickActions bool - errorMsg string - saveLogsInFile bool - showUpdate bool - showUpdateVersion string -} - -// parseFlags reads and returns all needed command-line flags. -func parseFlags() *cliFlags { - var flags cliFlags - - defaultDaemonAddr := "unix:///var/run/netbird.sock" - if runtime.GOOS == "windows" { - defaultDaemonAddr = "tcp://127.0.0.1:41731" - } - flag.StringVar(&flags.daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp]://[path|host:port]") - flag.BoolVar(&flags.showSettings, "settings", false, "run settings window") - flag.BoolVar(&flags.showNetworks, "networks", false, "run networks window") - flag.BoolVar(&flags.showProfiles, "profiles", false, "run profiles window") - flag.BoolVar(&flags.showDebug, "debug", false, "run debug window") - flag.BoolVar(&flags.showQuickActions, "quick-actions", false, "run quick actions window") - flag.StringVar(&flags.errorMsg, "error-msg", "", "displays an error message window") - flag.BoolVar(&flags.saveLogsInFile, "use-log-file", false, fmt.Sprintf("save logs in a file: %s/netbird-ui-PID.log", os.TempDir())) - flag.BoolVar(&flags.showLoginURL, "login-url", false, "show login URL in a popup window") - flag.BoolVar(&flags.showUpdate, "update", false, "show update progress window") - flag.StringVar(&flags.showUpdateVersion, "update-version", "", "version to update to") - flag.Parse() - return &flags -} - -// initLogFile initializes logging into a file. -func initLogFile() (string, error) { - logFile := path.Join(os.TempDir(), fmt.Sprintf("netbird-ui-%d.log", os.Getpid())) - return logFile, util.InitLog("trace", logFile) -} - -// watchSettingsChanges listens for Fyne theme/settings changes and updates the client icon. -func watchSettingsChanges(a fyne.App, client *serviceClient) { - a.Settings().AddListener(func(settings fyne.Settings) { - client.updateIcon() - }) -} - -// showErrorMessage displays an error message in a simple window. -func showErrorMessage(msg string) { - a := app.New() - w := a.NewWindow("NetBird Error") - label := widget.NewLabel(msg) - label.Wrapping = fyne.TextWrapWord - w.SetContent(label) - w.Resize(fyne.NewSize(400, 100)) - w.Show() - a.Run() -} - -//go:embed assets/netbird-systemtray-connected-macos.png -var iconConnectedMacOS []byte - -//go:embed assets/netbird-systemtray-disconnected-macos.png -var iconDisconnectedMacOS []byte - -//go:embed assets/netbird-systemtray-update-disconnected-macos.png -var iconUpdateDisconnectedMacOS []byte - -//go:embed assets/netbird-systemtray-update-connected-macos.png -var iconUpdateConnectedMacOS []byte - -//go:embed assets/netbird-systemtray-connecting-macos.png -var iconConnectingMacOS []byte - -//go:embed assets/netbird-systemtray-error-macos.png -var iconErrorMacOS []byte - -//go:embed assets/connected.png -var iconConnectedDot []byte - -//go:embed assets/disconnected.png -var iconDisconnectedDot []byte - -type serviceClient struct { - ctx context.Context - cancel context.CancelFunc - addr string - conn proto.DaemonServiceClient - connLock sync.Mutex - - eventHandler *eventHandler - - profileManager *profilemanager.ProfileManager - - icAbout []byte - icConnected []byte - icConnectedDot []byte - icDisconnected []byte - icDisconnectedDot []byte - icUpdateConnected []byte - icUpdateDisconnected []byte - icConnecting []byte - icError []byte - - // systray menu items - mStatus *systray.MenuItem - mUp *systray.MenuItem - mDown *systray.MenuItem - mSettings *systray.MenuItem - mProfile *profileMenu - mAbout *systray.MenuItem - mGitHub *systray.MenuItem - mVersionUI *systray.MenuItem - mVersionDaemon *systray.MenuItem - mUpdate *systray.MenuItem - mQuit *systray.MenuItem - mNetworks *systray.MenuItem - mAllowSSH *systray.MenuItem - mAutoConnect *systray.MenuItem - mEnableRosenpass *systray.MenuItem - mLazyConnEnabled *systray.MenuItem - mBlockInbound *systray.MenuItem - mNotifications *systray.MenuItem - mAdvancedSettings *systray.MenuItem - mCreateDebugBundle *systray.MenuItem - mExitNode *systray.MenuItem - - // application with main windows. - app fyne.App - wSettings fyne.Window - showAdvancedSettings bool - sendNotification bool - - // input elements for settings form - iMngURL *widget.Entry - iLogFile *widget.Entry - iPreSharedKey *widget.Entry - iInterfaceName *widget.Entry - iInterfacePort *widget.Entry - iMTU *widget.Entry - - // switch elements for settings form - sRosenpassPermissive *widget.Check - sNetworkMonitor *widget.Check - sDisableDNS *widget.Check - sDisableClientRoutes *widget.Check - sDisableServerRoutes *widget.Check - sBlockLANAccess *widget.Check - sEnableSSHRoot *widget.Check - sEnableSSHSFTP *widget.Check - sEnableSSHLocalPortForward *widget.Check - sEnableSSHRemotePortForward *widget.Check - sDisableSSHAuth *widget.Check - iSSHJWTCacheTTL *widget.Entry - - // observable settings over corresponding iMngURL and iPreSharedKey values. - managementURL string - preSharedKey string - - RosenpassPermissive bool - interfaceName string - interfacePort int - mtu uint16 - networkMonitor bool - disableDNS bool - disableClientRoutes bool - disableServerRoutes bool - blockLANAccess bool - enableSSHRoot bool - enableSSHSFTP bool - enableSSHLocalPortForward bool - enableSSHRemotePortForward bool - disableSSHAuth bool - sshJWTCacheTTL int - - connected bool - daemonVersion string - updateIndicationLock sync.Mutex - isUpdateIconActive bool - isEnforcedUpdate bool - lastNotifiedVersion string - settingsEnabled bool - profilesEnabled bool - networksEnabled bool - showNetworks bool - wNetworks fyne.Window - wProfiles fyne.Window - wQuickActions fyne.Window - - eventManager *event.Manager - - exitNodeMu sync.Mutex - mExitNodeItems []menuHandler - exitNodeRetryCancel context.CancelFunc - mExitNodeSeparator *systray.MenuItem - mExitNodeDeselectAll *systray.MenuItem - logFile string - wLoginURL fyne.Window - wUpdateProgress fyne.Window - updateContextCancel context.CancelFunc - - connectCancel context.CancelFunc -} - -type menuHandler struct { - *systray.MenuItem - cancel context.CancelFunc -} - -type newServiceClientArgs struct { - addr string - logFile string - app fyne.App - showSettings bool - showNetworks bool - showDebug bool - showLoginURL bool - showProfiles bool - showQuickActions bool - showUpdate bool - showUpdateVersion string -} - -// newServiceClient instance constructor -// -// This constructor also builds the UI elements for the settings window. -func newServiceClient(args *newServiceClientArgs) *serviceClient { - ctx, cancel := context.WithCancel(context.Background()) - s := &serviceClient{ - ctx: ctx, - cancel: cancel, - addr: args.addr, - app: args.app, - logFile: args.logFile, - sendNotification: false, - - showAdvancedSettings: args.showSettings, - showNetworks: args.showNetworks, - networksEnabled: true, - } - - s.eventHandler = newEventHandler(s) - s.profileManager = profilemanager.NewProfileManager() - s.setNewIcons() - - switch { - case args.showSettings: - s.showSettingsUI() - case args.showNetworks: - s.showNetworksUI() - case args.showLoginURL: - s.showLoginURL() - case args.showDebug: - s.showDebugUI() - case args.showProfiles: - s.showProfilesUI() - case args.showQuickActions: - s.showQuickActionsUI() - case args.showUpdate: - s.showUpdateProgress(ctx, args.showUpdateVersion) - } - - return s -} - -func (s *serviceClient) setNewIcons() { - s.icAbout = iconAbout - s.icConnectedDot = iconConnectedDot - s.icDisconnectedDot = iconDisconnectedDot - if s.app.Settings().ThemeVariant() == theme.VariantDark { - s.icConnected = iconConnectedDark - s.icDisconnected = iconDisconnected - s.icUpdateConnected = iconUpdateConnectedDark - s.icUpdateDisconnected = iconUpdateDisconnectedDark - s.icConnecting = iconConnectingDark - s.icError = iconErrorDark - } else { - s.icConnected = iconConnected - s.icDisconnected = iconDisconnected - s.icUpdateConnected = iconUpdateConnected - s.icUpdateDisconnected = iconUpdateDisconnected - s.icConnecting = iconConnecting - s.icError = iconError - } -} - -func (s *serviceClient) updateIcon() { - s.setNewIcons() - s.updateIndicationLock.Lock() - if s.connected { - if s.isUpdateIconActive { - systray.SetTemplateIcon(iconUpdateConnectedMacOS, s.icUpdateConnected) - } else { - systray.SetTemplateIcon(iconConnectedMacOS, s.icConnected) - } - } else { - if s.isUpdateIconActive { - systray.SetTemplateIcon(iconUpdateDisconnectedMacOS, s.icUpdateDisconnected) - } else { - systray.SetTemplateIcon(iconDisconnectedMacOS, s.icDisconnected) - } - } - s.updateIndicationLock.Unlock() -} - -func (s *serviceClient) showSettingsUI() { - // Check if update settings are disabled by daemon - features, err := s.getFeatures() - if err != nil { - log.Errorf("failed to get features from daemon: %v", err) - // Continue with default behavior if features can't be retrieved - } else if features != nil && features.DisableUpdateSettings { - log.Warn("Update settings are disabled by daemon") - return - } - - // add settings window UI elements. - s.wSettings = s.app.NewWindow("NetBird Settings") - s.wSettings.SetOnClosed(s.cancel) - - s.iMngURL = widget.NewEntry() - - s.iLogFile = widget.NewEntry() - s.iLogFile.Disable() - s.iPreSharedKey = widget.NewPasswordEntry() - s.iInterfaceName = widget.NewEntry() - s.iInterfacePort = widget.NewEntry() - s.iMTU = widget.NewEntry() - - s.sRosenpassPermissive = widget.NewCheck("Enable Rosenpass permissive mode", nil) - - s.sNetworkMonitor = widget.NewCheck("Restarts NetBird when the network changes", nil) - s.sDisableDNS = widget.NewCheck("Keeps system DNS settings unchanged", nil) - s.sDisableClientRoutes = widget.NewCheck("This peer won't route traffic to other peers", nil) - s.sDisableServerRoutes = widget.NewCheck("This peer won't act as router for others", nil) - s.sBlockLANAccess = widget.NewCheck("Blocks local network access when used as exit node", nil) - s.sEnableSSHRoot = widget.NewCheck("Enable SSH Root Login", nil) - s.sEnableSSHSFTP = widget.NewCheck("Enable SSH SFTP", nil) - s.sEnableSSHLocalPortForward = widget.NewCheck("Enable SSH Local Port Forwarding", nil) - s.sEnableSSHRemotePortForward = widget.NewCheck("Enable SSH Remote Port Forwarding", nil) - s.sDisableSSHAuth = widget.NewCheck("Disable SSH Authentication", nil) - s.iSSHJWTCacheTTL = widget.NewEntry() - - s.wSettings.SetContent(s.getSettingsForm()) - s.wSettings.Resize(fyne.NewSize(600, 400)) - s.wSettings.SetFixedSize(true) - - s.getSrvConfig() - s.wSettings.Show() -} - -func (s *serviceClient) getConnectionForm() *widget.Form { - var activeProfName string - activeProf, err := s.profileManager.GetActiveProfile() - if err != nil { - log.Errorf("get active profile: %v", err) - } else { - activeProfName = activeProf.Name - } - return &widget.Form{ - Items: []*widget.FormItem{ - {Text: "Profile", Widget: widget.NewLabel(activeProfName)}, - {Text: "Management URL", Widget: s.iMngURL}, - {Text: "Pre-shared Key", Widget: s.iPreSharedKey}, - {Text: "Quantum-Resistance", Widget: s.sRosenpassPermissive}, - {Text: "Interface Name", Widget: s.iInterfaceName}, - {Text: "Interface Port", Widget: s.iInterfacePort}, - {Text: "MTU", Widget: s.iMTU}, - {Text: "Log File", Widget: s.iLogFile}, - }, - } -} - -func (s *serviceClient) saveSettings() { - // Check if update settings are disabled by daemon - features, err := s.getFeatures() - if err != nil { - log.Errorf("failed to get features from daemon: %v", err) - // Continue with default behavior if features can't be retrieved - } else if features != nil && features.DisableUpdateSettings { - log.Warn("Configuration updates are disabled by daemon") - dialog.ShowError(fmt.Errorf("configuration updates are disabled by daemon"), s.wSettings) - return - } - - if err := s.validateSettings(); err != nil { - dialog.ShowError(err, s.wSettings) - return - } - - port, mtu, err := s.parseNumericSettings() - if err != nil { - dialog.ShowError(err, s.wSettings) - return - } - - iMngURL := strings.TrimSpace(s.iMngURL.Text) - - if s.hasSettingsChanged(iMngURL, port, mtu) { - if err := s.applySettingsChanges(iMngURL, port, mtu); err != nil { - dialog.ShowError(err, s.wSettings) - return - } - } - - s.wSettings.Close() -} - -func (s *serviceClient) validateSettings() error { - if s.iPreSharedKey.Text != "" && s.iPreSharedKey.Text != censoredPreSharedKey { - if _, err := wgtypes.ParseKey(s.iPreSharedKey.Text); err != nil { - return fmt.Errorf("invalid pre-shared key value") - } - } - return nil -} - -func (s *serviceClient) parseNumericSettings() (int64, int64, error) { - port, err := strconv.ParseInt(s.iInterfacePort.Text, 10, 64) - if err != nil { - return 0, 0, errors.New("invalid interface port") - } - if port < 1 || port > 65535 { - return 0, 0, errors.New("invalid interface port: out of range 1-65535") - } - - var mtu int64 - mtuText := strings.TrimSpace(s.iMTU.Text) - if mtuText != "" { - mtu, err = strconv.ParseInt(mtuText, 10, 64) - if err != nil { - return 0, 0, errors.New("invalid MTU value") - } - if mtu < iface.MinMTU || mtu > iface.MaxMTU { - return 0, 0, fmt.Errorf("MTU must be between %d and %d bytes", iface.MinMTU, iface.MaxMTU) - } - } - - return port, mtu, nil -} - -func (s *serviceClient) hasSettingsChanged(iMngURL string, port, mtu int64) bool { - return s.managementURL != iMngURL || - s.preSharedKey != s.iPreSharedKey.Text || - s.RosenpassPermissive != s.sRosenpassPermissive.Checked || - s.interfaceName != s.iInterfaceName.Text || - s.interfacePort != int(port) || - s.mtu != uint16(mtu) || - s.networkMonitor != s.sNetworkMonitor.Checked || - s.disableDNS != s.sDisableDNS.Checked || - s.disableClientRoutes != s.sDisableClientRoutes.Checked || - s.disableServerRoutes != s.sDisableServerRoutes.Checked || - s.blockLANAccess != s.sBlockLANAccess.Checked || - s.hasSSHChanges() -} - -func (s *serviceClient) applySettingsChanges(iMngURL string, port, mtu int64) error { - s.managementURL = iMngURL - s.preSharedKey = s.iPreSharedKey.Text - s.mtu = uint16(mtu) - - req, err := s.buildSetConfigRequest(iMngURL, port, mtu) - if err != nil { - return fmt.Errorf("build config request: %w", err) - } - - if err := s.sendConfigUpdate(req); err != nil { - return fmt.Errorf("set configuration: %w", err) - } - - return nil -} - -func (s *serviceClient) buildSetConfigRequest(iMngURL string, port, mtu int64) (*proto.SetConfigRequest, error) { - currUser, err := user.Current() - if err != nil { - return nil, fmt.Errorf("get current user: %w", err) - } - - activeProf, err := s.profileManager.GetActiveProfile() - if err != nil { - return nil, fmt.Errorf("get active profile: %w", err) - } - - req := &proto.SetConfigRequest{ - ProfileName: activeProf.Name, - Username: currUser.Username, - } - - if iMngURL != "" { - req.ManagementUrl = iMngURL - } - - req.RosenpassPermissive = &s.sRosenpassPermissive.Checked - req.InterfaceName = &s.iInterfaceName.Text - req.WireguardPort = &port - if mtu > 0 { - req.Mtu = &mtu - } - - req.NetworkMonitor = &s.sNetworkMonitor.Checked - req.DisableDns = &s.sDisableDNS.Checked - req.DisableClientRoutes = &s.sDisableClientRoutes.Checked - req.DisableServerRoutes = &s.sDisableServerRoutes.Checked - req.BlockLanAccess = &s.sBlockLANAccess.Checked - - req.EnableSSHRoot = &s.sEnableSSHRoot.Checked - req.EnableSSHSFTP = &s.sEnableSSHSFTP.Checked - req.EnableSSHLocalPortForwarding = &s.sEnableSSHLocalPortForward.Checked - req.EnableSSHRemotePortForwarding = &s.sEnableSSHRemotePortForward.Checked - req.DisableSSHAuth = &s.sDisableSSHAuth.Checked - - sshJWTCacheTTLText := strings.TrimSpace(s.iSSHJWTCacheTTL.Text) - if sshJWTCacheTTLText != "" { - sshJWTCacheTTL, err := strconv.ParseInt(sshJWTCacheTTLText, 10, 32) - if err != nil { - return nil, errors.New("invalid SSH JWT Cache TTL value") - } - if sshJWTCacheTTL < 0 || sshJWTCacheTTL > maxSSHJWTCacheTTL { - return nil, fmt.Errorf("SSH JWT Cache TTL must be between 0 and %d seconds", maxSSHJWTCacheTTL) - } - sshJWTCacheTTL32 := int32(sshJWTCacheTTL) - req.SshJWTCacheTTL = &sshJWTCacheTTL32 - } - - if s.iPreSharedKey.Text != censoredPreSharedKey { - req.OptionalPreSharedKey = &s.iPreSharedKey.Text - } - - return req, nil -} - -func (s *serviceClient) sendConfigUpdate(req *proto.SetConfigRequest) error { - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - return fmt.Errorf("get client: %w", err) - } - - _, err = conn.SetConfig(s.ctx, req) - if err != nil { - return fmt.Errorf("set config: %w", err) - } - - // Reconnect if connected to apply the new settings - go func() { - status, err := conn.Status(s.ctx, &proto.StatusRequest{}) - if err != nil { - log.Errorf("get service status: %v", err) - return - } - if status.Status == string(internal.StatusConnected) { - // run down & up - _, err = conn.Down(s.ctx, &proto.DownRequest{}) - if err != nil { - log.Errorf("down service: %v", err) - } - - _, err = conn.Up(s.ctx, &proto.UpRequest{}) - if err != nil { - log.Errorf("up service: %v", err) - return - } - } - }() - - return nil -} - -func (s *serviceClient) getSettingsForm() fyne.CanvasObject { - connectionForm := s.getConnectionForm() - networkForm := s.getNetworkForm() - sshForm := s.getSSHForm() - tabs := container.NewAppTabs( - container.NewTabItem("Connection", connectionForm), - container.NewTabItem("Network", networkForm), - container.NewTabItem("SSH", sshForm), - ) - saveButton := widget.NewButtonWithIcon("Save", theme.ConfirmIcon(), s.saveSettings) - saveButton.Importance = widget.HighImportance - cancelButton := widget.NewButtonWithIcon("Cancel", theme.CancelIcon(), func() { - s.wSettings.Close() - }) - buttonContainer := container.NewHBox( - layout.NewSpacer(), - cancelButton, - saveButton, - ) - return container.NewBorder(nil, buttonContainer, nil, nil, tabs) -} - -func (s *serviceClient) getNetworkForm() *widget.Form { - return &widget.Form{ - Items: []*widget.FormItem{ - {Text: "Network Monitor", Widget: s.sNetworkMonitor}, - {Text: "Disable DNS", Widget: s.sDisableDNS}, - {Text: "Disable Client Routes", Widget: s.sDisableClientRoutes}, - {Text: "Disable Server Routes", Widget: s.sDisableServerRoutes}, - {Text: "Disable LAN Access", Widget: s.sBlockLANAccess}, - }, - } -} - -func (s *serviceClient) getSSHForm() *widget.Form { - return &widget.Form{ - Items: []*widget.FormItem{ - {Text: "Enable SSH Root Login", Widget: s.sEnableSSHRoot}, - {Text: "Enable SSH SFTP", Widget: s.sEnableSSHSFTP}, - {Text: "Enable SSH Local Port Forwarding", Widget: s.sEnableSSHLocalPortForward}, - {Text: "Enable SSH Remote Port Forwarding", Widget: s.sEnableSSHRemotePortForward}, - {Text: "Disable SSH Authentication", Widget: s.sDisableSSHAuth}, - {Text: "JWT Cache TTL (seconds, 0=disabled)", Widget: s.iSSHJWTCacheTTL}, - }, - } -} - -func (s *serviceClient) hasSSHChanges() bool { - currentSSHJWTCacheTTL := s.sshJWTCacheTTL - if text := strings.TrimSpace(s.iSSHJWTCacheTTL.Text); text != "" { - val, err := strconv.Atoi(text) - if err != nil { - return true - } - currentSSHJWTCacheTTL = val - } - - return s.enableSSHRoot != s.sEnableSSHRoot.Checked || - s.enableSSHSFTP != s.sEnableSSHSFTP.Checked || - s.enableSSHLocalPortForward != s.sEnableSSHLocalPortForward.Checked || - s.enableSSHRemotePortForward != s.sEnableSSHRemotePortForward.Checked || - s.disableSSHAuth != s.sDisableSSHAuth.Checked || - s.sshJWTCacheTTL != currentSSHJWTCacheTTL -} - -func (s *serviceClient) login(ctx context.Context, openURL bool) (*proto.LoginResponse, error) { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return nil, fmt.Errorf("get daemon client: %w", err) - } - - activeProf, err := s.profileManager.GetActiveProfile() - if err != nil { - return nil, fmt.Errorf("get active profile: %w", err) - } - - currUser, err := user.Current() - if err != nil { - return nil, fmt.Errorf("get current user: %w", err) - } - - loginReq := &proto.LoginRequest{ - IsUnixDesktopClient: runtime.GOOS == "linux" || runtime.GOOS == "freebsd", - ProfileName: &activeProf.Name, - Username: &currUser.Username, - } - - profileState, err := s.profileManager.GetProfileState(activeProf.Name) - if err != nil { - log.Debugf("failed to get profile state for login hint: %v", err) - } else if profileState.Email != "" { - loginReq.Hint = &profileState.Email - } - - loginResp, err := conn.Login(ctx, loginReq) - if err != nil { - return nil, fmt.Errorf("login to management: %w", err) - } - - if loginResp.NeedsSSOLogin && openURL { - if err = s.handleSSOLogin(ctx, loginResp, conn); err != nil { - return nil, fmt.Errorf("SSO login: %w", err) - } - } - - return loginResp, nil -} - -func (s *serviceClient) handleSSOLogin(ctx context.Context, loginResp *proto.LoginResponse, conn proto.DaemonServiceClient) error { - if err := openURL(loginResp.VerificationURIComplete); err != nil { - return fmt.Errorf("open browser: %w", err) - } - - resp, err := conn.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{UserCode: loginResp.UserCode}) - if err != nil { - return fmt.Errorf("wait for SSO login: %w", err) - } - - if resp.Email != "" { - if err := s.profileManager.SetActiveProfileState(&profilemanager.ProfileState{ - Email: resp.Email, - }); err != nil { - log.Debugf("failed to set profile state: %v", err) - } else { - s.mProfile.refresh() - } - } - - return nil -} - -func (s *serviceClient) menuUpClick(ctx context.Context) error { - systray.SetTemplateIcon(iconConnectingMacOS, s.icConnecting) - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - systray.SetTemplateIcon(iconErrorMacOS, s.icError) - return fmt.Errorf("get daemon client: %w", err) - } - - _, err = s.login(ctx, true) - if err != nil { - return fmt.Errorf("login: %w", err) - } - - status, err := conn.Status(ctx, &proto.StatusRequest{}) - if err != nil { - return fmt.Errorf("get status: %w", err) - } - - if status.Status == string(internal.StatusConnected) { - return nil - } - - if _, err := s.conn.Up(s.ctx, &proto.UpRequest{}); err != nil { - return fmt.Errorf("start connection: %w", err) - } - - return nil -} - -func (s *serviceClient) menuDownClick() error { - systray.SetTemplateIcon(iconConnectingMacOS, s.icConnecting) - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return fmt.Errorf("get daemon client: %w", err) - } - - status, err := conn.Status(s.ctx, &proto.StatusRequest{}) - if err != nil { - return fmt.Errorf("get status: %w", err) - } - - if status.Status != string(internal.StatusConnected) && status.Status != string(internal.StatusConnecting) { - return nil - } - - if _, err := conn.Down(s.ctx, &proto.DownRequest{}); err != nil { - return fmt.Errorf("stop connection: %w", err) - } - - return nil -} - -func (s *serviceClient) updateStatus() error { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return err - } - err = backoff.Retry(func() error { - status, err := conn.Status(s.ctx, &proto.StatusRequest{}) - if err != nil { - log.Errorf("get service status: %v", err) - if s.connected { - s.app.SendNotification(fyne.NewNotification("Error", "Connection to service lost")) - } - s.setDisconnectedStatus() - return err - } - - s.updateIndicationLock.Lock() - defer s.updateIndicationLock.Unlock() - - // notify the user when the session has expired - if status.Status == string(internal.StatusSessionExpired) { - s.onSessionExpire() - } - - var systrayIconState bool - - switch { - case status.Status == string(internal.StatusConnected) && !s.connected: - s.connected = true - s.sendNotification = true - if s.isUpdateIconActive { - systray.SetTemplateIcon(iconUpdateConnectedMacOS, s.icUpdateConnected) - } else { - systray.SetTemplateIcon(iconConnectedMacOS, s.icConnected) - } - systray.SetTooltip("NetBird (Connected)") - s.mStatus.SetTitle("Connected") - s.mStatus.SetIcon(s.icConnectedDot) - s.mUp.Disable() - s.mDown.Enable() - if s.networksEnabled { - s.mNetworks.Enable() - s.mExitNode.Enable() - } - s.startExitNodeRefresh() - systrayIconState = true - case status.Status == string(internal.StatusConnecting): - s.setConnectingStatus() - case status.Status != string(internal.StatusConnected) && s.mUp.Disabled(): - s.setDisconnectedStatus() - systrayIconState = false - } - - // if the daemon version changed (e.g. after a successful update), reset the update indication - if s.daemonVersion != status.DaemonVersion { - if s.daemonVersion != "" { - s.mUpdate.Hide() - s.isUpdateIconActive = false - } - s.daemonVersion = status.DaemonVersion - if !s.isUpdateIconActive { - if systrayIconState { - systray.SetTemplateIcon(iconConnectedMacOS, s.icConnected) - } else { - systray.SetTemplateIcon(iconDisconnectedMacOS, s.icDisconnected) - } - } - - daemonVersionTitle := normalizedVersion(s.daemonVersion) - s.mVersionDaemon.SetTitle(fmt.Sprintf("Daemon: %s", daemonVersionTitle)) - s.mVersionDaemon.SetTooltip(fmt.Sprintf("Daemon version: %s", daemonVersionTitle)) - s.mVersionDaemon.Show() - } - - return nil - }, &backoff.ExponentialBackOff{ - InitialInterval: time.Second, - RandomizationFactor: backoff.DefaultRandomizationFactor, - Multiplier: backoff.DefaultMultiplier, - MaxInterval: 300 * time.Millisecond, - MaxElapsedTime: 2 * time.Second, - Stop: backoff.Stop, - Clock: backoff.SystemClock, - }) - if err != nil { - return err - } - - return nil -} - -func (s *serviceClient) setDisconnectedStatus() { - s.connected = false - if s.isUpdateIconActive { - systray.SetTemplateIcon(iconUpdateDisconnectedMacOS, s.icUpdateDisconnected) - } else { - systray.SetTemplateIcon(iconDisconnectedMacOS, s.icDisconnected) - } - systray.SetTooltip("NetBird (Disconnected)") - s.mStatus.SetTitle("Disconnected") - s.mStatus.SetIcon(s.icDisconnectedDot) - s.mDown.Disable() - s.mUp.Enable() - s.mNetworks.Disable() - s.mExitNode.Disable() - s.cancelExitNodeRetry() - go s.updateExitNodes() -} - -func (s *serviceClient) setConnectingStatus() { - s.connected = false - systray.SetTemplateIcon(iconConnectingMacOS, s.icConnecting) - systray.SetTooltip("NetBird (Connecting)") - s.mStatus.SetTitle("Connecting") - s.mUp.Disable() - s.mDown.Enable() - s.mNetworks.Disable() - s.mExitNode.Disable() -} - -func (s *serviceClient) onTrayReady() { - systray.SetTemplateIcon(iconDisconnectedMacOS, s.icDisconnected) - systray.SetTooltip("NetBird") - - // setup systray menu items - s.mStatus = systray.AddMenuItem("Disconnected", "Disconnected") - s.mStatus.SetIcon(s.icDisconnectedDot) - s.mStatus.Disable() - - profileMenuItem := systray.AddMenuItem("", "") - emailMenuItem := systray.AddMenuItem("", "") - - newProfileMenuArgs := &newProfileMenuArgs{ - ctx: s.ctx, - serviceClient: s, - profileManager: s.profileManager, - eventHandler: s.eventHandler, - profileMenuItem: profileMenuItem, - emailMenuItem: emailMenuItem, - downClickCallback: s.menuDownClick, - upClickCallback: s.menuUpClick, - getSrvClientCallback: s.getSrvClient, - loadSettingsCallback: s.loadSettings, - app: s.app, - } - - s.mProfile = newProfileMenu(*newProfileMenuArgs) - - systray.AddSeparator() - s.mUp = systray.AddMenuItem("Connect", "Connect") - s.mDown = systray.AddMenuItem("Disconnect", "Disconnect") - s.mDown.Disable() - systray.AddSeparator() - - s.mSettings = systray.AddMenuItem("Settings", disabledMenuDescr) - s.mAllowSSH = s.mSettings.AddSubMenuItemCheckbox("Allow SSH", allowSSHMenuDescr, false) - s.mAutoConnect = s.mSettings.AddSubMenuItemCheckbox("Connect on Startup", autoConnectMenuDescr, false) - s.mEnableRosenpass = s.mSettings.AddSubMenuItemCheckbox("Enable Quantum-Resistance", quantumResistanceMenuDescr, false) - s.mLazyConnEnabled = s.mSettings.AddSubMenuItemCheckbox("Enable Lazy Connections", lazyConnMenuDescr, false) - s.mBlockInbound = s.mSettings.AddSubMenuItemCheckbox("Block Inbound Connections", blockInboundMenuDescr, false) - s.mNotifications = s.mSettings.AddSubMenuItemCheckbox("Notifications", notificationsMenuDescr, false) - s.mSettings.AddSeparator() - s.mAdvancedSettings = s.mSettings.AddSubMenuItem("Advanced Settings", advancedSettingsMenuDescr) - s.mCreateDebugBundle = s.mSettings.AddSubMenuItem("Create Debug Bundle", debugBundleMenuDescr) - s.loadSettings() - - // Disable settings menu if update settings are disabled by daemon - features, err := s.getFeatures() - if err != nil { - log.Errorf("failed to get features from daemon: %v", err) - // Continue with default behavior if features can't be retrieved - } else { - if features != nil && features.DisableUpdateSettings { - s.setSettingsEnabled(false) - } - if features != nil && features.DisableProfiles { - s.mProfile.setEnabled(false) - } - } - - s.exitNodeMu.Lock() - s.mExitNode = systray.AddMenuItem("Exit Node", disabledMenuDescr) - s.mExitNode.Disable() - s.exitNodeMu.Unlock() - - s.mNetworks = systray.AddMenuItem("Networks", networksMenuDescr) - s.mNetworks.Disable() - systray.AddSeparator() - - s.mAbout = systray.AddMenuItem("About", "About") - s.mAbout.SetIcon(s.icAbout) - - s.mGitHub = s.mAbout.AddSubMenuItem("GitHub", "GitHub") - - versionString := normalizedVersion(version.NetbirdVersion()) - s.mVersionUI = s.mAbout.AddSubMenuItem(fmt.Sprintf("GUI: %s", versionString), fmt.Sprintf("GUI Version: %s", versionString)) - s.mVersionUI.Disable() - - s.mVersionDaemon = s.mAbout.AddSubMenuItem("", "") - s.mVersionDaemon.Disable() - s.mVersionDaemon.Hide() - - s.mUpdate = s.mAbout.AddSubMenuItem("Download latest version", latestVersionMenuDescr) - s.mUpdate.Hide() - - systray.AddSeparator() - s.mQuit = systray.AddMenuItem("Quit", quitMenuDescr) - - // update exit node menu in case service is already connected - go s.updateExitNodes() - - go func() { - s.getSrvConfig() - time.Sleep(100 * time.Millisecond) // To prevent race condition caused by systray not being fully initialized and ignoring setIcon - for { - // Check features before status so menus respect disable flags before being enabled - s.checkAndUpdateFeatures() - - err := s.updateStatus() - if err != nil { - log.Errorf("error while updating status: %v", err) - } - - time.Sleep(2 * time.Second) - } - }() - - s.eventManager = event.NewManager(s.app, s.addr) - s.eventManager.SetNotificationsEnabled(s.mNotifications.Checked()) - s.eventManager.AddHandler(func(event *proto.SystemEvent) { - if event.Category == proto.SystemEvent_SYSTEM { - s.updateExitNodes() - } - }) - s.eventManager.AddHandler(func(event *proto.SystemEvent) { - // todo use new Category - if windowAction, ok := event.Metadata["progress_window"]; ok { - targetVersion, ok := event.Metadata["version"] - if !ok { - targetVersion = "unknown" - } - log.Debugf("window action: %v", windowAction) - if windowAction == "show" { - if s.updateContextCancel != nil { - s.updateContextCancel() - s.updateContextCancel = nil - } - - subCtx, cancel := context.WithCancel(s.ctx) - go s.eventHandler.runSelfCommand(subCtx, "update", "--update-version", targetVersion) - s.updateContextCancel = cancel - } - } - }) - 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) - - // Start sleep detection listener - go s.startSleepListener() -} - -func (s *serviceClient) attachOutput(cmd *exec.Cmd) *os.File { - if s.logFile == "" { - // attach child's streams to parent's streams - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - return nil - } - - out, err := os.OpenFile(s.logFile, os.O_WRONLY|os.O_APPEND, 0) - if err != nil { - log.Errorf("Failed to open log file %s: %v", s.logFile, err) - return nil - } - cmd.Stdout = out - cmd.Stderr = out - return out -} - -func normalizedVersion(version string) string { - versionString := version - if unicode.IsDigit(rune(versionString[0])) { - versionString = fmt.Sprintf("v%s", versionString) - } - return versionString -} - -// onTrayExit is called when the tray icon is closed. -func (s *serviceClient) onTrayExit() { - s.cancel() -} - -// getSrvClient connection to the service. -func (s *serviceClient) getSrvClient(timeout time.Duration) (proto.DaemonServiceClient, error) { - s.connLock.Lock() - defer s.connLock.Unlock() - if s.conn != nil { - return s.conn, nil - } - - ctx, cancel := context.WithTimeout(s.ctx, timeout) - defer cancel() - - conn, err := grpc.DialContext( - ctx, - strings.TrimPrefix(s.addr, "tcp://"), - grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithBlock(), - grpc.WithUserAgent(desktop.GetUIUserAgent()), - ) - if err != nil { - return nil, fmt.Errorf("dial service: %w", err) - } - - s.conn = proto.NewDaemonServiceClient(conn) - return s.conn, nil -} - -// startSleepListener initializes the sleep detection service and listens for sleep events -func (s *serviceClient) startSleepListener() { - sleepService, err := sleep.New() - if err != nil { - log.Warnf("%v", err) - return - } - - if err := sleepService.Register(s.handleSleepEvents); err != nil { - log.Errorf("failed to start sleep detection: %v", err) - return - } - - log.Info("sleep detection service initialized") - - // Cleanup on context cancellation - go func() { - <-s.ctx.Done() - log.Info("stopping sleep event listener") - if err := sleepService.Deregister(); err != nil { - log.Errorf("failed to deregister sleep detection: %v", err) - } - }() -} - -// handleSleepEvents sends a sleep notification to the daemon via gRPC -func (s *serviceClient) handleSleepEvents(event sleep.EventType) { - conn, err := s.getSrvClient(0) - if err != nil { - log.Errorf("failed to get daemon client for sleep notification: %v", err) - return - } - - req := &proto.OSLifecycleRequest{} - - switch event { - case sleep.EventTypeWakeUp: - log.Infof("handle wakeup event: %v", event) - req.Type = proto.OSLifecycleRequest_WAKEUP - case sleep.EventTypeSleep: - log.Infof("handle sleep event: %v", event) - req.Type = proto.OSLifecycleRequest_SLEEP - default: - log.Infof("unknown event: %v", event) - return - } - - _, err = conn.NotifyOSLifecycle(s.ctx, req) - if err != nil { - log.Errorf("failed to notify daemon about os lifecycle notification: %v", err) - return - } - - log.Info("successfully notified daemon about os lifecycle") -} - -// setSettingsEnabled enables or disables the settings menu based on the provided state -func (s *serviceClient) setSettingsEnabled(enabled bool) { - if s.mSettings != nil { - if enabled { - s.mSettings.Enable() - } else { - s.mSettings.Hide() - s.mSettings.SetTooltip("Settings are disabled by daemon") - } - } -} - -// checkAndUpdateFeatures checks the current features and updates the UI accordingly -func (s *serviceClient) checkAndUpdateFeatures() { - features, err := s.getFeatures() - if err != nil { - log.Errorf("failed to get features from daemon: %v", err) - return - } - - s.updateIndicationLock.Lock() - defer s.updateIndicationLock.Unlock() - - // Update settings menu based on current features - settingsEnabled := features == nil || !features.DisableUpdateSettings - if s.settingsEnabled != settingsEnabled { - s.settingsEnabled = settingsEnabled - s.setSettingsEnabled(settingsEnabled) - } - - // Update profile menu based on current features - if s.mProfile != nil { - profilesEnabled := features == nil || !features.DisableProfiles - if s.profilesEnabled != profilesEnabled { - s.profilesEnabled = profilesEnabled - s.mProfile.setEnabled(profilesEnabled) - } - } - - // Update networks and exit node menus based on current features - s.networksEnabled = features == nil || !features.DisableNetworks - if s.networksEnabled && s.connected { - s.mNetworks.Enable() - s.mExitNode.Enable() - } else { - s.mNetworks.Disable() - s.mExitNode.Disable() - } -} - -// getFeatures from the daemon to determine which features are enabled/disabled. -func (s *serviceClient) getFeatures() (*proto.GetFeaturesResponse, error) { - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - return nil, fmt.Errorf("get client for features: %w", err) - } - - features, err := conn.GetFeatures(s.ctx, &proto.GetFeaturesRequest{}) - if err != nil { - return nil, fmt.Errorf("get features from daemon: %w", err) - } - - return features, nil -} - -// getSrvConfig from the service to show it in the settings window. -func (s *serviceClient) getSrvConfig() { - s.managementURL = profilemanager.DefaultManagementURL - - _, err := s.profileManager.GetActiveProfile() - if err != nil { - log.Errorf("get active profile: %v", err) - return - } - - var cfg *profilemanager.Config - - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - log.Errorf("get client: %v", err) - return - } - - currUser, err := user.Current() - if err != nil { - log.Errorf("get current user: %v", err) - return - } - - activeProf, err := s.profileManager.GetActiveProfile() - if err != nil { - log.Errorf("get active profile: %v", err) - return - } - - srvCfg, err := conn.GetConfig(s.ctx, &proto.GetConfigRequest{ - ProfileName: activeProf.Name, - Username: currUser.Username, - }) - if err != nil { - log.Errorf("get config settings from server: %v", err) - return - } - - cfg = protoConfigToConfig(srvCfg) - - if cfg.ManagementURL.String() != "" { - s.managementURL = cfg.ManagementURL.String() - } - s.preSharedKey = cfg.PreSharedKey - s.RosenpassPermissive = cfg.RosenpassPermissive - s.interfaceName = cfg.WgIface - s.interfacePort = cfg.WgPort - s.mtu = cfg.MTU - - s.networkMonitor = *cfg.NetworkMonitor - s.disableDNS = cfg.DisableDNS - s.disableClientRoutes = cfg.DisableClientRoutes - s.disableServerRoutes = cfg.DisableServerRoutes - s.blockLANAccess = cfg.BlockLANAccess - - if cfg.EnableSSHRoot != nil { - s.enableSSHRoot = *cfg.EnableSSHRoot - } - if cfg.EnableSSHSFTP != nil { - s.enableSSHSFTP = *cfg.EnableSSHSFTP - } - if cfg.EnableSSHLocalPortForwarding != nil { - s.enableSSHLocalPortForward = *cfg.EnableSSHLocalPortForwarding - } - if cfg.EnableSSHRemotePortForwarding != nil { - s.enableSSHRemotePortForward = *cfg.EnableSSHRemotePortForwarding - } - if cfg.DisableSSHAuth != nil { - s.disableSSHAuth = *cfg.DisableSSHAuth - } - if cfg.SSHJWTCacheTTL != nil { - s.sshJWTCacheTTL = *cfg.SSHJWTCacheTTL - } - - if s.showAdvancedSettings { - s.iMngURL.SetText(s.managementURL) - s.iPreSharedKey.SetText(cfg.PreSharedKey) - s.iInterfaceName.SetText(cfg.WgIface) - s.iInterfacePort.SetText(strconv.Itoa(cfg.WgPort)) - if cfg.MTU != 0 { - s.iMTU.SetText(strconv.Itoa(int(cfg.MTU))) - } else { - s.iMTU.SetText("") - s.iMTU.SetPlaceHolder(strconv.Itoa(int(iface.DefaultMTU))) - } - s.sRosenpassPermissive.SetChecked(cfg.RosenpassPermissive) - if !cfg.RosenpassEnabled { - s.sRosenpassPermissive.Disable() - } - s.sNetworkMonitor.SetChecked(*cfg.NetworkMonitor) - s.sDisableDNS.SetChecked(cfg.DisableDNS) - s.sDisableClientRoutes.SetChecked(cfg.DisableClientRoutes) - s.sDisableServerRoutes.SetChecked(cfg.DisableServerRoutes) - s.sBlockLANAccess.SetChecked(cfg.BlockLANAccess) - if cfg.EnableSSHRoot != nil { - s.sEnableSSHRoot.SetChecked(*cfg.EnableSSHRoot) - } - if cfg.EnableSSHSFTP != nil { - s.sEnableSSHSFTP.SetChecked(*cfg.EnableSSHSFTP) - } - if cfg.EnableSSHLocalPortForwarding != nil { - s.sEnableSSHLocalPortForward.SetChecked(*cfg.EnableSSHLocalPortForwarding) - } - if cfg.EnableSSHRemotePortForwarding != nil { - s.sEnableSSHRemotePortForward.SetChecked(*cfg.EnableSSHRemotePortForwarding) - } - if cfg.DisableSSHAuth != nil { - s.sDisableSSHAuth.SetChecked(*cfg.DisableSSHAuth) - } - if cfg.SSHJWTCacheTTL != nil { - s.iSSHJWTCacheTTL.SetText(strconv.Itoa(*cfg.SSHJWTCacheTTL)) - } - } - - if s.mNotifications == nil { - return - } - if cfg.DisableNotifications != nil && *cfg.DisableNotifications { - s.mNotifications.Uncheck() - } else { - s.mNotifications.Check() - } - if s.eventManager != nil { - s.eventManager.SetNotificationsEnabled(s.mNotifications.Checked()) - } -} - -func protoConfigToConfig(cfg *proto.GetConfigResponse) *profilemanager.Config { - - var config profilemanager.Config - - if cfg.ManagementUrl != "" { - parsed, err := url.Parse(cfg.ManagementUrl) - if err != nil { - log.Errorf("parse management URL: %v", err) - } else { - config.ManagementURL = parsed - } - } - - if cfg.PreSharedKey != "" { - if cfg.PreSharedKey != censoredPreSharedKey { - config.PreSharedKey = cfg.PreSharedKey - } else { - config.PreSharedKey = "" - } - } - if cfg.AdminURL != "" { - parsed, err := url.Parse(cfg.AdminURL) - if err != nil { - log.Errorf("parse admin URL: %v", err) - } else { - config.AdminURL = parsed - } - } - - config.WgIface = cfg.InterfaceName - if cfg.WireguardPort != 0 { - config.WgPort = int(cfg.WireguardPort) - } else { - config.WgPort = iface.DefaultWgPort - } - - if cfg.Mtu != 0 { - config.MTU = uint16(cfg.Mtu) - } else { - config.MTU = iface.DefaultMTU - } - - config.DisableAutoConnect = cfg.DisableAutoConnect - config.ServerSSHAllowed = &cfg.ServerSSHAllowed - config.RosenpassEnabled = cfg.RosenpassEnabled - config.RosenpassPermissive = cfg.RosenpassPermissive - config.DisableNotifications = &cfg.DisableNotifications - config.LazyConnectionEnabled = cfg.LazyConnectionEnabled - config.BlockInbound = cfg.BlockInbound - config.NetworkMonitor = &cfg.NetworkMonitor - config.DisableDNS = cfg.DisableDns - config.DisableClientRoutes = cfg.DisableClientRoutes - config.DisableServerRoutes = cfg.DisableServerRoutes - config.BlockLANAccess = cfg.BlockLanAccess - - config.EnableSSHRoot = &cfg.EnableSSHRoot - config.EnableSSHSFTP = &cfg.EnableSSHSFTP - config.EnableSSHLocalPortForwarding = &cfg.EnableSSHLocalPortForwarding - config.EnableSSHRemotePortForwarding = &cfg.EnableSSHRemotePortForwarding - config.DisableSSHAuth = &cfg.DisableSSHAuth - - ttl := int(cfg.SshJWTCacheTTL) - config.SSHJWTCacheTTL = &ttl - - return &config -} - -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 - - if s.connected { - systray.SetTemplateIcon(iconUpdateConnectedMacOS, s.icUpdateConnected) - } 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. -func (s *serviceClient) onSessionExpire() { - s.sendNotification = true - if s.sendNotification { - go s.eventHandler.runSelfCommand(s.ctx, "login-url", "true") - s.sendNotification = false - } -} - -// loadSettings loads the settings from the config file and updates the UI elements accordingly. -func (s *serviceClient) loadSettings() { - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - log.Errorf("get client: %v", err) - return - } - - currUser, err := user.Current() - if err != nil { - log.Errorf("get current user: %v", err) - return - } - - activeProf, err := s.profileManager.GetActiveProfile() - if err != nil { - log.Errorf("get active profile: %v", err) - return - } - - cfg, err := conn.GetConfig(s.ctx, &proto.GetConfigRequest{ - ProfileName: activeProf.Name, - Username: currUser.Username, - }) - if err != nil { - log.Errorf("get config settings from server: %v", err) - return - } - - if cfg.ServerSSHAllowed { - s.mAllowSSH.Check() - } else { - s.mAllowSSH.Uncheck() - } - - if cfg.DisableAutoConnect { - s.mAutoConnect.Uncheck() - } else { - s.mAutoConnect.Check() - } - - if cfg.RosenpassEnabled { - s.mEnableRosenpass.Check() - } else { - s.mEnableRosenpass.Uncheck() - } - - if cfg.LazyConnectionEnabled { - s.mLazyConnEnabled.Check() - } else { - s.mLazyConnEnabled.Uncheck() - } - - if cfg.BlockInbound { - s.mBlockInbound.Check() - } else { - s.mBlockInbound.Uncheck() - } - - if cfg.DisableNotifications { - s.mNotifications.Uncheck() - } else { - s.mNotifications.Check() - } - if s.eventManager != nil { - s.eventManager.SetNotificationsEnabled(s.mNotifications.Checked()) - } -} - -// updateConfig updates the configuration parameters -// based on the values selected in the settings window. -func (s *serviceClient) updateConfig() error { - disableAutoStart := !s.mAutoConnect.Checked() - sshAllowed := s.mAllowSSH.Checked() - rosenpassEnabled := s.mEnableRosenpass.Checked() - lazyConnectionEnabled := s.mLazyConnEnabled.Checked() - blockInbound := s.mBlockInbound.Checked() - notificationsDisabled := !s.mNotifications.Checked() - - activeProf, err := s.profileManager.GetActiveProfile() - if err != nil { - log.Errorf("get active profile: %v", err) - return err - } - - currUser, err := user.Current() - if err != nil { - log.Errorf("get current user: %v", err) - return err - } - - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - log.Errorf("get client: %v", err) - return err - } - - req := proto.SetConfigRequest{ - ProfileName: activeProf.Name, - Username: currUser.Username, - DisableAutoConnect: &disableAutoStart, - ServerSSHAllowed: &sshAllowed, - RosenpassEnabled: &rosenpassEnabled, - LazyConnectionEnabled: &lazyConnectionEnabled, - BlockInbound: &blockInbound, - DisableNotifications: ¬ificationsDisabled, - } - - if _, err := conn.SetConfig(s.ctx, &req); err != nil { - log.Errorf("set config settings on server: %v", err) - return err - } - - return nil -} - -// showLoginURL creates a borderless window styled like a pop-up in the top-right corner using s.wLoginURL. -// It also starts a background goroutine that periodically checks if the client is already connected -// and closes the window if so. The goroutine can be cancelled by the returned CancelFunc, and it is -// also cancelled when the window is closed. -func (s *serviceClient) showLoginURL() context.CancelFunc { - - // create a cancellable context for the background check goroutine - ctx, cancel := context.WithCancel(s.ctx) - - resIcon := fyne.NewStaticResource("netbird.png", iconAbout) - - if s.wLoginURL == nil { - s.wLoginURL = s.app.NewWindow("NetBird Session Expired") - s.wLoginURL.Resize(fyne.NewSize(400, 200)) - s.wLoginURL.SetIcon(resIcon) - } - // ensure goroutine is cancelled when the window is closed - s.wLoginURL.SetOnClosed(func() { cancel() }) - // add a description label - label := widget.NewLabel("Your NetBird session has expired.\nPlease re-authenticate to continue using NetBird.") - - btn := widget.NewButtonWithIcon("Re-authenticate", theme.ViewRefreshIcon(), func() { - - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - log.Errorf("get client: %v", err) - return - } - - resp, err := s.login(ctx, false) - if err != nil { - log.Errorf("failed to fetch login URL: %v", err) - return - } - verificationURL := resp.VerificationURIComplete - if verificationURL == "" { - verificationURL = resp.VerificationURI - } - - if verificationURL == "" { - log.Error("no verification URL provided in the login response") - return - } - - if err := openURL(verificationURL); err != nil { - log.Errorf("failed to open login URL: %v", err) - return - } - - _, err = conn.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{UserCode: resp.UserCode}) - if err != nil { - log.Errorf("Waiting sso login failed with: %v", err) - label.SetText("Waiting login failed, please create \na debug bundle in the settings and contact support.") - return - } - - label.SetText("Re-authentication successful.\nReconnecting") - status, err := conn.Status(ctx, &proto.StatusRequest{}) - if err != nil { - log.Errorf("get service status: %v", err) - return - } - - if status.Status == string(internal.StatusConnected) { - label.SetText("Already connected.\nClosing this window.") - time.Sleep(2 * time.Second) - s.wLoginURL.Close() - return - } - - _, err = conn.Up(ctx, &proto.UpRequest{}) - if err != nil { - label.SetText("Reconnecting failed, please create \na debug bundle in the settings and contact support.") - log.Errorf("Reconnecting failed with: %v", err) - return - } - - label.SetText("Connection successful.\nClosing this window.") - time.Sleep(time.Second) - - s.wLoginURL.Close() - }) - - img := canvas.NewImageFromResource(resIcon) - img.FillMode = canvas.ImageFillContain - img.SetMinSize(fyne.NewSize(64, 64)) - img.Resize(fyne.NewSize(64, 64)) - - // center the content vertically - content := container.NewVBox( - layout.NewSpacer(), - img, - label, - btn, - layout.NewSpacer(), - ) - s.wLoginURL.SetContent(container.NewCenter(content)) - - // start a goroutine to check connection status and close the window if connected - go func() { - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() - - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - return - } - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - status, err := conn.Status(s.ctx, &proto.StatusRequest{}) - if err != nil { - continue - } - if status.Status == string(internal.StatusConnected) { - if s.wLoginURL != nil { - s.wLoginURL.Close() - } - return - } - } - } - }() - - s.wLoginURL.Show() - - // return cancel func so callers can stop the background goroutine if desired - return cancel -} - -func openURL(url string) error { - if browser := os.Getenv("BROWSER"); browser != "" { - return exec.Command(browser, url).Start() - } - - var err error - switch runtime.GOOS { - case "windows": - err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() - case "darwin": - err = exec.Command("open", url).Start() - case "linux", "freebsd": - err = exec.Command("xdg-open", url).Start() - default: - err = fmt.Errorf("unsupported platform") - } - return err -} diff --git a/client/ui/const.go b/client/ui/const.go deleted file mode 100644 index 48619be75..000000000 --- a/client/ui/const.go +++ /dev/null @@ -1,16 +0,0 @@ -package main - -const ( - allowSSHMenuDescr = "Allow SSH connections" - autoConnectMenuDescr = "Connect automatically when the service starts" - quantumResistanceMenuDescr = "Enable post-quantum security via Rosenpass" - lazyConnMenuDescr = "[Experimental] Enable lazy connections" - blockInboundMenuDescr = "Block inbound connections to the local machine and routed networks" - notificationsMenuDescr = "Enable notifications" - advancedSettingsMenuDescr = "Advanced settings of the application" - debugBundleMenuDescr = "Create and open debug information bundle" - disabledMenuDescr = "" - networksMenuDescr = "Open the networks management window" - latestVersionMenuDescr = "Download latest version" - quitMenuDescr = "Quit the client app" -) diff --git a/client/ui/debug.go b/client/ui/debug.go deleted file mode 100644 index 4ebe4d675..000000000 --- a/client/ui/debug.go +++ /dev/null @@ -1,700 +0,0 @@ -//go:build !(linux && 386) - -package main - -import ( - "context" - "fmt" - "path/filepath" - "strconv" - "sync" - "time" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/dialog" - "fyne.io/fyne/v2/widget" - log "github.com/sirupsen/logrus" - "github.com/skratchdot/open-golang/open" - - "github.com/netbirdio/netbird/client/internal" - "github.com/netbirdio/netbird/client/proto" - uptypes "github.com/netbirdio/netbird/upload-server/types" -) - -// Initial state for the debug collection -type debugInitialState struct { - wasDown bool - needsRestoreUp bool - logLevel proto.LogLevel - isLevelTrace bool -} - -// Debug collection parameters -type debugCollectionParams struct { - duration time.Duration - anonymize bool - systemInfo bool - upload bool - uploadURL string - enablePersistence bool -} - -// UI components for progress tracking -type progressUI struct { - statusLabel *widget.Label - progressBar *widget.ProgressBar - uiControls []fyne.Disableable - window fyne.Window -} - -func (s *serviceClient) showDebugUI() { - w := s.app.NewWindow("NetBird Debug") - w.SetOnClosed(s.cancel) - - w.Resize(fyne.NewSize(600, 500)) - w.SetFixedSize(true) - - anonymizeCheck := widget.NewCheck("Anonymize sensitive information (public IPs, domains, ...)", nil) - systemInfoCheck := widget.NewCheck("Include system information (routes, interfaces, ...)", nil) - systemInfoCheck.SetChecked(true) - uploadCheck := widget.NewCheck("Upload bundle automatically after creation", nil) - uploadCheck.SetChecked(true) - - uploadURLLabel := widget.NewLabel("Debug upload URL:") - uploadURL := widget.NewEntry() - uploadURL.SetText(uptypes.DefaultBundleURL) - uploadURL.SetPlaceHolder("Enter upload URL") - - uploadURLContainer := container.NewVBox( - uploadURLLabel, - uploadURL, - ) - - uploadCheck.OnChanged = func(checked bool) { - if checked { - uploadURLContainer.Show() - } else { - uploadURLContainer.Hide() - } - } - - debugModeContainer := container.NewHBox() - runForDurationCheck := widget.NewCheck("Run with trace logs before creating bundle", nil) - runForDurationCheck.SetChecked(true) - - forLabel := widget.NewLabel("for") - - durationInput := widget.NewEntry() - durationInput.SetText("1") - minutesLabel := widget.NewLabel("minute") - durationInput.Validator = func(s string) error { - return validateMinute(s, minutesLabel) - } - - noteLabel := widget.NewLabel("Note: NetBird will be brought up and down during collection") - - runForDurationCheck.OnChanged = func(checked bool) { - if checked { - forLabel.Show() - durationInput.Show() - minutesLabel.Show() - noteLabel.Show() - } else { - forLabel.Hide() - durationInput.Hide() - minutesLabel.Hide() - noteLabel.Hide() - } - } - - debugModeContainer.Add(runForDurationCheck) - debugModeContainer.Add(forLabel) - debugModeContainer.Add(durationInput) - debugModeContainer.Add(minutesLabel) - - statusLabel := widget.NewLabel("") - statusLabel.Hide() - - progressBar := widget.NewProgressBar() - progressBar.Hide() - - createButton := widget.NewButton("Create Debug Bundle", nil) - - // UI controls that should be disabled during debug collection - uiControls := []fyne.Disableable{ - anonymizeCheck, - systemInfoCheck, - uploadCheck, - uploadURL, - runForDurationCheck, - durationInput, - createButton, - } - - createButton.OnTapped = s.getCreateHandler( - statusLabel, - progressBar, - uploadCheck, - uploadURL, - anonymizeCheck, - systemInfoCheck, - runForDurationCheck, - durationInput, - uiControls, - w, - ) - - content := container.NewVBox( - widget.NewLabel("Create a debug bundle to help troubleshoot issues with NetBird"), - widget.NewLabel(""), - anonymizeCheck, - systemInfoCheck, - uploadCheck, - uploadURLContainer, - widget.NewLabel(""), - debugModeContainer, - noteLabel, - widget.NewLabel(""), - statusLabel, - progressBar, - createButton, - ) - - paddedContent := container.NewPadded(content) - w.SetContent(paddedContent) - - w.Show() -} - -func validateMinute(s string, minutesLabel *widget.Label) error { - if val, err := strconv.Atoi(s); err != nil || val < 1 { - return fmt.Errorf("must be a number ≥ 1") - } - if s == "1" { - minutesLabel.SetText("minute") - } else { - minutesLabel.SetText("minutes") - } - return nil -} - -// disableUIControls disables the provided UI controls -func disableUIControls(controls []fyne.Disableable) { - for _, control := range controls { - control.Disable() - } -} - -// enableUIControls enables the provided UI controls -func enableUIControls(controls []fyne.Disableable) { - for _, control := range controls { - control.Enable() - } -} - -func (s *serviceClient) getCreateHandler( - statusLabel *widget.Label, - progressBar *widget.ProgressBar, - uploadCheck *widget.Check, - uploadURL *widget.Entry, - anonymizeCheck *widget.Check, - systemInfoCheck *widget.Check, - runForDurationCheck *widget.Check, - duration *widget.Entry, - uiControls []fyne.Disableable, - w fyne.Window, -) func() { - return func() { - disableUIControls(uiControls) - statusLabel.Show() - - var url string - if uploadCheck.Checked { - url = uploadURL.Text - if url == "" { - statusLabel.SetText("Error: Upload URL is required when upload is enabled") - enableUIControls(uiControls) - return - } - } - - params := &debugCollectionParams{ - anonymize: anonymizeCheck.Checked, - systemInfo: systemInfoCheck.Checked, - upload: uploadCheck.Checked, - uploadURL: url, - enablePersistence: true, - } - - runForDuration := runForDurationCheck.Checked - if runForDuration { - minutes, err := time.ParseDuration(duration.Text + "m") - if err != nil { - statusLabel.SetText(fmt.Sprintf("Error: Invalid duration: %v", err)) - enableUIControls(uiControls) - return - } - params.duration = minutes - - statusLabel.SetText(fmt.Sprintf("Running in debug mode for %d minutes...", int(minutes.Minutes()))) - progressBar.Show() - progressBar.SetValue(0) - - go s.handleRunForDuration( - statusLabel, - progressBar, - uiControls, - w, - params, - ) - return - } - - statusLabel.SetText("Creating debug bundle...") - go s.handleDebugCreation( - anonymizeCheck.Checked, - systemInfoCheck.Checked, - uploadCheck.Checked, - url, - statusLabel, - uiControls, - w, - ) - } -} - -func (s *serviceClient) handleRunForDuration( - statusLabel *widget.Label, - progressBar *widget.ProgressBar, - uiControls []fyne.Disableable, - w fyne.Window, - params *debugCollectionParams, -) { - progressUI := &progressUI{ - statusLabel: statusLabel, - progressBar: progressBar, - uiControls: uiControls, - window: w, - } - - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - handleError(progressUI, fmt.Sprintf("Failed to get client for debug: %v", err)) - return - } - - initialState, err := s.getInitialState(conn) - if err != nil { - handleError(progressUI, err.Error()) - return - } - - defer s.restoreServiceState(conn, initialState) - - if err := s.collectDebugData(conn, initialState, params, progressUI); err != nil { - handleError(progressUI, err.Error()) - return - } - - if err := s.createDebugBundleFromCollection(conn, params, progressUI); err != nil { - handleError(progressUI, err.Error()) - return - } - - progressUI.statusLabel.SetText("Bundle created successfully") -} - -// Get initial state of the service -func (s *serviceClient) getInitialState(conn proto.DaemonServiceClient) (*debugInitialState, error) { - statusResp, err := conn.Status(s.ctx, &proto.StatusRequest{}) - if err != nil { - return nil, fmt.Errorf(" get status: %v", err) - } - - logLevelResp, err := conn.GetLogLevel(s.ctx, &proto.GetLogLevelRequest{}) - if err != nil { - return nil, fmt.Errorf("get log level: %v", err) - } - - wasDown := statusResp.Status != string(internal.StatusConnected) && - statusResp.Status != string(internal.StatusConnecting) - - initialLogLevel := logLevelResp.GetLevel() - initialLevelTrace := initialLogLevel >= proto.LogLevel_TRACE - - return &debugInitialState{ - wasDown: wasDown, - logLevel: initialLogLevel, - isLevelTrace: initialLevelTrace, - }, nil -} - -// Handle progress tracking during collection -func startProgressTracker(ctx context.Context, wg *sync.WaitGroup, duration time.Duration, progress *progressUI) { - progress.progressBar.Show() - progress.progressBar.SetValue(0) - - startTime := time.Now() - endTime := startTime.Add(duration) - wg.Add(1) - - go func() { - defer wg.Done() - ticker := time.NewTicker(500 * time.Millisecond) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - remaining := time.Until(endTime) - if remaining <= 0 { - remaining = 0 - } - - elapsed := time.Since(startTime) - progressVal := float64(elapsed) / float64(duration) - if progressVal > 1.0 { - progressVal = 1.0 - } - - progress.progressBar.SetValue(progressVal) - progress.statusLabel.SetText(fmt.Sprintf("Running with trace logs... %s remaining", formatDuration(remaining))) - } - } - }() - -} - -func (s *serviceClient) configureServiceForDebug( - conn proto.DaemonServiceClient, - state *debugInitialState, - enablePersistence bool, -) { - if state.wasDown { - if _, err := conn.Up(s.ctx, &proto.UpRequest{}); err != nil { - log.Warnf("failed to bring service up: %v", err) - } else { - log.Info("Service brought up for debug") - time.Sleep(time.Second * 10) - } - } - - if !state.isLevelTrace { - if _, err := conn.SetLogLevel(s.ctx, &proto.SetLogLevelRequest{Level: proto.LogLevel_TRACE}); err != nil { - log.Warnf("failed to set log level to TRACE: %v", err) - } else { - log.Info("Log level set to TRACE for debug") - } - } - - if _, err := conn.Down(s.ctx, &proto.DownRequest{}); err != nil { - log.Warnf("failed to bring service down: %v", err) - } else { - state.needsRestoreUp = !state.wasDown - time.Sleep(time.Second) - } - - if enablePersistence { - if _, err := conn.SetSyncResponsePersistence(s.ctx, &proto.SetSyncResponsePersistenceRequest{ - Enabled: true, - }); err != nil { - log.Warnf("failed to enable sync response persistence: %v", err) - } else { - log.Info("Sync response persistence enabled for debug") - } - } - - if _, err := conn.Up(s.ctx, &proto.UpRequest{}); err != nil { - log.Warnf("failed to bring service back up: %v", err) - } else { - state.needsRestoreUp = false - time.Sleep(time.Second * 3) - } - - if _, err := conn.StartCPUProfile(s.ctx, &proto.StartCPUProfileRequest{}); err != nil { - log.Warnf("failed to start CPU profiling: %v", err) - } -} - -func (s *serviceClient) collectDebugData( - conn proto.DaemonServiceClient, - state *debugInitialState, - params *debugCollectionParams, - progress *progressUI, -) error { - ctx, cancel := context.WithTimeout(s.ctx, params.duration) - defer cancel() - var wg sync.WaitGroup - startProgressTracker(ctx, &wg, params.duration, progress) - - s.configureServiceForDebug(conn, state, params.enablePersistence) - - wg.Wait() - progress.progressBar.Hide() - progress.statusLabel.SetText("Collecting debug data...") - - if _, err := conn.StopCPUProfile(s.ctx, &proto.StopCPUProfileRequest{}); err != nil { - log.Warnf("failed to stop CPU profiling: %v", err) - } - - return nil -} - -// Create the debug bundle with collected data -func (s *serviceClient) createDebugBundleFromCollection( - conn proto.DaemonServiceClient, - params *debugCollectionParams, - progress *progressUI, -) error { - progress.statusLabel.SetText("Creating debug bundle with collected logs...") - - request := &proto.DebugBundleRequest{ - Anonymize: params.anonymize, - SystemInfo: params.systemInfo, - } - - if params.upload { - request.UploadURL = params.uploadURL - } - - resp, err := conn.DebugBundle(s.ctx, request) - if err != nil { - return fmt.Errorf("create debug bundle: %v", err) - } - - // Show appropriate dialog based on upload status - localPath := resp.GetPath() - uploadFailureReason := resp.GetUploadFailureReason() - uploadedKey := resp.GetUploadedKey() - - if params.upload { - if uploadFailureReason != "" { - showUploadFailedDialog(progress.window, localPath, uploadFailureReason) - } else { - showUploadSuccessDialog(s.app, progress.window, localPath, uploadedKey) - } - } else { - showBundleCreatedDialog(progress.window, localPath) - } - - enableUIControls(progress.uiControls) - return nil -} - -// Restore service to original state -func (s *serviceClient) restoreServiceState(conn proto.DaemonServiceClient, state *debugInitialState) { - if state.needsRestoreUp { - if _, err := conn.Up(s.ctx, &proto.UpRequest{}); err != nil { - log.Warnf("failed to restore up state: %v", err) - } else { - log.Info("Service state restored to up") - } - } - - if state.wasDown { - if _, err := conn.Down(s.ctx, &proto.DownRequest{}); err != nil { - log.Warnf("failed to restore down state: %v", err) - } else { - log.Info("Service state restored to down") - } - } - - if !state.isLevelTrace { - if _, err := conn.SetLogLevel(s.ctx, &proto.SetLogLevelRequest{Level: state.logLevel}); err != nil { - log.Warnf("failed to restore log level: %v", err) - } else { - log.Info("Log level restored to original setting") - } - } -} - -// Handle errors during debug collection -func handleError(progress *progressUI, errMsg string) { - log.Errorf("%s", errMsg) - progress.statusLabel.SetText(errMsg) - progress.progressBar.Hide() - enableUIControls(progress.uiControls) -} - -func (s *serviceClient) handleDebugCreation( - anonymize bool, - systemInfo bool, - upload bool, - uploadURL string, - statusLabel *widget.Label, - uiControls []fyne.Disableable, - w fyne.Window, -) { - log.Infof("Creating debug bundle (Anonymized: %v, System Info: %v, Upload Attempt: %v)...", - anonymize, systemInfo, upload) - - resp, err := s.createDebugBundle(anonymize, systemInfo, uploadURL) - if err != nil { - log.Errorf("Failed to create debug bundle: %v", err) - statusLabel.SetText(fmt.Sprintf("Error creating bundle: %v", err)) - enableUIControls(uiControls) - return - } - - localPath := resp.GetPath() - uploadFailureReason := resp.GetUploadFailureReason() - uploadedKey := resp.GetUploadedKey() - - if upload { - if uploadFailureReason != "" { - showUploadFailedDialog(w, localPath, uploadFailureReason) - } else { - showUploadSuccessDialog(s.app, w, localPath, uploadedKey) - } - } else { - showBundleCreatedDialog(w, localPath) - } - - enableUIControls(uiControls) - statusLabel.SetText("Bundle created successfully") -} - -func (s *serviceClient) createDebugBundle(anonymize bool, systemInfo bool, uploadURL string) (*proto.DebugBundleResponse, error) { - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - return nil, fmt.Errorf("get client: %v", err) - } - - request := &proto.DebugBundleRequest{ - Anonymize: anonymize, - SystemInfo: systemInfo, - } - - if uploadURL != "" { - request.UploadURL = uploadURL - } - - resp, err := conn.DebugBundle(s.ctx, request) - if err != nil { - return nil, fmt.Errorf("failed to create debug bundle via daemon: %v", err) - } - - return resp, nil -} - -// formatDuration formats a duration in HH:MM:SS format -func formatDuration(d time.Duration) string { - d = d.Round(time.Second) - h := d / time.Hour - d %= time.Hour - m := d / time.Minute - d %= time.Minute - s := d / time.Second - return fmt.Sprintf("%02d:%02d:%02d", h, m, s) -} - -// createButtonWithAction creates a button with the given label and action -func createButtonWithAction(label string, action func()) *widget.Button { - button := widget.NewButton(label, action) - return button -} - -// showUploadFailedDialog displays a dialog when upload fails -func showUploadFailedDialog(w fyne.Window, localPath, failureReason string) { - content := container.NewVBox( - widget.NewLabel(fmt.Sprintf("Bundle upload failed:\n%s\n\n"+ - "A local copy was saved at:\n%s", failureReason, localPath)), - ) - - customDialog := dialog.NewCustom("Upload Failed", "Cancel", content, w) - - buttonBox := container.NewHBox( - createButtonWithAction("Open file", func() { - log.Infof("Attempting to open local file: %s", localPath) - if openErr := open.Start(localPath); openErr != nil { - log.Errorf("Failed to open local file '%s': %v", localPath, openErr) - dialog.ShowError(fmt.Errorf("open the local file:\n%s\n\nError: %v", localPath, openErr), w) - } - }), - createButtonWithAction("Open folder", func() { - folderPath := filepath.Dir(localPath) - log.Infof("Attempting to open local folder: %s", folderPath) - if openErr := open.Start(folderPath); openErr != nil { - log.Errorf("Failed to open local folder '%s': %v", folderPath, openErr) - dialog.ShowError(fmt.Errorf("open the local folder:\n%s\n\nError: %v", folderPath, openErr), w) - } - }), - ) - - content.Add(buttonBox) - customDialog.Show() -} - -// showUploadSuccessDialog displays a dialog when upload succeeds -func showUploadSuccessDialog(a fyne.App, w fyne.Window, localPath, uploadedKey string) { - log.Infof("Upload key: %s", uploadedKey) - keyEntry := widget.NewEntry() - keyEntry.SetText(uploadedKey) - keyEntry.Disable() - - content := container.NewVBox( - widget.NewLabel("Bundle uploaded successfully!"), - widget.NewLabel(""), - widget.NewLabel("Upload key:"), - keyEntry, - widget.NewLabel(""), - widget.NewLabel(fmt.Sprintf("Local copy saved at:\n%s", localPath)), - ) - - customDialog := dialog.NewCustom("Upload Successful", "OK", content, w) - - copyBtn := createButtonWithAction("Copy key", func() { - a.Clipboard().SetContent(uploadedKey) - log.Info("Upload key copied to clipboard") - }) - - buttonBox := createButtonBox(localPath, w, copyBtn) - content.Add(buttonBox) - customDialog.Show() -} - -// showBundleCreatedDialog displays a dialog when bundle is created without upload -func showBundleCreatedDialog(w fyne.Window, localPath string) { - content := container.NewVBox( - widget.NewLabel(fmt.Sprintf("Bundle created locally at:\n%s\n\n"+ - "Administrator privileges may be required to access the file.", localPath)), - ) - - customDialog := dialog.NewCustom("Debug Bundle Created", "Cancel", content, w) - - buttonBox := createButtonBox(localPath, w, nil) - content.Add(buttonBox) - customDialog.Show() -} - -func createButtonBox(localPath string, w fyne.Window, elems ...fyne.Widget) *fyne.Container { - box := container.NewHBox() - for _, elem := range elems { - box.Add(elem) - } - - fileBtn := createButtonWithAction("Open file", func() { - log.Infof("Attempting to open local file: %s", localPath) - if openErr := open.Start(localPath); openErr != nil { - log.Errorf("Failed to open local file '%s': %v", localPath, openErr) - dialog.ShowError(fmt.Errorf("open the local file:\n%s\n\nError: %v", localPath, openErr), w) - } - }) - - folderBtn := createButtonWithAction("Open folder", func() { - folderPath := filepath.Dir(localPath) - log.Infof("Attempting to open local folder: %s", folderPath) - if openErr := open.Start(folderPath); openErr != nil { - log.Errorf("Failed to open local folder '%s': %v", folderPath, openErr) - dialog.ShowError(fmt.Errorf("open the local folder:\n%s\n\nError: %v", folderPath, openErr), w) - } - }) - - box.Add(fileBtn) - box.Add(folderBtn) - - return box -} diff --git a/client/ui/dock_darwin.go b/client/ui/dock_darwin.go new file mode 100644 index 000000000..dd8c60073 --- /dev/null +++ b/client/ui/dock_darwin.go @@ -0,0 +1,70 @@ +//go:build darwin + +package main + +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework Cocoa +#import + +static int lastDockState = -1; + +static void refreshDockPolicy(void) { + dispatch_async(dispatch_get_main_queue(), ^{ + Class cls = NSClassFromString(@"WebviewWindow"); + if (cls == nil) { + return; + } + int visible = 0; + for (NSWindow *w in [NSApp windows]) { + if ([w isKindOfClass:cls] && [w isVisible]) { + visible = 1; + break; + } + } + if (visible == lastDockState) { + return; + } + lastDockState = visible; + + // Set application to "Regular" and show dock icon (when visible) or to "Accessory" (when hidden) + if (visible) { + [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular]; + [NSApp activateIgnoringOtherApps:YES]; + } else { + [NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory]; + } + }); +} + +static int dockObserverInstalled = 0; + +static void initDockObserver(void) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (dockObserverInstalled) { + return; + } + dockObserverInstalled = 1; + NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; + void (^trigger)(NSNotification *) = ^(NSNotification *_) { + refreshDockPolicy(); + }; + + [nc addObserverForName:NSWindowDidChangeOcclusionStateNotification + object:nil + queue:nil + usingBlock:trigger]; + [nc addObserverForName:NSWindowWillCloseNotification + object:nil + queue:nil + usingBlock:trigger]; + + refreshDockPolicy(); + }); +} +*/ +import "C" + +func initDockObserver() { + C.initDockObserver() +} diff --git a/client/ui/dock_other.go b/client/ui/dock_other.go new file mode 100644 index 000000000..0ace89552 --- /dev/null +++ b/client/ui/dock_other.go @@ -0,0 +1,7 @@ +//go:build !darwin && !android && !ios && !freebsd && !js + +package main + +func initDockObserver() { + // macOS-only; Linux and Windows taskbar entries already gate on window visibility natively. +} diff --git a/client/ui/event/event.go b/client/ui/event/event.go deleted file mode 100644 index b8ed09a5c..000000000 --- a/client/ui/event/event.go +++ /dev/null @@ -1,171 +0,0 @@ -package event - -import ( - "context" - "fmt" - "slices" - "strings" - "sync" - "time" - - "fyne.io/fyne/v2" - "github.com/cenkalti/backoff/v4" - log "github.com/sirupsen/logrus" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" - - "github.com/netbirdio/netbird/client/proto" - "github.com/netbirdio/netbird/client/ui/desktop" -) - -type Handler func(*proto.SystemEvent) - -type Manager struct { - app fyne.App - addr string - - mu sync.Mutex - ctx context.Context - cancel context.CancelFunc - enabled bool - handlers []Handler -} - -func NewManager(app fyne.App, addr string) *Manager { - return &Manager{ - app: app, - addr: addr, - } -} - -func (e *Manager) Start(ctx context.Context) { - e.mu.Lock() - e.ctx, e.cancel = context.WithCancel(ctx) - e.mu.Unlock() - - expBackOff := backoff.WithContext(&backoff.ExponentialBackOff{ - InitialInterval: time.Second, - RandomizationFactor: backoff.DefaultRandomizationFactor, - Multiplier: backoff.DefaultMultiplier, - MaxInterval: 10 * time.Second, - MaxElapsedTime: 0, - Stop: backoff.Stop, - Clock: backoff.SystemClock, - }, ctx) - - if err := backoff.Retry(e.streamEvents, expBackOff); err != nil { - log.Errorf("event stream ended: %v", err) - } -} - -func (e *Manager) streamEvents() error { - e.mu.Lock() - ctx := e.ctx - e.mu.Unlock() - - client, err := getClient(e.addr) - if err != nil { - return fmt.Errorf("create client: %w", err) - } - - stream, err := client.SubscribeEvents(ctx, &proto.SubscribeRequest{}) - if err != nil { - return fmt.Errorf("failed to subscribe to events: %w", err) - } - - log.Info("subscribed to daemon events") - defer func() { - log.Info("unsubscribed from daemon events") - }() - - for { - event, err := stream.Recv() - if err != nil { - return fmt.Errorf("error receiving event: %w", err) - } - e.handleEvent(event) - } -} - -func (e *Manager) Stop() { - e.mu.Lock() - defer e.mu.Unlock() - if e.cancel != nil { - e.cancel() - } -} - -func (e *Manager) SetNotificationsEnabled(enabled bool) { - e.mu.Lock() - defer e.mu.Unlock() - e.enabled = enabled -} - -func (e *Manager) handleEvent(event *proto.SystemEvent) { - e.mu.Lock() - enabled := e.enabled - handlers := slices.Clone(e.handlers) - e.mu.Unlock() - - if event.UserMessage != "" && (enabled || event.Severity == proto.SystemEvent_CRITICAL) { - title := e.getEventTitle(event) - body := event.UserMessage - id := event.Metadata["id"] - if id != "" { - body += fmt.Sprintf(" ID: %s", id) - } - e.app.SendNotification(fyne.NewNotification(title, body)) - } - - for _, handler := range handlers { - go handler(event) - } -} - -func (e *Manager) AddHandler(handler Handler) { - e.mu.Lock() - defer e.mu.Unlock() - e.handlers = append(e.handlers, handler) -} - -func (e *Manager) getEventTitle(event *proto.SystemEvent) string { - var prefix string - switch event.Severity { - case proto.SystemEvent_CRITICAL: - prefix = "Critical" - case proto.SystemEvent_ERROR: - prefix = "Error" - case proto.SystemEvent_WARNING: - prefix = "Warning" - default: - prefix = "Info" - } - - var category string - switch event.Category { - case proto.SystemEvent_DNS: - category = "DNS" - case proto.SystemEvent_NETWORK: - category = "Network" - case proto.SystemEvent_AUTHENTICATION: - category = "Authentication" - case proto.SystemEvent_CONNECTIVITY: - category = "Connectivity" - default: - category = "System" - } - - return fmt.Sprintf("%s: %s", prefix, category) -} - -func getClient(addr string) (proto.DaemonServiceClient, error) { - conn, err := grpc.NewClient( - strings.TrimPrefix(addr, "tcp://"), - grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithUserAgent(desktop.GetUIUserAgent()), - ) - if err != nil { - return nil, err - } - return proto.NewDaemonServiceClient(conn), nil -} diff --git a/client/ui/event_handler.go b/client/ui/event_handler.go deleted file mode 100644 index 60a580dae..000000000 --- a/client/ui/event_handler.go +++ /dev/null @@ -1,327 +0,0 @@ -//go:build !(linux && 386) - -package main - -import ( - "context" - "errors" - "fmt" - "os" - "os/exec" - - "fyne.io/fyne/v2" - "fyne.io/systray" - log "github.com/sirupsen/logrus" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - - "github.com/netbirdio/netbird/client/proto" - "github.com/netbirdio/netbird/version" -) - -type eventHandler struct { - client *serviceClient -} - -func newEventHandler(client *serviceClient) *eventHandler { - return &eventHandler{ - client: client, - } -} - -func (h *eventHandler) listen(ctx context.Context) { - for { - select { - case <-ctx.Done(): - return - case <-h.client.mUp.ClickedCh: - h.handleConnectClick() - case <-h.client.mDown.ClickedCh: - h.handleDisconnectClick() - case <-h.client.mAllowSSH.ClickedCh: - h.handleAllowSSHClick() - case <-h.client.mAutoConnect.ClickedCh: - h.handleAutoConnectClick() - case <-h.client.mEnableRosenpass.ClickedCh: - h.handleRosenpassClick() - case <-h.client.mLazyConnEnabled.ClickedCh: - h.handleLazyConnectionClick() - case <-h.client.mBlockInbound.ClickedCh: - h.handleBlockInboundClick() - case <-h.client.mAdvancedSettings.ClickedCh: - h.handleAdvancedSettingsClick() - case <-h.client.mCreateDebugBundle.ClickedCh: - h.handleCreateDebugBundleClick() - case <-h.client.mQuit.ClickedCh: - h.handleQuitClick() - return - case <-h.client.mGitHub.ClickedCh: - h.handleGitHubClick() - case <-h.client.mUpdate.ClickedCh: - h.handleUpdateClick() - case <-h.client.mNetworks.ClickedCh: - h.handleNetworksClick() - case <-h.client.mNotifications.ClickedCh: - h.handleNotificationsClick() - case <-systray.TrayOpenedCh: - h.client.updateExitNodes() - } - } -} - -func (h *eventHandler) handleConnectClick() { - h.client.mUp.Disable() - - if h.client.connectCancel != nil { - h.client.connectCancel() - } - - connectCtx, connectCancel := context.WithCancel(h.client.ctx) - h.client.connectCancel = connectCancel - - go func() { - defer connectCancel() - - 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") - } else { - h.client.app.SendNotification(fyne.NewNotification("Error", "Failed to connect")) - log.Errorf("connect failed: %v", err) - } - } - - if err := h.client.updateStatus(); err != nil { - log.Debugf("failed to update status after connect: %v", err) - } - }() -} - -func (h *eventHandler) handleDisconnectClick() { - h.client.mDown.Disable() - h.client.cancelExitNodeRetry() - - if h.client.connectCancel != nil { - log.Debugf("cancelling ongoing connect operation") - h.client.connectCancel() - h.client.connectCancel = nil - } - - go func() { - if err := h.client.menuDownClick(); err != nil { - st, ok := status.FromError(err) - if !errors.Is(err, context.Canceled) && !(ok && st.Code() == codes.Canceled) { - h.client.app.SendNotification(fyne.NewNotification("Error", "Failed to disconnect")) - log.Errorf("disconnect failed: %v", err) - } else { - log.Debugf("disconnect cancelled or already disconnecting") - } - } - - if err := h.client.updateStatus(); err != nil { - log.Debugf("failed to update status after disconnect: %v", err) - } - }() -} - -func (h *eventHandler) handleAllowSSHClick() { - h.toggleCheckbox(h.client.mAllowSSH) - if err := h.updateConfigWithErr(); err != nil { - h.toggleCheckbox(h.client.mAllowSSH) // revert checkbox state on error - log.Errorf("failed to update config: %v", err) - h.client.app.SendNotification(fyne.NewNotification("Error", "Failed to update SSH settings")) - } - -} - -func (h *eventHandler) handleAutoConnectClick() { - h.toggleCheckbox(h.client.mAutoConnect) - if err := h.updateConfigWithErr(); err != nil { - h.toggleCheckbox(h.client.mAutoConnect) // revert checkbox state on error - log.Errorf("failed to update config: %v", err) - h.client.app.SendNotification(fyne.NewNotification("Error", "Failed to update auto-connect settings")) - } -} - -func (h *eventHandler) handleRosenpassClick() { - h.toggleCheckbox(h.client.mEnableRosenpass) - if err := h.updateConfigWithErr(); err != nil { - h.toggleCheckbox(h.client.mEnableRosenpass) // revert checkbox state on error - log.Errorf("failed to update config: %v", err) - h.client.app.SendNotification(fyne.NewNotification("Error", "Failed to update Rosenpass settings")) - } -} - -func (h *eventHandler) handleLazyConnectionClick() { - h.toggleCheckbox(h.client.mLazyConnEnabled) - if err := h.updateConfigWithErr(); err != nil { - h.toggleCheckbox(h.client.mLazyConnEnabled) // revert checkbox state on error - log.Errorf("failed to update config: %v", err) - h.client.app.SendNotification(fyne.NewNotification("Error", "Failed to update lazy connection settings")) - } -} - -func (h *eventHandler) handleBlockInboundClick() { - h.toggleCheckbox(h.client.mBlockInbound) - if err := h.updateConfigWithErr(); err != nil { - h.toggleCheckbox(h.client.mBlockInbound) // revert checkbox state on error - log.Errorf("failed to update config: %v", err) - h.client.app.SendNotification(fyne.NewNotification("Error", "Failed to update block inbound settings")) - } -} - -func (h *eventHandler) handleNotificationsClick() { - h.toggleCheckbox(h.client.mNotifications) - if err := h.updateConfigWithErr(); err != nil { - h.toggleCheckbox(h.client.mNotifications) // revert checkbox state on error - log.Errorf("failed to update config: %v", err) - h.client.app.SendNotification(fyne.NewNotification("Error", "Failed to update notifications settings")) - } else if h.client.eventManager != nil { - h.client.eventManager.SetNotificationsEnabled(h.client.mNotifications.Checked()) - } - -} - -func (h *eventHandler) handleAdvancedSettingsClick() { - h.client.mAdvancedSettings.Disable() - go func() { - defer h.client.mAdvancedSettings.Enable() - defer h.client.getSrvConfig() - h.runSelfCommand(h.client.ctx, "settings") - }() -} - -func (h *eventHandler) handleCreateDebugBundleClick() { - h.client.mCreateDebugBundle.Disable() - go func() { - defer h.client.mCreateDebugBundle.Enable() - h.runSelfCommand(h.client.ctx, "debug") - }() -} - -func (h *eventHandler) handleQuitClick() { - systray.Quit() -} - -func (h *eventHandler) handleGitHubClick() { - if err := openURL("https://github.com/netbirdio/netbird"); err != nil { - log.Errorf("failed to open GitHub URL: %v", err) - } -} - -func (h *eventHandler) handleUpdateClick() { - 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() { - h.client.mNetworks.Disable() - go func() { - defer h.client.mNetworks.Enable() - h.runSelfCommand(h.client.ctx, "networks") - }() -} - -func (h *eventHandler) toggleCheckbox(item *systray.MenuItem) { - if item.Checked() { - item.Uncheck() - } else { - item.Check() - } -} - -func (h *eventHandler) updateConfigWithErr() error { - if err := h.client.updateConfig(); err != nil { - return err - } - - return nil -} - -func (h *eventHandler) runSelfCommand(ctx context.Context, command string, args ...string) { - proc, err := os.Executable() - if err != nil { - log.Errorf("error getting executable path: %v", err) - return - } - - // Build the full command arguments - cmdArgs := []string{ - fmt.Sprintf("--%s=true", command), - fmt.Sprintf("--daemon-addr=%s", h.client.addr), - } - cmdArgs = append(cmdArgs, args...) - - cmd := exec.CommandContext(ctx, proc, cmdArgs...) - - if out := h.client.attachOutput(cmd); out != nil { - defer func() { - if err := out.Close(); err != nil { - log.Errorf("error closing log file %s: %v", h.client.logFile, err) - } - }() - } - - log.Printf("running command: %s", cmd.String()) - - if err := cmd.Run(); err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - log.Printf("command '%s' failed with exit code %d", cmd.String(), exitErr.ExitCode()) - } - return - } - - log.Printf("command '%s' completed successfully", cmd.String()) -} - -func (h *eventHandler) logout(ctx context.Context) error { - client, err := h.client.getSrvClient(defaultFailTimeout) - if err != nil { - return fmt.Errorf("failed to get service client: %w", err) - } - - _, err = client.Logout(ctx, &proto.LogoutRequest{}) - if err != nil { - return fmt.Errorf("logout failed: %w", err) - } - - h.client.getSrvConfig() - - return nil -} diff --git a/client/ui/font_bsd.go b/client/ui/font_bsd.go deleted file mode 100644 index 139f38f40..000000000 --- a/client/ui/font_bsd.go +++ /dev/null @@ -1,30 +0,0 @@ -//go:build freebsd || openbsd || netbsd || dragonfly - -package main - -import ( - "os" - "runtime" - - log "github.com/sirupsen/logrus" -) - -func (s *serviceClient) setDefaultFonts() { - paths := []string{ - "/usr/local/share/fonts/TTF/DejaVuSans.ttf", - "/usr/local/share/fonts/dejavu/DejaVuSans.ttf", - "/usr/local/share/noto/NotoSans-Regular.ttf", - "/usr/local/share/fonts/noto/NotoSans-Regular.ttf", - "/usr/local/share/fonts/liberation-fonts-ttf/LiberationSans-Regular.ttf", - } - - for _, fontPath := range paths { - if _, err := os.Stat(fontPath); err == nil { - os.Setenv("FYNE_FONT", fontPath) - log.Debugf("Using font: %s", fontPath) - return - } - } - - log.Errorf("Failed to find any suitable font files for %s", runtime.GOOS) -} diff --git a/client/ui/font_darwin.go b/client/ui/font_darwin.go deleted file mode 100644 index cafb72f59..000000000 --- a/client/ui/font_darwin.go +++ /dev/null @@ -1,18 +0,0 @@ -package main - -import ( - "os" - - log "github.com/sirupsen/logrus" -) - -const defaultFontPath = "/Library/Fonts/Arial Unicode.ttf" - -func (s *serviceClient) setDefaultFonts() { - if _, err := os.Stat(defaultFontPath); err != nil { - log.Errorf("Failed to find default font file: %v", err) - return - } - - os.Setenv("FYNE_FONT", defaultFontPath) -} diff --git a/client/ui/font_linux.go b/client/ui/font_linux.go deleted file mode 100644 index 4aa92494a..000000000 --- a/client/ui/font_linux.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build !386 - -package main - -func (s *serviceClient) setDefaultFonts() { - //TODO: Linux Multiple Language Support -} diff --git a/client/ui/font_windows.go b/client/ui/font_windows.go deleted file mode 100644 index 6346a9fb9..000000000 --- a/client/ui/font_windows.go +++ /dev/null @@ -1,90 +0,0 @@ -package main - -import ( - "os" - "path" - "unsafe" - - log "github.com/sirupsen/logrus" - "golang.org/x/sys/windows" -) - -func (s *serviceClient) setDefaultFonts() { - defaultFontPath := s.getWindowsFontFilePath() - - if _, err := os.Stat(defaultFontPath); err != nil { - log.Errorf("Failed to find default font file: %v", err) - return - } - - os.Setenv("FYNE_FONT", defaultFontPath) -} - -func (s *serviceClient) getWindowsFontFilePath() string { - var ( - fontFolder = "C:/Windows/Fonts" - fontMapping = map[string]string{ - "default": "Segoeui.ttf", - "zh-CN": "Segoeui.ttf", - "am-ET": "Ebrima.ttf", - "nirmala": "Nirmala.ttf", - "chr-CHER-US": "Gadugi.ttf", - "zh-HK": "Segoeui.ttf", - "zh-TW": "Segoeui.ttf", - "km-KH": "Leelawui.ttf", - "ko-KR": "Malgun.ttf", - "th-TH": "Leelawui.ttf", - "ti-ET": "Ebrima.ttf", - } - nirMalaLang = []string{ - "as-IN", - "bn-BD", - "bn-IN", - "gu-IN", - "hi-IN", - "kn-IN", - "kok-IN", - "ml-IN", - "mr-IN", - "ne-NP", - "or-IN", - "pa-IN", - "si-LK", - "ta-IN", - "te-IN", - } - ) - - // getUserDefaultLocaleName.Call() panics if the func is not found - defer func() { - if r := recover(); r != nil { - log.Errorf("Recovered from panic: %v", r) - } - }() - - kernel32 := windows.NewLazySystemDLL("kernel32.dll") - getUserDefaultLocaleName := kernel32.NewProc("GetUserDefaultLocaleName") - - buf := make([]uint16, 85) // LOCALE_NAME_MAX_LENGTH is usually 85 - r, _, err := getUserDefaultLocaleName.Call(uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf))) - // returns 0 on failure, err is always non-nil - // https://learn.microsoft.com/en-us/windows/win32/api/winnls/nf-winnls-getuserdefaultlocalename - if r == 0 { - log.Errorf("GetUserDefaultLocaleName call failed: %v", err) - return path.Join(fontFolder, fontMapping["default"]) - } - - defaultLanguage := windows.UTF16ToString(buf) - - for _, lang := range nirMalaLang { - if defaultLanguage == lang { - return path.Join(fontFolder, fontMapping["nirmala"]) - } - } - - if font, ok := fontMapping[defaultLanguage]; ok { - return path.Join(fontFolder, font) - } - - return path.Join(fontFolder, fontMapping["default"]) -} diff --git a/client/ui/frontend/.prettierignore b/client/ui/frontend/.prettierignore new file mode 100644 index 000000000..c78cb7cc3 --- /dev/null +++ b/client/ui/frontend/.prettierignore @@ -0,0 +1,7 @@ +dist +build +node_modules +pnpm-lock.yaml +wailsjs +*.min.js +*.min.css diff --git a/client/ui/frontend/.prettierrc b/client/ui/frontend/.prettierrc new file mode 100644 index 000000000..e47a94f56 --- /dev/null +++ b/client/ui/frontend/.prettierrc @@ -0,0 +1,12 @@ +{ + "tabWidth": 4, + "useTabs": false, + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "printWidth": 100, + "arrowParens": "always", + "endOfLine": "lf", + "plugins": ["prettier-plugin-tailwindcss"], + "tailwindFunctions": ["cn", "clsx", "cva", "tw"] +} diff --git a/client/ui/frontend/WAILS-API.md b/client/ui/frontend/WAILS-API.md new file mode 100644 index 000000000..494812d35 --- /dev/null +++ b/client/ui/frontend/WAILS-API.md @@ -0,0 +1,296 @@ +# Wails Go API reference (frontend) + +Reference for every binding method and model shape exposed to the frontend. Generated from `client/ui/services/*.go` via `wails3 generate bindings -clean=true -ts` — regenerate after any Go-side change. Authoritative source is always `bindings/github.com/netbirdio/netbird/client/ui/services/*.ts`. + +Every method returns `$CancellablePromise` (a Wails3 wrapper around `Promise`). Call `.cancel()` to abort the underlying gRPC call; in practice we just `await` and let it run. + +## Imports + +```ts +// Services +import { + Connection, Peers, ProfileSwitcher, Profiles, + Settings, Networks, Forwarding, Debug, Update, WindowManager, + I18n, Preferences, +} from "@bindings/services"; + +// Models (types-only) +import type { + Status, PeerStatus, PeerLink, LocalPeer, SystemEvent, + Profile, ProfileRef, ActiveProfile, + Config, ConfigParams, SetConfigParams, Features, + Network, SelectNetworksParams, + ForwardingRule, PortInfo, PortRange, + LoginParams, LoginResult, LogoutParams, WaitSSOParams, UpParams, + DebugBundleParams, DebugBundleResult, LogLevel, + UpdateResult, UpdateAvailable, UpdateProgress, +} from "@bindings/services/models.js"; + +// i18n / preferences models live in sibling packages, not services/models +import { LanguageCode, type Language } from "@bindings/i18n/models.js"; +import type { UIPreferences } from "@bindings/preferences/models.js"; +``` + +## Push events + +Subscribe with `Events.On(name, handler)` from `@wailsio/runtime`. Handlers receive `{ data: }`. + +| Event | Payload | Fires on | +|---|---|---| +| `netbird:status` | `Status` | Daemon SubscribeStatus snapshot — connection-state change, peer-list change, address change, mgmt/signal flip. Synthetic `StatusDaemonUnavailable` is emitted when the gRPC socket is unreachable, and a synthetic `Connecting` is emitted at the start of an active profile switch. | +| `netbird:event` | `SystemEvent` | One push per daemon SubscribeEvents item (DNS / network / authentication / connectivity / system). Used by the tray for OS toasts; the TS side reads events through `Status.events` instead. | +| `netbird:update:available` | `UpdateAvailable` | Daemon detected a new version (fan-out of the `new_version_available` metadata key). | +| `netbird:preferences:changed` | `{ language: string }` | Fires after every successful `Preferences.SetLanguage` (including the caller's own window). `src/lib/i18n.ts` subscribes and calls `i18next.changeLanguage`. | +| `netbird:update:progress` | `UpdateProgress` | Daemon enforced-update install progress (`action: "show"` etc.). | +| `browser-login:cancel` | (none) | Either the user closed the `BrowserLogin` window (Go-emitted) or the page's Cancel button (frontend-emitted). | +| `trigger-login` | (none) | Reserved by the tray for asking the frontend to start an SSO flow. `layouts/ConnectionStatusSwitch.tsx` subscribes and runs `startLogin()`; no Go-side emitter today. | + +The two stream loops behind `netbird:status` and `netbird:event` start automatically — `main.go` calls `peers.Watch(context.Background())` at boot. `Peers.Watch` is still exported but the frontend doesn't need to invoke it. + +## `Connection` + +```ts +Connection.Login(p: LoginParams): Promise +Connection.WaitSSOLogin(p: WaitSSOParams): Promise // returns email +Connection.Up(p: UpParams): Promise // async on the daemon +Connection.Down(): Promise +Connection.Logout(p: LogoutParams): Promise +Connection.OpenURL(url: string): Promise // honors $BROWSER +``` + +`Login` Down-resets the daemon first to dislodge a stale `WaitSSOLogin` (so a previously abandoned SSO flow doesn't fail the next attempt). `Up` always uses async mode — status flows back through `netbird:status`. **Do not call `Up` on an `Idle` / `NeedsLogin` daemon** — the daemon's internal 50s `waitForUp` will block and return `DeadlineExceeded`. + +Full SSO sequence: `Login` → if `result.needsSsoLogin`, open `result.verificationUriComplete` via `OpenURL` + `WindowManager.OpenBrowserLogin(uri)` → `WaitSSOLogin({ userCode })` → `Up({})`. The canonical implementation is `startLogin()` in `layouts/ConnectionStatusSwitch.tsx`. + +## `Peers` + +```ts +Peers.Get(): Promise // one-shot snapshot +Peers.Watch(): Promise // already invoked from main.go +Peers.BeginProfileSwitch(): Promise +Peers.CancelProfileSwitch(): Promise +``` + +`BeginProfileSwitch` and `CancelProfileSwitch` are normally driven by `ProfileSwitcher` / the tray, not the frontend. + +## `ProfileSwitcher` + +```ts +ProfileSwitcher.SwitchActive(p: ProfileRef): Promise +``` + +The single entry point both tray and frontend should use for profile flips. Applies the reconnect policy below, mirrors the switch into the user-side `profilemanager` (so the CLI's `netbird up` reads a consistent active profile), and drives the optimistic-Connecting paint via `Peers.BeginProfileSwitch`. + +Reconnect policy (driven by `prevStatus` captured at entry): + +| Previous status | Action | Optimistic UI | Suppressed events until new flow | +|---|---|---|---| +| Connected | Switch + Down + Up | Connecting (synthetic) | Connected, Idle | +| Connecting | Switch + Down + Up | Connecting (unchanged) | Connected, Idle | +| NeedsLogin / LoginFailed / SessionExpired | Switch + Down | (no change) | — | +| Idle | Switch only | (no change) | — | + +## `Profiles` + +```ts +Profiles.Username(): Promise // current OS username +Profiles.List(username: string): Promise +Profiles.GetActive(): Promise +Profiles.Switch(p: ProfileRef): Promise // raw daemon RPC; prefer ProfileSwitcher.SwitchActive +Profiles.Add(p: ProfileRef): Promise +Profiles.Remove(p: ProfileRef): Promise +``` + +`Profile.email` is populated by the **UI process** reading the per-profile state file (`~/Library/Application Support/netbird/.state.json` on macOS), not by the daemon — the daemon runs as root and can't read user-owned files. + +## `Settings` + +```ts +Settings.GetConfig(p: ConfigParams): Promise +Settings.SetConfig(p: SetConfigParams): Promise // partial update +Settings.GetFeatures(): Promise // operator-disabled UI sections +``` + +`SetConfig` is a partial update: only fields you set are pushed to the daemon. `profileName` + `username` are always required; the typed fields in `SetConfigParams` are optional (`field?: T | null`). `managementUrl` and `adminUrl` are always-string for historical reasons. + +**PSK mask quirk:** `GetConfig` returns existing pre-shared keys as `"**********"`. If you send the mask back, `wgtypes.ParseKey` fails on the next connect. `SettingsContext.save` drops the field when it equals `"**********"`. See `modules/settings/SettingsContext.tsx`. + +`SetConfigParams` carries one field that `Config` does not: `disableFirewall`. There's no current GET path for it. + +## `Networks` + +```ts +Networks.List(): Promise +Networks.Select(p: SelectNetworksParams): Promise +Networks.Deselect(p: SelectNetworksParams): Promise +``` + +`SelectNetworksParams.append=true` merges into the existing selection; `false` replaces. `all=true` ignores `networkIds` and targets every network (Select-All / Deselect-All). + +Exit-node filter: `range === "0.0.0.0/0" || range === "::/0"`. Domain network: `domains.length > 0`. CIDR overlap check is client-side. + +## `Forwarding` + +```ts +Forwarding.List(): Promise +``` + +`PortInfo` is a daemon-side oneof — exactly one of `port?: number` or `range?: PortRange` is populated. `protocol` is the lowercase daemon string (`"tcp"` / `"udp"`). + +## `Debug` + +```ts +Debug.GetLogLevel(): Promise +Debug.SetLogLevel(lvl: LogLevel): Promise +Debug.Bundle(p: DebugBundleParams): Promise +Debug.RevealFile(path: string): Promise // OS file-manager focus +``` + +**Log level case sensitivity bug:** `proto.LogLevel_value` is keyed on uppercase enum names (`"TRACE"`, `"DEBUG"`, `"INFO"`, `"WARN"`, `"ERROR"`, `"PANIC"`, `"FATAL"`, `"UNKNOWN"`). `Debug.SetLogLevel` calls `proto.LogLevel_value[lvl.Level]` and falls back to `INFO` on miss. `useDebugBundle` currently passes `"trace"` (lowercase), which silently maps to `INFO` — the trace-capture flow doesn't actually raise the log level today. To raise to trace, pass `{ level: "TRACE" }`. Fix on the cleanup list. + +`Debug.Bundle` uploads when `uploadUrl != ""`. Result fields: `path` (local copy), `uploadedKey` (set on success), `uploadFailureReason` (set on upload failure — the local copy is still saved). + +## `Update` + +```ts +Update.Trigger(): Promise // start the install +Update.GetInstallerResult(): Promise // poll the outcome (long-running) +Update.Quit(): Promise // 100ms later, app.Quit() +``` + +Typical enforced-update flow on the `/update` route: call `Trigger` once, then poll `GetInstallerResult` every 2s with a 15-minute total timeout. On `success: true` call `Quit`. On `success: false` show `errorMsg`. If the gRPC poll itself starts failing for `DAEMON_DOWN_GRACE_MS` (5s), treat that as success and quit too — the installer commonly takes the daemon offline mid-upgrade. See `pages/Update.tsx` for the canonical implementation. + +## `WindowManager` + +```ts +WindowManager.OpenSettings(): Promise +WindowManager.OpenBrowserLogin(uri: string): Promise // uri appended as ?uri=… +WindowManager.CloseBrowserLogin(): Promise +WindowManager.OpenError(title: string, message: string): Promise // custom branded error window; both query-escaped as ?title=…&message=… +WindowManager.CloseError(): Promise +``` + +Prefer `errorDialog({Title, Message})` from `lib/dialogs.ts` over calling `OpenError` directly — it's the app's single error surface (the old native MessageBox wrapper now routes here). Both strings must be pre-localised. + +Both auxiliary windows are created on first open and destroyed on close (mutex-guarded singleton). The BrowserLogin window's red-X close fires the `browser-login:cancel` event so `startLogin()` can tear down the pending daemon `WaitSSOLogin`. + +## `I18n` + +```ts +I18n.Languages(): Promise // from _index.json +I18n.Bundle(code: LanguageCode): Promise> // full key→text map +``` + +Source of truth is `client/ui/i18n/locales/` (shared with the Go tray). The frontend's i18next bootstrap doesn't need `I18n.Bundle` at runtime (bundles are statically imported by Vite via the glob in `src/lib/i18n.ts`), but the language picker reads `I18n.Languages()` so the list matches `_index.json` without duplicating it in TS. + +## `Preferences` + +```ts +Preferences.Get(): Promise // { language: string } +Preferences.SetLanguage(code: LanguageCode): Promise // rejects on unknown code +``` + +`SetLanguage` validates against the loaded `i18n.Bundle`, persists to `os.UserConfigDir()/netbird/ui-preferences.json`, and emits `netbird:preferences:changed`. The frontend's `src/lib/i18n.ts` listens to that event and calls `i18next.changeLanguage` so a flip in any window paints in all of them. Missing preferences file → defaults to `en`, written on first read. + +## Daemon `Status.status` values + +Mirror `internal.Status*` in `client/internal/state.go` plus the synthetic UI label: + +| Value | Meaning | +|---|---| +| `"Idle"` | Tunnel down (Up never invoked or Down completed) | +| `"Connecting"` | Up in progress | +| `"Connected"` | Tunnel up | +| `"NeedsLogin"` | Fresh install or token cleared; needs Login → SSO → Up | +| `"LoginFailed"` | Previous Login attempt errored | +| `"SessionExpired"` | SSO token expired; needs re-Login | +| `"DaemonUnavailable"` | **Synthetic** — UI side, emitted when the daemon gRPC socket is unreachable. Not a real daemon enum. | + +The tray also reads a tray-only synthetic `"Error"` for icon purposes; the frontend doesn't see that. + +## Model field reference + +`Status`: +```ts +{ status, daemonVersion: string; + management: PeerLink; signal: PeerLink; + local: LocalPeer; + peers: PeerStatus[]; + events: SystemEvent[]; } +``` + +`PeerLink`: `{ url: string; connected: boolean; error?: string }`. + +`LocalPeer`: `{ ip, pubKey, fqdn: string; networks: string[] }`. + +`PeerStatus`: +```ts +{ ip, pubKey, fqdn, connStatus: string; + connStatusUpdateUnix: number; + relayed: boolean; + localIceCandidateType, remoteIceCandidateType: string; // pion: "host"|"srflx"|"prflx"|"relay"|"" + localIceCandidateEndpoint, remoteIceCandidateEndpoint: string; + bytesRx, bytesTx, latencyMs, lastHandshakeUnix: number; + relayAddress: string; // set when relayed=true + rosenpassEnabled: boolean; + networks: string[]; } +``` + +`SystemEvent`: +```ts +{ id: string; + severity: string; // "info"|"warning"|"error"|"critical" (lowercased proto enum, "SystemEvent_" prefix stripped) + category: string; // "network"|"dns"|"authentication"|"connectivity"|"system" (same casing rules) + message: string; // technical / log line + userMessage: string; // human-friendly — render this + timestamp: number; // unix seconds + metadata: Record; } // keys: "new_version_available", "enforced", "id", "network", "version", "progress_window", … +``` + +`Profile`: `{ name: string; isActive: boolean; email: string }`. + +`Config` (read-only mirror, all required): +```ts +{ managementUrl, adminUrl, configFile, logFile, preSharedKey, interfaceName: string; + wireguardPort, mtu, sshJwtCacheTtl: number; + disableAutoConnect, serverSshAllowed, + rosenpassEnabled, rosenpassPermissive, + disableNotifications, lazyConnectionEnabled, blockInbound, + networkMonitor, disableClientRoutes, disableServerRoutes, + disableDns, disableIpv6, blockLanAccess, + enableSshRoot, enableSshSftp, + enableSshLocalPortForwarding, enableSshRemotePortForwarding, + disableSshAuth: boolean; } +``` + +`SetConfigParams` has all `Config` fields as `field?: T | null` (partial update), plus the write-only `disableFirewall?: boolean | null`, plus `profileName` / `username` / `managementUrl` / `adminUrl` as required strings. + +`Features`: `{ disableProfiles, disableUpdateSettings, disableNetworks: boolean }`. + +`Network`: `{ id, range: string; selected: boolean; domains: string[]; resolvedIps: Record }`. + +`ForwardingRule`: `{ protocol: string; destinationPort: PortInfo; translatedAddress, translatedHostname: string; translatedPort: PortInfo }`. + +`PortInfo`: `{ port?: number | null; range?: PortRange | null }` (exactly one populated). + +`PortRange`: `{ start, end: number }` (inclusive). + +`LoginParams`: `{ profileName, username, managementUrl, setupKey, preSharedKey, hostname, hint: string }`. + +`LoginResult`: `{ needsSsoLogin: boolean; userCode, verificationUri, verificationUriComplete: string }`. + +`WaitSSOParams`: `{ userCode, hostname: string }`. Resolves to the user's email. + +`UpParams` / `LogoutParams` / `ProfileRef` / `ConfigParams` / `ActiveProfile`: all `{ profileName, username: string }` (different names but same shape — kept distinct by Wails for clarity). + +`DebugBundleParams`: `{ anonymize, systemInfo: boolean; uploadUrl: string; logFileCount: number }`. + +`DebugBundleResult`: `{ path, uploadedKey, uploadFailureReason: string }`. + +`LogLevel`: `{ level: string }` — **uppercase** proto enum name (`"TRACE"`, `"DEBUG"`, `"INFO"`, `"WARN"`, `"ERROR"`, `"PANIC"`, `"FATAL"`). + +`UpdateResult`: `{ success: boolean; errorMsg: string }`. + +`UpdateAvailable`: `{ version: string; enforced: boolean }`. + +`UpdateProgress`: `{ action: string; version: string }`. diff --git a/client/ui/frontend/eslint.config.js b/client/ui/frontend/eslint.config.js new file mode 100644 index 000000000..f00623b68 --- /dev/null +++ b/client/ui/frontend/eslint.config.js @@ -0,0 +1,75 @@ +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; +import react from "eslint-plugin-react"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import jsxA11y from "eslint-plugin-jsx-a11y"; +import globals from "globals"; + +export default tseslint.config( + { + ignores: ["dist/**", "node_modules/**", "bindings/**", "sonar/**"], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["src/**/*.{ts,tsx}"], + plugins: { + react, + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + "jsx-a11y": jsxA11y, + }, + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + globals: { ...globals.browser }, + parserOptions: { + ecmaFeatures: { jsx: true }, + }, + }, + settings: { + react: { version: "detect" }, + }, + rules: { + // ----- a11y / semantic HTML (jsx-a11y recommended) ----- + ...jsxA11y.configs.recommended.rules, + "jsx-a11y/no-autofocus": ["warn", { ignoreNonDOM: true }], + + // ----- React ----- + ...react.configs.recommended.rules, + ...react.configs["jsx-runtime"].rules, + "react/prop-types": "off", + "react/jsx-no-target-blank": ["error", { allowReferrer: true }], + "react/self-closing-comp": "warn", + + // ----- React hooks ----- + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + + // ----- Vite / HMR (Fast Refresh) ----- + "react-refresh/only-export-components": "off", + + // ----- TypeScript ----- + "@typescript-eslint/no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + "@typescript-eslint/consistent-type-imports": [ + "warn", + { prefer: "type-imports", fixStyle: "inline-type-imports" }, + ], + "@typescript-eslint/no-explicit-any": "warn", + + // ----- General correctness ----- + eqeqeq: ["error", "smart"], + "no-console": ["warn", { allow: ["warn", "error", "info"] }], + "no-debugger": "error", + "prefer-const": "warn", + }, + }, +); diff --git a/client/ui/frontend/index.html b/client/ui/frontend/index.html new file mode 100644 index 000000000..e62139956 --- /dev/null +++ b/client/ui/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + NetBird + + + +
                                                + + + diff --git a/client/ui/frontend/package.json b/client/ui/frontend/package.json new file mode 100644 index 000000000..dcef99ad3 --- /dev/null +++ b/client/ui/frontend/package.json @@ -0,0 +1,70 @@ +{ + "name": "netbird-ui", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build:dev": "tsc && vite build --minify false --mode development", + "build": "tsc && vite build --mode production", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "bindings": "cd .. && wails3 generate bindings -clean=true -ts", + "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,css,json,md}\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,css,json,md}\"", + "lint": "eslint \"src/**/*.{ts,tsx}\"", + "lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix", + "check": "pnpm lint && pnpm typecheck && pnpm format:check", + "check:fix": "pnpm lint:fix && pnpm format && pnpm typecheck", + "i18n:check": "node ../i18n/check-translations.mjs" + }, + "dependencies": { + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "@radix-ui/react-visually-hidden": "^1.2.4", + "@wailsio/runtime": "latest", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "framer-motion": "^12.38.0", + "i18next": "^26.2.0", + "lucide-react": "^0.566.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-i18next": "^17.0.8", + "react-loading-skeleton": "^3.5.0", + "react-router-dom": "^7.1.3", + "react-virtuoso": "^4.12.5", + "tailwind-merge": "^2.6.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^25.6.0", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "eslint": "^9.39.4", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.3", + "globals": "^17.6.0", + "postcss": "^8.5.1", + "prettier": "^3.8.3", + "prettier-plugin-tailwindcss": "^0.8.0", + "tailwindcss": "^3.4.17", + "tailwindcss-animate": "^1.0.7", + "typescript": "^5.7.3", + "typescript-eslint": "^8.61.1", + "vite": "^6.0.7" + }, + "packageManager": "pnpm@11.4.0+sha512.f0febc7e37552ab485494a914241b338e0b3580b93d54ce31f00933015880863129038a1b4ae4e414a0ee63ac35bf21197e990172c4a68256450b5636310968f" +} diff --git a/client/ui/frontend/pnpm-lock.yaml b/client/ui/frontend/pnpm-lock.yaml new file mode 100644 index 000000000..b6b3dd336 --- /dev/null +++ b/client/ui/frontend/pnpm-lock.yaml @@ -0,0 +1,5240 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@radix-ui/react-dialog': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.16 + version: 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-label': + specifier: ^2.1.8 + version: 2.1.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-popover': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-radio-group': + specifier: ^1.3.8 + version: 1.3.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-scroll-area': + specifier: ^1.2.10 + version: 1.2.10(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-switch': + specifier: ^1.2.6 + version: 1.2.6(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-tabs': + specifier: ^1.1.13 + version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-tooltip': + specifier: ^1.2.8 + version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-visually-hidden': + specifier: ^1.2.4 + version: 1.2.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wailsio/runtime': + specifier: latest + version: 3.0.0-alpha.79 + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + framer-motion: + specifier: ^12.38.0 + version: 12.40.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + i18next: + specifier: ^26.2.0 + version: 26.3.0(typescript@5.9.3) + lucide-react: + specifier: ^0.566.0 + version: 0.566.0(react@18.3.1) + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + react-i18next: + specifier: ^17.0.8 + version: 17.0.8(i18next@26.3.0(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + react-loading-skeleton: + specifier: ^3.5.0 + version: 3.5.0(react@18.3.1) + react-router-dom: + specifier: ^7.1.3 + version: 7.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-virtuoso: + specifier: ^4.12.5 + version: 4.18.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + tailwind-merge: + specifier: ^2.6.0 + version: 2.6.1 + devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@9.39.4(jiti@1.21.7)) + '@types/node': + specifier: ^25.6.0 + version: 25.9.1 + '@types/react': + specifier: ^18.3.18 + version: 18.3.29 + '@types/react-dom': + specifier: ^18.3.5 + version: 18.3.7(@types/react@18.3.29) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@6.4.2(@types/node@25.9.1)(jiti@1.21.7)) + autoprefixer: + specifier: ^10.4.20 + version: 10.5.0(postcss@8.5.15) + eslint: + specifier: ^9.39.4 + version: 9.39.4(jiti@1.21.7) + eslint-plugin-jsx-a11y: + specifier: ^6.10.2 + version: 6.10.2(eslint@9.39.4(jiti@1.21.7)) + eslint-plugin-react: + specifier: ^7.37.5 + version: 7.37.5(eslint@9.39.4(jiti@1.21.7)) + eslint-plugin-react-hooks: + specifier: ^7.1.1 + version: 7.1.1(eslint@9.39.4(jiti@1.21.7)) + eslint-plugin-react-refresh: + specifier: ^0.5.3 + version: 0.5.3(eslint@9.39.4(jiti@1.21.7)) + globals: + specifier: ^17.6.0 + version: 17.6.0 + postcss: + specifier: ^8.5.1 + version: 8.5.15 + prettier: + specifier: ^3.8.3 + version: 3.8.3 + prettier-plugin-tailwindcss: + specifier: ^0.8.0 + version: 0.8.0(prettier@3.8.3) + tailwindcss: + specifier: ^3.4.17 + version: 3.4.19 + tailwindcss-animate: + specifier: ^1.0.7 + version: 1.0.7(tailwindcss@3.4.19) + typescript: + specifier: ^5.7.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.61.1 + version: 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + vite: + specifier: ^6.0.7 + version: 6.4.2(@types/node@25.9.1)(jiti@1.21.7) + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.8': + resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.4': + resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-radio-group@1.3.8': + resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.2.10': + resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.2.6': + resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tooltip@1.2.8': + resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-visually-hidden@1.2.4': + resolution: {integrity: sha512-kaeiyGCe844dkb9AVF+rb4yTyb1LiLN/e3es3nLiRyN4dC8AduBYPMnnNlDjX2VDOcvDEiPnRNMJeWCfsX0txg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + cpu: [x64] + os: [win32] + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@25.9.1': + resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.29': + resolution: {integrity: sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==} + + '@typescript-eslint/eslint-plugin@8.61.1': + resolution: {integrity: sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.61.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.61.1': + resolution: {integrity: sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.61.1': + resolution: {integrity: sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.61.1': + resolution: {integrity: sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.61.1': + resolution: {integrity: sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.61.1': + resolution: {integrity: sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.61.1': + resolution: {integrity: sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.61.1': + resolution: {integrity: sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.61.1': + resolution: {integrity: sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.61.1': + resolution: {integrity: sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + '@wailsio/runtime@3.0.0-alpha.79': + resolution: {integrity: sha512-NITzxKmJsMEruc39L166lbPJVECxzcbdqpHVqOOF7Cu/7Zqk/e3B/gNpkUjhNyo5rVb3V1wpS8oEgLUmpu1cwA==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + ast-types-flow@0.0.8: + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + autoprefixer@10.5.0: + resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + axe-core@4.12.1: + resolution: {integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==} + engines: {node: '>=4'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.10.32: + resolution: {integrity: sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==} + engines: {node: '>=6.0.0'} + hasBin: true + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + brace-expansion@1.1.15: + resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001793: + resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + damerau-levenshtein@1.0.8: + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.362: + resolution: {integrity: sha512-PUY2DrLvkjkUuWqq+KPL2iWshrJsZOcIojzRQ7eXFacc9dWga7MGMJAa15VbiejSZB1PAXaRLAiKgruHP8LB1w==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + es-abstract-get@1.0.0: + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} + engines: {node: '>= 0.4'} + + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-iterator-helpers@1.3.3: + resolution: {integrity: sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.1: + resolution: {integrity: sha512-CxN9N56HYfd2m/acc/NOFrZQsN9kU4eh+2kk6A707Kz1krH8tKmfrs5RnftB8WNX80T0NS7vSQsDOlg23diR2g==} + engines: {node: '>= 0.4'} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-jsx-a11y@6.10.2: + resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} + engines: {node: '>=4.0'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 + + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + 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 || ^10.0.0 + + eslint-plugin-react-refresh@0.5.3: + resolution: {integrity: sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==} + peerDependencies: + eslint: ^9 || ^10 + + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + framer-motion@12.40.0: + resolution: {integrity: sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.2.0: + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@17.6.0: + resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + + i18next@26.3.0: + resolution: {integrity: sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==} + peerDependencies: + typescript: ^5 || ^6 + peerDependenciesMeta: + typescript: + optional: true + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-document.all@1.0.0: + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + language-subtag-registry@0.3.23: + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} + + language-tags@1.0.9: + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} + engines: {node: '>=0.10'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@0.566.0: + resolution: {integrity: sha512-b18qC/JAh1X9rVKlF5EtSIyumdIYuh78b0JShynZnHbcaWR4AW4oZyi8Ms/aQYVSnLPlAnMhug2hSr19BgVZAw==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + motion-dom@12.40.0: + resolution: {integrity: sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==} + + motion-utils@12.39.0: + resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-exports-info@1.6.0: + resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} + engines: {node: '>= 0.4'} + + node-releases@2.0.46: + resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} + engines: {node: '>=18'} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-plugin-tailwindcss@0.8.0: + resolution: {integrity: sha512-V8ITGH87yuBDF6JpEZTOVlUz/saAwqb8f3HRgUj8Lh+tGCcrmorhsLpYqzygwFwK0PE2Ib6Mv3M7T/uE2tZV1g==} + engines: {node: '>=20.19'} + peerDependencies: + '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-hermes': '*' + '@prettier/plugin-oxc': '*' + '@prettier/plugin-pug': '*' + '@shopify/prettier-plugin-liquid': '*' + '@trivago/prettier-plugin-sort-imports': '*' + '@zackad/prettier-plugin-twig': '*' + prettier: ^3.0 + prettier-plugin-astro: '*' + prettier-plugin-css-order: '*' + prettier-plugin-jsdoc: '*' + prettier-plugin-marko: '*' + prettier-plugin-multiline-arrays: '*' + prettier-plugin-organize-attributes: '*' + prettier-plugin-organize-imports: '*' + prettier-plugin-sort-imports: '*' + prettier-plugin-svelte: '*' + peerDependenciesMeta: + '@ianvs/prettier-plugin-sort-imports': + optional: true + '@prettier/plugin-hermes': + optional: true + '@prettier/plugin-oxc': + optional: true + '@prettier/plugin-pug': + optional: true + '@shopify/prettier-plugin-liquid': + optional: true + '@trivago/prettier-plugin-sort-imports': + optional: true + '@zackad/prettier-plugin-twig': + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-css-order: + optional: true + prettier-plugin-jsdoc: + optional: true + prettier-plugin-marko: + optional: true + prettier-plugin-multiline-arrays: + optional: true + prettier-plugin-organize-attributes: + optional: true + prettier-plugin-organize-imports: + optional: true + prettier-plugin-sort-imports: + optional: true + prettier-plugin-svelte: + optional: true + + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + engines: {node: '>=14'} + hasBin: true + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-i18next@17.0.8: + resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==} + peerDependencies: + i18next: '>= 26.2.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 || ^6 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-loading-skeleton@3.5.0: + resolution: {integrity: sha512-gxxSyLbrEAdXTKgfbpBEFZCO/P153DnqSCQau2+o6lNy1jgMRr2MmRmOzMmyrwSaSYLRB8g7b0waYPmUjz7IhQ==} + peerDependencies: + react: '>=16.8.0' + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-router-dom@7.15.1: + resolution: {integrity: sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.15.1: + resolution: {integrity: sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-virtuoso@4.18.7: + resolution: {integrity: sha512-xNF5zDGEEIMB7cKwcen/pLig0YDf6OnfFrVgKFa7sHPf9fRem0CaLshyObbBcP88jzn0enavL39EgplgdyT21g==} + peerDependencies: + react: '>=16 || >=17 || >= 18 || >= 19' + react-dom: '>=16 || >=17 || >= 18 || >=19' + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + resolve@2.0.0-next.7: + resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} + engines: {node: '>=0.4'} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.4: + resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + string.prototype.includes@2.0.1: + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} + engines: {node: '>= 0.4'} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.11: + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.10: + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tailwind-merge@2.6.1: + resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==} + + tailwindcss-animate@1.0.7: + resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders' + + tailwindcss@3.4.19: + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} + engines: {node: '>=14.0.0'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.8: + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} + engines: {node: '>= 0.4'} + + typescript-eslint@8.61.1: + resolution: {integrity: sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite@6.4.2: + resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + 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 + + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.22: + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))': + dependencies: + eslint: 9.39.4(jiti@1.21.7) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.2.0 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@10.0.1(eslint@9.39.4(jiti@1.21.7))': + optionalDependencies: + eslint: 9.39.4(jiti@1.21.7) + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@floating-ui/utils@0.2.11': {} + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@radix-ui/number@1.1.1': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.29)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-context@1.1.2(@types/react@18.3.29)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-dialog@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.29)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-direction@1.1.1(@types/react@18.3.29)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@18.3.29)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-id@1.1.1(@types/react@18.3.29)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-label@2.1.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.29)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.29)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-rect': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/rect': 1.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.2.4(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-slot@1.2.3(@types/react@18.3.29)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-slot@1.2.4(@types/react@18.3.29)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-switch@1.2.6(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-tabs@1.1.13(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.29)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.3.29)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@18.3.29)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@18.3.29)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.29)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-use-previous@1.1.1(@types/react@18.3.29)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-use-rect@1.1.1(@types/react@18.3.29)(react@18.3.1)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-use-size@1.1.1(@types/react@18.3.29)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-visually-hidden@1.2.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/rect@1.1.1': {} + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.60.4': + optional: true + + '@rollup/rollup-android-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-x64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.4': + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@25.9.1': + dependencies: + undici-types: 7.24.6 + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.29)': + dependencies: + '@types/react': 18.3.29 + + '@types/react@18.3.29': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + '@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/type-utils': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.61.1 + eslint: 9.39.4(jiti@1.21.7) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.61.1 + debug: 4.4.3 + eslint: 9.39.4(jiti@1.21.7) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.61.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@5.9.3) + '@typescript-eslint/types': 8.61.1 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.61.1': + dependencies: + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/visitor-keys': 8.61.1 + + '@typescript-eslint/tsconfig-utils@8.61.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4(jiti@1.21.7) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.61.1': {} + + '@typescript-eslint/typescript-estree@8.61.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.61.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@5.9.3) + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/visitor-keys': 8.61.1 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.4 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) + eslint: 9.39.4(jiti@1.21.7) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.61.1': + dependencies: + '@typescript-eslint/types': 8.61.1 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@25.9.1)(jiti@1.21.7))': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.2(@types/node@25.9.1)(jiti@1.21.7) + transitivePeerDependencies: + - supports-color + + '@wailsio/runtime@3.0.0-alpha.79': {} + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + arg@5.0.2: {} + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + aria-query@5.3.2: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + ast-types-flow@0.0.8: {} + + async-function@1.0.0: {} + + autoprefixer@10.5.0(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + caniuse-lite: 1.0.30001793 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + axe-core@4.12.1: {} + + axobject-query@4.1.0: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.32: {} + + binary-extensions@2.3.0: {} + + brace-expansion@1.1.15: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.32 + caniuse-lite: 1.0.30001793 + electron-to-chromium: 1.5.362 + node-releases: 2.0.46 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001793: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + clsx@2.1.1: {} + + cmdk@1.1.1(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@4.1.1: {} + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + damerau-levenshtein@1.0.8: {} + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + detect-node-es@1.1.0: {} + + didyoumean@1.2.2: {} + + dlv@1.1.3: {} + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.362: {} + + emoji-regex@9.2.2: {} + + es-abstract-get@1.0.0: + dependencies: + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + is-callable: 1.2.7 + object-inspect: 1.13.4 + + es-abstract@1.24.2: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.1 + function.prototype.name: 1.2.0 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.4 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.11 + string.prototype.trimend: 1.0.10 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.8 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.22 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-iterator-helpers@1.3.3: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + math-intrinsics: 1.1.0 + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.3 + + es-to-primitive@1.3.1: + dependencies: + es-abstract-get: 1.0.0 + es-errors: 1.3.0 + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@1.21.7)): + dependencies: + aria-query: 5.3.2 + array-includes: 3.1.9 + array.prototype.flatmap: 1.3.3 + ast-types-flow: 0.0.8 + axe-core: 4.12.1 + axobject-query: 4.1.0 + damerau-levenshtein: 1.0.8 + emoji-regex: 9.2.2 + eslint: 9.39.4(jiti@1.21.7) + hasown: 2.0.3 + jsx-ast-utils: 3.3.5 + language-tags: 1.0.9 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + safe-regex-test: 1.1.0 + string.prototype.includes: 2.0.1 + + eslint-plugin-react-hooks@7.1.1(eslint@9.39.4(jiti@1.21.7)): + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + eslint: 9.39.4(jiti@1.21.7) + hermes-parser: 0.25.1 + zod: 4.4.3 + zod-validation-error: 4.0.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react-refresh@0.5.3(eslint@9.39.4(jiti@1.21.7)): + dependencies: + eslint: 9.39.4(jiti@1.21.7) + + eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@1.21.7)): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.3.3 + eslint: 9.39.4(jiti@1.21.7) + estraverse: 5.3.0 + hasown: 2.0.3 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.5 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.7 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4(jiti@1.21.7): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 1.21.7 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + fraction.js@5.3.4: {} + + framer-motion@12.40.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + motion-dom: 12.40.0 + motion-utils: 12.39.0 + tslib: 2.8.1 + optionalDependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.2.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + es-define-property: 1.0.1 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + hasown: 2.0.4 + is-callable: 1.2.7 + is-document.all: 1.0.0 + + functions-have-names@1.2.3: {} + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@17.6.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + gopd@1.2.0: {} + + has-bigints@1.1.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + + i18next@26.3.0(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.3 + side-channel: 1.1.1 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-callable@1.2.7: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.3 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-document.all@1.0.0: + dependencies: + call-bound: 1.0.4 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.22 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + + jiti@1.21.7: {} + + js-tokens@4.0.0: {} + + js-yaml@4.2.0: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.1.9 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + language-subtag-registry@0.3.23: {} + + language-tags@1.0.9: + dependencies: + language-subtag-registry: 0.3.23 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@0.566.0(react@18.3.1): + dependencies: + react: 18.3.1 + + math-intrinsics@1.1.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.15 + + motion-dom@12.40.0: + dependencies: + motion-utils: 12.39.0 + + motion-utils@12.39.0: {} + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.12: {} + + natural-compare@1.4.0: {} + + node-exports-info@1.6.0: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + + node-releases@2.0.46: {} + + normalize-path@3.0.0: {} + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pify@2.3.0: {} + + pirates@4.0.7: {} + + possible-typed-array-names@1.1.0: {} + + postcss-import@15.1.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.12 + + postcss-js@4.1.0(postcss@8.5.15): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.15 + + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.15): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 1.21.7 + postcss: 8.5.15 + + postcss-nested@6.2.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-selector-parser: 6.1.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier-plugin-tailwindcss@0.8.0(prettier@3.8.3): + dependencies: + prettier: 3.8.3 + + prettier@3.8.3: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-i18next@17.0.8(i18next@26.3.0(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + html-parse-stringify: 3.0.1 + i18next: 26.3.0(typescript@5.9.3) + react: 18.3.1 + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + react-dom: 18.3.1(react@18.3.1) + typescript: 5.9.3 + + react-is@16.13.1: {} + + react-loading-skeleton@3.5.0(react@18.3.1): + dependencies: + react: 18.3.1 + + react-refresh@0.17.0: {} + + react-remove-scroll-bar@2.3.8(@types/react@18.3.29)(react@18.3.1): + dependencies: + react: 18.3.1 + react-style-singleton: 2.2.3(@types/react@18.3.29)(react@18.3.1) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.29 + + react-remove-scroll@2.7.2(@types/react@18.3.29)(react@18.3.1): + dependencies: + react: 18.3.1 + react-remove-scroll-bar: 2.3.8(@types/react@18.3.29)(react@18.3.1) + react-style-singleton: 2.2.3(@types/react@18.3.29)(react@18.3.1) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@18.3.29)(react@18.3.1) + use-sidecar: 1.1.3(@types/react@18.3.29)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + + react-router-dom@7.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router: 7.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + + react-router@7.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + cookie: 1.1.1 + react: 18.3.1 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 18.3.1(react@18.3.1) + + react-style-singleton@2.2.3(@types/react@18.3.29)(react@18.3.1): + dependencies: + get-nonce: 1.0.1 + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.29 + + react-virtuoso@4.18.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + resolve-from@4.0.0: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + resolve@2.0.0-next.7: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + node-exports-info: 1.6.0 + object-keys: 1.1.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rollup@4.60.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-array-concat@1.1.4: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + semver@6.3.1: {} + + semver@7.8.4: {} + + set-cookie-parser@2.7.2: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + source-map-js@1.2.1: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + string.prototype.includes@2.0.1: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.1 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.2 + + string.prototype.trim@1.2.11: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + has-property-descriptors: 1.0.2 + safe-regex-test: 1.1.0 + + string.prototype.trimend@1.0.10: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + strip-json-comments@3.1.1: {} + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.16 + ts-interface-checker: 0.1.13 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tailwind-merge@2.6.1: {} + + tailwindcss-animate@1.0.7(tailwindcss@3.4.19): + dependencies: + tailwindcss: 3.4.19 + + tailwindcss@3.4.19: + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.15 + postcss-import: 15.1.0(postcss@8.5.15) + postcss-js: 4.1.0(postcss@8.5.15) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.15) + postcss-nested: 6.2.0(postcss@8.5.15) + postcss-selector-parser: 6.1.2 + resolve: 1.22.12 + sucrase: 3.35.1 + transitivePeerDependencies: + - tsx + - yaml + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-interface-checker@0.1.13: {} + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.8: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typescript-eslint@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.61.1(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.39.4(jiti@1.21.7) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + undici-types@7.24.6: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-callback-ref@1.3.3(@types/react@18.3.29)(react@18.3.1): + dependencies: + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.29 + + use-sidecar@1.1.3(@types/react@18.3.29)(react@18.3.1): + dependencies: + detect-node-es: 1.1.0 + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.29 + + use-sync-external-store@1.6.0(react@18.3.1): + dependencies: + react: 18.3.1 + + util-deprecate@1.0.2: {} + + vite@6.4.2(@types/node@25.9.1)(jiti@1.21.7): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.60.4 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.9.1 + fsevents: 2.3.3 + jiti: 1.21.7 + + void-elements@3.1.0: {} + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.2.0 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.22 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.22: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yallist@3.1.1: {} + + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} diff --git a/client/ui/frontend/pnpm-workspace.yaml b/client/ui/frontend/pnpm-workspace.yaml new file mode 100644 index 000000000..5ed0b5af0 --- /dev/null +++ b/client/ui/frontend/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/client/ui/frontend/postcss.config.js b/client/ui/frontend/postcss.config.js new file mode 100644 index 000000000..2aa7205d4 --- /dev/null +++ b/client/ui/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/client/ui/frontend/src/app.tsx b/client/ui/frontend/src/app.tsx new file mode 100644 index 000000000..7f1359510 --- /dev/null +++ b/client/ui/frontend/src/app.tsx @@ -0,0 +1,64 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import "./globals.css"; +import { HashRouter, Navigate, Route, Routes } from "react-router-dom"; +import SessionExpirationDialog from "@/modules/session/SessionExpirationDialog.tsx"; +import UpdateInProgressDialog from "@/modules/auto-update/UpdateInProgressDialog.tsx"; +import WelcomeDialog from "@/modules/welcome/WelcomeDialog.tsx"; +import ErrorDialog from "@/modules/error/ErrorDialog.tsx"; +import { AppLayout } from "@/layouts/AppLayout.tsx"; +import { MainPage } from "@/modules/main/MainPage.tsx"; +import { SettingsPage } from "@/modules/settings/SettingsPage.tsx"; +import { SkeletonTheme } from "react-loading-skeleton"; +import "react-loading-skeleton/dist/skeleton.css"; +import { welcome } from "@/lib/welcome"; +import LoginWaitingForBrowserDialog from "@/modules/login/LoginWaitingForBrowserDialog.tsx"; +import { initI18n } from "@/lib/i18n"; +import { initPlatform } from "@/lib/platform"; +import { initLogForwarding } from "@/lib/logs"; +import { initStallWatch } from "@/lib/stallwatch"; + +// Must run first so even init-time logs reach the Go log pipeline. +initLogForwarding(); + +initStallWatch(); + +welcome(); + +Promise.all([ + initI18n().catch((e) => { + console.error("i18n init failed:", e); + }), + initPlatform().catch((e) => { + console.error("platform init failed:", e); + }), +]).finally(() => { + ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + + } + /> + } /> + } + /> + } /> + } /> + + }> + } /> + } /> + } /> + + + + + , + ); +}); diff --git a/client/ui/frontend/src/assets/fonts/inter-variable.ttf b/client/ui/frontend/src/assets/fonts/inter-variable.ttf new file mode 100644 index 000000000..4ab79e010 Binary files /dev/null and b/client/ui/frontend/src/assets/fonts/inter-variable.ttf differ diff --git a/client/ui/frontend/src/assets/fonts/jetbrains-mono-variable.ttf b/client/ui/frontend/src/assets/fonts/jetbrains-mono-variable.ttf new file mode 100644 index 000000000..b60e77f5d Binary files /dev/null and b/client/ui/frontend/src/assets/fonts/jetbrains-mono-variable.ttf differ diff --git a/client/ui/frontend/src/assets/img/tray-darwin.png b/client/ui/frontend/src/assets/img/tray-darwin.png new file mode 100644 index 000000000..75df803d8 Binary files /dev/null and b/client/ui/frontend/src/assets/img/tray-darwin.png differ diff --git a/client/ui/frontend/src/assets/img/tray-linux.png b/client/ui/frontend/src/assets/img/tray-linux.png new file mode 100644 index 000000000..08cea81af Binary files /dev/null and b/client/ui/frontend/src/assets/img/tray-linux.png differ diff --git a/client/ui/frontend/src/assets/img/tray-windows.png b/client/ui/frontend/src/assets/img/tray-windows.png new file mode 100644 index 000000000..cebbf6153 Binary files /dev/null and b/client/ui/frontend/src/assets/img/tray-windows.png differ diff --git a/client/ui/frontend/src/assets/logos/netbird-full.svg b/client/ui/frontend/src/assets/logos/netbird-full.svg new file mode 100644 index 000000000..f925d5761 --- /dev/null +++ b/client/ui/frontend/src/assets/logos/netbird-full.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/client/ui/frontend/src/assets/logos/netbird.svg b/client/ui/frontend/src/assets/logos/netbird.svg new file mode 100644 index 000000000..6254931c6 --- /dev/null +++ b/client/ui/frontend/src/assets/logos/netbird.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/client/ui/frontend/src/components/Badge.tsx b/client/ui/frontend/src/components/Badge.tsx new file mode 100644 index 000000000..c5e2b5f22 --- /dev/null +++ b/client/ui/frontend/src/components/Badge.tsx @@ -0,0 +1,43 @@ +import { forwardRef, type ComponentType, type HTMLAttributes } from "react"; +import type { LucideProps } from "lucide-react"; +import { cn } from "@/lib/cn"; + +export type BadgeVariant = "info" | "neutral" | "brand" | "success" | "warning" | "danger"; + +type Props = HTMLAttributes & { + variant?: BadgeVariant; + icon?: ComponentType; + iconSize?: number; +}; + +const VARIANT_CLASSES: Record = { + info: "bg-sky-900 border border-sky-700 text-sky-200", + neutral: "bg-nb-gray-900 border border-nb-gray-850 text-nb-gray-200", + brand: "bg-netbird/15 border border-netbird/30 text-netbird", + success: "bg-green-900 border border-green-700 text-green-200", + warning: "bg-yellow-900 border border-yellow-700 text-yellow-200", + danger: "bg-red-900 border border-red-700 text-red-200", +}; + +export const Badge = forwardRef(function Badge( + { variant = "info", icon: Icon, iconSize = 10, className, children, ...rest }, + ref, +) { + return ( + + {Icon && } + {children} + + ); +}); + +export default Badge; diff --git a/client/ui/frontend/src/components/CopyToClipboard.tsx b/client/ui/frontend/src/components/CopyToClipboard.tsx new file mode 100644 index 000000000..3cf681a1c --- /dev/null +++ b/client/ui/frontend/src/components/CopyToClipboard.tsx @@ -0,0 +1,131 @@ +import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Check, Copy } from "lucide-react"; +import { cn } from "@/lib/cn"; + +const VARIANT_HOVER = { + default: "group-hover/copy:[&_*]:text-nb-gray-300", + bright: "group-hover/copy:[&_*]:text-nb-gray-200", +} as const; + +type CopyToClipboardVariant = keyof typeof VARIANT_HOVER; + +type CopyToClipboardProps = { + children: ReactNode; + message?: string; + size?: number; + iconAlignment?: "left" | "right"; + className?: string; + iconClassName?: string; + alwaysShowIcon?: boolean; + // wrap lets long content (a shell command, a path) break across lines + // instead of being truncated to one line. + wrap?: boolean; + variant?: CopyToClipboardVariant; + "aria-label"?: string; + tabIndex?: number; + onKeyDown?: (e: KeyboardEvent) => void; +}; + +export const CopyToClipboard = ({ + children, + message, + size = 10, + iconAlignment = "right", + className, + iconClassName, + alwaysShowIcon = false, + wrap = false, + variant = "default", + "aria-label": ariaLabel, + tabIndex = 0, + onKeyDown, +}: CopyToClipboardProps) => { + const { t } = useTranslation(); + const wrapperRef = useRef(null); + const [copied, setCopied] = useState(false); + const copyTimer = useRef | null>(null); + useEffect( + () => () => { + if (copyTimer.current) clearTimeout(copyTimer.current); + }, + [], + ); + + const handleClick = async (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + const text = message ?? wrapperRef.current?.innerText ?? ""; + if (!text) return; + try { + await navigator.clipboard.writeText(text); + setCopied(true); + if (copyTimer.current) clearTimeout(copyTimer.current); + copyTimer.current = setTimeout(() => setCopied(false), 500); + } catch (e) { + console.warn("copy to clipboard failed", e); + } + }; + + const resolvedLabel = + ariaLabel ?? (message ? `${t("common.copy")} ${message}` : t("common.copy")); + + return ( + + ); +}; diff --git a/client/ui/frontend/src/components/DropdownMenu.tsx b/client/ui/frontend/src/components/DropdownMenu.tsx new file mode 100644 index 000000000..d43c37e1b --- /dev/null +++ b/client/ui/frontend/src/components/DropdownMenu.tsx @@ -0,0 +1,233 @@ +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { cva } from "class-variance-authority"; +import { Check, ChevronRight, Circle } from "lucide-react"; +import * as React from "react"; +import { cn } from "@/lib/cn"; + +const DropdownMenu = DropdownMenuPrimitive.Root; +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; +const DropdownMenuGroup = DropdownMenuPrimitive.Group; +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; +const DropdownMenuSub = DropdownMenuPrimitive.Sub; +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const menuItemVariants = cva("", { + variants: { + variant: { + default: + "text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50 focus-visible:bg-nb-gray-900 focus-visible:text-nb-gray-50 data-[state=open]:bg-nb-gray-900 data-[state=open]:text-nb-gray-50", + danger: "text-red-500 hover:bg-red-900/20 hover:text-red-500 focus-visible:bg-red-900/20 focus-visible:text-red-500", + }, + }, + defaultVariants: { variant: "default" }, +}); + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + variant?: "default" | "danger"; + } +>(({ className, inset, children, variant, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + variant?: "default" | "danger"; + href?: string; + target?: string; + rel?: string; + } +>(({ className, inset, variant, onClick, href, target, rel, children, ...props }, ref) => ( + { + if (href) return; + e.preventDefault(); + e.stopPropagation(); + onClick?.(e); + }} + {...props} + > + {href ? ( + + {children} + + ) : ( + children + )} + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => ( + +); +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +}; diff --git a/client/ui/frontend/src/components/LanguagePicker.tsx b/client/ui/frontend/src/components/LanguagePicker.tsx new file mode 100644 index 000000000..7a30f8b33 --- /dev/null +++ b/client/ui/frontend/src/components/LanguagePicker.tsx @@ -0,0 +1,235 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import * as Popover from "@radix-ui/react-popover"; +import * as ScrollArea from "@radix-ui/react-scroll-area"; +import { Command } from "cmdk"; +import { CheckIcon, ChevronDown, LanguagesIcon, Search } from "lucide-react"; +import { Preferences } from "@bindings/services"; +import { type LanguageCode, type Language } from "@bindings/i18n/models.js"; +import { HelpText } from "@/components/typography/HelpText"; +import { Label } from "@/components/typography/Label"; +import { useFocusVisible } from "@/hooks/useFocusVisible"; +import { loadLanguages } from "@/lib/i18n"; +import { cn } from "@/lib/cn"; +import { errorDialog, formatErrorMessage } from "@/lib/errors"; + +// No flag icons: flags represent countries, not languages. https://www.flagsarenotlanguages.com/blog/ + +const labelFor = (lang: Language): string => + lang.englishName && lang.englishName !== lang.displayName + ? `${lang.displayName} (${lang.englishName})` + : lang.displayName; + +export function LanguagePicker() { + const { t, i18n } = useTranslation(); + const [languages, setLanguages] = useState([]); + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState(false); + const isFocusVisible = useFocusVisible(); + + useEffect(() => { + let cancelled = false; + loadLanguages() + .then((list) => { + if (!cancelled) setLanguages(list); + }) + .catch((err: unknown) => console.error("load languages failed", err)); + return () => { + cancelled = true; + }; + }, []); + + const sorted = useMemo( + () => [...languages].sort((a, b) => a.displayName.localeCompare(b.displayName)), + [languages], + ); + + const current = useMemo( + () => + languages.find((l) => l.code === i18n.language) ?? + languages.find((l) => l.code === "en"), + [languages, i18n.language], + ); + + const handleTriggerKeyDown = (e: React.KeyboardEvent) => { + if (open) return; + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + setOpen(true); + } + }; + + const select = async (code: string) => { + setOpen(false); + if (busy || code === i18n.language) return; + setBusy(true); + try { + await Preferences.SetLanguage(code as LanguageCode); + } catch (e) { + await errorDialog({ + Title: t("settings.error.saveTitle"), + Message: formatErrorMessage(e), + }); + } finally { + setBusy(false); + } + }; + + return ( +
                                                +
                                                + + {t("settings.general.language.help")} +
                                                +
                                                + + + + + + + + +
                                                +
                                                + + +
                                                +
                                                + + + + + +
                                                + {t("settings.general.language.empty")} +
                                                +
                                                + + {sorted.map((lang) => { + const checked = lang.code === i18n.language; + return ( + void select(lang.code)} + className={cn( + "my-0.5 flex cursor-default items-center gap-2 rounded-md px-2 py-2 outline-none", + "text-xs font-semibold text-nb-gray-200", + "data-[selected=true]:bg-nb-gray-850 data-[selected=true]:text-nb-gray-50", + )} + > + + {labelFor(lang)} + + + {checked && ( + + )} + + + ); + })} +
                                                +
                                                + + + +
                                                +
                                                +
                                                +
                                                +
                                                +
                                                +
                                                + ); +} diff --git a/client/ui/frontend/src/components/ManagementServerSwitch.tsx b/client/ui/frontend/src/components/ManagementServerSwitch.tsx new file mode 100644 index 000000000..0083a767a --- /dev/null +++ b/client/ui/frontend/src/components/ManagementServerSwitch.tsx @@ -0,0 +1,38 @@ +import { useTranslation } from "react-i18next"; +import netbirdLogo from "@/assets/logos/netbird.svg"; +import { SwitchItem } from "@/components/switches/SwitchItem"; +import { SwitchItemGroup } from "@/components/switches/SwitchItemGroup"; +import { ManagementMode } from "@/hooks/useManagementUrl.ts"; + +type Props = { + value: ManagementMode; + onChange: (mode: ManagementMode) => void; + fullWidth?: boolean; +}; + +export const ManagementServerSwitch = ({ value, onChange, fullWidth = false }: Props) => { + const { t, i18n } = useTranslation(); + const itemClass = fullWidth ? "flex-1" : undefined; + return ( + onChange(v as ManagementMode)} + aria-label={t("settings.general.management.label")} + className={fullWidth ? "w-full" : undefined} + > + + {""} + {t("settings.general.management.cloud")} + + + {t("settings.general.management.selfHosted")} + + + ); +}; diff --git a/client/ui/frontend/src/components/ReadySignal.tsx b/client/ui/frontend/src/components/ReadySignal.tsx new file mode 100644 index 000000000..0d040cabc --- /dev/null +++ b/client/ui/frontend/src/components/ReadySignal.tsx @@ -0,0 +1,18 @@ +import { useEffect, useRef } from "react"; +import { Events } from "@wailsio/runtime"; +import { useStatus } from "@/contexts/StatusContext.tsx"; + +const EVENT_WINDOW_PAINTED = "netbird:window-painted"; + +export const ReadySignal = () => { + const { isReady } = useStatus(); + const sent = useRef(false); + + useEffect(() => { + if (!isReady || sent.current) return; + sent.current = true; + void Events.Emit(EVENT_WINDOW_PAINTED); + }, [isReady]); + + return null; +}; diff --git a/client/ui/frontend/src/components/SquareIcon.tsx b/client/ui/frontend/src/components/SquareIcon.tsx new file mode 100644 index 000000000..e904d2de5 --- /dev/null +++ b/client/ui/frontend/src/components/SquareIcon.tsx @@ -0,0 +1,37 @@ +import { type ComponentType } from "react"; +import { type LucideProps } from "lucide-react"; +import { cn } from "@/lib/cn"; + +export type SquareIconVariant = "default" | "info" | "warning" | "danger"; + +const variantClass: Record = { + default: "text-white", + info: "text-sky-400", + warning: "text-netbird", + danger: "text-red-500", +}; + +type SquareIconProps = { + icon: ComponentType; + iconSize?: number; + variant?: SquareIconVariant; + className?: string; +}; + +export const SquareIcon = ({ + icon: Icon, + iconSize = 18, + variant = "default", + className, +}: SquareIconProps) => ( +
                                                + +
                                                +); diff --git a/client/ui/frontend/src/components/Tooltip.tsx b/client/ui/frontend/src/components/Tooltip.tsx new file mode 100644 index 000000000..2c77ba139 --- /dev/null +++ b/client/ui/frontend/src/components/Tooltip.tsx @@ -0,0 +1,98 @@ +import { type ReactNode, useEffect, useRef, useState } from "react"; +import * as RTooltip from "@radix-ui/react-tooltip"; +import { cn } from "@/lib/cn"; + +type Props = { + content: ReactNode; + children: ReactNode; + side?: RTooltip.TooltipContentProps["side"]; + align?: RTooltip.TooltipContentProps["align"]; + delayDuration?: number; + sideOffset?: number; + alignOffset?: number; + interactive?: boolean; + keepOpenOnClick?: boolean; + contentClassName?: string; + closeDelay?: number; +}; + +export const Tooltip = ({ + content, + children, + side = "bottom", + align = "center", + delayDuration = 200, + sideOffset = 6, + alignOffset = 0, + interactive = false, + keepOpenOnClick = true, + contentClassName, + closeDelay = 0, +}: Props) => { + const [open, setOpen] = useState(false); + const hoveringRef = useRef(false); + const closeTimer = useRef | null>(null); + + const cancelClose = () => { + if (closeTimer.current) { + clearTimeout(closeTimer.current); + closeTimer.current = null; + } + }; + const scheduleClose = () => { + cancelClose(); + if (closeDelay <= 0) { + setOpen(false); + return; + } + closeTimer.current = setTimeout(() => setOpen(false), closeDelay); + }; + useEffect(() => () => cancelClose(), []); + + const handleOpenChange = (next: boolean) => { + if (!next && keepOpenOnClick && hoveringRef.current) return; + if (next) cancelClose(); + setOpen(next); + }; + + return ( + + + { + hoveringRef.current = true; + cancelClose(); + }} + onPointerLeave={() => { + hoveringRef.current = false; + scheduleClose(); + }} + > + {children} + + + e.preventDefault()} + className={cn( + "z-50 select-none text-xs text-nb-gray-100 shadow-lg", + "data-[state=delayed-open]:animate-in data-[state=closed]:animate-out", + "data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0", + !interactive && "pointer-events-none", + contentClassName ?? + "rounded-md border border-nb-gray-850 bg-nb-gray-900 px-2 py-1", + )} + > + {content} + + + + + ); +}; diff --git a/client/ui/frontend/src/components/TruncatedText.tsx b/client/ui/frontend/src/components/TruncatedText.tsx new file mode 100644 index 000000000..5b2d2160c --- /dev/null +++ b/client/ui/frontend/src/components/TruncatedText.tsx @@ -0,0 +1,32 @@ +import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { Tooltip } from "@/components/Tooltip"; + +type Props = { + text: string; + className?: string; + tooltipContent?: ReactNode; + delayDuration?: number; +}; + +export const TruncatedText = ({ text, className, tooltipContent, delayDuration = 600 }: Props) => { + const ref = useRef(null); + const [overflowing, setOverflowing] = useState(false); + + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + setOverflowing(el.scrollWidth > el.clientWidth); + }, [text]); + + const span = ( + + {text} + + ); + if (!overflowing) return span; + return ( + + {span} + + ); +}; diff --git a/client/ui/frontend/src/components/VerticalTabs.tsx b/client/ui/frontend/src/components/VerticalTabs.tsx new file mode 100644 index 000000000..1aedf82a6 --- /dev/null +++ b/client/ui/frontend/src/components/VerticalTabs.tsx @@ -0,0 +1,98 @@ +import { type ComponentType, type ReactNode, forwardRef } from "react"; +import * as Tabs from "@radix-ui/react-tabs"; +import { type LucideProps } from "lucide-react"; +import { cn } from "@/lib/cn"; +import { useFocusVisible } from "@/hooks/useFocusVisible"; + +const Root = forwardRef>( + function VerticalTabsRoot({ className, ...props }, ref) { + return ( + + ); + }, +); + +const List = forwardRef(function VerticalTabsList( + { className, ...props }, + ref, +) { + return ( + + ); +}); + +type TriggerProps = Tabs.TabsTriggerProps & { + icon: ComponentType; + title: string; + iconSize?: number; + adornment?: ReactNode; +}; + +const Trigger = forwardRef(function VerticalTabsTrigger( + { icon: Icon, title, iconSize = 16, adornment, className, ...props }, + ref, +) { + const isFocusVisible = useFocusVisible(); + return ( + + + + {title} + + {adornment && ( +
                                                + {adornment} +
                                                + )} +
                                                + ); +}); + +const Content = forwardRef(function VerticalTabsContent( + { className, ...props }, + ref, +) { + return ( + + ); +}); + +export const VerticalTabs = Object.assign(Root, { List, Trigger, Content }); diff --git a/client/ui/frontend/src/components/buttons/Button.tsx b/client/ui/frontend/src/components/buttons/Button.tsx new file mode 100644 index 000000000..6b151c17b --- /dev/null +++ b/client/ui/frontend/src/components/buttons/Button.tsx @@ -0,0 +1,195 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { Check, Copy, Loader2 } from "lucide-react"; +import { type ButtonHTMLAttributes, forwardRef, useEffect, useRef, useState } from "react"; + +import { cn } from "@/lib/cn"; + +type ButtonVariants = VariantProps; + +interface ButtonProps extends ButtonHTMLAttributes, ButtonVariants { + disabled?: boolean; + stopPropagation?: boolean; + copy?: string; + loading?: boolean; +} + +const buttonVariants = cva( + [ + "relative", + "cursor-default select-none whitespace-nowrap text-sm font-medium shadow-sm focus:z-10 focus:outline-none focus:ring-2", + "inline-flex items-center justify-center gap-2 transition-colors focus:ring-offset-1", + "disabled:cursor-not-allowed disabled:opacity-40 dark:ring-offset-neutral-950/50 disabled:dark:text-nb-gray-300", + ], + { + variants: { + variant: { + default: [ + "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "dark:border-gray-700/30 dark:bg-nb-gray dark:text-gray-400 dark:hover:bg-zinc-800/50 dark:hover:text-white dark:focus:ring-zinc-800/50", + ], + primary: [ + "dark:text-gray-100 dark:ring-offset-neutral-950/50 dark:focus:ring-netbird-600/50 enabled:dark:bg-netbird enabled:dark:hover:bg-netbird-500/80 enabled:dark:hover:text-white disabled:dark:bg-nb-gray-900", + "enabled:bg-netbird enabled:text-white enabled:hover:bg-netbird-500 enabled:focus:ring-netbird-400/50", + ], + secondary: [ + "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", + "dark:border-gray-700/40 dark:bg-nb-gray-920 dark:text-gray-400 dark:hover:bg-nb-gray-910 dark:hover:text-white", + ], + secondaryLighter: [ + "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", + "dark:border-gray-700/70 dark:bg-nb-gray-900/70 dark:text-gray-400 dark:hover:bg-nb-gray-800/60 dark:hover:text-white", + ], + subtle: [ + "border-nb-gray-200 bg-nb-gray-50 text-nb-gray-900 hover:bg-nb-gray-100 focus:ring-nb-gray-200/60", + "dark:ring-offset-neutral-950/50 dark:focus:ring-nb-gray-200/40", + "dark:border-nb-gray-200 dark:bg-nb-gray-50 dark:text-nb-gray-900 dark:hover:bg-nb-gray-100 dark:hover:text-nb-gray-950", + ], + input: [ + "border-neutral-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", + "dark:border-nb-gray-700 dark:bg-nb-gray-900 dark:text-gray-400 dark:hover:bg-nb-gray-900/80", + ], + dropdown: [ + "border-neutral-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", + "dark:border-nb-gray-900 dark:bg-nb-gray-900/40 dark:text-gray-400 dark:hover:bg-nb-gray-900/50", + ], + dotted: [ + "border-dashed border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", + "dark:border-gray-500/40 dark:bg-nb-gray-900/30 dark:text-gray-400 dark:hover:bg-nb-gray-900/50 dark:hover:text-white", + ], + tertiary: [ + "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "dark:border-gray-700/40 dark:bg-white dark:text-gray-800 dark:hover:bg-neutral-200 dark:focus:ring-zinc-800/50 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300", + ], + white: [ + "border-white bg-white text-gray-800 outline-none hover:bg-neutral-200 focus:ring-white/50 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300", + "disabled:dark:border-nb-gray-900 disabled:dark:bg-nb-gray-900 disabled:dark:text-nb-gray-300", + ], + outline: [ + "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "dark:border-netbird dark:bg-transparent dark:text-netbird dark:hover:bg-nb-gray-900/30 dark:focus:ring-zinc-800/50", + ], + "danger-outline": [ + "dark:bg-transparent dark:text-red-500 enabled:dark:hover:border-red-800/50 enabled:hover:dark:bg-red-950/50 enabled:dark:focus:bg-red-950/40 enabled:dark:focus:ring-red-800/20", + ], + "danger-text": [ + "rounded-sm !px-0 !py-0 !shadow-none focus:ring-red-500/30 dark:border-transparent dark:bg-transparent dark:text-red-500 dark:ring-offset-neutral-950/50 dark:hover:text-red-600", + ], + "default-outline": [ + "dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20", + "dark:border-transparent dark:bg-transparent dark:text-nb-gray-400 dark:hover:border-nb-gray-800/50 dark:hover:bg-nb-gray-900/30 dark:hover:text-white", + "data-[state=open]:dark:border-nb-gray-800/50 data-[state=open]:dark:bg-nb-gray-900/30 data-[state=open]:dark:text-white", + ], + ghost: [ + "dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20", + "dark:border-transparent dark:bg-transparent dark:text-nb-gray-400 dark:hover:bg-nb-gray-900/30 dark:hover:text-white", + ], + danger: [ + "dark:bg-red-600 dark:text-red-100 dark:hover:border-red-800/50 hover:dark:bg-red-700 dark:focus:bg-red-700 dark:focus:ring-red-700/20", + ], + }, + size: { + xs: "px-3.5 py-2.5 text-xs", + xs2: "px-4 py-[1.1rem] text-[0.78rem] leading-[0]", + sm: "px-4 py-[9px] text-sm", + md: "px-4 py-[9px]", + lg: "px-4 py-[9px] text-lg", + }, + rounded: { + true: "rounded-md", + false: "", + }, + border: { + 0: "border", + 1: "border border-transparent", + 2: "border border-b-0 border-t-0", + }, + }, + }, +); + +export const Button = forwardRef(function Button( + { + variant = "default", + rounded = true, + border = 1, + size = "md", + stopPropagation = true, + type = "button", + children, + className, + onClick, + disabled, + copy, + loading = false, + ...props + }, + ref, +) { + const [copied, setCopied] = useState(false); + const copyTimer = useRef | null>(null); + useEffect( + () => () => { + if (copyTimer.current) clearTimeout(copyTimer.current); + }, + [], + ); + const iconSize = size === "xs" ? 12 : 14; + return ( + + ); +}); + +export default Button; diff --git a/client/ui/frontend/src/components/buttons/IconButton.tsx b/client/ui/frontend/src/components/buttons/IconButton.tsx new file mode 100644 index 000000000..3d36bc111 --- /dev/null +++ b/client/ui/frontend/src/components/buttons/IconButton.tsx @@ -0,0 +1,36 @@ +import { type ButtonHTMLAttributes, type ComponentType, forwardRef } from "react"; +import { type LucideProps } from "lucide-react"; +import { useFocusVisible } from "@/hooks/useFocusVisible"; +import { cn } from "@/lib/cn"; + +type Props = ButtonHTMLAttributes & { + icon: ComponentType; + iconSize?: number; + iconClassName?: string; +}; + +export const IconButton = forwardRef(function IconButton( + { icon: Icon, iconSize = 17, iconClassName, className, type = "button", disabled, ...props }, + ref, +) { + const isFocusVisible = useFocusVisible(); + return ( + + ); +}); diff --git a/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx b/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx new file mode 100644 index 000000000..caf98c2c8 --- /dev/null +++ b/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx @@ -0,0 +1,35 @@ +import { type ReactNode, forwardRef } from "react"; +import { cn } from "@/lib/cn.ts"; +import { isMacOS } from "@/lib/platform.ts"; + +type ConfirmDialogProps = { + children: ReactNode; + "aria-label"?: string; + "aria-labelledby"?: string; +}; + +export const ConfirmDialog = forwardRef(function ConfirmDialog( + { children, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy }, + ref, +) { + return ( + +
                                                + {children} +
                                                +
                                                + ); +}); diff --git a/client/ui/frontend/src/components/dialog/ConfirmModal.tsx b/client/ui/frontend/src/components/dialog/ConfirmModal.tsx new file mode 100644 index 000000000..241a69ec9 --- /dev/null +++ b/client/ui/frontend/src/components/dialog/ConfirmModal.tsx @@ -0,0 +1,84 @@ +import { type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import * as Dialog from "@/components/dialog/Dialog"; +import { Button } from "@/components/buttons/Button"; +import { DialogHeading } from "@/components/dialog/DialogHeading"; +import { DialogDescription } from "@/components/dialog/DialogDescription"; +import { DialogActions } from "@/components/dialog/DialogActions"; + +type ConfirmModalProps = { + open: boolean; + title: ReactNode; + description: ReactNode; + confirmLabel: string; + cancelLabel?: string; + danger?: boolean; + busy?: boolean; + onConfirm: () => void; + onCancel: () => void; +}; + +export const ConfirmModal = ({ + open, + title, + description, + confirmLabel, + cancelLabel, + danger = false, + busy = false, + onConfirm, + onCancel, +}: ConfirmModalProps) => { + const { t } = useTranslation(); + const resolvedCancel = cancelLabel ?? t("common.cancel"); + + const srTitle = typeof title === "string" ? title : undefined; + const srDescription = typeof description === "string" ? description : undefined; + + return ( + { + if (!next && !busy) onCancel(); + }} + > + e.preventDefault()} + > +
                                                +
                                                + {title} + + {description} + +
                                                + + + + + +
                                                +
                                                +
                                                + ); +}; diff --git a/client/ui/frontend/src/components/dialog/Dialog.tsx b/client/ui/frontend/src/components/dialog/Dialog.tsx new file mode 100644 index 000000000..fa8007d9f --- /dev/null +++ b/client/ui/frontend/src/components/dialog/Dialog.tsx @@ -0,0 +1,159 @@ +import { + forwardRef, + type ComponentPropsWithoutRef, + type ElementRef, + type HTMLAttributes, +} from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; +import { X } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/cn"; + +export const Root = DialogPrimitive.Root; + +type OverlayProps = ComponentPropsWithoutRef & { + exitAnimation?: boolean; +}; + +const Overlay = forwardRef, OverlayProps>( + function DialogOverlay({ className, exitAnimation = false, ...props }, ref) { + return ( + + ); + }, +); + +type ContentProps = ComponentPropsWithoutRef & { + showClose?: boolean; + maxWidthClass?: string; + exitAnimation?: boolean; + srTitle?: string; + srDescription?: string; +}; + +export const Content = forwardRef, ContentProps>( + function DialogContent( + { + className, + children, + showClose = true, + maxWidthClass = "max-w-md", + exitAnimation = false, + srTitle, + srDescription, + ...props + }, + ref, + ) { + const { t } = useTranslation(); + return ( + + + e.stopPropagation()} + {...props} + > + + + {srTitle ?? t("common.netbird")} + + + {srDescription && ( + + + {srDescription} + + + )} + {children} + {showClose && ( + + + + )} + + + + ); + }, +); + +export const Title = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(function DialogTitle({ className, ...props }, ref) { + return ( + + ); +}); + +export const Description = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(function DialogDescription({ className, ...props }, ref) { + return ( + + ); +}); + +type FooterProps = HTMLAttributes & { + separator?: boolean; +}; + +export const Footer = ({ className, separator = true, ...props }: FooterProps) => ( +
                                                +
                                                *]:w-full sm:[&>*]:w-auto", + "px-8 pt-6", + className, + )} + {...props} + /> +
                                                +); diff --git a/client/ui/frontend/src/components/dialog/DialogActions.tsx b/client/ui/frontend/src/components/dialog/DialogActions.tsx new file mode 100644 index 000000000..3aa3a1fe5 --- /dev/null +++ b/client/ui/frontend/src/components/dialog/DialogActions.tsx @@ -0,0 +1,13 @@ +import { type ReactNode } from "react"; +import { cn } from "@/lib/cn"; + +type DialogActionsProps = { + children: ReactNode; + className?: string; +}; + +export const DialogActions = ({ children, className }: DialogActionsProps) => ( +
                                                + {children} +
                                                +); diff --git a/client/ui/frontend/src/components/dialog/DialogDescription.tsx b/client/ui/frontend/src/components/dialog/DialogDescription.tsx new file mode 100644 index 000000000..12c358043 --- /dev/null +++ b/client/ui/frontend/src/components/dialog/DialogDescription.tsx @@ -0,0 +1,26 @@ +import { type ReactNode } from "react"; +import { cn } from "@/lib/cn"; + +type DialogAlign = "left" | "center" | "right"; + +const alignClass: Record = { + left: "text-left", + center: "text-center", + right: "text-right", +}; + +type DialogDescriptionProps = { + children: ReactNode; + className?: string; + align?: DialogAlign; +}; + +export const DialogDescription = ({ + children, + className, + align = "center", +}: DialogDescriptionProps) => ( +

                                                + {children} +

                                                +); diff --git a/client/ui/frontend/src/components/dialog/DialogHeading.tsx b/client/ui/frontend/src/components/dialog/DialogHeading.tsx new file mode 100644 index 000000000..b9dda72a9 --- /dev/null +++ b/client/ui/frontend/src/components/dialog/DialogHeading.tsx @@ -0,0 +1,35 @@ +import { type ReactNode } from "react"; +import { cn } from "@/lib/cn"; + +type DialogAlign = "left" | "center" | "right"; + +const alignClass: Record = { + left: "text-left", + center: "text-center", + right: "text-right", +}; + +type DialogHeadingProps = { + children: ReactNode; + className?: string; + align?: DialogAlign; + id?: string; +}; + +export const DialogHeading = ({ + children, + className, + align = "center", + id, +}: DialogHeadingProps) => ( +

                                                + {children} +

                                                +); diff --git a/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx b/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx new file mode 100644 index 000000000..e8e7108eb --- /dev/null +++ b/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx @@ -0,0 +1,99 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { AlertTriangleIcon, DownloadIcon } from "lucide-react"; +import { Browser } from "@wailsio/runtime"; +import { Version } from "@bindings/services"; +import { Button } from "@/components/buttons/Button"; +import { useStatus } from "@/contexts/StatusContext.tsx"; + +const RELEASES_URL = "https://github.com/netbirdio/netbird/releases/latest"; +const RC_RELEASES_URL = "https://pkgs.netbird.io/releases/rc"; + +function openUrl(url: string) { + Browser.OpenURL(url).catch(() => globalThis.open(url, "_blank")); +} + +export const DaemonOutdatedOverlay = () => { + const { t } = useTranslation(); + const { status, isDaemonOutdated } = useStatus(); + + const [guiVersion, setGuiVersion] = useState("-"); + const clientVersion = status?.daemonVersion ?? "—"; + + const isRc = /-rc/i.test(guiVersion) || /-rc/i.test(clientVersion); + const downloadUrl = isRc ? RC_RELEASES_URL : RELEASES_URL; + + useEffect(() => { + if (!isDaemonOutdated) return; + let cancelled = false; + Version.GUI() + .then((v) => { + if (!cancelled) setGuiVersion(v); + }) + .catch((err) => console.error("[DaemonOutdatedOverlay] GUI version error", err)); + return () => { + cancelled = true; + }; + }, [isDaemonOutdated]); + + if (!isDaemonOutdated) return null; + + return ( +
                                                +
                                                +
                                                + +
                                                + +
                                                +

                                                + {t("daemon.outdated.title")} +

                                                +

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

                                                +
                                                + +
                                                +

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

                                                +

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

                                                +
                                                + +
                                                + +
                                                +
                                                +
                                                + ); +}; diff --git a/client/ui/frontend/src/components/empty-state/DaemonUnavailableOverlay.tsx b/client/ui/frontend/src/components/empty-state/DaemonUnavailableOverlay.tsx new file mode 100644 index 000000000..89c121e21 --- /dev/null +++ b/client/ui/frontend/src/components/empty-state/DaemonUnavailableOverlay.tsx @@ -0,0 +1,52 @@ +import { useTranslation } from "react-i18next"; +import { AlertCircleIcon, BookText } from "lucide-react"; +import { Browser } from "@wailsio/runtime"; +import { Button } from "@/components/buttons/Button"; +import { useStatus } from "@/contexts/StatusContext.tsx"; + +const DOCS_URL = "https://docs.netbird.io/how-to/installation"; + +function openUrl(url: string) { + Browser.OpenURL(url).catch(() => globalThis.open(url, "_blank")); +} + +export const DaemonUnavailableOverlay = () => { + const { t } = useTranslation(); + const { isDaemonUnavailable } = useStatus(); + + if (!isDaemonUnavailable) return null; + + return ( +
                                                +
                                                +
                                                + +
                                                + +
                                                +

                                                + {t("daemon.unavailable.title")} +

                                                +

                                                + {t("daemon.unavailable.description")} +

                                                +
                                                + +
                                                + +
                                                +
                                                +
                                                + ); +}; diff --git a/client/ui/frontend/src/components/empty-state/EmptyState.tsx b/client/ui/frontend/src/components/empty-state/EmptyState.tsx new file mode 100644 index 000000000..7d6890a98 --- /dev/null +++ b/client/ui/frontend/src/components/empty-state/EmptyState.tsx @@ -0,0 +1,31 @@ +import { type ComponentType } from "react"; +import { type LucideProps } from "lucide-react"; +import { cn } from "@/lib/cn"; +import { SquareIcon } from "@/components/SquareIcon"; +import { isMacOS } from "@/lib/platform"; + +// Knob to shift the centered main-window content up/down together. +export const contentVerticalOffset = (): string => (isMacOS() ? "0.6rem" : "-1.4rem"); +export const contentTop = (base: string) => `calc(${base} + ${contentVerticalOffset()})`; + +type Props = { + icon: ComponentType; + title: string; + description?: string; + className?: string; +}; + +export const EmptyState = ({ icon, title, description, className }: Props) => { + return ( +
                                                +
                                                + +

                                                {title}

                                                + {description &&

                                                {description}

                                                } +
                                                +
                                                + ); +}; diff --git a/client/ui/frontend/src/components/empty-state/NoResults.tsx b/client/ui/frontend/src/components/empty-state/NoResults.tsx new file mode 100644 index 000000000..cf0995b37 --- /dev/null +++ b/client/ui/frontend/src/components/empty-state/NoResults.tsx @@ -0,0 +1,22 @@ +import { type ComponentType } from "react"; +import { FunnelXIcon, type LucideProps } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { EmptyState } from "./EmptyState"; + +type Props = { + icon?: ComponentType; + title?: string; + description?: string; +}; + +export const NoResults = ({ icon = FunnelXIcon, title, description }: Props) => { + const { t } = useTranslation(); + return ( + + ); +}; diff --git a/client/ui/frontend/src/components/empty-state/NotConnectedState.tsx b/client/ui/frontend/src/components/empty-state/NotConnectedState.tsx new file mode 100644 index 000000000..2bcf70376 --- /dev/null +++ b/client/ui/frontend/src/components/empty-state/NotConnectedState.tsx @@ -0,0 +1,16 @@ +import { GlobeOffIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { EmptyState } from "./EmptyState"; + +export const NotConnectedState = () => { + const { t } = useTranslation(); + return ( +
                                                + +
                                                + ); +}; diff --git a/client/ui/frontend/src/components/inputs/Input.tsx b/client/ui/frontend/src/components/inputs/Input.tsx new file mode 100644 index 000000000..2dad80d7a --- /dev/null +++ b/client/ui/frontend/src/components/inputs/Input.tsx @@ -0,0 +1,374 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { Check, ChevronDown, ChevronUp, Copy, Eye, EyeOff } from "lucide-react"; +import { + forwardRef, + type InputHTMLAttributes, + type ReactNode, + useEffect, + useId, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/cn"; +import { Label } from "@/components/typography/Label"; + +type InputVariants = VariantProps; + +export interface InputProps extends InputHTMLAttributes, InputVariants { + label?: string; + customPrefix?: ReactNode; + customSuffix?: ReactNode; + maxWidthClass?: string; + icon?: ReactNode; + error?: string; + warning?: string; + prefixClassName?: string; + showPasswordToggle?: boolean; + copy?: boolean; +} + +const inputVariants = cva("", { + variants: { + variant: { + default: [ + "border-neutral-200 placeholder:text-neutral-500 dark:border-nb-gray-700 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70", + "ring-offset-neutral-200/20 focus-visible:ring-neutral-300/10 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20", + ], + darker: [ + "border-neutral-300 placeholder:text-neutral-500 dark:border-nb-gray-800 dark:bg-nb-gray-920 dark:placeholder:text-neutral-400/70", + "ring-offset-neutral-200/20 focus-visible:ring-neutral-300/10 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20", + ], + error: [ + "border-neutral-200 text-red-500 placeholder:text-neutral-500 dark:border-red-500 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70", + "ring-offset-red-500/10 focus-visible:ring-red-500/10 dark:ring-offset-red-500/10 dark:focus-visible:ring-red-500/10", + ], + warning: [ + "border-neutral-200 text-orange-400 placeholder:text-neutral-500 dark:border-orange-400 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70", + "ring-offset-orange-400/10 focus-visible:ring-orange-400/10 dark:ring-offset-orange-400/10 dark:focus-visible:ring-orange-400/10", + ], + }, + prefixSuffixVariant: { + default: [ + "border-neutral-200 text-nb-gray-300 dark:border-nb-gray-700 dark:bg-nb-gray-900", + ], + error: ["border-red-500 text-nb-gray-300 text-red-500 dark:bg-nb-gray-900"], + }, + }, +}); + +function computeNextStepValue(el: HTMLInputElement, delta: 1 | -1): number { + const stepAttr = el.step === "" ? 1 : Number(el.step); + const step = Number.isFinite(stepAttr) && stepAttr > 0 ? stepAttr : 1; + const min = el.min === "" ? -Infinity : Number(el.min); + const max = el.max === "" ? Infinity : Number(el.max); + const current = el.value === "" ? 0 : Number(el.value); + let next = (Number.isFinite(current) ? current : 0) + delta * step; + if (next < min) next = min; + if (next > max) next = max; + return next; +} + +function buildInputClassName( + opts: Readonly<{ + variant: InputVariants["variant"]; + hasCustomPrefix: boolean; + hasSuffix: boolean; + hasIcon: boolean; + readOnly?: boolean; + showStepper: boolean; + className?: string; + }>, +): string { + return cn( + inputVariants({ variant: opts.variant }), + "flex h-[40px] w-full select-text rounded-md bg-white px-3 py-2 text-sm", + "file:border-0 file:bg-transparent file:text-sm file:font-medium", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", + "disabled:cursor-not-allowed disabled:opacity-40", + opts.hasCustomPrefix && "!rounded-l-none !border-l-0", + opts.hasSuffix && "!pr-9", + opts.hasIcon && "!pl-10", + "border", + opts.readOnly && "!border-nb-gray-800 !bg-nb-gray-910 text-nb-gray-350", + opts.showStepper && + "!rounded-r-none [-moz-appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none", + opts.className, + ); +} + +function InputAffix({ + content, + error, + disabled, + className, +}: Readonly<{ content: ReactNode; error?: string; disabled?: boolean; className?: string }>) { + return ( +
                                                + {content} +
                                                + ); +} + +function InputIconSlot({ icon, disabled }: Readonly<{ icon: ReactNode; disabled?: boolean }>) { + return ( +
                                                + {icon} +
                                                + ); +} + +function InputSuffixSlot({ + suffix, + disabled, +}: Readonly<{ suffix: ReactNode; disabled?: boolean }>) { + return ( +
                                                + {suffix} +
                                                + ); +} + +function NumberStepper({ + error, + disabled, + onStep, +}: Readonly<{ error?: string; disabled?: boolean; onStep: (delta: 1 | -1) => void }>) { + const { t } = useTranslation(); + return ( +
                                                + + +
                                                + ); +} + +function FieldMessage({ + id, + error, + warning, +}: Readonly<{ id?: string; error?: string; warning?: string }>) { + if (!error && !warning) return null; + return ( + + {error ?? warning} + + ); +} + +export const Input = forwardRef(function Input( + { + className, + type, + label, + customSuffix, + customPrefix, + icon, + maxWidthClass = "", + error, + warning, + variant = "default", + prefixClassName, + showPasswordToggle = false, + copy = false, + id, + ...props + }, + ref, +) { + const { t } = useTranslation(); + const [showPassword, setShowPassword] = useState(false); + const [copied, setCopied] = useState(false); + const isPasswordType = type === "password"; + const inputType = isPasswordType && showPassword ? "text" : type; + const isNumber = type === "number"; + + const reactId = useId(); + const fallbackId = `input-${reactId}`; + const inputId = id ?? (label ? fallbackId : undefined); + const messageId = error || warning ? `${inputId ?? fallbackId}-message` : undefined; + + const copyTimer = useRef | null>(null); + useEffect( + () => () => { + if (copyTimer.current) clearTimeout(copyTimer.current); + }, + [], + ); + + const internalRef = useRef(null); + const setRefs = (el: HTMLInputElement | null) => { + internalRef.current = el; + if (typeof ref === "function") ref(el); + else if (ref) ref.current = el; + }; + + const stepBy = (delta: 1 | -1) => { + const el = internalRef.current; + if (!el || el.disabled || el.readOnly) return; + const setter = Object.getOwnPropertyDescriptor( + globalThis.HTMLInputElement.prototype, + "value", + )?.set; + const next = computeNextStepValue(el, delta); + setter?.call(el, String(next)); + el.dispatchEvent(new Event("input", { bubbles: true })); + }; + + const passwordToggle = + isPasswordType && showPasswordToggle ? ( + + ) : null; + + const onCopy = async () => { + const text = props.value == null ? (internalRef.current?.value ?? "") : String(props.value); + if (!text) return; + try { + await navigator.clipboard.writeText(text); + setCopied(true); + if (copyTimer.current) clearTimeout(copyTimer.current); + copyTimer.current = setTimeout(() => setCopied(false), 1500); + } catch (e) { + console.warn("copy to clipboard failed", e); + } + }; + + const copyToggle = copy ? ( + + ) : null; + + const suffix = passwordToggle || copyToggle || customSuffix; + const showStepper = isNumber; + const warningVariant = warning ? "warning" : variant; + const resolvedVariant = error ? "error" : warningVariant; + + const inputClassName = buildInputClassName({ + variant: resolvedVariant, + hasCustomPrefix: !!customPrefix, + hasSuffix: !!suffix, + hasIcon: !!icon, + readOnly: props.readOnly, + showStepper, + className, + }); + + return ( +
                                                + {label && } +
                                                + {customPrefix && ( + + )} + + {icon && } + +
                                                + + + {suffix && } +
                                                + + {showStepper && ( + + )} +
                                                + +
                                                + ); +}); + +export default Input; diff --git a/client/ui/frontend/src/components/inputs/SearchInput.tsx b/client/ui/frontend/src/components/inputs/SearchInput.tsx new file mode 100644 index 000000000..5f46e8fba --- /dev/null +++ b/client/ui/frontend/src/components/inputs/SearchInput.tsx @@ -0,0 +1,59 @@ +import { forwardRef, type InputHTMLAttributes, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { SearchIcon } from "lucide-react"; +import { cn } from "@/lib/cn"; + +type Props = InputHTMLAttributes & { + iconSize?: number; + shortcut?: ReactNode; +}; + +export const SearchInput = forwardRef(function SearchInput( + { iconSize = 16, className, disabled, shortcut, "aria-label": ariaLabel, ...props }, + ref, +) { + const { t } = useTranslation(); + return ( +
                                                + + + {shortcut && ( + + {shortcut} + + )} +
                                                + ); +}); diff --git a/client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx b/client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx new file mode 100644 index 000000000..45e3e333a --- /dev/null +++ b/client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx @@ -0,0 +1,102 @@ +import React from "react"; +import { HelpText } from "@/components/typography/HelpText"; +import { Label } from "@/components/typography/Label"; +import { ToggleSwitch } from "@/components/switches/ToggleSwitch"; +import { cn } from "@/lib/cn"; + +interface Props { + value: boolean; + onChange: (value: boolean) => void; + helpText?: React.ReactNode; + label?: React.ReactNode; + children?: React.ReactNode; + disabled?: boolean; + loading?: boolean; + dataCy?: string; + className?: string; + labelClassName?: string; + textWrapperClassName?: string; +} + +export default function FancyToggleSwitch({ + value, + onChange, + helpText, + label, + children, + disabled = false, + loading = false, + dataCy, + className, + labelClassName, + textWrapperClassName = "max-w-lg", +}: Readonly) { + const switchId = React.useId(); + const descriptionId = React.useId(); + + if (loading) { + const shimmer = + "text-transparent select-none rounded bg-[#25282d] box-decoration-clone animate-pulse"; + return ( +
                                                +
                                                +
                                                + + + + {helpText} + + +
                                                +
                                                +
                                                +
                                                +
                                                +
                                                + ); + } + + return ( +
                                                +
                                                +
                                                + + + {helpText} + +
                                                +
                                                + +
                                                +
                                                + {children && value ?
                                                {children}
                                                : null} +
                                                + ); +} diff --git a/client/ui/frontend/src/components/switches/SwitchItem.tsx b/client/ui/frontend/src/components/switches/SwitchItem.tsx new file mode 100644 index 000000000..e23c73fe3 --- /dev/null +++ b/client/ui/frontend/src/components/switches/SwitchItem.tsx @@ -0,0 +1,42 @@ +import * as RadioGroup from "@radix-ui/react-radio-group"; +import { motion } from "framer-motion"; +import { type ReactNode } from "react"; +import { cn } from "@/lib/cn"; +import { useSwitchItemGroup } from "@/components/switches/SwitchItemGroup"; + +type Props = { + value: string; + children: ReactNode; + className?: string; +}; + +export const SwitchItem = ({ value, children, className }: Props) => { + const { value: activeValue, layoutId } = useSwitchItemGroup(); + const active = activeValue === value; + + return ( + + {active && ( + + )} + + {children} + + + ); +}; diff --git a/client/ui/frontend/src/components/switches/SwitchItemGroup.tsx b/client/ui/frontend/src/components/switches/SwitchItemGroup.tsx new file mode 100644 index 000000000..b4361d530 --- /dev/null +++ b/client/ui/frontend/src/components/switches/SwitchItemGroup.tsx @@ -0,0 +1,60 @@ +import * as RadioGroup from "@radix-ui/react-radio-group"; +import { createContext, type ReactNode, useContext, useId, useMemo } from "react"; +import { cn } from "@/lib/cn"; + +type SwitchItemGroupContextValue = { + value: string; + layoutId: string; +}; + +const SwitchItemGroupContext = createContext(null); + +export const useSwitchItemGroup = () => { + const ctx = useContext(SwitchItemGroupContext); + if (!ctx) { + throw new Error("SwitchItem must be used inside a SwitchItemGroup"); + } + return ctx; +}; + +type Props = { + value: string; + onChange: (value: string) => void; + children: ReactNode; + className?: string; + disabled?: boolean; + "aria-label"?: string; + "aria-labelledby"?: string; +}; + +export const SwitchItemGroup = ({ + value, + onChange, + children, + className, + disabled = false, + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledBy, +}: Props) => { + const layoutId = useId(); + const contextValue = useMemo(() => ({ value, layoutId }), [value, layoutId]); + + return ( + + + {children} + + + ); +}; diff --git a/client/ui/frontend/src/components/switches/ToggleSwitch.tsx b/client/ui/frontend/src/components/switches/ToggleSwitch.tsx new file mode 100644 index 000000000..2d9f597e6 --- /dev/null +++ b/client/ui/frontend/src/components/switches/ToggleSwitch.tsx @@ -0,0 +1,77 @@ +"use client"; + +import * as SwitchPrimitives from "@radix-ui/react-switch"; +import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; +import { cn } from "@/lib/cn"; + +type SwitchVariants = VariantProps; + +const switchVariants = cva("", { + variants: { + size: { + default: "h-[24px] w-[44px]", + small: "h-[18px] w-[36px]", + large: "h-[36px] w-[66px]", + }, + variant: { + default: [ + "dark:data-[state=checked]:bg-netbird dark:data-[state=unchecked]:bg-nb-gray-700", + "dark:data-[state=checked]:hover:bg-netbird-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600", + "data-[state=checked]:bg-neutral-900 data-[state=unchecked]:bg-neutral-200", + "data-[state=checked]:hover:bg-neutral-800 data-[state=unchecked]:hover:bg-neutral-300", + ], + "red-green": [ + "dark:data-[state=checked]:bg-red-600 dark:data-[state=unchecked]:bg-nb-gray-700", + "dark:data-[state=checked]:hover:bg-red-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600", + "data-[state=checked]:bg-red-500 data-[state=unchecked]:bg-red-200", + "data-[state=checked]:hover:bg-red-400 data-[state=unchecked]:hover:bg-red-300", + ], + red: [ + "dark:data-[state=checked]:bg-red-600 dark:data-[state=unchecked]:bg-nb-gray-700", + "dark:data-[state=checked]:hover:bg-red-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600", + "data-[state=checked]:bg-red-500 data-[state=unchecked]:bg-red-200", + "data-[state=checked]:hover:bg-red-400 data-[state=unchecked]:hover:bg-red-300", + ], + }, + "thumb-size": { + default: + "h-5 w-5 data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0", + small: "h-[14px] w-[14px] data-[state=checked]:translate-x-[17px] data-[state=unchecked]:translate-x-0", + large: "h-[30px] w-[30px] data-[state=checked]:translate-x-[31px] data-[state=unchecked]:translate-x-[1px]", + }, + }, +}); + +const ToggleSwitch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + SwitchVariants & { dataCy?: string } +>(({ className, size = "default", variant = "default", dataCy, disabled, ...props }, ref) => ( + { + e.stopPropagation(); + props.onClick?.(e); + }} + ref={ref} + > + + +)); +ToggleSwitch.displayName = SwitchPrimitives.Root.displayName; + +export { ToggleSwitch }; diff --git a/client/ui/frontend/src/components/typography/HelpText.tsx b/client/ui/frontend/src/components/typography/HelpText.tsx new file mode 100644 index 000000000..8c52ff714 --- /dev/null +++ b/client/ui/frontend/src/components/typography/HelpText.tsx @@ -0,0 +1,24 @@ +import { type ReactNode } from "react"; +import { cn } from "@/lib/cn"; + +type Props = { + children?: ReactNode; + margin?: boolean; + className?: string; + disabled?: boolean; +}; + +export const HelpText = ({ children, margin = true, className, disabled = false }: Props) => ( + + {children} + +); + +export default HelpText; diff --git a/client/ui/frontend/src/components/typography/Label.tsx b/client/ui/frontend/src/components/typography/Label.tsx new file mode 100644 index 000000000..a8e1a446f --- /dev/null +++ b/client/ui/frontend/src/components/typography/Label.tsx @@ -0,0 +1,42 @@ +import * as LabelPrimitive from "@radix-ui/react-label"; +import { cva, type VariantProps } from "class-variance-authority"; +import { type ComponentPropsWithoutRef, forwardRef, type Ref } from "react"; +import { cn } from "@/lib/cn"; + +const labelVariants = cva( + "mb-1.5 inline-block flex items-center gap-2 text-sm font-medium leading-none tracking-wider peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-nb-gray-100", +); + +type LabelProps = ComponentPropsWithoutRef & + VariantProps & { + as?: "label" | "div"; + disabled?: boolean; + }; + +export const Label = forwardRef(function Label( + { className, as = "label", disabled = false, children, ...props }, + ref, +) { + const classes = cn( + labelVariants(), + className, + "select-none transition-all duration-300", + disabled && "pointer-events-none opacity-30", + ); + + if (as === "div") { + return ( +
                                                } className={classes}> + {children} +
                                                + ); + } + + return ( + } className={classes} {...props}> + {children} + + ); +}); + +export default Label; diff --git a/client/ui/frontend/src/contexts/ClientVersionContext.tsx b/client/ui/frontend/src/contexts/ClientVersionContext.tsx new file mode 100644 index 000000000..c0699a9ee --- /dev/null +++ b/client/ui/frontend/src/contexts/ClientVersionContext.tsx @@ -0,0 +1,114 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { Events } from "@wailsio/runtime"; + +import { Update as UpdateSvc, WindowManager } from "@bindings/services"; +import type { State as UpdateState } from "@bindings/updater/models.js"; +import i18next from "@/lib/i18n"; +import { errorDialog, formatErrorMessage } from "@/lib/errors"; + +const isDaemonUnavailable = (e: unknown): boolean => { + const msg = e instanceof Error ? e.message : String(e); + return msg.includes("code = Unavailable"); +}; + +type ClientVersionContextValue = { + updateAvailable: boolean; + updateVersion: string | null; + enforced: boolean; + installing: boolean; + triggerUpdate: () => void; + updating: boolean; +}; + +const EVENT_UPDATE_STATE = "netbird:update:state"; + +const emptyState: UpdateState = { + available: false, + version: "", + enforced: false, + installing: false, +}; + +const ClientVersionContext = createContext(null); + +export const useClientVersion = () => { + const ctx = useContext(ClientVersionContext); + if (!ctx) { + throw new Error("useClientVersion must be used inside ClientVersionProvider"); + } + return ctx; +}; + +export const ClientVersionProvider = ({ children }: { children: ReactNode }) => { + const [state, setState] = useState(emptyState); + const [updating, setUpdating] = useState(false); + + useEffect(() => { + let cancelled = false; + UpdateSvc.GetState() + .then((s) => { + if (cancelled || !s) return; + setState(s); + }) + .catch((e) => { + if (cancelled || isDaemonUnavailable(e)) return; + void errorDialog({ + Title: i18next.t("update.error.loadStateTitle"), + Message: formatErrorMessage(e), + }); + }); + const off = Events.On(EVENT_UPDATE_STATE, (ev: { data: UpdateState }) => { + if (ev?.data) setState(ev.data); + }); + return () => { + cancelled = true; + off?.(); + }; + }, []); + + const prevInstallingRef = useRef(false); + useEffect(() => { + if (state.installing && !prevInstallingRef.current) { + WindowManager.OpenInstallProgress(state.version || "").catch(console.error); + } + prevInstallingRef.current = state.installing; + }, [state.installing, state.version]); + + const triggerUpdate = useCallback(() => { + setUpdating(true); + WindowManager.OpenInstallProgress(state.version || "").catch(console.error); + UpdateSvc.Trigger() + .catch(async (e) => { + if (isDaemonUnavailable(e)) return; + WindowManager.CloseInstallProgress().catch(console.error); + await errorDialog({ + Title: i18next.t("update.error.triggerTitle"), + Message: formatErrorMessage(e), + }); + }) + .finally(() => setUpdating(false)); + }, [state.version]); + + const value = useMemo( + () => ({ + updateAvailable: state.available, + updateVersion: state.version || null, + enforced: state.enforced, + installing: state.installing, + triggerUpdate, + updating, + }), + [state, triggerUpdate, updating], + ); + + return {children}; +}; diff --git a/client/ui/frontend/src/contexts/DebugBundleContext.tsx b/client/ui/frontend/src/contexts/DebugBundleContext.tsx new file mode 100644 index 000000000..5f2ed9041 --- /dev/null +++ b/client/ui/frontend/src/contexts/DebugBundleContext.tsx @@ -0,0 +1,319 @@ +import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react"; +import { Connection as ConnectionSvc, Debug as DebugSvc } from "@bindings/services"; +import type { DebugBundleResult } from "@bindings/services/models.js"; +import i18next from "@/lib/i18n"; +import { errorDialog, formatErrorMessage } from "@/lib/errors.ts"; +import { startConnection } from "@/lib/connection.ts"; + +const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url"; +const TRACE_LOG_FILE_COUNT = 5; +const PLAIN_LOG_FILE_COUNT = 1; +const TRACE_LOG_LEVEL = "trace"; +const DEFAULT_LOG_LEVEL = "info"; + +export type DebugStage = + | { kind: "idle" } + | { kind: "preparing-trace" } + | { kind: "reconnecting" } + | { kind: "capturing"; remainingSec: number; totalSec: number } + | { kind: "restoring-level" } + | { kind: "bundling" } + | { kind: "uploading" } + | { kind: "cancelling" } + | { kind: "done"; result: DebugBundleResult; uploadAttempted: boolean }; + +const sleep = (ms: number, signal: AbortSignal) => + new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new DOMException("aborted", "AbortError")); + return; + } + const onAbort = () => { + clearTimeout(id); + reject(new DOMException("aborted", "AbortError")); + }; + const id = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal.addEventListener("abort", onAbort); + }); + +const isAbort = (e: unknown) => e instanceof DOMException && e.name === "AbortError"; + +const throwIfAborted = (signal: AbortSignal) => { + if (signal.aborted) throw new DOMException("aborted", "AbortError"); +}; + +const setLogLevelBestEffort = async (level: string) => { + try { + await DebugSvc.SetLogLevel({ level }); + } catch (e) { + console.warn("[DebugBundle] best-effort set log level failed", e); + } +}; + +const stopCaptureBestEffort = async () => { + try { + await DebugSvc.StopBundleCapture(); + } catch (e) { + console.warn("[DebugBundle] best-effort stop packet capture failed", e); + } +}; + +type LevelState = { original: string; raised: boolean }; +type CaptureState = { started: boolean }; + +type BundleOptions = { + trace: boolean; + capture: boolean; + capturePackets: boolean; + hasWindow: boolean; + totalSec: number; + uploadUrl: string; + anonymizeLevel: AnonymizeLevel; + systemInfo: boolean; +}; + +export type AnonymizeLevel = "none" | "default" | "strict"; + +const startCaptureBestEffort = async (totalSec: number, pcap: CaptureState) => { + try { + // Mirror the CLI's safety margin: window + 30s, server caps at 10m. + await DebugSvc.StartBundleCapture(totalSec + 30); + pcap.started = true; + } catch (e) { + console.warn("[DebugBundle] start packet capture failed", e); + } +}; + +const cleanupBestEffort = async (pcap: CaptureState, level: LevelState, restoreLevel: boolean) => { + if (pcap.started) { + await stopCaptureBestEffort(); + pcap.started = false; + } + if (restoreLevel && level.raised) { + await setLogLevelBestEffort(level.original); + } +}; + +const raiseToTrace = async ( + signal: AbortSignal, + level: LevelState, + setStage: (s: DebugStage) => void, +) => { + setStage({ kind: "preparing-trace" }); + try { + const cur = await DebugSvc.GetLogLevel(); + if (cur?.level) level.original = cur.level; + } catch (e) { + console.warn("[DebugBundle] read current log level failed", e); + } + throwIfAborted(signal); + await DebugSvc.SetLogLevel({ level: TRACE_LOG_LEVEL }); + level.raised = true; +}; + +const cycleConnection = async (signal: AbortSignal, setStage: (s: DebugStage) => void) => { + throwIfAborted(signal); + setStage({ kind: "reconnecting" }); + try { + await ConnectionSvc.Down(); + } catch (e) { + console.warn("[DebugBundle] disconnect before capture failed", e); + } + throwIfAborted(signal); + await startConnection(undefined, signal); +}; + +const restoreLogLevel = async (level: LevelState, setStage: (s: DebugStage) => void) => { + setStage({ kind: "restoring-level" }); + try { + await DebugSvc.SetLogLevel({ level: level.original }); + level.raised = false; + } catch (e) { + console.warn("[DebugBundle] restore log level failed", e); + } +}; + +const waitCaptureWindow = async ( + signal: AbortSignal, + setStage: (s: DebugStage) => void, + totalSec: number, +) => { + for (let remaining = totalSec; remaining > 0; remaining--) { + setStage({ kind: "capturing", remainingSec: remaining, totalSec }); + await sleep(1000, signal); + } +}; + +const runBundleFlow = async ( + signal: AbortSignal, + opts: BundleOptions, + level: LevelState, + pcap: CaptureState, + setStage: (s: DebugStage) => void, + setLastBundlePath: (p: string) => void, +) => { + if (opts.trace) { + await raiseToTrace(signal, level, setStage); + } + throwIfAborted(signal); + + if (opts.capture) { + await cycleConnection(signal, setStage); + } + throwIfAborted(signal); + + if (opts.hasWindow && opts.capturePackets) { + await startCaptureBestEffort(opts.totalSec, pcap); + } + throwIfAborted(signal); + + if (opts.hasWindow) { + await waitCaptureWindow(signal, setStage, opts.totalSec); + } + + if (pcap.started) { + await stopCaptureBestEffort(); + pcap.started = false; + } + + if (level.raised) { + await restoreLogLevel(level, setStage); + } + + throwIfAborted(signal); + setStage({ kind: "bundling" }); + const logFileCount = opts.trace ? TRACE_LOG_FILE_COUNT : PLAIN_LOG_FILE_COUNT; + + if (opts.uploadUrl) setStage({ kind: "uploading" }); + const result = await DebugSvc.Bundle({ + anonymize: opts.anonymizeLevel !== "none", + // The daemon only knows "default" and "strict"; "none" is expressed + // through the anonymize flag being off. + anonymizeLevel: opts.anonymizeLevel === "strict" ? "strict" : "default", + systemInfo: opts.systemInfo, + uploadUrl: opts.uploadUrl, + logFileCount, + }); + throwIfAborted(signal); + if (result.path) setLastBundlePath(result.path); + setStage({ kind: "done", result, uploadAttempted: Boolean(opts.uploadUrl) }); +}; + +const useDebugBundle = () => { + const [anonymizeLevel, setAnonymizeLevel] = useState("none"); + const [systemInfo, setSystemInfo] = useState(true); + const [upload, setUpload] = useState(true); + const [trace, setTrace] = useState(true); + const [capture, setCapture] = useState(false); + const [traceMinutes, setTraceMinutes] = useState(1); + const [capturePackets, setCapturePackets] = useState(true); + const [stage, setStage] = useState({ kind: "idle" }); + const [lastBundlePath, setLastBundlePath] = useState(""); + const abortRef = useRef(null); + + useEffect(() => { + return () => { + abortRef.current?.abort(); + }; + }, []); + + const isRunning = stage.kind !== "idle" && stage.kind !== "done"; + + const reset = () => setStage({ kind: "idle" }); + + const cancel = () => { + if (!abortRef.current || abortRef.current.signal.aborted) return; + abortRef.current.abort(); + setStage({ kind: "cancelling" }); + }; + + const run = async () => { + const ctrl = new AbortController(); + abortRef.current = ctrl; + const signal = ctrl.signal; + + const totalSec = Math.max(1, Math.min(30, traceMinutes)) * 60; + const level: LevelState = { original: DEFAULT_LOG_LEVEL, raised: false }; + const pcap: CaptureState = { started: false }; + const opts: BundleOptions = { + trace, + capture, + capturePackets, + hasWindow: capture && totalSec > 0, + totalSec, + uploadUrl: upload ? NETBIRD_UPLOAD_URL : "", + anonymizeLevel, + systemInfo, + }; + + try { + await runBundleFlow(signal, opts, level, pcap, setStage, setLastBundlePath); + } catch (e) { + if (isAbort(e)) { + setStage({ kind: "cancelling" }); + await cleanupBestEffort(pcap, level, true); + setStage({ kind: "idle" }); + return; + } + await cleanupBestEffort(pcap, level, false); + setStage({ kind: "idle" }); + await errorDialog({ + Title: i18next.t("settings.error.debugBundleTitle"), + Message: formatErrorMessage(e), + }); + } finally { + if (abortRef.current === ctrl) abortRef.current = null; + } + }; + + const openBundleDir = () => { + if (!lastBundlePath) return; + DebugSvc.RevealFile(lastBundlePath).catch((err: unknown) => + console.error("[DebugBundleContext] reveal failed", err), + ); + }; + + return { + anonymizeLevel, + setAnonymizeLevel, + systemInfo, + setSystemInfo, + upload, + setUpload, + trace, + setTrace, + capture, + setCapture, + traceMinutes, + setTraceMinutes, + capturePackets, + setCapturePackets, + stage, + isRunning, + lastBundlePath, + run, + cancel, + reset, + openBundleDir, + }; +}; + +export type DebugBundleContextValue = ReturnType; + +const DebugBundleContext = createContext(null); + +export const DebugBundleProvider = ({ children }: { children: ReactNode }) => { + const value = useDebugBundle(); + return {children}; +}; + +export const useDebugBundleContext = () => { + const ctx = useContext(DebugBundleContext); + if (!ctx) { + throw new Error("useDebugBundleContext must be used inside DebugBundleProvider"); + } + return ctx; +}; diff --git a/client/ui/frontend/src/contexts/DialogContext.tsx b/client/ui/frontend/src/contexts/DialogContext.tsx new file mode 100644 index 000000000..8a52e0dd0 --- /dev/null +++ b/client/ui/frontend/src/contexts/DialogContext.tsx @@ -0,0 +1,68 @@ +import { + createContext, + type ReactNode, + useCallback, + useContext, + useMemo, + useRef, + useState, +} from "react"; +import { ConfirmModal } from "@/components/dialog/ConfirmModal"; + +export type ConfirmOptions = { + title: ReactNode; + description: ReactNode; + confirmLabel: string; + cancelLabel?: string; + danger?: boolean; +}; + +type DialogContextValue = { + confirm: (options: ConfirmOptions) => Promise; +}; + +const DialogContext = createContext(null); + +export function DialogProvider({ children }: Readonly<{ children: ReactNode }>) { + const [open, setOpen] = useState(false); + const [options, setOptions] = useState(null); + const resolverRef = useRef<((result: boolean) => void) | null>(null); + + const confirm = useCallback((opts: ConfirmOptions) => { + setOptions(opts); + setOpen(true); + return new Promise((resolve) => { + resolverRef.current = resolve; + }); + }, []); + + const settle = (result: boolean) => { + resolverRef.current?.(result); + resolverRef.current = null; + setOpen(false); + }; + + const value = useMemo(() => ({ confirm }), [confirm]); + + return ( + + {children} + settle(true)} + onCancel={() => settle(false)} + /> + + ); +} + +export const useConfirm = () => { + const ctx = useContext(DialogContext); + if (!ctx) throw new Error("useConfirm must be used within a DialogProvider"); + return ctx.confirm; +}; diff --git a/client/ui/frontend/src/contexts/NavSectionContext.tsx b/client/ui/frontend/src/contexts/NavSectionContext.tsx new file mode 100644 index 000000000..c08a3c824 --- /dev/null +++ b/client/ui/frontend/src/contexts/NavSectionContext.tsx @@ -0,0 +1,24 @@ +import { createContext, useContext, useMemo, useState, type ReactNode } from "react"; + +export type NavSection = "peers" | "networks"; + +type NavSectionContextValue = { + section: NavSection; + setSection: (s: NavSection) => void; +}; + +const NavSectionContext = createContext(null); + +export const useNavSection = (): NavSectionContextValue => { + const ctx = useContext(NavSectionContext); + if (!ctx) { + throw new Error("useNavSection must be used inside NavSectionProvider"); + } + return ctx; +}; + +export const NavSectionProvider = ({ children }: { children: ReactNode }) => { + const [section, setSection] = useState("peers"); + const value = useMemo(() => ({ section, setSection }), [section]); + return {children}; +}; diff --git a/client/ui/frontend/src/contexts/NetworksContext.tsx b/client/ui/frontend/src/contexts/NetworksContext.tsx new file mode 100644 index 000000000..ef7231700 --- /dev/null +++ b/client/ui/frontend/src/contexts/NetworksContext.tsx @@ -0,0 +1,222 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { Networks as NetworksSvc } from "@bindings/services"; +import type { Network } from "@bindings/services/models.js"; +import { useStatus } from "@/contexts/StatusContext"; + +// A route that covers all traffic (0.0.0.0/0 or ::/0) is an exit node. +// The daemon may merge a v4+v6 pair into a single comma-joined range string. +export const isExitNode = (range: string): boolean => + range.split(",").some((part) => { + const trimmed = part.trim(); + return trimmed === "0.0.0.0/0" || trimmed === "::/0"; + }); + +type NetworksContextValue = { + routes: Network[]; + networkRoutes: Network[]; + exitNodes: Network[]; + activeExitNode: Network | null; + refresh: () => Promise; + toggleNetwork: (id: string, selected: boolean) => Promise; + toggleExitNode: (id: string, selected: boolean) => Promise; + setNetworksSelected: (ids: string[], selected: boolean) => Promise; +}; + +const NetworksContext = createContext(null); + +export const useNetworks = () => { + const ctx = useContext(NetworksContext); + if (!ctx) { + throw new Error("useNetworks must be used inside NetworksProvider"); + } + return ctx; +}; + +export const NetworksProvider = ({ children }: { children: ReactNode }) => { + const { status } = useStatus(); + const [routes, setRoutes] = useState([]); + const [pending, setPending] = useState>(new Map()); + const pendingRef = useRef(pending); + useEffect(() => { + pendingRef.current = pending; + }, [pending]); + + // Safety timer: if a prediction diverges from the daemon, the override would mask the true value forever. + const STUCK_OVERRIDE_MS = 4000; + const timersRef = useRef>>(new Map()); + + const clearTimer = useCallback((id: string) => { + const tid = timersRef.current.get(id); + if (tid !== undefined) { + clearTimeout(tid); + timersRef.current.delete(id); + } + }, []); + + const clearPendingFor = useCallback( + (ids: string[]) => { + for (const id of ids) clearTimer(id); + setPending((prev) => { + if (ids.every((id) => !prev.has(id))) return prev; + const next = new Map(prev); + for (const id of ids) next.delete(id); + return next; + }); + }, + [clearTimer], + ); + + const setPendingFor = useCallback( + (updates: Array<[string, boolean]>) => { + setPending((prev) => { + const next = new Map(prev); + for (const [id, sel] of updates) next.set(id, sel); + return next; + }); + for (const [id] of updates) { + clearTimer(id); + timersRef.current.set( + id, + setTimeout(() => clearPendingFor([id]), STUCK_OVERRIDE_MS), + ); + } + }, + [clearTimer, clearPendingFor], + ); + + useEffect(() => { + const timers = timersRef.current; + return () => { + for (const tid of timers.values()) clearTimeout(tid); + timers.clear(); + }; + }, []); + + const refresh = useCallback(async () => { + try { + const list = await NetworksSvc.List(); + setRoutes(list); + } catch (e) { + console.error("[NetworksContext] refresh failed", e); + } + }, []); + + const networksRevision = status?.networksRevision; + useEffect(() => { + refresh().catch((err: unknown) => console.error("[NetworksContext] refresh failed", err)); + }, [refresh, networksRevision]); + + useEffect(() => { + if (pendingRef.current.size === 0) return; + const confirmed: string[] = []; + for (const r of routes) { + const expected = pendingRef.current.get(r.id); + if (expected !== undefined && r.selected === expected) { + confirmed.push(r.id); + } + } + if (confirmed.length > 0) clearPendingFor(confirmed); + }, [routes, clearPendingFor]); + + const mutate = useCallback( + async (ids: string[], selected: boolean, rollback: Array<[string, boolean]>) => { + try { + if (selected) { + await NetworksSvc.Select({ networkIds: ids, append: true, all: false }); + } else { + await NetworksSvc.Deselect({ networkIds: ids, append: false, all: false }); + } + // Don't clear pending here — let the snapshot-match effect confirm, else a refresh racing the RPC return flashes back. + await refresh(); + } catch (e) { + console.error(e); + setPending((prev) => { + const next = new Map(prev); + for (const [id] of rollback) next.delete(id); + return next; + }); + throw e; + } + }, + [refresh], + ); + + const toggleNetwork = useCallback( + async (id: string, selected: boolean) => { + const target = !selected; + setPendingFor([[id, target]]); + await mutate([id], target, [[id, selected]]).catch(() => {}); + }, + [mutate, setPendingFor], + ); + + const setNetworksSelected = useCallback( + async (ids: string[], selected: boolean) => { + if (ids.length === 0) return; + const prevById = new Map(routes.map((r) => [r.id, r.selected])); + const rollback: Array<[string, boolean]> = ids.map((id) => [ + id, + prevById.get(id) ?? !selected, + ]); + setPendingFor(ids.map((id) => [id, selected])); + await mutate(ids, selected, rollback).catch(() => {}); + }, + [mutate, setPendingFor, routes], + ); + + // Daemon enforces exit-node mutual exclusion; mirror it locally so the optimistic paint matches. + const toggleExitNode = useCallback( + async (id: string, selected: boolean) => { + const target = !selected; + const updates: Array<[string, boolean]> = [[id, target]]; + const rollback: Array<[string, boolean]> = [[id, selected]]; + if (target) { + for (const r of routes) { + if (r.id !== id && isExitNode(r.range) && r.selected) { + updates.push([r.id, false]); + rollback.push([r.id, true]); + } + } + } + setPendingFor(updates); + await mutate([id], target, rollback).catch(() => {}); + }, + [mutate, setPendingFor, routes], + ); + + const value = useMemo(() => { + const effective = + pending.size === 0 + ? routes + : routes.map((r) => { + const override = pending.get(r.id); + return override === undefined || override === r.selected + ? r + : { ...r, selected: override }; + }); + const networkRoutes = effective.filter((r) => !isExitNode(r.range)); + const exitNodes = effective.filter((r) => isExitNode(r.range)); + const activeExitNode = exitNodes.find((r) => r.selected) ?? null; + return { + routes: effective, + networkRoutes, + exitNodes, + activeExitNode, + refresh, + toggleNetwork, + toggleExitNode, + setNetworksSelected, + }; + }, [routes, pending, refresh, toggleNetwork, toggleExitNode, setNetworksSelected]); + + return {children}; +}; diff --git a/client/ui/frontend/src/contexts/PeerDetailContext.tsx b/client/ui/frontend/src/contexts/PeerDetailContext.tsx new file mode 100644 index 000000000..3ab20891f --- /dev/null +++ b/client/ui/frontend/src/contexts/PeerDetailContext.tsx @@ -0,0 +1,50 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import type { PeerStatus } from "@bindings/services/models.js"; + +type PeerDetailContextValue = { + selected: PeerStatus | null; + setSelected: (p: PeerStatus | null) => void; +}; + +const PeerDetailContext = createContext(null); + +export const usePeerDetail = (): PeerDetailContextValue => { + const ctx = useContext(PeerDetailContext); + if (!ctx) { + throw new Error("usePeerDetail must be used inside PeerDetailProvider"); + } + return ctx; +}; + +export const PeerDetailProvider = ({ children }: { children: ReactNode }) => { + const [selected, setSelected] = useState(null); + const openerRef = useRef(null); + + const select = useCallback((p: PeerStatus | null) => { + if (p) { + const active = document.activeElement; + openerRef.current = active instanceof HTMLElement ? active : null; + } else { + const opener = openerRef.current; + openerRef.current = null; + if (opener?.isConnected) { + queueMicrotask(() => opener.focus()); + } + } + setSelected(p); + }, []); + + const value = useMemo( + () => ({ selected, setSelected: select }), + [selected, select], + ); + return {children}; +}; diff --git a/client/ui/frontend/src/contexts/ProfileContext.tsx b/client/ui/frontend/src/contexts/ProfileContext.tsx new file mode 100644 index 000000000..62377f1bc --- /dev/null +++ b/client/ui/frontend/src/contexts/ProfileContext.tsx @@ -0,0 +1,195 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { Events } from "@wailsio/runtime"; +import { Connection, ProfileSwitcher, Profiles as ProfilesSvc } from "@bindings/services"; +import type { Profile } from "@bindings/services/models.js"; +import i18next from "@/lib/i18n"; +import { errorDialog, formatErrorMessage } from "@/lib/errors"; + +const EVENT_PROFILE_CHANGED = "netbird:profile:changed"; + +type ProfileContextValue = { + username: string; + // activeProfile is the display NAME of the active profile (for rendering + // and the "default" check). activeProfileId is its stable on-disk ID, used + // as the handle for daemon requests and for active-profile comparisons, + // since display names can collide. + activeProfile: string; + activeProfileId: string; + profiles: Profile[]; + loaded: boolean; + refresh: () => Promise; + switchProfile: (id: string) => Promise; + switchProfileNoConnect: (id: string) => Promise; + addProfile: (name: string) => Promise; + removeProfile: (id: string) => Promise; + renameProfile: (id: string, newName: string) => Promise; + logoutProfile: (id: string) => Promise; +}; + +const ProfileContext = createContext(null); + +export const useProfile = () => { + const ctx = useContext(ProfileContext); + if (!ctx) { + throw new Error("useProfile must be used inside ProfileProvider"); + } + return ctx; +}; + +export const ProfileProvider = ({ children }: { children: ReactNode }) => { + const [username, setUsername] = useState(""); + const [activeProfile, setActiveProfile] = useState(""); + const [activeProfileId, setActiveProfileId] = useState(""); + const [profiles, setProfiles] = useState([]); + const [loaded, setLoaded] = useState(false); + const retryRef = useRef | null>(null); + + const refresh = useCallback(async () => { + if (retryRef.current) { + clearTimeout(retryRef.current); + retryRef.current = null; + } + try { + const u = await ProfilesSvc.Username(); + const [active, list] = await Promise.all([ + ProfilesSvc.GetActive(), + ProfilesSvc.List(u), + ]); + setUsername(u); + setActiveProfile(active.profileName || "default"); + setActiveProfileId(active.id || "default"); + setProfiles(list); + setLoaded(true); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes("code = Unavailable")) { + retryRef.current = setTimeout(() => { + void refresh(); + }, 1000); + return; + } + setLoaded(true); + await errorDialog({ + Title: i18next.t("profile.error.loadTitle"), + Message: formatErrorMessage(e), + }); + } + }, []); + + useEffect(() => { + refresh().catch((err: unknown) => console.error("[ProfileContext] refresh failed", err)); + return () => { + if (retryRef.current) clearTimeout(retryRef.current); + }; + }, [refresh]); + + useEffect(() => { + const off = Events.On(EVENT_PROFILE_CHANGED, () => { + refresh().catch((err: unknown) => + console.error("[ProfileContext] refresh failed", err), + ); + }); + return () => { + off(); + }; + }, [refresh]); + + // id is a handle: the daemon resolves an exact ID, ID prefix, or unique + // display name. The UI passes the profile's ID for precision. + const switchProfile = useCallback( + async (id: string) => { + await ProfileSwitcher.SwitchActive({ profileName: id, username }); + await refresh(); + }, + [username, refresh], + ); + + // Manage-profiles variant: switches without connecting, so the user can + // still adjust the management URL before bringing the connection up. + const switchProfileNoConnect = useCallback( + async (id: string) => { + await ProfileSwitcher.SwitchActiveNoConnect({ profileName: id, username }); + await refresh(); + }, + [username, refresh], + ); + + // addProfile creates a profile by display name and returns the + // daemon-generated ID, so the caller can immediately address it by ID. + const addProfile = useCallback( + async (name: string) => { + const id = await ProfilesSvc.Add({ profileName: name, username }); + await refresh(); + return id; + }, + [username, refresh], + ); + + const removeProfile = useCallback( + async (id: string) => { + await ProfilesSvc.Remove({ profileName: id, username }); + await refresh(); + }, + [username, refresh], + ); + + // The daemon resolves the handle (exact ID, ID prefix, or unique display + // name) — passing the ID is precise and avoids collisions on rename. + const renameProfile = useCallback( + async (id: string, newName: string) => { + await ProfilesSvc.Rename({ handle: id, newName, username }); + await refresh(); + }, + [username, refresh], + ); + + const logoutProfile = useCallback( + async (id: string) => { + await Connection.Logout({ profileName: id, username }); + await refresh(); + }, + [username, refresh], + ); + + const value = useMemo( + () => ({ + username, + activeProfile, + activeProfileId, + profiles, + loaded, + refresh, + switchProfile, + switchProfileNoConnect, + addProfile, + removeProfile, + renameProfile, + logoutProfile, + }), + [ + username, + activeProfile, + activeProfileId, + profiles, + loaded, + refresh, + switchProfile, + switchProfileNoConnect, + addProfile, + removeProfile, + renameProfile, + logoutProfile, + ], + ); + + return {children}; +}; diff --git a/client/ui/frontend/src/contexts/RestrictionsContext.tsx b/client/ui/frontend/src/contexts/RestrictionsContext.tsx new file mode 100644 index 000000000..572bf17ec --- /dev/null +++ b/client/ui/frontend/src/contexts/RestrictionsContext.tsx @@ -0,0 +1,65 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import { Events } from "@wailsio/runtime"; +import { Settings as SettingsSvc } from "@bindings/services"; +import { Restrictions } from "@bindings/services/models.js"; +import { useStatus } from "@/contexts/StatusContext.tsx"; + +const EVENT_SYSTEM = "netbird:event"; +const EMPTY = new Restrictions(); + +const RestrictionsContext = createContext(EMPTY); + +export const useRestrictions = () => useContext(RestrictionsContext); + +export const RestrictionsProvider = ({ children }: { children: ReactNode }) => { + const [restrictions, setRestrictions] = useState(EMPTY); + const mounted = useRef(true); + const { status } = useStatus(); + + const refresh = useCallback(async () => { + try { + const r = await SettingsSvc.GetRestrictions(); + if (mounted.current) setRestrictions(r); + } catch (e) { + console.error("[RestrictionsContext] refresh failed", e); + } + }, []); + + useEffect(() => { + mounted.current = true; + + const off = Events.On( + EVENT_SYSTEM, + (e: { data?: { metadata?: { [k: string]: string | undefined } } }) => { + if (e.data?.metadata?.type === "config_changed") refresh(); + }, + ); + + const onVisible = () => { + if (document.visibilityState === "visible") refresh(); + }; + document.addEventListener("visibilitychange", onVisible); + + return () => { + mounted.current = false; + off(); + document.removeEventListener("visibilitychange", onVisible); + }; + }, [refresh]); + + useEffect(() => { + if (status?.status) refresh(); + }, [status?.status, refresh]); + + return ( + {children} + ); +}; diff --git a/client/ui/frontend/src/contexts/SettingsContext.tsx b/client/ui/frontend/src/contexts/SettingsContext.tsx new file mode 100644 index 000000000..a7574c7e5 --- /dev/null +++ b/client/ui/frontend/src/contexts/SettingsContext.tsx @@ -0,0 +1,377 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { Events } from "@wailsio/runtime"; +import { Autostart, Settings as SettingsSvc, Version } from "@bindings/services"; +import type { Config } from "@bindings/services/models.js"; +import i18next from "@/lib/i18n"; +import { useProfile } from "@/contexts/ProfileContext.tsx"; +import { SettingsSkeleton } from "@/modules/settings/SettingsSkeleton.tsx"; +import { errorCommand, errorDialog, formatErrorMessage as errorMessage } from "@/lib/errors.ts"; + +const SAVE_DEBOUNCE_MS = 400; + +const logSaveError = (err: unknown) => console.error("[SettingsContext] save failed", err); + +export type AutostartState = { supported: boolean; enabled: boolean }; + +// GuardedField is a setting the daemon only accepts from root/administrator. +// Turning one on goes through saveGuardedField, which asks the operating system +// for the privileges rather than sending a request that would be refused. +export type GuardedField = "serverSshAllowed" | "enableSshRoot" | "disableSshAuth"; + +type SettingsContextValue = { + config: Config; + guiVersion: string; + setField: (k: K, v: Config[K]) => void; + saveField: (k: K, v: Config[K]) => Promise; + saveFields: (partial: Partial, opts?: { preSharedKey?: string }) => Promise; + saveGuardedField: (k: GuardedField, v: boolean) => Promise; + saveNow: () => Promise; +}; + +type AutostartContextValue = { + autostart: AutostartState | null; + setAutostartEnabled: (enabled: boolean) => Promise; +}; + +const SettingsContext = createContext(null); +const AutostartContext = createContext(null); + +export const useSettings = () => { + const ctx = useContext(SettingsContext); + if (!ctx) { + throw new Error("useSettings must be used inside SettingsProvider"); + } + return ctx; +}; + +export const useAutostartSetting = () => { + const ctx = useContext(AutostartContext); + if (!ctx) { + throw new Error("useAutostartSetting must be used inside AutostartSettingsProvider"); + } + return ctx; +}; + +type LoadedConfig = { profileName: string; data: Config }; + +const useSettingsState = () => { + const { username, activeProfileId, loaded: profileLoaded } = useProfile(); + const [loaded, setLoaded] = useState(null); + const [guiVersion, setGuiVersion] = useState("—"); + const saveTimer = useRef | null>(null); + const loadedRef = useRef(null); + // Set when the daemon's config changed while a save was pending, so the read + // that was skipped to protect the pending edit happens once it is through. + // Without it the form keeps values the daemon no longer has and the next save + // submits them, which for a guarded setting means asking the user to authorize + // a change they never made. + const reloadOwed = useRef(false); + + useEffect(() => { + loadedRef.current = loaded; + }, [loaded]); + + // reload re-reads the daemon's config, which is authoritative. Used on + // mount, on the daemon's config_changed event, and to undo an optimistic + // update the daemon then rejected. + const reload = useCallback( + async (profileName: string) => { + reloadOwed.current = false; + try { + const data = await SettingsSvc.GetConfig({ profileName, username }); + setLoaded({ profileName, data }); + } catch (e) { + console.warn("[SettingsContext] reload after rejected save failed", e); + } + }, + [username], + ); + + useEffect(() => { + if (!profileLoaded || !activeProfileId) return; + let cancelled = false; + + const load = async (showError: boolean) => { + try { + const data = await SettingsSvc.GetConfig({ + profileName: activeProfileId, + username, + }); + if (cancelled) return; + // A pending edit outranks the daemon's copy until it is saved, so + // the read is owed rather than dropped: see reloadOwed. + if (saveTimer.current) { + reloadOwed.current = true; + return; + } + setLoaded({ profileName: activeProfileId, data }); + } catch (e) { + if (cancelled || !showError) return; + await errorDialog({ + Title: i18next.t("settings.error.loadTitle"), + Message: errorMessage(e), + }); + } + }; + + load(true); + + const off = Events.On( + "netbird:event", + (e: { data?: { metadata?: { [k: string]: string | undefined } } }) => { + if (e.data?.metadata?.type === "config_changed") load(false); + }, + ); + + return () => { + cancelled = true; + off(); + }; + }, [profileLoaded, activeProfileId, username]); + + useEffect(() => { + let cancelled = false; + Version.GUI().then((v) => { + if (!cancelled) setGuiVersion(v); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect( + () => () => { + if (saveTimer.current) clearTimeout(saveTimer.current); + }, + [], + ); + + const save = useCallback( + async (profileName: string, next: Config, preSharedKey?: string) => { + const preSharedKeyWrite = preSharedKey === undefined ? {} : { preSharedKey }; + try { + const { declined } = await SettingsSvc.SetConfig({ + ...next, + ...preSharedKeyWrite, + profileName, + username, + }); + // The change needed authorization and the user said no, so the + // optimistic update is wrong. Nothing to report: they know. + if (declined || reloadOwed.current) { + await reload(profileName); + } + } catch (e) { + // The optimistic update is wrong now: the daemon refused it + // (a change that needs elevated privileges, an MDM-managed + // field, ...). Snap the controls back to what it actually + // holds before reporting, so the UI never shows a value the + // daemon does not have. + await reload(profileName); + await errorDialog({ + Title: i18next.t("settings.error.saveTitle"), + Message: errorMessage(e), + Command: errorCommand(e), + }); + } + }, + [username, reload], + ); + + const setField = useCallback( + (k: K, v: Config[K]) => { + const cur = loadedRef.current; + if (!cur) return; + const next: LoadedConfig = { + profileName: cur.profileName, + data: { ...cur.data, [k]: v }, + }; + loadedRef.current = next; + setLoaded(next); + if (saveTimer.current) clearTimeout(saveTimer.current); + saveTimer.current = setTimeout(() => { + saveTimer.current = null; + save(next.profileName, next.data).catch(logSaveError); + }, SAVE_DEBOUNCE_MS); + }, + [save], + ); + + const saveNow = useCallback(async () => { + if (!loaded) return; + if (saveTimer.current) { + clearTimeout(saveTimer.current); + saveTimer.current = null; + } + await save(loaded.profileName, loaded.data); + }, [loaded, save]); + + const saveField = useCallback( + async (k: K, v: Config[K]) => { + if (!loaded) return; + if (saveTimer.current) { + clearTimeout(saveTimer.current); + saveTimer.current = null; + } + const next = { ...loaded.data, [k]: v }; + setLoaded({ profileName: loaded.profileName, data: next }); + await save(loaded.profileName, next); + }, + [loaded, save], + ); + + // saveGuardedField applies a setting the daemon restricts to + // root/administrator by having the Go side run the app again under the + // platform's elevation prompt (UAC, the macOS authentication dialog, polkit). + // The prompt is the user's, so the call is made straight from their gesture + // and never from the debounce. + const saveGuardedField = useCallback( + async (k: GuardedField, v: boolean) => { + const cur = loadedRef.current; + if (!cur) return; + + // Flush what the debounce still owes, before the optimistic update + // below joins it: a later save carrying the guarded value would be + // refused, and its error dialog would be the second one for a change + // the user already authorized. + if (saveTimer.current) { + clearTimeout(saveTimer.current); + saveTimer.current = null; + await save(cur.profileName, cur.data); + } + + const next: LoadedConfig = { + profileName: cur.profileName, + data: { ...cur.data, [k]: v }, + }; + loadedRef.current = next; + setLoaded(next); + + try { + await SettingsSvc.SetGuardedSettings({ + profileName: cur.profileName, + username, + [k]: v, + }); + } catch (e) { + // The daemon is authoritative either way, so re-read before + // reporting. A declined prompt is not an error and does not come + // through here at all; this is a prompt that could not be raised, + // which carries the command that would have done it. + await reload(cur.profileName); + await errorDialog({ + Title: i18next.t("settings.error.saveTitle"), + Message: errorMessage(e), + Command: errorCommand(e), + }); + return; + } + // Either the change went through or the user declined it. The daemon + // says which. + await reload(cur.profileName); + }, + [username, save, reload], + ); + + const saveFields = useCallback( + async (partial: Partial, opts?: { preSharedKey?: string }) => { + if (!loaded) return; + if (saveTimer.current) { + clearTimeout(saveTimer.current); + saveTimer.current = null; + } + + const merged: Config = { ...loaded.data, ...partial }; + const next: Config = + opts?.preSharedKey === undefined + ? merged + : { ...merged, preSharedKeySet: opts.preSharedKey !== "" }; + setLoaded({ profileName: loaded.profileName, data: next }); + await save(loaded.profileName, next, opts?.preSharedKey); + }, + [loaded, save], + ); + + return { + config: loaded?.data ?? null, + guiVersion, + setField, + saveField, + saveFields, + saveGuardedField, + saveNow, + }; +}; + +export const SettingsProvider = ({ children }: { children: ReactNode }) => { + const { config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow } = + useSettingsState(); + + const value = useMemo( + () => + config + ? { config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow } + : null, + [config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow], + ); + + if (!value) { + return ( +
                                                + +
                                                + ); + } + + return {children}; +}; + +export const AutostartSettingsProvider = ({ children }: { children: ReactNode }) => { + const [autostart, setAutostart] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + const supported = await Autostart.Supported(); + const enabled = supported ? await Autostart.IsEnabled() : false; + if (cancelled) return; + setAutostart({ supported, enabled }); + })().catch((err: unknown) => { + if (cancelled) return; + console.warn("[SettingsContext] load autostart state failed", err); + setAutostart({ supported: false, enabled: false }); + }); + return () => { + cancelled = true; + }; + }, []); + + const setAutostartEnabled = useCallback(async (enabled: boolean) => { + setAutostart((s) => (s ? { ...s, enabled } : s)); + try { + await Autostart.SetEnabled(enabled); + } catch (e) { + setAutostart((s) => (s ? { ...s, enabled: !enabled } : s)); + await errorDialog({ + Title: i18next.t("settings.general.autostart.errorTitle"), + Message: errorMessage(e), + }); + } + }, []); + + const value = useMemo( + () => ({ autostart, setAutostartEnabled }), + [autostart, setAutostartEnabled], + ); + + return {children}; +}; diff --git a/client/ui/frontend/src/contexts/StatusContext.tsx b/client/ui/frontend/src/contexts/StatusContext.tsx new file mode 100644 index 000000000..0ad9c7875 --- /dev/null +++ b/client/ui/frontend/src/contexts/StatusContext.tsx @@ -0,0 +1,106 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { Events } from "@wailsio/runtime"; +import { DaemonFeed } from "@bindings/services"; +import { Status } from "@bindings/services/models.js"; +import { DaemonOutdatedOverlay } from "@/components/empty-state/DaemonOutdatedOverlay.tsx"; +import { DaemonUnavailableOverlay } from "@/components/empty-state/DaemonUnavailableOverlay.tsx"; +import { isDaemonCompatible } from "@/lib/compat"; + +const EVENT_STATUS = "netbird:status"; + +type StatusContextValue = { + status: Status | null; + error: string | null; + refresh: () => Promise; + isReady: boolean; + isDaemonUnavailable: boolean; + isDaemonAvailable: boolean; + isDaemonOutdated: boolean; +}; + +const StatusContext = createContext(null); + +export const useStatus = () => { + const ctx = useContext(StatusContext); + if (!ctx) { + throw new Error("useStatus must be used inside StatusProvider"); + } + return ctx; +}; + +export const StatusProvider = ({ children }: { children: ReactNode }) => { + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const [isDaemonOutdated, setIsDaemonOutdated] = useState(false); + + const refresh = useCallback(async () => { + try { + const s = await DaemonFeed.Get(); + setStatus(s); + setError(null); + } catch (e) { + // Synthesize DaemonUnavailable so cold-start-without-daemon isn't a blank UI (isReady stays false otherwise). + setStatus(Status.createFrom({ status: "DaemonUnavailable" })); + setError(String(e)); + } + }, []); + + useEffect(() => { + refresh().catch((err: unknown) => console.error("[StatusContext] refresh failed", err)); + const off = Events.On(EVENT_STATUS, (ev: { data: Status }) => { + setStatus(ev.data); + setError(null); + }); + return () => { + off(); + }; + }, [refresh]); + + const isReady = status !== null; + const isDaemonUnavailable = isReady && status.status === "DaemonUnavailable"; + const isDaemonAvailable = isReady && !isDaemonUnavailable; + + useEffect(() => { + if (!isDaemonAvailable) return; + let cancelled = false; + isDaemonCompatible() + .then((ok) => { + if (!cancelled) setIsDaemonOutdated(!ok); + }) + .catch((err) => { + console.error("[StatusContext] daemon compatible error", err); + }); + return () => { + cancelled = true; + }; + }, [isDaemonAvailable]); + + const value = useMemo( + () => ({ + status, + error, + refresh, + isReady, + isDaemonUnavailable, + isDaemonAvailable, + isDaemonOutdated, + }), + [status, error, refresh, isReady, isDaemonUnavailable, isDaemonAvailable, isDaemonOutdated], + ); + + return ( + + {isDaemonAvailable && !isDaemonOutdated && children} + + + + ); +}; diff --git a/client/ui/frontend/src/contexts/ViewModeContext.tsx b/client/ui/frontend/src/contexts/ViewModeContext.tsx new file mode 100644 index 000000000..7db3abae0 --- /dev/null +++ b/client/ui/frontend/src/contexts/ViewModeContext.tsx @@ -0,0 +1,89 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { Window } from "@wailsio/runtime"; +import { Preferences } from "@bindings/services"; +import { ViewMode as ViewModePref } from "@bindings/preferences/models.js"; + +export type ViewMode = "default" | "advanced"; + +// Don't pass a fixed height to Window.SetSize: macOS SetSize is frame (incl. ~28px +// title bar) while creation is content, so re-asserting a constant chops the content on first switch. +export const VIEW_WIDTH: Record = { + default: 380, + advanced: 900, +}; + +type ViewModeContextValue = { + viewMode: ViewMode; + setViewMode: (mode: ViewMode) => void; +}; + +const ViewModeContext = createContext(null); + +export const ViewModeProvider = ({ children }: { children: ReactNode }) => { + const [mode, setMode] = useState("default"); + const modeRef = useRef("default"); + + useEffect(() => { + let cancelled = false; + Preferences.Get() + .then((prefs) => { + if (cancelled) return; + const saved = prefs?.viewMode as ViewMode | undefined; + if (saved === "default" || saved === "advanced") { + modeRef.current = saved; + setMode(saved); + } + }) + .catch((err: unknown) => + console.warn("[ViewModeContext] load preferences failed", err), + ); + return () => { + cancelled = true; + }; + }, []); + + // Resize before flipping React state, else the layout paints into a window that hasn't grown yet. + const setViewMode = useCallback((mode: ViewMode) => { + if (modeRef.current === mode) return; + modeRef.current = mode; + (async () => { + const size = await Window.Size().catch((err: unknown) => { + console.warn("[ViewModeContext] read window size failed", err); + return null; + }); + const width = VIEW_WIDTH[mode]; + const height = size?.height ?? 640; + await Window.SetSize(width, height).catch((err: unknown) => + console.warn("[ViewModeContext] set window size failed", err), + ); + setMode(mode); + const pref = + mode === "advanced" ? ViewModePref.ViewModeAdvanced : ViewModePref.ViewModeDefault; + Preferences.SetViewMode(pref).catch((err: unknown) => + console.error("[ViewModeContext] SetViewMode failed", err), + ); + })().catch((err: unknown) => console.error("[ViewModeContext] setViewMode failed", err)); + }, []); + + const value = useMemo( + () => ({ viewMode: mode, setViewMode }), + [mode, setViewMode], + ); + + return {children}; +}; + +export const useViewMode = () => { + const ctx = useContext(ViewModeContext); + if (!ctx) throw new Error("useViewMode must be used inside ViewModeProvider"); + return ctx; +}; diff --git a/client/ui/frontend/src/globals.css b/client/ui/frontend/src/globals.css new file mode 100644 index 000000000..84ddfdcfe --- /dev/null +++ b/client/ui/frontend/src/globals.css @@ -0,0 +1,45 @@ +@font-face { + font-family: "Inter Variable"; + font-style: normal; + font-weight: 100 900; + src: url("./assets/fonts/inter-variable.ttf") format("truetype"); +} + +@font-face { + font-family: "JetBrains Mono Variable"; + font-style: normal; + font-weight: 100 800; + src: url("./assets/fonts/jetbrains-mono-variable.ttf") format("truetype"); +} + +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body, +#root { + height: 100%; + overflow: hidden; +} + +/* + * Body bg is fully opaque on purpose. The main window uses + * MacBackdropTranslucent (main.go) and TitleBarHiddenInset, which on macOS + * lets the desktop wallpaper bleed through any non-opaque pixel. A 90% + * body alpha meant two machines with different wallpapers saw different + * effective backgrounds. Matching Wails' BackgroundColour (#181A1D / nb-gray + * DEFAULT) here keeps things consistent regardless of the OS backdrop. + */ +body { + @apply bg-nb-gray font-sans text-nb-gray-200 antialiased; +} + +.wails-draggable { + --wails-draggable: drag; + cursor: default; +} + +.wails-no-draggable { + --wails-draggable: no-drag; +} diff --git a/client/ui/frontend/src/hooks/useAutoSizeWindow.ts b/client/ui/frontend/src/hooks/useAutoSizeWindow.ts new file mode 100644 index 000000000..d4f4d80b2 --- /dev/null +++ b/client/ui/frontend/src/hooks/useAutoSizeWindow.ts @@ -0,0 +1,60 @@ +import { useLayoutEffect, useRef } from "react"; +import { Window } from "@wailsio/runtime"; +import i18next from "@/lib/i18n"; +import { isLinux } from "@/lib/platform"; + +// Sizes the current Wails window to the measured content height (keeping `width`), +// then shows it. Re-applies on content resize and language change. +export function useAutoSizeWindow(width: number, ready: boolean = true) { + const ref = useRef(null); + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + let shown = false; + let raf1 = 0; + let raf2 = 0; + const showOnce = () => { + if (shown) return; + shown = true; + Window.Show().catch(() => {}); + Window.Focus().catch(() => {}); + }; + const apply = async () => { + if (!ready) return; + const h = Math.ceil(el.getBoundingClientRect().height); + if (h <= 0) return; + try { + // Window.SetSize takes the frame size, so add the OS title-bar height or content clips. + const frame = await Window.Size(); + const targetH = h + Math.max(0, frame.height - window.innerHeight); + // Linux: SetSize no-ops on a mapped non-resizable window (X11), so pin via min/max instead. + if (isLinux()) { + await Window.SetMinSize(width, targetH); + await Window.SetMaxSize(width, targetH); + } + await Window.SetSize(width, targetH); + showOnce(); + } catch { + // window gone / not ready — ignore + } + }; + const scheduleApply = () => { + cancelAnimationFrame(raf1); + cancelAnimationFrame(raf2); + raf1 = requestAnimationFrame(() => { + raf2 = requestAnimationFrame(apply); + }); + }; + apply(); + const ro = new ResizeObserver(apply); + ro.observe(el); + i18next.on("languageChanged", scheduleApply); + return () => { + ro.disconnect(); + cancelAnimationFrame(raf1); + cancelAnimationFrame(raf2); + i18next.off("languageChanged", scheduleApply); + }; + }, [width, ready]); + return ref; +} diff --git a/client/ui/frontend/src/hooks/useFocusVisible.ts b/client/ui/frontend/src/hooks/useFocusVisible.ts new file mode 100644 index 000000000..6061beb9c --- /dev/null +++ b/client/ui/frontend/src/hooks/useFocusVisible.ts @@ -0,0 +1,49 @@ +import { useEffect, useState } from "react"; + +// Tracks the user's current input modality (keyboard vs pointer) at module +// scope, mirroring what @react-aria/interactions does. Radix programmatically +// focuses elements like Tabs triggers and Select triggers, which makes the +// browser's :focus-visible heuristic light up on mouse-driven interactions too. +// Gating focus styles on this hook lets us only paint a focus ring when the +// user is actually navigating with the keyboard. +// See react-aria's useFocusVisible for context. + +type Modality = "keyboard" | "pointer"; + +let currentModality: Modality = "pointer"; +const subscribers = new Set<(m: Modality) => void>(); + +const setModality = (m: Modality) => { + if (m === currentModality) return; + currentModality = m; + subscribers.forEach((cb) => cb(m)); +}; + +const isKeyboardEvent = (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey || e.altKey) return false; + return e.key === "Tab" || e.key === "Escape" || e.key.startsWith("Arrow"); +}; + +if (globalThis.window !== undefined) { + globalThis.addEventListener( + "keydown", + (e) => { + if (isKeyboardEvent(e)) setModality("keyboard"); + }, + true, + ); + globalThis.addEventListener("pointerdown", () => setModality("pointer"), true); +} + +export const useFocusVisible = (): boolean => { + const [visible, setVisible] = useState(currentModality === "keyboard"); + useEffect(() => { + setVisible(currentModality === "keyboard"); + const cb = (m: Modality) => setVisible(m === "keyboard"); + subscribers.add(cb); + return () => { + subscribers.delete(cb); + }; + }, []); + return visible; +}; diff --git a/client/ui/frontend/src/hooks/useKeepConnectedOnQuit.ts b/client/ui/frontend/src/hooks/useKeepConnectedOnQuit.ts new file mode 100644 index 000000000..eb68dd997 --- /dev/null +++ b/client/ui/frontend/src/hooks/useKeepConnectedOnQuit.ts @@ -0,0 +1,35 @@ +import { useCallback, useEffect, useState } from "react"; +import { Preferences } from "@bindings/services"; + +export const useKeepConnectedOnQuit = () => { + const [keepConnected, setKeepConnected] = useState(null); + + useEffect(() => { + let cancelled = false; + Preferences.Get() + .then((prefs) => { + if (cancelled) return; + setKeepConnected(prefs?.keepConnectedOnQuit ?? false); + }) + .catch((err: unknown) => { + if (cancelled) return; + console.warn("[useKeepConnectedOnQuit] load preferences failed", err); + setKeepConnected(false); + }); + return () => { + cancelled = true; + }; + }, []); + + const setKeepConnectedOnQuit = useCallback(async (keep: boolean) => { + setKeepConnected(keep); + try { + await Preferences.SetKeepConnectedOnQuit(keep); + } catch (err: unknown) { + setKeepConnected(!keep); + console.error("[useKeepConnectedOnQuit] SetKeepConnectedOnQuit failed", err); + } + }, []); + + return { keepConnected, setKeepConnectedOnQuit }; +}; diff --git a/client/ui/frontend/src/hooks/useKeyboardShortcut.ts b/client/ui/frontend/src/hooks/useKeyboardShortcut.ts new file mode 100644 index 000000000..ea67d8375 --- /dev/null +++ b/client/ui/frontend/src/hooks/useKeyboardShortcut.ts @@ -0,0 +1,46 @@ +import { useEffect } from "react"; +import { isMacOS } from "@/lib/platform"; + +export type Shortcut = { + key: string; + cmd?: boolean; + shift?: boolean; + alt?: boolean; + preventDefault?: boolean; +}; + +export const useKeyboardShortcut = (shortcut: Shortcut, callback: () => void, enabled = true) => { + useEffect(() => { + if (!enabled) return; + const onKey = (e: KeyboardEvent) => { + if (e.key.toLowerCase() !== shortcut.key.toLowerCase()) return; + const mod = e.metaKey || e.ctrlKey; + if (!!shortcut.cmd !== mod) return; + if (!!shortcut.shift !== e.shiftKey) return; + if (!!shortcut.alt !== e.altKey) return; + if (shortcut.preventDefault !== false) e.preventDefault(); + callback(); + }; + globalThis.addEventListener("keydown", onKey); + return () => globalThis.removeEventListener("keydown", onKey); + }, [ + shortcut.key, + shortcut.cmd, + shortcut.shift, + shortcut.alt, + shortcut.preventDefault, + callback, + enabled, + ]); +}; + +export const formatShortcut = (shortcut: Shortcut): string => { + // navigator.platform is empty on some WebView2 builds → misrenders ⌘ as Ctrl on Mac. + const mac = isMacOS(); + const parts: string[] = []; + if (shortcut.cmd) parts.push(mac ? "⌘" : "Ctrl"); + if (shortcut.shift) parts.push(mac ? "⇧" : "Shift"); + if (shortcut.alt) parts.push(mac ? "⌥" : "Alt"); + parts.push(shortcut.key.length === 1 ? shortcut.key.toUpperCase() : shortcut.key); + return parts.join(mac ? "" : "+"); +}; diff --git a/client/ui/frontend/src/hooks/useManagementUrl.ts b/client/ui/frontend/src/hooks/useManagementUrl.ts new file mode 100644 index 000000000..6a0ef1b81 --- /dev/null +++ b/client/ui/frontend/src/hooks/useManagementUrl.ts @@ -0,0 +1,143 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useSettings } from "@/contexts/SettingsContext.tsx"; +import { useConfirm } from "@/contexts/DialogContext.tsx"; + +export const CLOUD_MANAGEMENT_URL = "https://api.netbird.io:443"; +const CLOUD_MANAGEMENT_URLS = new Set([ + CLOUD_MANAGEMENT_URL, + "https://api.wiretrustee.com:443", // legacy cloud endpoint +]); + +export function isNetbirdCloud(url: string): boolean { + if (!url || url.trim() === "") return true; + return CLOUD_MANAGEMENT_URLS.has(url); +} + +// Matches http(s)://host[:port][/path][?query][#fragment]; host = domain, localhost, or IPv4. +// Syntactic validation only — reachability is checked via checkManagementUrlReachable. +export const URL_PATTERN = new RegExp( + String.raw`^(https?:\/\/)?` + + String.raw`((([a-z\d]([a-z\d-]*[a-z\d])?)\.)+[a-z]{2,}|localhost|` + + String.raw`((\d{1,3}\.){3}\d{1,3}))` + + String.raw`(\:\d+)?(\/[-a-z\d%_.~+]*)*` + + String.raw`(\?[;&a-z\d%_.~+=-]*)?` + + String.raw`(\#[-a-z\d_]*)?$`, + "i", +); + +export function normalizeManagementUrl(input: string): string { + const trimmed = input.trim(); + if (!trimmed) return ""; + if (/^https?:\/\//i.test(trimmed)) return trimmed; + return `https://${trimmed}`; +} + +export function isValidManagementUrl(input: string): boolean { + const trimmed = input.trim(); + if (!trimmed) return false; + return URL_PATTERN.test(trimmed); +} + +// Can false-negative for self-hosted behind internal DNS / self-signed certs — treat as a soft warning, not a hard block. +export async function checkManagementUrlReachable( + url: string, + timeoutMs: number = 5000, +): Promise { + const target = normalizeManagementUrl(url); + if (!target) return false; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + await fetch(target, { method: "GET", mode: "no-cors", signal: controller.signal }); + return true; + } catch { + return false; + } finally { + clearTimeout(timer); + } +} + +export enum ManagementMode { + Cloud = "cloud", + SelfHosted = "selfhosted", +} + +function modeFromUrl(url: string): ManagementMode { + return isNetbirdCloud(url) ? ManagementMode.Cloud : ManagementMode.SelfHosted; +} + +export function useManagementUrl() { + const { t } = useTranslation(); + const confirm = useConfirm(); + const { config, saveField } = useSettings(); + const [modeState, setModeState] = useState(modeFromUrl(config.managementUrl)); + const [url, setUrl] = useState( + isNetbirdCloud(config.managementUrl) ? "" : config.managementUrl, + ); + const [checking, setChecking] = useState(false); + const [unreachable, setUnreachable] = useState(false); + + useEffect(() => { + setModeState(modeFromUrl(config.managementUrl)); + if (!isNetbirdCloud(config.managementUrl)) { + setUrl(config.managementUrl); + } + }, [config.managementUrl]); + + useEffect(() => { + setUnreachable(false); + }, [url, modeState]); + + const setMode = async (next: ManagementMode) => { + if (next === ManagementMode.Cloud && !isNetbirdCloud(config.managementUrl)) { + const ok = await confirm({ + title: t("settings.general.management.switchCloudTitle"), + description: t("settings.general.management.switchCloudMessage"), + confirmLabel: t("settings.general.management.switchCloudConfirm"), + }); + if (!ok) return; + setModeState(ManagementMode.Cloud); + saveField("managementUrl", CLOUD_MANAGEMENT_URL).catch((err: unknown) => + console.error("save managementUrl failed", err), + ); + return; + } + setModeState(next); + }; + + const normalizedUrl = normalizeManagementUrl(url); + const urlValid = isValidManagementUrl(url); + const targetUrl = modeState === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : normalizedUrl; + const dirty = targetUrl !== config.managementUrl; + const showError = modeState === ManagementMode.SelfHosted && url.trim() !== "" && !urlValid; + const canSave = dirty && (modeState === ManagementMode.Cloud || urlValid); + const displayUrl = modeState === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : url; + + const save = async () => { + if (modeState === ManagementMode.SelfHosted && !unreachable) { + setChecking(true); + const reachable = await checkManagementUrlReachable(targetUrl); + setChecking(false); + if (!reachable) { + setUnreachable(true); + return; + } + } + await saveField("managementUrl", targetUrl); + setUnreachable(false); + }; + + return { + mode: modeState, + setMode, + url, + setUrl, + displayUrl, + showError, + canSave, + save, + checking, + unreachable, + }; +} diff --git a/client/ui/frontend/src/hooks/usePrivilege.ts b/client/ui/frontend/src/hooks/usePrivilege.ts new file mode 100644 index 000000000..d67fcc4b1 --- /dev/null +++ b/client/ui/frontend/src/hooks/usePrivilege.ts @@ -0,0 +1,32 @@ +import { useEffect, useState } from "react"; +import { Settings as SettingsSvc } from "@bindings/services"; +import { type Privilege } from "@bindings/services/models.js"; + +// usePrivilege reports whether this UI process may perform the changes the daemon +// restricts to root/administrator. It is answered in-process from our own token +// with the daemon's own rule, so there is no round-trip and it works while the +// daemon is down. +// +// null means "not known yet", which includes the read having failed. Callers must +// treat that as "do not restrict": the daemon enforces this regardless, so the +// only thing a wrong guess here costs is a control that looks unavailable when it +// is not, or a save that fails with the daemon's own guidance. +export const usePrivilege = (): Privilege | null => { + const [privilege, setPrivilege] = useState(null); + + useEffect(() => { + let cancelled = false; + SettingsSvc.Privilege() + .then((p) => { + if (!cancelled) setPrivilege(p); + }) + .catch((e: unknown) => { + console.warn("[usePrivilege] read failed, not restricting controls", e); + }); + return () => { + cancelled = true; + }; + }, []); + + return privilege; +}; diff --git a/client/ui/frontend/src/layouts/AppLayout.tsx b/client/ui/frontend/src/layouts/AppLayout.tsx new file mode 100644 index 000000000..0c2837b53 --- /dev/null +++ b/client/ui/frontend/src/layouts/AppLayout.tsx @@ -0,0 +1,29 @@ +import { Outlet } from "react-router-dom"; +import { ClientVersionProvider } from "@/contexts/ClientVersionContext.tsx"; +import { StatusProvider } from "@/contexts/StatusContext.tsx"; +import { DebugBundleProvider } from "@/contexts/DebugBundleContext.tsx"; +import { ProfileProvider } from "@/contexts/ProfileContext.tsx"; +import { DialogProvider } from "@/contexts/DialogContext.tsx"; +import { RestrictionsProvider } from "@/contexts/RestrictionsContext.tsx"; +import { ReadySignal } from "@/components/ReadySignal.tsx"; + +export const AppLayout = () => { + return ( +
                                                + + + + + + + + + + + + + + +
                                                + ); +}; diff --git a/client/ui/frontend/src/layouts/AppRightPanel.tsx b/client/ui/frontend/src/layouts/AppRightPanel.tsx new file mode 100644 index 000000000..8474bd71f --- /dev/null +++ b/client/ui/frontend/src/layouts/AppRightPanel.tsx @@ -0,0 +1,38 @@ +import { type ReactNode } from "react"; +import { motion } from "framer-motion"; +import { cn } from "@/lib/cn.ts"; + +type Props = { + children: ReactNode; + overlay?: ReactNode; + overlayOpen?: boolean; + className?: string; +}; + +const PANEL_TRANSITION = { + duration: 0.32, + ease: [0.32, 0.72, 0, 1] as [number, number, number, number], +}; + +export const AppRightPanel = ({ children, overlay, overlayOpen = false, className }: Props) => { + return ( +
                                                + + {children} + + {overlay} +
                                                + ); +}; diff --git a/client/ui/frontend/src/lib/cn.ts b/client/ui/frontend/src/lib/cn.ts new file mode 100644 index 000000000..e6a8be071 --- /dev/null +++ b/client/ui/frontend/src/lib/cn.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/client/ui/frontend/src/lib/compat.ts b/client/ui/frontend/src/lib/compat.ts new file mode 100644 index 000000000..b2981da90 --- /dev/null +++ b/client/ui/frontend/src/lib/compat.ts @@ -0,0 +1,19 @@ +import { Compat } from "@bindings/services"; + +let cached: boolean | null = null; + +/** + * isDaemonCompatible probes whether the running daemon implements the WailsUIReady + * RPC. A false result means the daemon predates this UI (Unimplemented) and is too + * old to drive it. The Go side returns an error instead when the daemon is simply + * unreachable, so a throw here is NOT an outdated daemon — treat it as "unknown" + * and let the normal connection flow report it. + * + * The result is cached for the session: daemon identity does not change without a + * UI restart, and a freshly started daemon is reachable again under the same socket. + */ +export async function isDaemonCompatible(): Promise { + if (cached !== null) return cached; + cached = await Compat.DaemonReady(); + return cached; +} diff --git a/client/ui/frontend/src/lib/connection.ts b/client/ui/frontend/src/lib/connection.ts new file mode 100644 index 000000000..cc0e67cb3 --- /dev/null +++ b/client/ui/frontend/src/lib/connection.ts @@ -0,0 +1,133 @@ +import { Events } from "@wailsio/runtime"; +import { Connection, WindowManager } from "@bindings/services"; +import i18next from "@/lib/i18n"; +import { errorDialog, formatErrorMessage } from "@/lib/errors.ts"; + +export const EVENT_BROWSER_LOGIN_CANCEL = "browser-login:cancel"; +export const EVENT_TRIGGER_LOGIN = "trigger-login"; + +let connectionInFlight = false; + +type SsoState = { + cancelled: boolean; + offCancel?: () => void; + offSignal?: () => void; +}; + +async function openBrowserLoginUri(uri: string): Promise { + try { + await WindowManager.OpenBrowserLogin(uri); + } catch (e) { + console.error(e); + } +} + +function buildSsoCancelPromise(state: SsoState, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + state.offCancel = Events.On(EVENT_BROWSER_LOGIN_CANCEL, () => { + state.cancelled = true; + resolve(); + }); + if (!signal) return; + const onAbort = () => { + state.cancelled = true; + resolve(); + }; + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener("abort", onAbort); + state.offSignal = () => signal.removeEventListener("abort", onAbort); + }); +} + +async function runSsoLogin( + result: { + verificationUri: string; + verificationUriComplete: string; + userCode: string; + profileId: string; + }, + state: SsoState, + signal?: AbortSignal, +): Promise { + const uri = result.verificationUriComplete || result.verificationUri; + if (uri) await openBrowserLoginUri(uri); + + const cancelPromise = buildSsoCancelPromise(state, signal); + // Combine wait + up in Go so the connection comes up the moment SSO + // completes. During SSO the tray window is hidden and the webview is + // suspended, so a frontend-driven Up (a promise continuation) would not + // fire until the user woke the window (e.g. hovering the tray icon). + const waitPromise = Connection.WaitSSOLoginAndUp( + { userCode: result.userCode, hostname: "", profileId: result.profileId }, + { profileName: "", username: "" }, + ); + + try { + await Promise.race([waitPromise, cancelPromise]); + } finally { + WindowManager.CloseBrowserLogin().catch(console.error); + } + + if (state.cancelled) { + waitPromise.cancel?.(); + waitPromise.catch(() => {}); + } +} + +export async function startConnection(onSettled?: () => void, signal?: AbortSignal): Promise { + if (connectionInFlight || signal?.aborted) { + onSettled?.(); + return; + } + connectionInFlight = true; + + const state: SsoState = { cancelled: false }; + let connectError: unknown; + + try { + const result = await Connection.Login({ + profileName: "", + username: "", + managementUrl: "", + setupKey: "", + preSharedKey: "", + hostname: "", + hint: "", + }); + + if (signal?.aborted) state.cancelled = true; + + if (!state.cancelled && result.needsSsoLogin) { + // runSsoLogin brings the connection up in Go once SSO completes. + await runSsoLogin(result, state, signal); + } else { + if (!state.cancelled && signal?.aborted) state.cancelled = true; + if (!state.cancelled) { + await Connection.Up({ profileName: "", username: "" }); + } + } + } catch (e) { + WindowManager.CloseBrowserLogin().catch(console.error); + if (!state.cancelled) connectError = e; + } finally { + state.offCancel?.(); + state.offSignal?.(); + connectionInFlight = false; + onSettled?.(); + } + + if (connectError !== undefined) { + await errorDialog({ + Title: i18next.t("connect.error.loginTitle"), + Message: formatErrorMessage(connectError), + }); + return; + } + + if (state.cancelled && signal) { + throw new DOMException("aborted", "AbortError"); + } +} diff --git a/client/ui/frontend/src/lib/errors.ts b/client/ui/frontend/src/lib/errors.ts new file mode 100644 index 000000000..b4dee2717 --- /dev/null +++ b/client/ui/frontend/src/lib/errors.ts @@ -0,0 +1,73 @@ +import { WindowManager } from "@bindings/services"; + +type ClassifiedError = { short: string; long: string; command: string }; + +const asObject = (v: unknown): Record | null => + v && typeof v === "object" ? (v as Record) : null; + +const parseJsonObject = (s: unknown): Record | null => { + if (typeof s !== "string") return null; + const t = s.trim(); + if (!t.startsWith("{") || !t.endsWith("}")) return null; + try { + return asObject(JSON.parse(t)); + } catch { + return null; + } +}; + +const toWailsEnvelope = (e: unknown): Record | null => { + const obj = asObject(e); + if (!obj) return null; + return asObject(obj.cause) ?? parseJsonObject(obj.message); +}; + +// Read { short, long, command } from wherever the classified error sits in the envelope +const toClassifiedError = (v: unknown): ClassifiedError | null => { + const o = asObject(v); + if (!o) return null; + const short = typeof o.short === "string" ? o.short : ""; + const long = typeof o.long === "string" ? o.long : ""; + const command = typeof o.command === "string" ? o.command : ""; + return short || long ? { short, long, command } : null; +}; + +const classify = (e: unknown): ClassifiedError | null => { + const envelope = toWailsEnvelope(e); + return toClassifiedError(envelope?.cause) ?? toClassifiedError(envelope); +}; + +export const formatErrorMessage = (e: unknown): string => { + // Prefer the structured { short, long } the daemon classifier produced. + const classified = classify(e); + if (classified) { + const { short, long } = classified; + if (short && long && long !== short) return `${short} Details: ${long}`; + if (short) return short; + if (long) return long; + } + + // Unclassified (a service returned the raw daemon error) + const envelope = toWailsEnvelope(e); + const message = envelope?.message; + if (typeof message === "string" && message) return message; + if (e instanceof Error) return e.message; + return String(e); +}; + +// errorCommand returns a command the user can run to complete an operation the +// daemon refused, when the error carries one (a change that needs elevated +// privileges). Empty for every other error. +export const errorCommand = (e: unknown): string => classify(e)?.command ?? ""; + +export type ErrorDialogOptions = { + Title: string; + Message: string; + // Command is shown for copying below the message. Defaults to the one the + // error carries, so callers only pass it to override. + Command?: string; +}; + +export function errorDialog(options: ErrorDialogOptions): Promise { + return WindowManager.OpenError(options.Title, options.Message, options.Command ?? ""); +} diff --git a/client/ui/frontend/src/lib/formatters.ts b/client/ui/frontend/src/lib/formatters.ts new file mode 100644 index 000000000..231967a38 --- /dev/null +++ b/client/ui/frontend/src/lib/formatters.ts @@ -0,0 +1,44 @@ +export const formatBytes = (bytes: number, decimals: number = 2): string => { + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; + + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k))); + + return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + " " + sizes[i]; +}; + +export const latencyColor = (ms: number): string => { + if (ms <= 0) return "text-nb-gray-400"; + if (ms < 100) return "text-green-400"; + return "text-yellow-400"; +}; + +export const formatRelative = (unixSeconds: number, nowMs: number = Date.now()): string | null => { + if (!Number.isFinite(unixSeconds) || unixSeconds <= 0) return null; + const diff = Math.max(0, Math.floor(nowMs / 1000 - unixSeconds)); + if (diff < 60) return `${diff}s ago`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +}; + +// Base domain is operator-configurable, so cut at the first dot rather than match a known suffix. +export const shortenDns = (fqdn: string | undefined | null): string => { + if (!fqdn) return ""; + const dot = fqdn.indexOf("."); + return dot === -1 ? fqdn : fqdn.slice(0, dot); +}; + +// Countdown clock: mm:ss, widening to hh:mm:ss / dd:hh:mm:ss as the duration grows. +export const formatRemaining = (seconds: number): string => { + const s = Math.max(0, Math.trunc(seconds)); + const days = Math.floor(s / 86400); + const hours = Math.floor((s % 86400) / 3600); + const minutes = Math.floor((s % 3600) / 60); + const secs = s % 60; + const pad = (n: number) => String(n).padStart(2, "0"); + if (days > 0) return `${pad(days)}:${pad(hours)}:${pad(minutes)}:${pad(secs)}`; + if (hours > 0) return `${pad(hours)}:${pad(minutes)}:${pad(secs)}`; + return `${pad(minutes)}:${pad(secs)}`; +}; diff --git a/client/ui/frontend/src/lib/i18n.ts b/client/ui/frontend/src/lib/i18n.ts new file mode 100644 index 000000000..dc7fc7902 --- /dev/null +++ b/client/ui/frontend/src/lib/i18n.ts @@ -0,0 +1,103 @@ +import i18next from "i18next"; +import { initReactI18next } from "react-i18next"; +import { Events } from "@wailsio/runtime"; + +import { Preferences, I18n } from "@bindings/services"; +import { type LanguageCode } from "@bindings/i18n/models.js"; + +// Relative path on purpose — alias globs (`@/…`) silently match nothing in some Vite dev setups. +type BundleEntry = { message: string; description?: string }; +const bundleModules = import.meta.glob>( + "../../../i18n/locales/*/common.json", + { eager: true, import: "default" }, +); + +const resources: Record }> = {}; +for (const path in bundleModules) { + const match = /locales\/([^/]+)\/common\.json$/.exec(path); + if (match) { + const entries = bundleModules[path]; + const messages: Record = {}; + for (const key in entries) { + messages[key] = entries[key].message; + } + resources[match[1]] = { common: messages }; + } +} + +function detectBrowserLanguage(available: string[]): string | null { + const tags = [navigator.language, ...(navigator.languages ?? [])].filter( + (tag): tag is string => typeof tag === "string" && tag.length > 0, + ); + const byLower = new Map(available.map((code) => [code.toLowerCase(), code])); + for (const tag of tags) { + const lower = tag.toLowerCase(); + const exact = byLower.get(lower); + if (exact) return exact; + const base = byLower.get(lower.split("-")[0]); + if (base) return base; + } + return null; +} + +// An empty persisted language code is the Go-side signal for first run. +export async function initI18n(): Promise { + const available = Object.keys(resources); + let language = "en"; + let firstRun = false; + try { + const prefs = await Preferences.Get(); + if (prefs?.language) { + language = prefs.language; + } else { + firstRun = true; + language = detectBrowserLanguage(available) ?? "en"; + } + } catch (e) { + console.warn("read preferences for language failed, defaulting to en", e); + } + + if (firstRun) { + Preferences.SetLanguage(language as LanguageCode).catch((err: unknown) => + console.warn("persist detected language failed", err), + ); + } + + await i18next.use(initReactI18next).init({ + lng: language, + fallbackLng: "en", + defaultNS: "common", + ns: ["common"], + resources, + interpolation: { + prefix: "{", + suffix: "}", + escapeValue: false, + }, + returnNull: false, + }); + + syncDocumentLang(); + i18next.on("languageChanged", syncDocumentLang); + + Events.On("netbird:preferences:changed", (e) => { + const next = e.data?.language; + if (next && next !== i18next.language) { + i18next.changeLanguage(next).catch((err: unknown) => { + console.error("changeLanguage failed", err); + }); + } + }); +} + +function syncDocumentLang() { + if (typeof document !== "undefined") { + document.documentElement.lang = i18next.language; + } +} + +export async function loadLanguages() { + return I18n.Languages(); +} + +export default i18next; diff --git a/client/ui/frontend/src/lib/logs.ts b/client/ui/frontend/src/lib/logs.ts new file mode 100644 index 000000000..499023fa3 --- /dev/null +++ b/client/ui/frontend/src/lib/logs.ts @@ -0,0 +1,139 @@ +import { UILog } from "@bindings/services"; + +type Level = "trace" | "debug" | "info" | "warn" | "error"; + +const METHOD_LEVELS: Record = { + trace: "trace", + debug: "debug", + log: "info", + info: "info", + warn: "warn", + error: "error", +}; + +const IGNORED_SOURCES = new Set(["welcome.ts"]); + +const RATE_LIMIT = 50; +const RATE_WINDOW_MS = 1000; + +let installed = false; +let inForward = false; +let windowStart = 0; +let windowCount = 0; + +function describeCause(rawCause: unknown): string { + if (rawCause instanceof Error) return `${rawCause.name}: ${rawCause.message}`; + if (typeof rawCause === "object" && rawCause !== null) { + try { + return JSON.stringify(rawCause); + } catch { + // Circular ref — fall through to a tag instead of "[object Object]". + return `<${rawCause.constructor?.name ?? "object"}>`; + } + } + return String(rawCause); +} + +function formatCause(rawCause: unknown): string { + if (rawCause === undefined) return ""; + return `\ncaused by ${describeCause(rawCause)}`; +} + +// WebKit (macOS WKWebView) omits the "Name: message" header from Error.stack, +// so a bare stack hides the real cause. Prepend name+message, then the stack. +function formatError(e: Error): string { + const head = `${e.name}: ${e.message}`; + const cause = formatCause((e as { cause?: unknown }).cause); + if (!e.stack) return `${head}${cause}`; + if (e.stack.startsWith(head)) return `${head}${cause}`; + return `${head}${cause}\n${e.stack}`; +} + +function format(args: unknown[]): string { + return args + .map((a) => { + if (typeof a === "string") return a; + if (a instanceof Error) return formatError(a); + try { + return JSON.stringify(a); + } catch { + return String(a); + } + }) + .join(" "); +} + +function parseStackLine(line: string): string { + // Find the file:line:col tail at the end of the path. + const colonCol = line.lastIndexOf(":"); + if (colonCol <= 0) return ""; + const colonLine = line.lastIndexOf(":", colonCol - 1); + if (colonLine <= 0) return ""; + const col = line.slice(colonCol + 1); + const lineNo = line.slice(colonLine + 1, colonCol); + if (!/^\d+$/.test(col) || !/^\d+$/.test(lineNo)) return ""; + const before = line.slice(0, colonLine); + const sep = Math.max( + before.lastIndexOf("/"), + before.lastIndexOf("\\"), + before.lastIndexOf("("), + before.lastIndexOf(" "), + ); + const file = before.slice(sep + 1); + if (!file.includes(".")) return ""; + return `${file}:${lineNo}`; +} + +function callerSource(): string { + const stack = new Error().stack; + if (!stack) return ""; + for (const line of stack.split("\n").slice(1)) { + if (line.includes("/logs.ts")) continue; + const parsed = parseStackLine(line); + if (parsed) return parsed; + } + return ""; +} + +function forward(level: Level, args: unknown[]) { + if (inForward) return; + inForward = true; + try { + const now = Date.now(); + if (now - windowStart >= RATE_WINDOW_MS) { + windowStart = now; + windowCount = 0; + } + if (++windowCount > RATE_LIMIT) return; + + const source = callerSource(); + if (IGNORED_SOURCES.has(source.split(":")[0])) return; + // Don't touch console here — it would recurse back into forward(). + UILog.Log(level, source, format(args)).catch(() => {}); + } catch { + // Swallow — log forwarding must never throw back into the caller. + } finally { + inForward = false; + } +} + +export function initLogForwarding() { + if (installed) return; + installed = true; + + const c = console as unknown as Record void>; + for (const [method, level] of Object.entries(METHOD_LEVELS)) { + const original = c[method]?.bind(console); + c[method] = (...args: unknown[]) => { + original?.(...args); + forward(level, args); + }; + } + + globalThis.addEventListener("error", (e) => { + forward("error", [`uncaught error: ${e.message}`, e.error ?? ""]); + }); + globalThis.addEventListener("unhandledrejection", (e) => { + forward("error", ["unhandled promise rejection:", e.reason]); + }); +} diff --git a/client/ui/frontend/src/lib/platform.ts b/client/ui/frontend/src/lib/platform.ts new file mode 100644 index 000000000..c256dde23 --- /dev/null +++ b/client/ui/frontend/src/lib/platform.ts @@ -0,0 +1,39 @@ +import { System } from "@wailsio/runtime"; + +export type Platform = { + isWindows: boolean; + isMacOS: boolean; +}; + +let cached: Platform | null = null; + +export async function initPlatform(): Promise { + if (cached) return; + + const syncIsMac = System.IsMac(); + const syncIsWindows = System.IsWindows(); + + let env: Awaited> | null = null; + try { + env = await System.Environment(); + } catch (e) { + console.error("[platform] System.Environment() threw:", e); + } + + const os = (env?.OS ?? "").toLowerCase(); + cached = { + isWindows: os ? os === "windows" : syncIsWindows, + isMacOS: os ? os === "darwin" : syncIsMac, + }; +} + +function get(): Platform { + if (!cached) { + throw new Error("platform: initPlatform() must complete before sync getters are used"); + } + return cached; +} + +export const isWindows = (): boolean => get().isWindows; +export const isMacOS = (): boolean => get().isMacOS; +export const isLinux = (): boolean => !get().isWindows && !get().isMacOS; diff --git a/client/ui/frontend/src/lib/sorting.ts b/client/ui/frontend/src/lib/sorting.ts new file mode 100644 index 000000000..70622f705 --- /dev/null +++ b/client/ui/frontend/src/lib/sorting.ts @@ -0,0 +1,27 @@ +// Stable, order-preserving reconciliation for lists that re-fetch from the daemon +// on every status push (peers, networks, profiles). Re-sorting on each refresh would +// make rows jump around under the user, so instead: +// - items already on screen keep their existing order (from `prev`), +// - items that vanished are dropped, +// - newly-arrived items are sorted among themselves (`compareFresh`) and appended. +// Net effect: the only visible movement is new rows landing at the bottom. +// +// Must stay pure and idempotent: callers write the returned `order` into a ref +// during render (useMemo), so a rerun must reproduce the first pass — never +// branch on run count or read external mutable state. +export function reconcileOrder( + prev: string[], + items: T[], + keyOf: (item: T) => string, + compareFresh: (a: T, b: T) => number, +): { order: string[]; items: T[] } { + const byKey = new Map(items.map((i) => [keyOf(i), i])); + const kept = prev.filter((k) => byKey.has(k)); + const known = new Set(kept); + const fresh = items + .filter((i) => !known.has(keyOf(i))) + .sort(compareFresh) + .map(keyOf); + const order = [...kept, ...fresh]; + return { order, items: order.map((k) => byKey.get(k)!) }; +} diff --git a/client/ui/frontend/src/lib/stallwatch.ts b/client/ui/frontend/src/lib/stallwatch.ts new file mode 100644 index 000000000..aca7d75bb --- /dev/null +++ b/client/ui/frontend/src/lib/stallwatch.ts @@ -0,0 +1,31 @@ +// Detects webview suspension (macOS App Nap / hidden-window timer throttling). +// While the webview is suspended no JS runs at all, so detection happens on +// resume: a 1s interval measures wall-clock drift and reports how long timers +// were frozen. Silent unless a stall actually occurred; a stalled webview is +// what delays promise continuations such as the WaitSSOLogin → Up handoff. + +const INTERVAL_MS = 1000; +const STALL_THRESHOLD_MS = 5000; +const REPORT_COOLDOWN_MS = 60_000; + +let started = false; + +export function initStallWatch() { + if (started) return; + started = true; + + let last = Date.now(); + let lastReport = 0; + setInterval(() => { + const now = Date.now(); + const stall = now - last - INTERVAL_MS; + last = now; + if (stall < STALL_THRESHOLD_MS) return; + if (now - lastReport < REPORT_COOLDOWN_MS) return; + lastReport = now; + console.warn( + `webview timers were suspended for ${(stall / 1000).toFixed(1)}s ` + + `(App Nap / hidden-window throttling); pending UI work ran late`, + ); + }, INTERVAL_MS); +} diff --git a/client/ui/frontend/src/lib/welcome.ts b/client/ui/frontend/src/lib/welcome.ts new file mode 100644 index 000000000..d9df3fc8d --- /dev/null +++ b/client/ui/frontend/src/lib/welcome.ts @@ -0,0 +1,25 @@ +const ART = ` + _ __ __ ____ _ __ ______ __ __ __ + / | / /__ / /_/ __ )(_)________/ / / ____/___ ___ / /_ / / / / + / |/ / _ \\/ __/ __ / / ___/ __ / / / __/ __ \`__ \\/ __ \\/ /_/ / + / /| / __/ /_/ /_/ / / / / /_/ / / /_/ / / / / / / /_/ / __ / +/_/ |_/\\___/\\__/_____/_/_/ \\__,_/ \\____/_/ /_/ /_/_.___/_/ /_/ +`; + +export function welcome() { + const message = `%c${ART}%c +NetBird — The Only Secure Access Platform You'll Ever Need. + +WEBSITE: https://netbird.io/ +WE'RE HIRING: https://netbird.io/careers +OPEN SOURCE: https://github.com/netbirdio/netbird +`; + + // Intentional NetBird ASCII banner in the devtools console. + // eslint-disable-next-line no-console + console.log( + message, + "color: #f68330; font-family: monospace; font-weight: normal; line-height: 1;", + "color: #f5f5f5; font-family: monospace; font-weight: normal; line-height: 1.4;", + ); +} diff --git a/client/ui/frontend/src/modules/auto-update/UpdateBadge.tsx b/client/ui/frontend/src/modules/auto-update/UpdateBadge.tsx new file mode 100644 index 000000000..3345e0a9f --- /dev/null +++ b/client/ui/frontend/src/modules/auto-update/UpdateBadge.tsx @@ -0,0 +1,27 @@ +import { forwardRef, type HTMLAttributes } from "react"; +import { ArrowUpCircleIcon } from "lucide-react"; +import { cn } from "@/lib/cn"; + +type Props = HTMLAttributes & { + size?: number; +}; + +export const UpdateBadge = forwardRef(function UpdateBadge( + { size = 15, className, ...rest }, + ref, +) { + return ( +
                                                + + +
                                                + ); +}); diff --git a/client/ui/frontend/src/modules/auto-update/UpdateInProgressDialog.tsx b/client/ui/frontend/src/modules/auto-update/UpdateInProgressDialog.tsx new file mode 100644 index 000000000..1519613e0 --- /dev/null +++ b/client/ui/frontend/src/modules/auto-update/UpdateInProgressDialog.tsx @@ -0,0 +1,187 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useSearchParams } from "react-router-dom"; +import { Loader2, XCircle } from "lucide-react"; +import { Update as UpdateSvc, WindowManager } from "@bindings/services"; +import { Button } from "@/components/buttons/Button"; +import { ConfirmDialog } from "@/components/dialog/ConfirmDialog"; +import { DialogActions } from "@/components/dialog/DialogActions"; +import { DialogDescription } from "@/components/dialog/DialogDescription"; +import { DialogHeading } from "@/components/dialog/DialogHeading"; +import { SquareIcon } from "@/components/SquareIcon"; +import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; + +const TIMEOUT_MS = 15 * 60 * 1000; +const POLL_INTERVAL_MS = 2000; +// Sustained gRPC failure during install is taken as success (installer restarts the daemon mid-flight). +const DAEMON_DOWN_GRACE_MS = 5000; +const WINDOW_WIDTH = 360; + +type Phase = + | { kind: "running" } + | { kind: "timeout" } + | { kind: "canceled" } + | { kind: "failed"; message: string }; + +export default function UpdateInProgressDialog() { + const { t } = useTranslation(); + const [params] = useSearchParams(); + const version = params.get("version") ?? ""; + const [phase, setPhase] = useState({ kind: "running" }); + const phaseRef = useRef(phase); + phaseRef.current = phase; + const contentRef = useAutoSizeWindow(WINDOW_WIDTH); + + useEffect(() => { + let cancelled = false; + let done = false; + let timer: ReturnType | null = null; + const start = Date.now(); + let firstUnreachableAt: number | null = null; + + const poll = async () => { + if (cancelled || done) return; + if (phaseRef.current.kind !== "running") return; + + if (Date.now() - start > TIMEOUT_MS) { + done = true; + setPhase({ kind: "timeout" }); + return; + } + + try { + const r = await UpdateSvc.GetInstallerResult(); + if (cancelled || done || phaseRef.current.kind !== "running") return; + firstUnreachableAt = null; + if (r.success) { + done = true; + UpdateSvc.Quit().catch(console.error); + return; + } + if (r.errorMsg) { + done = true; + setPhase(mapInstallError(r.errorMsg)); + return; + } + } catch { + if (cancelled || done || phaseRef.current.kind !== "running") return; + const now = Date.now(); + if (firstUnreachableAt === null) { + firstUnreachableAt = now; + } else if (now - firstUnreachableAt >= DAEMON_DOWN_GRACE_MS) { + done = true; + UpdateSvc.Quit().catch(console.error); + return; + } + } + + if (!cancelled && !done) { + timer = setTimeout(poll, POLL_INTERVAL_MS); + } + }; + + timer = setTimeout(poll, POLL_INTERVAL_MS); + + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + }; + }, []); + + const isError = phase.kind !== "running"; + const errorInfo = isError ? classifyPhase(phase, version, t) : null; + const updatingHeading = version + ? t("update.overlay.updatingVersion", { version }) + : t("update.overlay.updating"); + + return ( + + {isError ? ( + + ) : ( + + )} + +
                                                + + {errorInfo ? errorInfo.title : updatingHeading} + + + {errorInfo ? ( + <> + {errorInfo.description} + {errorInfo.message && ( + <> +
                                                + + {errorInfo.message} + + + )} + + ) : ( + t("update.overlay.description") + )} +
                                                +
                                                + + {isError && ( + + + + )} +
                                                + ); +} + +function mapInstallError(msg: string): Phase { + const m = msg.trim().toLowerCase(); + if (m === "") return { kind: "failed", message: "" }; + if (m.includes("deadline exceeded") || m.includes("timeout") || m.includes("timed out")) { + return { kind: "timeout" }; + } + if (m.includes("canceled") || m.includes("cancelled") || m.includes("cancel")) { + return { kind: "canceled" }; + } + return { kind: "failed", message: msg }; +} + +type Variant = { title: string; description: string; message?: string }; + +function classifyPhase( + phase: Phase, + version: string, + t: (key: string, options?: Record) => string, +): Variant { + const target = version + ? t("update.overlay.error.targetVersion", { version }) + : t("update.overlay.error.targetFallback"); + switch (phase.kind) { + case "timeout": + return { + title: t("update.overlay.error.timeoutTitle"), + description: t("update.overlay.error.timeoutDescription", { target }), + }; + case "canceled": + return { + title: t("update.overlay.error.canceledTitle"), + description: t("update.overlay.error.canceledDescription", { target }), + }; + case "failed": + return { + title: t("update.overlay.error.failTitle"), + description: t("update.overlay.error.failDescription", { target }), + message: phase.message || t("update.overlay.error.unknownMessage"), + }; + default: + return { title: "", description: "" }; + } +} diff --git a/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx b/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx new file mode 100644 index 000000000..4861c492a --- /dev/null +++ b/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx @@ -0,0 +1,99 @@ +import { type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Browser } from "@wailsio/runtime"; +import { DownloadIcon, NotepadText } from "lucide-react"; +import { Update as UpdateSvc } from "@bindings/services"; +import { Button } from "@/components/buttons/Button"; +import { useClientVersion } from "@/contexts/ClientVersionContext"; +import { cn } from "@/lib/cn"; + +const GITHUB_RELEASES = "https://github.com/netbirdio/netbird/releases/latest"; + +function openUrl(url: string) { + Browser.OpenURL(url).catch(() => { + window.open(url, "_blank"); + }); +} + +function openInstallerDownload() { + UpdateSvc.DownloadURL() + .then(openUrl) + .catch(() => openUrl(GITHUB_RELEASES)); +} + +export function UpdateVersionCard() { + const { t } = useTranslation(); + const { updateVersion, enforced, triggerUpdate } = useClientVersion(); + + if (updateVersion) { + const titleKey = enforced + ? "update.card.versionAvailableInstall" + : "update.card.versionAvailableDownload"; + return ( + +
                                                + {t(titleKey, { version: updateVersion })} + + {t("update.card.whatsNew")} + +
                                                + {enforced ? ( + + ) : ( + + )} +
                                                + ); + } + + return ( + +
                                                + {t("update.card.onLatestVersion")} +

                                                {t("update.card.autoCheckInterval")}

                                                +
                                                + +
                                                + ); +} + +function Card({ children, className }: Readonly<{ children: ReactNode; className?: string }>) { + return ( +
                                                + {children} +
                                                + ); +} + +function Title({ children }: Readonly<{ children: ReactNode }>) { + return

                                                {children}

                                                ; +} + +function Link({ url, children }: Readonly<{ url: string; children: ReactNode }>) { + return ( + + ); +} diff --git a/client/ui/frontend/src/modules/error/ErrorDialog.tsx b/client/ui/frontend/src/modules/error/ErrorDialog.tsx new file mode 100644 index 000000000..4fbb78052 --- /dev/null +++ b/client/ui/frontend/src/modules/error/ErrorDialog.tsx @@ -0,0 +1,95 @@ +import { useCallback, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useSearchParams } from "react-router-dom"; +import { AlertCircleIcon } from "lucide-react"; +import { Button } from "@/components/buttons/Button"; +import { CopyToClipboard } from "@/components/CopyToClipboard"; +import { ConfirmDialog } from "@/components/dialog/ConfirmDialog"; +import { DialogActions } from "@/components/dialog/DialogActions"; +import { DialogDescription } from "@/components/dialog/DialogDescription"; +import { DialogHeading } from "@/components/dialog/DialogHeading"; +import { SquareIcon } from "@/components/SquareIcon"; +import { WindowManager } from "@bindings/services"; +import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; + +const WINDOW_WIDTH = 380; +// A command needs the room to wrap at a sensible number of characters instead of +// breaking every few words. +const WINDOW_WIDTH_WITH_COMMAND = 460; + +export default function ErrorDialog() { + const { t } = useTranslation(); + const [params] = useSearchParams(); + + const title = params.get("title") || t("window.title.error"); + const message = params.get("message") || ""; + // Set when the daemon refused an operation that needs elevated privileges: + // the command that performs it, offered for copying. + const command = params.get("command") || ""; + const contentRef = useAutoSizeWindow( + command ? WINDOW_WIDTH_WITH_COMMAND : WINDOW_WIDTH, + ); + + const close = useCallback(() => { + WindowManager.CloseError().catch(console.error); + }, []); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") close(); + }; + globalThis.addEventListener("keydown", onKey); + return () => globalThis.removeEventListener("keydown", onKey); + }, [close]); + + return ( + + + +
                                                + + {title} + + {message && ( + + {/* select-text: the message often names a path, a flag or an + address the user needs to act on. */} + + {message} + + + )} + {command && ( + + + {command} + + + )} +
                                                + + + + +
                                                + ); +} diff --git a/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx b/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx new file mode 100644 index 000000000..efbd1ee84 --- /dev/null +++ b/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx @@ -0,0 +1,90 @@ +import { useCallback, useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { useSearchParams } from "react-router-dom"; +import { Events } from "@wailsio/runtime"; +import { Loader2 } from "lucide-react"; +import { Connection } from "@bindings/services"; +import { Button } from "@/components/buttons/Button"; +import { ConfirmDialog } from "@/components/dialog/ConfirmDialog"; +import { DialogActions } from "@/components/dialog/DialogActions"; +import { DialogDescription } from "@/components/dialog/DialogDescription"; +import { DialogHeading } from "@/components/dialog/DialogHeading"; +import { SquareIcon } from "@/components/SquareIcon"; +import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; +import { errorDialog, formatErrorMessage } from "@/lib/errors"; + +const EVENT_CANCEL = "browser-login:cancel"; +const WINDOW_WIDTH = 360; + +export default function LoginWaitingForBrowserDialog() { + const { t } = useTranslation(); + const [params] = useSearchParams(); + const uri = params.get("uri") ?? ""; + const contentRef = useAutoSizeWindow(WINDOW_WIDTH); + const openedRef = useRef(false); + + const reportOpenFailure = useCallback( + (e: unknown) => { + void errorDialog({ + Title: t("browserLogin.openFailedTitle"), + Message: formatErrorMessage(e), + }); + }, + [t], + ); + + // Open the browser only after mount, or it lands on top of the still-hidden popup. + useEffect(() => { + if (!uri || openedRef.current) return; + openedRef.current = true; + Connection.OpenURL(uri).catch(reportOpenFailure); + }, [uri, reportOpenFailure]); + + const tryAgain = useCallback(() => { + if (!uri) return; + Connection.OpenURL(uri).catch(reportOpenFailure); + }, [uri, reportOpenFailure]); + + const cancel = useCallback(() => { + Events.Emit(EVENT_CANCEL).catch((err: unknown) => + console.error("emit browser-login cancel", err), + ); + }, []); + + return ( + + + +
                                                + + {t("browserLogin.title")} + + + {t("browserLogin.notSeeing")}{" "} + + +
                                                + + + + +
                                                + ); +} diff --git a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx new file mode 100644 index 000000000..c2a648278 --- /dev/null +++ b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx @@ -0,0 +1,410 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Events } from "@wailsio/runtime"; +import { Connection, WindowManager } from "@bindings/services"; +import { ToggleSwitch } from "@/components/switches/ToggleSwitch.tsx"; +import { useStatus } from "@/contexts/StatusContext.tsx"; +import { useProfile } from "@/contexts/ProfileContext.tsx"; +import { cn } from "@/lib/cn.ts"; +import { errorDialog, formatErrorMessage } from "@/lib/errors.ts"; +import { + startConnection, + EVENT_BROWSER_LOGIN_CANCEL, + EVENT_TRIGGER_LOGIN, +} from "@/lib/connection.ts"; +import { CopyToClipboard } from "@/components/CopyToClipboard"; +import { TruncatedText } from "@/components/TruncatedText"; +import { shortenDns } from "@/lib/formatters"; +import { contentTop } from "@/components/empty-state/EmptyState"; +import { useFocusVisible } from "@/hooks/useFocusVisible"; +import { Check as CheckIcon, ChevronDownIcon, Copy as CopyIcon } from "lucide-react"; +import * as Popover from "@radix-ui/react-popover"; +import netbirdFullLogo from "@/assets/logos/netbird-full.svg"; + +enum ConnectionState { + Disconnected = "disconnected", + Connecting = "connecting", + Connected = "connected", + Disconnecting = "disconnecting", +} + +const STATUS_KEY: Record = { + [ConnectionState.Disconnected]: "connect.status.disconnected", + [ConnectionState.Connecting]: "connect.status.connecting", + [ConnectionState.Connected]: "connect.status.connected", + [ConnectionState.Disconnecting]: "connect.status.disconnecting", +}; + +const NEEDS_LOGIN_STATES = new Set(["NeedsLogin", "SessionExpired", "LoginFailed"]); + +const FORCE_TOGGLE_DELAY_MS = 7000; + +const errorMessage = formatErrorMessage; + +export const MainConnectionStatusSwitch = () => { + const { t } = useTranslation(); + const { status, refresh } = useStatus(); + const { activeProfileId, username } = useProfile(); + + const daemonState = status?.status ?? "Idle"; + const needsLogin = NEEDS_LOGIN_STATES.has(daemonState); + const unreachable = daemonState === "DaemonUnavailable"; + + type Action = "connect" | "logging-in" | "disconnect" | null; + const [action, setAction] = useState(null); + + const loginGuard = useRef(false); + const driveLogin = useCallback(() => { + if (loginGuard.current) return; + loginGuard.current = true; + setAction("logging-in"); + void startConnection(() => { + loginGuard.current = false; + setAction(null); + refresh().catch((err: unknown) => console.error("refresh after login failed", err)); + }); + }, [refresh]); + + const connState: ConnectionState = useMemo(() => { + if (action === "disconnect" && daemonState === "Connected") { + return ConnectionState.Disconnecting; + } + if ((action === "connect" || action === "logging-in") && daemonState !== "Connected") { + return ConnectionState.Connecting; + } + switch (daemonState) { + case "Connected": + return ConnectionState.Connected; + case "Connecting": + return ConnectionState.Connecting; + case "Idle": + case "NeedsLogin": + case "LoginFailed": + case "SessionExpired": + case "DaemonUnavailable": + return ConnectionState.Disconnected; + default: + return ConnectionState.Disconnected; + } + }, [daemonState, action]); + + const connect = async () => { + setAction("connect"); + try { + await Connection.Up({ + profileName: activeProfileId, + username, + }); + await refresh(); + } catch (e) { + setAction(null); + await refresh(); + await errorDialog({ + Title: t("connect.error.connectTitle"), + Message: errorMessage(e), + }); + } + }; + + const disconnect = async () => { + setAction("disconnect"); + try { + await Connection.Down(); + await refresh(); + } catch (e) { + setAction(null); + await refresh(); + await errorDialog({ + Title: t("connect.error.disconnectTitle"), + Message: errorMessage(e), + }); + } + }; + + const sawConnectingRef = useRef(false); + + useEffect(() => { + if (action === null) { + sawConnectingRef.current = false; + return; + } + if (daemonState === "Connecting") { + sawConnectingRef.current = true; + } + if (action === "connect") { + if (needsLogin) { + driveLogin(); + return; + } + if (daemonState === "Connected" || unreachable) { + setAction(null); + return; + } + if (sawConnectingRef.current && daemonState === "Idle") { + setAction(null); + } + return; + } + if (action === "disconnect") { + if (daemonState === "Idle" || daemonState === "Disconnected" || unreachable) { + setAction(null); + } + } + }, [action, daemonState, needsLogin, unreachable, driveLogin]); + + useEffect(() => { + const off = Events.On(EVENT_TRIGGER_LOGIN, () => { + driveLogin(); + }); + return () => off(); + }, [driveLogin]); + + const handleSwitch = (next: boolean) => { + if (unreachable) return; + if (isTransitioning) { + if (canForceCancel) void forceCancel(); + return; + } + if (action !== null) return; + if (needsLogin) { + driveLogin(); + return; + } + if (next && connState === ConnectionState.Disconnected) { + void connect(); + } else if (!next && connState === ConnectionState.Connected) { + void disconnect(); + } + }; + + const isTransitioning = + connState === ConnectionState.Connecting || connState === ConnectionState.Disconnecting; + const isOn = + connState === ConnectionState.Connected || connState === ConnectionState.Connecting; + + const [canForceCancel, setCanForceCancel] = useState(false); + useEffect(() => { + if (!isTransitioning) { + setCanForceCancel(false); + return; + } + const id = setTimeout(() => setCanForceCancel(true), FORCE_TOGGLE_DELAY_MS); + return () => clearTimeout(id); + }, [isTransitioning]); + + const forceCancel = async () => { + if (action === "logging-in") { + Events.Emit(EVENT_BROWSER_LOGIN_CANCEL).catch((err: unknown) => + console.error("emit browser-login cancel failed", err), + ); + } + WindowManager.CloseBrowserLogin().catch((err: unknown) => + console.warn("close browser-login window failed", err), + ); + setAction("disconnect"); + try { + await Connection.Down(); + await refresh(); + } catch (e) { + setAction(null); + await refresh(); + await errorDialog({ + Title: t("connect.error.disconnectTitle"), + Message: errorMessage(e), + }); + } + }; + const show = connState === ConnectionState.Connected; + const fqdn = status?.local.fqdn || ""; + const ip = status?.local.ip || ""; + const ipv6 = status?.local.ipv6 || ""; + + return ( +
                                                + {"NetBird"} + + + +
                                                +

                                                + {t(STATUS_KEY[connState])} +

                                                + + + + +
                                                +
                                                + ); +}; + +const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boolean }) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const isFocusVisible = useFocusVisible(); + const hasV6 = !!ipv6; + + if (!hasV6) { + return ( + + + {ip || " "} + + + ); + } + + return ( +
                                                + + + + + + e.preventDefault()} + className={cn( + "z-50 min-w-64 max-w-[280px] overflow-hidden", + "rounded-lg border border-nb-gray-900 bg-nb-gray-935", + "p-1 text-nb-gray-200 shadow-lg outline-none", + "flex flex-col", + )} + > + +
                                                + + + + +
                                                + ); +}; + +const IpRow = ({ value }: { value: string }) => { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + const isFocusVisible = useFocusVisible(); + const handleClick = async () => { + if (!value) return; + try { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 500); + } catch (e) { + console.warn("copy IP to clipboard failed", e); + } + }; + return ( + + ); +}; diff --git a/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx b/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx new file mode 100644 index 000000000..fc7ce37b2 --- /dev/null +++ b/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx @@ -0,0 +1,256 @@ +import { forwardRef, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import * as Popover from "@radix-ui/react-popover"; +import * as ScrollArea from "@radix-ui/react-scroll-area"; +import { Command } from "cmdk"; +import { Check, ChevronsUpDown, type LucideProps, SquareArrowUpRight } from "lucide-react"; +import { cn } from "@/lib/cn"; +import { TruncatedText } from "@/components/TruncatedText"; +import { useNetworks } from "@/contexts/NetworksContext"; +import { useStatus } from "@/contexts/StatusContext"; +import { useFocusVisible } from "@/hooks/useFocusVisible"; + +const NONE_VALUE = "__none__"; + +export const MainExitNodeSwitcher = () => { + const { t } = useTranslation(); + const { status } = useStatus(); + const { exitNodes, toggleExitNode } = useNetworks(); + const active = exitNodes.find((n) => n.selected) ?? null; + const isConnected = status?.status === "Connected"; + const hasAny = exitNodes.length > 0; + const disabled = !isConnected || !hasAny; + + const [open, setOpen] = useState(false); + const listRef = useRef(null); + + const handleTriggerKeyDown = (e: React.KeyboardEvent) => { + if (open || disabled) return; + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + setOpen(true); + } + }; + + const handleSelect = (next: string) => { + setOpen(false); + if (next === NONE_VALUE) { + if (active) + toggleExitNode(active.id, true).catch((err: unknown) => + console.error("toggle exit node failed", err), + ); + return; + } + if (active?.id === next) return; + toggleExitNode(next, false).catch((err: unknown) => + console.error("toggle exit node failed", err), + ); + }; + + const title = active ? active.id : t("exitNodes.card.title"); + const activeDescription = active + ? t("exitNodes.card.statusActive") + : t("exitNodes.card.statusInactive"); + const description = hasAny ? activeDescription : t("exitNodes.empty.title"); + + return ( + + + + + + { + e.preventDefault(); + listRef.current?.focus(); + }} + style={{ width: "var(--radix-popover-trigger-width)" }} + className={cn( + "wails-no-draggable z-50 select-none overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg", + "data-[state=open]:animate-in data-[state=closed]:animate-out", + "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", + "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2", + "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + )} + > + e.stopPropagation()} + className={"outline-none focus:outline-none focus-visible:outline-none"} + > + + handleSelect(NONE_VALUE)} /> + {hasAny &&
                                                } + {hasAny && ( + + + {exitNodes.map((n) => ( + handleSelect(n.id)} + /> + ))} + + + + + + )} + + + + + + ); +}; + +type TriggerProps = React.ButtonHTMLAttributes & { + title: string; + description: string; + active?: boolean; +}; + +const ExitNodeTriggerCard = forwardRef( + function ExitNodeTriggerCard( + { title, description, disabled, active = false, className, ...props }, + ref, + ) { + const isFocusVisible = useFocusVisible(); + return ( + + ); + }, +); + +type NoneRowProps = { + isActive: boolean; + onSelect: () => void; +}; + +const NoneRow = ({ isActive, onSelect }: NoneRowProps) => { + const { t } = useTranslation(); + return ( + + {t("exitNodes.dropdown.noneTitle")} + {isActive && ( + + )} + + ); +}; + +type ExitNodeRowProps = { + id: string; + label: string; + isActive: boolean; + onSelect: () => void; +}; + +const ExitNodeRow = ({ id, label, isActive, onSelect }: ExitNodeRowProps) => ( + + {label} + {isActive && } + +); + +const ExitNodeIcon = ({ size, ...props }: LucideProps) => ( + +); diff --git a/client/ui/frontend/src/modules/main/MainHeader.tsx b/client/ui/frontend/src/modules/main/MainHeader.tsx new file mode 100644 index 000000000..becfde47c --- /dev/null +++ b/client/ui/frontend/src/modules/main/MainHeader.tsx @@ -0,0 +1,192 @@ +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + ArrowUpCircleIcon, + Check, + MoreVertical, + PanelsRightBottom, + RectangleVertical, + Settings, + type LucideIcon, +} from "lucide-react"; +import { WindowManager } from "@bindings/services"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@/components/DropdownMenu"; +import { IconButton } from "@/components/buttons/IconButton"; +import { ProfileDropdown } from "@/modules/profiles/ProfileDropdown"; +import { useClientVersion } from "@/contexts/ClientVersionContext"; +import { cn } from "@/lib/cn"; +import { formatShortcut, useKeyboardShortcut } from "@/hooks/useKeyboardShortcut"; +import { useViewMode, type ViewMode } from "@/contexts/ViewModeContext"; +import { useRestrictions } from "@/contexts/RestrictionsContext"; +import { isWindows } from "@/lib/platform.ts"; + +const SETTINGS_SHORTCUT = { key: ",", cmd: true } as const; + +export const MainHeader = () => { + const { t } = useTranslation(); + const [menuOpen, setMenuOpen] = useState(false); + const { viewMode, setViewMode } = useViewMode(); + const { updateAvailable } = useClientVersion(); + const { mdm, features } = useRestrictions(); + + const openSettings = useCallback(() => { + setMenuOpen(false); + WindowManager.OpenSettings("").catch((err: unknown) => + console.error("open settings window failed", err), + ); + }, []); + + useKeyboardShortcut(SETTINGS_SHORTCUT, openSettings); + + const openAbout = () => { + setMenuOpen(false); + WindowManager.OpenSettings("about").catch((err: unknown) => + console.error("open settings (about) window failed", err), + ); + }; + + const openManageProfiles = () => { + WindowManager.OpenSettings("profiles").catch((err: unknown) => + console.error("open settings (profiles) window failed", err), + ); + }; + + const selectMode = (mode: ViewMode) => { + setMenuOpen(false); + setViewMode(mode); + }; + + const profileSlot = features.disableProfiles ? null : ( + + ); + + const settingsSlot = ( +
                                                + + + + + + {updateAvailable && ( + <> + +
                                                + + + {t("header.menu.updateAvailable")} + +
                                                +
                                                + + + )} + +
                                                + + {t("header.menu.settings")} + + {formatShortcut(SETTINGS_SHORTCUT)} + +
                                                +
                                                + {!mdm.disableAdvancedView && ( + <> + + selectMode("default")} + /> + selectMode("advanced")} + /> + + )} +
                                                +
                                                + {updateAvailable && ( + + + + + )} +
                                                + ); + + return ( +
                                                + {/* Windows narrower width compensates for the OS frame Wails counts differently than macOS. + See https://github.com/wailsapp/wails/issues/3260 */} +
                                                +
                                                +
                                                {profileSlot}
                                                +
                                                +
                                                +
                                                {settingsSlot}
                                                +
                                                + ); +}; + +type ViewModeItemProps = { + icon: LucideIcon; + label: string; + selected: boolean; + onSelect: () => void; +}; + +const ViewModeItem = ({ icon: Icon, label, selected, onSelect }: ViewModeItemProps) => ( + +
                                                + + {label} + {selected && } +
                                                +
                                                +); diff --git a/client/ui/frontend/src/modules/main/MainPage.tsx b/client/ui/frontend/src/modules/main/MainPage.tsx new file mode 100644 index 000000000..c05b3a025 --- /dev/null +++ b/client/ui/frontend/src/modules/main/MainPage.tsx @@ -0,0 +1,114 @@ +import { MainConnectionStatusSwitch } from "@/modules/main/MainConnectionStatusSwitch.tsx"; +import { MainExitNodeSwitcher } from "@/modules/main/MainExitNodeSwitcher.tsx"; +import { MainHeader } from "@/modules/main/MainHeader.tsx"; +import { AppRightPanel } from "@/layouts/AppRightPanel.tsx"; +import { Navigation } from "@/modules/main/advanced/Navigation.tsx"; +import { cn } from "@/lib/cn"; +import { NavSectionProvider, useNavSection } from "@/contexts/NavSectionContext"; +import { ViewModeProvider, useViewMode } from "@/contexts/ViewModeContext"; +import { useEffect } from "react"; +import { NotConnectedState } from "@/components/empty-state/NotConnectedState"; +import { useStatus } from "@/contexts/StatusContext"; +import { Peers } from "@/modules/main/advanced/peers/Peers"; +import { Networks } from "@/modules/main/advanced/networks/Networks"; +import { NetworksProvider } from "@/contexts/NetworksContext"; +import { PeerDetailProvider, usePeerDetail } from "@/contexts/PeerDetailContext"; +import { useRestrictions } from "@/contexts/RestrictionsContext"; +import { PeerDetailPanel } from "@/modules/main/advanced/peers/PeerDetailPanel"; +import { isWindows } from "@/lib/platform.ts"; + +export const MainPage = () => { + return ( + + + + + + + + + ); +}; + +const MainBody = () => { + const { viewMode, setViewMode } = useViewMode(); + const { mdm, features } = useRestrictions(); + + // Force flip the view if MDM disabled advanced + useEffect(() => { + if (mdm.disableAdvancedView && viewMode === "advanced") { + setViewMode("default"); + } + }, [mdm.disableAdvancedView, viewMode, setViewMode]); + + const isAdvanced = viewMode === "advanced"; + + return ( +
                                                + {/* Windows narrower width compensates for the OS frame Wails counts differently than macOS. + See https://github.com/wailsapp/wails/issues/3260 */} +
                                                + + {!features.disableNetworks && ( +
                                                + +
                                                + )} +
                                                + {isAdvanced && ( + + + + )} +
                                                + ); +}; + +const AdvancedAppRightPanel = () => { + const { section } = useNavSection(); + const { selected } = usePeerDetail(); + const { status } = useStatus(); + const isConnected = status?.status === "Connected"; + + return ( + } + overlayOpen={selected !== null} + className={"m-5 ml-0"} + > +
                                                { + if (!el) return; + if (isConnected) el.removeAttribute("inert"); + else el.setAttribute("inert", ""); + }} + className={cn( + "flex min-h-0 min-w-0 flex-1 flex-col", + !isConnected && "pointer-events-none select-none", + )} + aria-hidden={!isConnected} + > + +
                                                + {section === "peers" && } + {section === "networks" && } +
                                                +
                                                + {!isConnected && ( +
                                                + +
                                                + )} +
                                                + ); +}; diff --git a/client/ui/frontend/src/modules/main/advanced/Navigation.tsx b/client/ui/frontend/src/modules/main/advanced/Navigation.tsx new file mode 100644 index 000000000..8d69f3580 --- /dev/null +++ b/client/ui/frontend/src/modules/main/advanced/Navigation.tsx @@ -0,0 +1,134 @@ +import { type ComponentType, type KeyboardEvent, useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { Layers3Icon, type LucideProps, MonitorSmartphoneIcon } from "lucide-react"; +import { cn } from "@/lib/cn"; +import { useNavSection, type NavSection } from "@/contexts/NavSectionContext"; +import { useStatus } from "@/contexts/StatusContext"; +import { useRestrictions } from "@/contexts/RestrictionsContext"; + +type TabEntry = { + value: NavSection; + label: string; + icon: ComponentType; +}; + +export const Navigation = () => { + const { t } = useTranslation(); + const { section, setSection } = useNavSection(); + const { status } = useStatus(); + const { features } = useRestrictions(); + const isConnected = status?.status === "Connected"; + + // Reset back to peers tab if mdm or feature flag flipped it + useEffect(() => { + if (features.disableNetworks && section === "networks") { + setSection("peers"); + } + }, [features.disableNetworks, section, setSection]); + + const tabs: TabEntry[] = [ + { + value: "peers", + label: t("nav.peers.title"), + icon: MonitorSmartphoneIcon, + }, + ]; + if (!features.disableNetworks) { + tabs.push({ + value: "networks", + label: t("nav.resources.title"), + icon: Layers3Icon, + }); + } + + const tabRefs = useRef>({}); + + const focusTab = (value: NavSection) => { + setSection(value); + requestAnimationFrame(() => tabRefs.current[value]?.focus()); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + const enabled = tabs.filter((t) => isConnected || t.value === section); + if (enabled.length < 2) return; + const currentIndex = enabled.findIndex((t) => t.value === section); + if (currentIndex === -1) return; + let nextIndex: number; + switch (e.key) { + case "ArrowRight": + nextIndex = (currentIndex + 1) % enabled.length; + break; + case "ArrowLeft": + nextIndex = (currentIndex - 1 + enabled.length) % enabled.length; + break; + case "Home": + nextIndex = 0; + break; + case "End": + nextIndex = enabled.length - 1; + break; + default: + return; + } + e.preventDefault(); + focusTab(enabled[nextIndex].value); + }; + + return ( +
                                                + {tabs.map((tab, index) => { + const isActive = tab.value === section; + const isDisabled = !isConnected && !isActive; + const isFirst = index === 0; + const isLast = index === tabs.length - 1; + const Icon = tab.icon; + return ( + + ); + })} +
                                                + ); +}; + +export type { NavSection } from "@/contexts/NavSectionContext"; diff --git a/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx b/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx new file mode 100644 index 000000000..ec1823f0b --- /dev/null +++ b/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { CheckIcon, ChevronDown, ListFilter } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/cn"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/DropdownMenu"; + +export type NetworkFilter = "all" | "active" | "overlapping"; + +type Props = { + value: NetworkFilter; + onChange: (value: NetworkFilter) => void; + counts: Record; + disabled?: boolean; +}; + +export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const filters: { value: NetworkFilter; label: string }[] = [ + { value: "all", label: t("networks.filter.all") }, + { value: "active", label: t("networks.filter.active") }, + { value: "overlapping", label: t("networks.filter.overlapping") }, + ]; + const active = filters.find((f) => f.value === value) ?? filters[0]; + + const handleSelect = (v: NetworkFilter) => { + onChange(v); + setOpen(false); + }; + + return ( + + + + + {active.label} ({counts[active.value]}) + + + + + {filters.map((f) => { + const checked = f.value === value; + return ( + handleSelect(f.value)} + role={"menuitemradio"} + aria-checked={checked} + className={"gap-2"} + > + + {f.label}{" "} + ({counts[f.value]}) + + + {checked && } + + + ); + })} + + + ); +}; diff --git a/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx new file mode 100644 index 000000000..f6fe23aaf --- /dev/null +++ b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx @@ -0,0 +1,528 @@ +import { + type KeyboardEvent, + useEffect, + useMemo, + useRef, + useState, + type ComponentType, + type ReactNode, +} from "react"; +import { useTranslation } from "react-i18next"; +import * as ScrollArea from "@radix-ui/react-scroll-area"; +import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; +import { GlobeIcon, Layers3Icon, type LucideProps, NetworkIcon, WorkflowIcon } from "lucide-react"; +import type { Network } from "@bindings/services/models.js"; +import { cn } from "@/lib/cn"; +import { reconcileOrder } from "@/lib/sorting"; +import { CopyToClipboard } from "@/components/CopyToClipboard"; +import { Tooltip } from "@/components/Tooltip"; +import { TruncatedText } from "@/components/TruncatedText"; +import { SearchInput } from "@/components/inputs/SearchInput"; +import { EmptyState } from "@/components/empty-state/EmptyState"; +import { NoResults } from "@/components/empty-state/NoResults"; +import { useStatus } from "@/contexts/StatusContext"; +import { useNetworks } from "@/contexts/NetworksContext"; +import { type NetworkFilter, NetworkFilters } from "./NetworkFilters"; + +// Daemon renders DNS-route prefixes (zero netip.Prefix) as "invalid Prefix". +const INVALID_PREFIX = "invalid Prefix"; + +const isDnsRoute = (n: Network): boolean => + n.domains.length > 0 && (!n.range || n.range === INVALID_PREFIX); + +type ResourceType = "host" | "subnet" | "domain"; + +const isHostCidr = (cidr: string): boolean => { + const [addr, bitsStr] = cidr.split("/"); + if (!addr || !bitsStr) return false; + const bits = Number(bitsStr); + const isV6 = addr.includes(":"); + return isV6 ? bits === 128 : bits === 32; +}; + +const resourceTypeOf = (n: Network): ResourceType => { + if (isDnsRoute(n)) return "domain"; + const primary = n.range.split(",")[0].trim(); + return isHostCidr(primary) ? "host" : "subnet"; +}; + +const resourceIconFor = (type: ResourceType): ComponentType => { + if (type === "host") return WorkflowIcon; + if (type === "domain") return GlobeIcon; + return NetworkIcon; +}; + +const buildOverlapMap = ( + routes: { id: string; range: string; domains: string[] }[], +): Map => { + const byRange = new Map(); + for (const r of routes) { + if (r.domains.length > 0) continue; + const arr = byRange.get(r.range) ?? []; + arr.push(r.id); + byRange.set(r.range, arr); + } + const out = new Map(); + for (const [range, ids] of byRange) { + if (ids.length > 1) out.set(range, ids); + } + return out; +}; + +export const Networks = () => { + const { t } = useTranslation(); + const { status } = useStatus(); + const isConnected = status?.status === "Connected"; + const { networkRoutes, toggleNetwork, setNetworksSelected } = useNetworks(); + const [search, setSearch] = useState(""); + const [filter, setFilter] = useState("all"); + const [scrollParent, setScrollParent] = useState(null); + const searchRef = useRef(null); + + useEffect(() => { + searchRef.current?.focus(); + }, []); + + const overlapGroups = useMemo(() => buildOverlapMap(networkRoutes), [networkRoutes]); + + const overlapById = useMemo(() => { + const map = new Map(); + for (const ids of overlapGroups.values()) { + for (const id of ids) map.set(id, ids); + } + return map; + }, [overlapGroups]); + + const counts = useMemo>( + () => ({ + all: networkRoutes.length, + active: networkRoutes.filter((r) => r.selected).length, + overlapping: overlapById.size, + }), + [networkRoutes, overlapById], + ); + + const orderRef = useRef([]); + const ordered = useMemo(() => { + const { order, items } = reconcileOrder( + orderRef.current, + networkRoutes, + (r) => r.id, + (a, b) => { + if (a.selected !== b.selected) return a.selected ? -1 : 1; + return a.id.localeCompare(b.id); + }, + ); + orderRef.current = order; + return items; + }, [networkRoutes]); + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + return ordered.filter((r) => { + if (filter === "active" && !r.selected) return false; + if (filter === "overlapping" && !overlapById.has(r.id)) return false; + if (q) { + const haystack = [r.id, r.range, ...r.domains].join(" ").toLowerCase(); + if (!haystack.includes(q)) return false; + } + return true; + }); + }, [ordered, search, filter, overlapById]); + + if (isConnected && networkRoutes.length === 0) { + return ( + + ); + } + + const selectedInView = filtered.filter((r) => r.selected).length; + const allSelected = filtered.length > 0 && selectedInView === filtered.length; + const bulkLabel = allSelected ? t("networks.bulk.disableAll") : t("networks.bulk.enableAll"); + + const onBulkClick = () => { + if (filtered.length === 0) return; + if (allSelected) { + setNetworksSelected( + filtered.map((r) => r.id), + false, + ).catch((err: unknown) => console.error("disable all networks failed", err)); + } else { + const ids = filtered.filter((r) => !r.selected).map((r) => r.id); + setNetworksSelected(ids, true).catch((err: unknown) => + console.error("enable all networks failed", err), + ); + } + }; + + return ( +
                                                +
                                                +
                                                + setSearch(e.target.value)} + /> +
                                                + +
                                                + {filtered.length === 0 ? ( + + ) : ( + + + {scrollParent && ( + + )} + + + + + + )} + {filtered.length > 0 && ( +
                                                + + {t("networks.bulk.selectionCount", { + selected: selectedInView, + total: filtered.length, + })} + + +
                                                + )} +
                                                + ); +}; + +type NetworksListProps = { + data: Network[]; + onToggle: (id: string, selected: boolean) => void; + scrollParent: HTMLElement; +}; + +const NetworksHeader = () =>
                                                ; + +const NetworksList = ({ data, onToggle, scrollParent }: NetworksListProps) => { + const virtuosoRef = useRef(null); + const rowRefs = useRef>(new Map()); + + const focusRow = (index: number) => { + if (index < 0 || index >= data.length) return; + const row = data[index]; + const tryFocus = () => { + const el = rowRefs.current.get(row.id); + if (el) { + el.focus(); + return true; + } + return false; + }; + if (!tryFocus()) { + virtuosoRef.current?.scrollToIndex({ index, behavior: "auto" }); + requestAnimationFrame(() => { + if (!tryFocus()) requestAnimationFrame(tryFocus); + }); + } + }; + + const handleRowKeyDown = (e: KeyboardEvent, index: number) => { + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + focusRow(Math.min(index + 1, data.length - 1)); + break; + case "ArrowUp": + e.preventDefault(); + focusRow(Math.max(index - 1, 0)); + break; + case "Home": + e.preventDefault(); + focusRow(0); + break; + case "End": + e.preventDefault(); + focusRow(data.length - 1); + break; + } + }; + + const setRowRef = (id: string, el: HTMLButtonElement | null) => { + if (el) rowRefs.current.set(id, el); + else rowRefs.current.delete(id); + }; + + const ctx = useMemo( + () => ({ onKeyDown: handleRowKeyDown, onToggle, setRowRef }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [data, onToggle], + ); + + return ( + + ref={virtuosoRef} + data={data} + customScrollParent={scrollParent} + increaseViewportBy={400} + computeItemKey={(_, n) => n.id} + components={{ Header: NetworksHeader }} + context={ctx} + itemContent={renderNetworkRow} + /> + ); +}; + +type NetworkRowContext = { + onKeyDown: (e: KeyboardEvent, index: number) => void; + onToggle: (id: string, selected: boolean) => void; + setRowRef: (id: string, el: HTMLButtonElement | null) => void; +}; + +const renderNetworkRow = (index: number, n: Network, ctx: NetworkRowContext): ReactNode => ( + +); + +type NetworkRowProps = { + network: Network; + index: number; + onKeyDown: (e: KeyboardEvent, index: number) => void; + onToggle: (id: string, selected: boolean) => void; + setRowRef: (id: string, el: HTMLButtonElement | null) => void; +}; + +const NetworkRow = ({ network: n, index, onKeyDown, onToggle, setRowRef }: NetworkRowProps) => { + const { t } = useTranslation(); + // Same handler is attached to the overlay button and to the network-id copy + // button so arrow nav works wherever focus sits inside the row. + const handleKey = (e: KeyboardEvent) => onKeyDown(e, index); + return ( +
                                                +
                                                + ); +}; + +const ResourceIconBadge = ({ type }: { type: ResourceType }) => { + const Icon = resourceIconFor(type); + return ( +
                                                + +
                                                + ); +}; + +type SubtitleProps = { + network: Network; + onKeyDown: (e: KeyboardEvent) => void; +}; + +const Subtitle = ({ network, onKeyDown }: SubtitleProps) => { + if (isDnsRoute(network)) { + const domain = network.domains[0]; + const ips = network.resolvedIps[domain] ?? []; + return ; + } + + if (network.range && network.range !== INVALID_PREFIX) { + return ( +
                                                + + + +
                                                + ); + } + + return null; +}; + +type DomainSubtitleProps = { + domain: string; + ips: string[]; + onKeyDown: (e: KeyboardEvent) => void; +}; + +const DomainSubtitle = ({ domain, ips, onKeyDown }: DomainSubtitleProps) => { + const span = ( + + {domain} + + ); + return ( +
                                                + + {ips.length > 0 ? ( + } + delayDuration={300} + closeDelay={300} + side={"right"} + align={"start"} + alignOffset={-8} + interactive + keepOpenOnClick + contentClassName={cn( + "max-h-72 max-w-[18rem] overflow-auto", + "rounded-lg border border-nb-gray-900 bg-nb-gray-935", + "p-2 pr-4", + )} + > + {span} + + ) : ( + span + )} + +
                                                + ); +}; + +const ResolvedIpsTooltip = ({ ips }: { ips: string[] }) => { + const { t } = useTranslation(); + return ( + <> +
                                                + {t("networks.ips.heading")} +
                                                +
                                                  + {ips.map((ip) => ( +
                                                • + + + {ip} + + +
                                                • + ))} +
                                                + + ); +}; + +type ToggleProps = { + checked: boolean; + mixed?: boolean; +}; + +const NetworkToggle = ({ checked, mixed }: ToggleProps) => { + const checkedTranslate = checked ? "translate-x-[1.125rem]" : "translate-x-0.5"; + return ( + + + + ); +}; diff --git a/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx b/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx new file mode 100644 index 000000000..16500589d --- /dev/null +++ b/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx @@ -0,0 +1,575 @@ +import { + type ComponentType, + Fragment, + type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { AnimatePresence, motion, type Transition } from "framer-motion"; +import * as Popover from "@radix-ui/react-popover"; +import * as ScrollArea from "@radix-ui/react-scroll-area"; +import { + ArrowDownIcon, + ArrowLeftIcon, + ArrowUpDownIcon, + ArrowUpIcon, + Check as CheckIcon, + ChevronDownIcon, + ChevronsLeftRightEllipsisIcon, + ClockIcon, + Copy as CopyIcon, + GaugeIcon, + HandshakeIcon, + KeyRoundIcon, + Layers3Icon, + type LucideProps, + MapPinIcon, + MonitorIcon, + Radio, + RefreshCwIcon, + WaypointsIcon, +} from "lucide-react"; +import type { PeerStatus } from "@bindings/services/models.js"; +import { cn } from "@/lib/cn"; +import { CopyToClipboard } from "@/components/CopyToClipboard"; +import { Tooltip } from "@/components/Tooltip"; +import { TruncatedText } from "@/components/TruncatedText"; +import { formatBytes, formatRelative, latencyColor, shortenDns } from "@/lib/formatters"; +import { useStatus } from "@/contexts/StatusContext"; +import { usePeerDetail } from "@/contexts/PeerDetailContext"; +import { useFocusVisible } from "@/hooks/useFocusVisible"; +import { peerStatusLabelKey } from "./Peers"; + +const DEFAULT_TRANSITION: Transition = { + duration: 0.32, + ease: [0.32, 0.72, 0, 1], +}; + +const DASH = "-"; + +const dotClass = (connStatus: string): string => { + switch (connStatus) { + case "Connected": + return "bg-green-400"; + case "Connecting": + return "bg-yellow-300 animate-pulse-slow"; + default: + return "bg-nb-gray-500"; + } +}; + +type Props = { + transition?: Transition; +}; + +export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { + const { t } = useTranslation(); + const { selected, setSelected } = usePeerDetail(); + const { status, refresh } = useStatus(); + + useEffect(() => { + if (!selected) return; + const peers = status?.peers ?? []; + const fresh = peers.find((p) => p.pubKey === selected.pubKey); + if (!fresh) { + setSelected(null); + return; + } + if (fresh !== selected) setSelected(fresh); + }, [status, selected, setSelected]); + + // Daemon updates latency/bytes/handshake without pushing a fresh status + // snapshot, so tick locally to keep relative timestamps live. + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!selected) return; + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, [selected]); + + const [refreshing, setRefreshing] = useState(false); + const onRefresh = useCallback(async () => { + if (refreshing) return; + setRefreshing(true); + const MIN_SPIN_MS = 600; + const minDelay = new Promise((r) => setTimeout(r, MIN_SPIN_MS)); + try { + await Promise.all([refresh(), minDelay]); + } finally { + setRefreshing(false); + } + }, [refresh, refreshing]); + + useEffect(() => { + if (!selected) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setSelected(null); + return; + } + if (e.key === "ArrowLeft") { + const target = e.target as HTMLElement | null; + const tag = target?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) return; + setSelected(null); + } + }; + globalThis.addEventListener("keydown", onKey); + return () => globalThis.removeEventListener("keydown", onKey); + }, [selected, setSelected]); + + const dialogRef = useRef(null); + const backButtonRef = useRef(null); + + useEffect(() => { + if (!selected) return; + // Defer focus until the slide-in animation has started rendering. + // preventScroll avoids the browser scrolling the parent to chase the + // still-offscreen button, which lands as a stutter at the end of the slide. + requestAnimationFrame(() => backButtonRef.current?.focus({ preventScroll: true })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selected?.pubKey]); + + const getFocusable = (): HTMLElement[] => { + const root = dialogRef.current; + if (!root) return []; + const sel = + "button:not([disabled]), [href], input:not([disabled]), select:not([disabled])," + + ' textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + return Array.from(root.querySelectorAll(sel)).filter( + (el) => el.offsetParent !== null || el === document.activeElement, + ); + }; + + const onDialogKeyDown = (e: ReactKeyboardEvent) => { + if (e.key !== "Tab") return; + const focusables = getFocusable(); + if (focusables.length === 0) return; + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + const active = document.activeElement as HTMLElement | null; + if (e.shiftKey) { + if (active === first || !active || !dialogRef.current?.contains(active)) { + e.preventDefault(); + last.focus(); + } + } else if (active === last) { + e.preventDefault(); + first.focus(); + } + }; + + return ( + + {selected && ( + +
                                                + + + + + + + {shortenDns(selected.fqdn) || selected.ip} + + + + + +
                                                + + + + + + + + +
                                                + )} +
                                                + ); +}; + +const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => { + const { t } = useTranslation(); + const formatAge = (unix: number, fallback: string): string => { + if (!Number.isFinite(unix) || unix <= 0) return fallback; + const diff = Math.floor(now / 1000 - unix); + if (diff < 1) return t("peers.details.justNow"); + return formatRelative(unix, now) ?? fallback; + }; + const lastHandshake = formatAge(peer.lastHandshakeUnix, t("peers.details.never")); + const statusSince = formatAge(peer.connStatusUpdateUnix, DASH); + const isConnected = peer.connStatus === "Connected"; + const connectionLabel = peer.relayed ? t("peers.details.relayed") : t("peers.details.p2p"); + + return ( +
                                                  + + {peer.ip ? ( + + {peer.ip} + + ) : ( + DASH + )} + + {peer.ipv6 && ( + + + + + + )} + {isConnected && ( + + {connectionLabel} + + )} + {peer.relayed && ( + + {peer.relayAddress ? ( + + + + ) : ( + DASH + )} + + )} + {peer.latencyMs > 0 && ( + + + {peer.latencyMs} ms + + + )} + {(peer.bytesRx > 0 || peer.bytesTx > 0) && ( + +
                                                  +
                                                  + + {t("peers.details.bytesReceived")}: + {formatBytes(peer.bytesRx)} +
                                                  +
                                                  + + {t("peers.details.bytesSent")}: + {formatBytes(peer.bytesTx)} +
                                                  +
                                                  +
                                                  + )} + + {lastHandshake} + + + {statusSince} + + {peer.networks.length > 0 && ( + + + + )} + + + + {peer.pubKey ? ( + + + + ) : ( + DASH + )} + +
                                                + ); +}; + +type RowProps = { + icon: ComponentType; + iconClassName?: string; + label: string; + children: ReactNode; +}; + +type IceRowProps = { + icon: ComponentType; + baseLabel: string; + type: string; + endpoint: string; +}; + +const capitalize = (s: string): string => (s ? s[0].toUpperCase() + s.slice(1) : s); + +const IceRow = ({ icon, baseLabel, type, endpoint }: IceRowProps) => { + if (!type && !endpoint) return null; + const label = type ? `${baseLabel} (${capitalize(type)})` : baseLabel; + return ( + + {endpoint ? ( + + + + ) : ( + {capitalize(type)} + )} + + ); +}; + +const ResourcesValue = ({ networks }: { networks: string[] }) => ( + +); + +const ResourcesPopover = ({ networks }: { networks: string[] }) => { + const [open, setOpen] = useState(false); + + return ( + + + + + + e.preventDefault()} + className={cn( + "z-50 max-h-72 min-w-64 max-w-[280px] overflow-auto", + "rounded-lg border border-nb-gray-900 bg-nb-gray-935", + "p-1 text-nb-gray-200 shadow-lg outline-none", + "flex flex-col", + )} + > + {networks.map((n, i) => ( + + {i > 0 &&
                                                } + + + ))} + + + + ); +}; + +const ResourceRow = ({ value }: { value: string }) => { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + const isFocusVisible = useFocusVisible(); + const handleClick = async () => { + if (!value) return; + try { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 500); + } catch (e) { + console.warn("copy resource to clipboard failed", e); + } + }; + return ( + + ); +}; + +const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) => ( + +); + +const Row = ({ icon: Icon, iconClassName, label, children }: RowProps) => ( +
                                              • + + {label} + + {children} + +
                                              • +); diff --git a/client/ui/frontend/src/modules/main/advanced/peers/PeerFilters.tsx b/client/ui/frontend/src/modules/main/advanced/peers/PeerFilters.tsx new file mode 100644 index 000000000..25d9ef822 --- /dev/null +++ b/client/ui/frontend/src/modules/main/advanced/peers/PeerFilters.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { CheckIcon, ChevronDown, ListFilter } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/cn"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/DropdownMenu"; + +export type StatusFilter = "all" | "online" | "offline"; + +type Props = { + value: StatusFilter; + onChange: (value: StatusFilter) => void; + counts: Record; + disabled?: boolean; +}; + +export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const filters: { value: StatusFilter; label: string }[] = [ + { value: "all", label: t("peers.filter.all") }, + { value: "online", label: t("peers.filter.online") }, + { value: "offline", label: t("peers.filter.offline") }, + ]; + const active = filters.find((f) => f.value === value) ?? filters[0]; + + const handleSelect = (v: StatusFilter) => { + onChange(v); + setOpen(false); + }; + + return ( + + + + + {active.label} ({counts[active.value]}) + + + + + {filters.map((f) => { + const checked = f.value === value; + return ( + handleSelect(f.value)} + role={"menuitemradio"} + aria-checked={checked} + className={"gap-2"} + > + + {f.label}{" "} + ({counts[f.value]}) + + + {checked && } + + + ); + })} + + + ); +}; diff --git a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx new file mode 100644 index 000000000..d61221758 --- /dev/null +++ b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx @@ -0,0 +1,353 @@ +import { type KeyboardEvent, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import * as ScrollArea from "@radix-ui/react-scroll-area"; +import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; +import { ChevronRightIcon, MonitorSmartphoneIcon } from "lucide-react"; +import type { PeerStatus } from "@bindings/services/models.js"; +import { cn } from "@/lib/cn"; +import { reconcileOrder } from "@/lib/sorting"; +import { CopyToClipboard } from "@/components/CopyToClipboard"; +import { SearchInput } from "@/components/inputs/SearchInput"; +import { EmptyState } from "@/components/empty-state/EmptyState"; +import { NoResults } from "@/components/empty-state/NoResults"; +import { latencyColor, shortenDns } from "@/lib/formatters"; +import { useStatus } from "@/contexts/StatusContext"; +import { usePeerDetail } from "@/contexts/PeerDetailContext"; +import { Tooltip } from "@/components/Tooltip"; +import { TruncatedText } from "@/components/TruncatedText"; +import { PeerFilters, type StatusFilter } from "./PeerFilters"; + +const isOnline = (connStatus: string) => connStatus === "Connected"; + +const dotClass = (connStatus: string): string => { + switch (connStatus) { + case "Connected": + return "bg-green-400"; + case "Connecting": + return "bg-yellow-300 animate-pulse-slow"; + default: + return "bg-nb-gray-500"; + } +}; + +export const peerStatusLabelKey = (connStatus: string): string => { + switch (connStatus) { + case "Connected": + return "peers.status.connected"; + case "Connecting": + return "peers.status.connecting"; + default: + return "peers.status.disconnected"; + } +}; + +export const Peers = () => { + const { t } = useTranslation(); + const { status } = useStatus(); + const [search, setSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [scrollParent, setScrollParent] = useState(null); + const searchRef = useRef(null); + + useEffect(() => { + searchRef.current?.focus(); + }, []); + + const isConnected = status?.status === "Connected"; + const peers = useMemo(() => status?.peers ?? [], [status?.peers]); + + const counts = useMemo>(() => { + const online = peers.filter((p) => isOnline(p.connStatus)).length; + return { + all: peers.length, + online, + offline: peers.length - online, + }; + }, [peers]); + + // Stay in live-sort until every peer is stable. Right after Up the daemon + // emits all peers as "Connecting"; committing then would lock that + // alphabetical-only order forever. + const orderRef = useRef([]); + const stickyRef = useRef(false); + const ordered = useMemo(() => { + const compare = (a: PeerStatus, b: PeerStatus) => { + const aOnline = isOnline(a.connStatus); + const bOnline = isOnline(b.connStatus); + if (aOnline !== bOnline) return aOnline ? -1 : 1; + const aName = (a.fqdn || a.ip).toLowerCase(); + const bName = (b.fqdn || b.ip).toLowerCase(); + return aName.localeCompare(bName); + }; + + if (peers.length === 0) { + orderRef.current = []; + stickyRef.current = false; + return []; + } + + if (!stickyRef.current) { + const sorted = [...peers].sort(compare); + if (peers.every((p) => p.connStatus !== "Connecting")) { + orderRef.current = sorted.map((p) => p.pubKey); + stickyRef.current = true; + } + return sorted; + } + + const { order, items } = reconcileOrder(orderRef.current, peers, (p) => p.pubKey, compare); + orderRef.current = order; + return items; + }, [peers]); + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + return ordered.filter((p) => { + if (statusFilter === "online" && !isOnline(p.connStatus)) return false; + if (statusFilter === "offline" && isOnline(p.connStatus)) return false; + return !q || p.fqdn.toLowerCase().includes(q) || p.ip.includes(q); + }); + }, [ordered, search, statusFilter]); + + if (isConnected && peers.length === 0) { + return ( + + ); + } + + return ( +
                                                +
                                                +
                                                + setSearch(e.target.value)} + /> +
                                                + +
                                                + {filtered.length === 0 ? ( + + ) : ( + + + {scrollParent && } + + + + + + )} +
                                                + ); +}; + +const ListTopSpacer = () =>
                                                ; + +type PeersListProps = { + data: PeerStatus[]; + scrollParent: HTMLElement; +}; + +const PeersList = ({ data, scrollParent }: PeersListProps) => { + const { setSelected } = usePeerDetail(); + const virtuosoRef = useRef(null); + const rowRefs = useRef>(new Map()); + + const focusRow = (index: number) => { + if (index < 0 || index >= data.length) return; + const peer = data[index]; + const tryFocus = () => { + const el = rowRefs.current.get(peer.pubKey); + if (el) { + el.focus(); + return true; + } + return false; + }; + if (!tryFocus()) { + virtuosoRef.current?.scrollToIndex({ index, behavior: "auto" }); + // Row may not be mounted yet — retry after Virtuoso renders it. + requestAnimationFrame(() => { + if (!tryFocus()) requestAnimationFrame(tryFocus); + }); + } + }; + + const handleRowKeyDown = (e: KeyboardEvent, index: number) => { + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + focusRow(Math.min(index + 1, data.length - 1)); + break; + case "ArrowUp": + e.preventDefault(); + focusRow(Math.max(index - 1, 0)); + break; + case "ArrowRight": + e.preventDefault(); + setSelected(data[index]); + break; + case "Home": + e.preventDefault(); + focusRow(0); + break; + case "End": + e.preventDefault(); + focusRow(data.length - 1); + break; + } + }; + + const setRowRef = (pubKey: string, el: HTMLButtonElement | null) => { + if (el) rowRefs.current.set(pubKey, el); + else rowRefs.current.delete(pubKey); + }; + + const ctx = useMemo( + () => ({ onKeyDown: handleRowKeyDown, onSelect: setSelected, setRowRef }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [data, setSelected], + ); + + return ( + + ref={virtuosoRef} + data={data} + customScrollParent={scrollParent} + increaseViewportBy={400} + computeItemKey={(_, peer) => peer.pubKey} + components={{ Header: ListTopSpacer }} + context={ctx} + itemContent={renderPeerRow} + /> + ); +}; + +type PeerRowContext = { + onKeyDown: (e: KeyboardEvent, index: number) => void; + onSelect: (peer: PeerStatus) => void; + setRowRef: (pubKey: string, el: HTMLButtonElement | null) => void; +}; + +const renderPeerRow = (index: number, peer: PeerStatus, ctx: PeerRowContext): ReactNode => ( + +); + +type PeerRowProps = { + peer: PeerStatus; + index: number; + onKeyDown: (e: KeyboardEvent, index: number) => void; + onSelect: (peer: PeerStatus) => void; + setRowRef: (pubKey: string, el: HTMLButtonElement | null) => void; +}; + +const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps) => { + const { t } = useTranslation(); + const isConnected = peer.connStatus === "Connected"; + const peerName = shortenDns(peer.fqdn) || peer.ip; + const statusLabel = t(peerStatusLabelKey(peer.connStatus)); + const handleKey = (e: KeyboardEvent) => onKeyDown(e, index); + return ( +
                                                +
                                                + ); +}; diff --git a/client/ui/frontend/src/modules/profiles/ProfileAvatar.tsx b/client/ui/frontend/src/modules/profiles/ProfileAvatar.tsx new file mode 100644 index 000000000..70d45acc5 --- /dev/null +++ b/client/ui/frontend/src/modules/profiles/ProfileAvatar.tsx @@ -0,0 +1,76 @@ +import { type ButtonHTMLAttributes, forwardRef } from "react"; +import { + Briefcase, + Building, + Cloud, + Construction, + FlaskConical, + Gamepad2, + GraduationCap, + House, + Radio, + Server, + SquareCode, + Terminal, + UserCircle, + UserPlus, + Users, + type LucideIcon, +} from "lucide-react"; +import { cn } from "@/lib/cn"; + +// Scanned in order — put more-specific tokens first (e.g. "staging" before "stage"). +const ICON_MAP: ReadonlyArray<[RegExp, LucideIcon]> = [ + [/(default|personal)/i, UserCircle], + [/(work|business|office|company|corp|corporate)/i, Briefcase], + [/(home|house|private)/i, House], + [/(dev|development|developer|code|coding|engineering)/i, SquareCode], + [/(local|localhost|loopback)/i, Terminal], + [/(stage|staging|preprod|pre-prod)/i, Construction], + [/(test|testing|qa)/i, FlaskConical], + [/(prod|production)/i, Cloud], + [/(live)/i, Radio], + [/(selfhosted|self-hosted|on-prem|onprem)/i, Server], + [/(school|university|edu|study|student)/i, GraduationCap], + [/(client|customer)/i, Building], + [/(family)/i, Users], + [/(gaming|game)/i, Gamepad2], + [/(guest)/i, UserPlus], +]; + +export const pickProfileIcon = (name: string | undefined): LucideIcon | null => { + if (!name) return null; + for (const [pattern, Icon] of ICON_MAP) { + if (pattern.test(name)) return Icon; + } + return null; +}; + +type Props = ButtonHTMLAttributes & { + name?: string; + size?: number; +}; + +export const ProfileAvatar = forwardRef(function ProfileAvatar( + { name = "", size = 28, className, type = "button", ...props }, + ref, +) { + const Icon = pickProfileIcon(name) ?? UserCircle; + return ( + + ); +}); diff --git a/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx b/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx new file mode 100644 index 000000000..19313ccb3 --- /dev/null +++ b/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx @@ -0,0 +1,263 @@ +import { type FormEvent, useEffect, useId, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import * as Dialog from "@/components/dialog/Dialog"; +import { Input } from "@/components/inputs/Input"; +import { Button } from "@/components/buttons/Button"; +import { DialogActions } from "@/components/dialog/DialogActions"; +import { Label } from "@/components/typography/Label"; +import { HelpText } from "@/components/typography/HelpText"; +import { ManagementServerSwitch } from "@/components/ManagementServerSwitch"; +import { + CLOUD_MANAGEMENT_URL, + ManagementMode, + checkManagementUrlReachable, + isValidManagementUrl, + normalizeManagementUrl, +} from "@/hooks/useManagementUrl"; +import { useRestrictions } from "@/contexts/RestrictionsContext.tsx"; + +export type ProfileFormInitial = { + name: string; + managementUrl: string; +}; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (name: string, managementUrl: string) => void | Promise; + initial?: ProfileFormInitial; +}; + +const MAX_PROFILE_NAME_LEN = 128; + +export const ProfileCreationModal = ({ open, onOpenChange, onSubmit, initial }: Props) => { + const { t } = useTranslation(); + const { mdm } = useRestrictions(); + const managedManagementUrl = mdm.managementURL; + const nameId = useId(); + const urlId = useId(); + const isEdit = !!initial; + const initialModeFromUrl = (u: string): ManagementMode => + u && u !== CLOUD_MANAGEMENT_URL ? ManagementMode.SelfHosted : ManagementMode.Cloud; + const initialSelfHostedUrl = (u: string): string => (u && u !== CLOUD_MANAGEMENT_URL ? u : ""); + + const [name, setName] = useState(initial?.name ?? ""); + const [nameError, setNameError] = useState(null); + const nameRef = useRef(null); + + const [mode, setMode] = useState( + initial ? initialModeFromUrl(initial.managementUrl) : ManagementMode.Cloud, + ); + const [url, setUrl] = useState(initial ? initialSelfHostedUrl(initial.managementUrl) : ""); + const [urlError, setUrlError] = useState(null); + const [unreachable, setUnreachable] = useState(false); + const [checking, setChecking] = useState(false); + const urlRef = useRef(null); + + useEffect(() => { + if (open) { + setName(initial?.name ?? ""); + setMode(initial ? initialModeFromUrl(initial.managementUrl) : ManagementMode.Cloud); + setUrl(initial ? initialSelfHostedUrl(initial.managementUrl) : ""); + setNameError(null); + setUrlError(null); + setUnreachable(false); + setChecking(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, initial?.name, initial?.managementUrl]); + + const initialModeRef = useRef(ManagementMode.Cloud); + useEffect(() => { + if (!open) return; + initialModeRef.current = mode; + const id = globalThis.setTimeout(() => { + nameRef.current?.focus(); + nameRef.current?.select(); + }, 0); + return () => globalThis.clearTimeout(id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + // When the user toggles to Self-hosted inside the dialog (not on initial + // open), move focus to the URL input so they can start typing immediately. + useEffect(() => { + if (!open) return; + if (mode === initialModeRef.current) return; + if (mode !== ManagementMode.SelfHosted) return; + urlRef.current?.focus(); + }, [open, mode]); + + useEffect(() => { + setUrlError(null); + setUnreachable(false); + }, [url, mode]); + + const resolveTargetUrl = (): { url: string; needsReachCheck: boolean } | null => { + if (managedManagementUrl) { + return { url: managedManagementUrl, needsReachCheck: false }; + } + if (mode === ManagementMode.Cloud) { + return { url: CLOUD_MANAGEMENT_URL, needsReachCheck: false }; + } + const trimmed = url.trim(); + if (!trimmed || !isValidManagementUrl(trimmed)) { + setUrlError(t("settings.general.management.urlError")); + urlRef.current?.focus(); + return null; + } + const target = normalizeManagementUrl(trimmed); + + const unchanged = target === initial?.managementUrl; + return { url: target, needsReachCheck: !unchanged }; + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + if (checking) return; + + const sanitized = name.trim(); + if (sanitized.length === 0) { + setNameError(t("profile.dialog.required")); + nameRef.current?.focus(); + return; + } + + const target = resolveTargetUrl(); + if (!target) return; + + if (target.needsReachCheck) { + setChecking(true); + const reachable = await checkManagementUrlReachable(target.url); + setChecking(false); + if (!reachable && !unreachable) { + setUnreachable(true); + return; + } + } + + await onSubmit(sanitized, target.url); + onOpenChange(false); + }; + + const handleNameChange = (value: string) => { + setName(value); + if (nameError) setNameError(null); + }; + + const trimmedUrl = url.trim(); + const showUrlSyntaxError = + mode === ManagementMode.SelfHosted && + trimmedUrl !== "" && + !isValidManagementUrl(trimmedUrl); + const urlInputError = showUrlSyntaxError + ? t("settings.general.management.urlError") + : (urlError ?? undefined); + const urlInputWarning = + !urlInputError && unreachable ? t("profile.dialog.urlUnreachable") : undefined; + + return ( + + { + e.preventDefault(); + // Focus + select-all so editing an existing name is one + // keystroke away from overwriting it. + nameRef.current?.focus(); + nameRef.current?.select(); + }} + > +
                                                +
                                                +
                                                +
                                                + + + {t("profile.dialog.description")} + +
                                                + handleNameChange(e.target.value)} + error={nameError ?? undefined} + maxLength={MAX_PROFILE_NAME_LEN} + spellCheck={false} + autoComplete={"off"} + autoCapitalize={"off"} + /> +
                                                + + {!managedManagementUrl && ( +
                                                +
                                                + + + {t("profile.dialog.managementHelp")} + +
                                                +
                                                + + {mode === ManagementMode.SelfHosted && ( + setUrl(e.target.value)} + error={urlInputError} + warning={urlInputWarning} + spellCheck={false} + autoComplete={"off"} + autoCorrect={"off"} + autoCapitalize={"off"} + /> + )} +
                                                +
                                                + )} + + + + + +
                                                +
                                                +
                                                +
                                                + ); +}; diff --git a/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx b/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx new file mode 100644 index 000000000..e76e300c1 --- /dev/null +++ b/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx @@ -0,0 +1,293 @@ +import { forwardRef, useLayoutEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import * as Popover from "@radix-ui/react-popover"; +import * as ScrollArea from "@radix-ui/react-scroll-area"; +import { Command } from "cmdk"; +import { Check, ChevronDown, Settings2, UserCircle } from "lucide-react"; +import { pickProfileIcon } from "@/modules/profiles/ProfileAvatar"; +import type { Profile } from "@bindings/services/models.js"; +import { Tooltip } from "@/components/Tooltip"; +import { useProfile } from "@/contexts/ProfileContext"; +import { useFocusVisible } from "@/hooks/useFocusVisible"; +import { cn } from "@/lib/cn"; +import { errorDialog, formatErrorMessage } from "@/lib/errors"; + +type ProfileDropdownProps = { + onManageProfiles?: () => void; +}; + +const MANAGE_VALUE = "__manage_profiles__"; + +export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => { + const { t } = useTranslation(); + const { activeProfile, activeProfileId, profiles, switchProfile, loaded } = useProfile(); + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState(false); + const listRef = useRef(null); + + const handleTriggerKeyDown = (e: React.KeyboardEvent) => { + if (open) return; + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + setOpen(true); + } + }; + + const sortedProfiles = [...profiles].sort((a, b) => { + if (a.id === activeProfileId) return -1; + if (b.id === activeProfileId) return 1; + return a.name.localeCompare(b.name); + }); + + const guarded = async (title: string, fn: () => Promise) => { + if (busy) return; + setBusy(true); + try { + await fn(); + } catch (e) { + await errorDialog({ + Title: title, + Message: formatErrorMessage(e), + }); + } finally { + setBusy(false); + } + }; + + const handleSelect = (id: string) => { + setOpen(false); + if (id === activeProfileId) return; + void guarded(t("profile.error.switchTitle"), () => switchProfile(id)); + }; + + const handleManage = () => { + setOpen(false); + onManageProfiles?.(); + }; + + if (!loaded) return ; + + const hasProfile = !!activeProfileId; + const activeFromList = profiles.find((p) => p.id === activeProfileId)?.name; + const displayName = hasProfile + ? (activeFromList ?? activeProfile) + : t("profile.selector.noProfile"); + + return ( + + + + + + { + e.preventDefault(); + listRef.current?.focus(); + }} + className={cn( + "wails-no-draggable z-50 min-w-64 select-none overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg", + "data-[state=open]:animate-in data-[state=closed]:animate-out", + "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", + "data-[side=bottom]:origin-top data-[side=top]:origin-bottom", + "data-[side=left]:origin-right data-[side=right]:origin-left", + "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2", + "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + )} + > + e.stopPropagation()} + className={"outline-none focus:outline-none focus-visible:outline-none"} + > + + {sortedProfiles.length > 0 && ( + <> + + + {sortedProfiles.map((profile) => ( + + ))} + + + + + +
                                                + + )} + +
                                                + + + + {t("profile.dropdown.manageProfiles")} + + +
                                                + + + + + + ); +}; + +const ProfileTriggerSkeleton = () => ( +
                                                +
                                                +
                                                +
                                                +); + +type ProfileTriggerButtonProps = React.ButtonHTMLAttributes & { + name: string; +}; + +const ProfileTriggerButton = forwardRef( + function ProfileTriggerButton({ name, className, disabled, ...props }, ref) { + const { t } = useTranslation(); + const isFocusVisible = useFocusVisible(); + const Icon = pickProfileIcon(name) ?? UserCircle; + return ( + + ); + }, +); + +type ProfileRowProps = { + profile: Profile; + isActive: boolean; + onSelect: (id: string) => void; +}; + +const ProfileRow = ({ profile, isActive, onSelect }: ProfileRowProps) => { + const showEmail = !!profile.email; + return ( + onSelect(profile.id)} + className={cn( + "flex w-auto gap-2 px-2 py-2 pr-3 last:mb-1", + "cursor-default rounded-md text-sm outline-none", + "data-[selected=true]:bg-nb-gray-900", + showEmail ? "items-start" : "items-center", + )} + > +
                                                + {profile.name} + {showEmail && } +
                                                + {isActive && ( + + )} +
                                                + ); +}; + +const TruncatedEmail = ({ email }: { email: string }) => { + const ref = useRef(null); + const [overflowing, setOverflowing] = useState(false); + + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + setOverflowing(el.scrollWidth > el.clientWidth); + }, [email]); + + const span = ( + + {email} + + ); + if (!overflowing) return span; + return {span}; +}; diff --git a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx new file mode 100644 index 000000000..97261ccc9 --- /dev/null +++ b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx @@ -0,0 +1,678 @@ +import { type KeyboardEvent, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + CircleMinus, + LogIn, + MoreVertical, + PencilLine, + PlusCircle, + Trash2, + UserCircle, +} from "lucide-react"; +import type { Profile } from "@bindings/services/models.js"; +import { Badge } from "@/components/Badge"; +import { Button } from "@/components/buttons/Button"; +import HelpText from "@/components/typography/HelpText"; +import { + ProfileCreationModal, + type ProfileFormInitial, +} from "@/modules/profiles/ProfileCreationModal"; +import { pickProfileIcon } from "@/modules/profiles/ProfileAvatar"; +import { Tooltip } from "@/components/Tooltip"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/DropdownMenu"; +import i18next from "@/lib/i18n"; +import { useProfile } from "@/contexts/ProfileContext"; +import { useConfirm } from "@/contexts/DialogContext"; +import { Settings as SettingsSvc } from "@bindings/services"; +import { SetConfigParams } from "@bindings/services/models.js"; +import { isNetbirdCloud } from "@/hooks/useManagementUrl.ts"; +import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx"; +import { cn } from "@/lib/cn"; +import { reconcileOrder } from "@/lib/sorting"; +import { errorDialog, formatErrorMessage } from "@/lib/errors"; + +const DEFAULT_PROFILE_ID = "default"; + +export function ProfilesTab() { + const { t } = useTranslation(); + const { + profiles, + activeProfileId, + loaded, + username, + switchProfileNoConnect, + addProfile, + removeProfile, + renameProfile, + logoutProfile, + } = useProfile(); + + const confirm = useConfirm(); + const [newOpen, setNewOpen] = useState(false); + const [editTarget, setEditTarget] = useState<{ + profile: Profile; + initial: ProfileFormInitial; + } | null>(null); + const [busy, setBusy] = useState(false); + + // Order is held stable so switching only flips the badge, never reorders rows + // (else the clicked row jumps to the top under the cursor). + const orderRef = useRef([]); + const ordered = useMemo(() => { + const { order, items } = reconcileOrder( + orderRef.current, + profiles, + (p) => p.id, + (a, b) => { + if (a.id === activeProfileId) return -1; + if (b.id === activeProfileId) return 1; + return a.name.localeCompare(b.name); + }, + ); + orderRef.current = order; + return items; + }, [profiles, activeProfileId]); + + const guarded = async (title: string, fn: () => Promise) => { + if (busy) return; + setBusy(true); + try { + await fn(); + } catch (e) { + await errorDialog({ + Title: title, + Message: formatErrorMessage(e), + }); + } finally { + setBusy(false); + } + }; + + const handleSwitch = async (id: string, name: string) => { + const ok = await confirm({ + title: t("profile.switch.title", { name }), + description: t("profile.switch.message", { name }), + confirmLabel: t("profile.switch.confirm"), + }); + if (!ok) return; + await guarded(i18next.t("profile.error.switchTitle"), () => switchProfileNoConnect(id)); + }; + + const handleDeregister = async (id: string, name: string) => { + const ok = await confirm({ + title: t("profile.deregister.title", { name }), + description: t("profile.deregister.message", { name }), + confirmLabel: t("profile.deregister.confirm"), + }); + if (!ok) return; + void guarded(i18next.t("profile.error.deregisterTitle"), () => logoutProfile(id)); + }; + + const handleDelete = async (id: string, name: string) => { + if (id === DEFAULT_PROFILE_ID) return; + const ok = await confirm({ + title: t("profile.delete.title", { name }), + description: t("profile.delete.message", { name }), + confirmLabel: t("common.delete"), + danger: true, + }); + if (!ok) return; + void guarded(i18next.t("profile.error.deleteTitle"), () => removeProfile(id)); + }; + + const handleCreate = async (name: string, managementUrl: string) => { + await guarded(i18next.t("profile.error.createTitle"), async () => { + const id = await addProfile(name); + // SetConfig is keyed by the new profile's ID, so it writes the + // not-yet-active profile before the switch makes it current. + if (!isNetbirdCloud(managementUrl)) { + await SettingsSvc.SetConfig( + new SetConfigParams({ profileName: id, username, managementUrl }), + ); + } + await switchProfileNoConnect(id); + }); + }; + + const handleEdit = async (id: string, name: string) => { + await guarded(i18next.t("profile.error.editTitle"), async () => { + const config = await SettingsSvc.GetConfig({ profileName: id, username }); + const profile = profiles.find((p) => p.id === id); + if (!profile) return; + setEditTarget({ + profile, + initial: { name, managementUrl: config.managementUrl }, + }); + }); + }; + + const handleSave = async (name: string, managementUrl: string) => { + if (!editTarget) return; + const { profile, initial } = editTarget; + await guarded(i18next.t("profile.error.editTitle"), async () => { + if (name !== initial.name) { + await renameProfile(profile.id, name); + } + if (managementUrl !== initial.managementUrl) { + await SettingsSvc.SetConfig( + new SetConfigParams({ + profileName: profile.id, + username, + managementUrl, + }), + ); + } + }); + }; + + return ( +
                                                + + {t("settings.profiles.intro")} + +
                                                + + + {loaded && ordered.length === 0 && ( +
                                                + +

                                                + {t("settings.profiles.emptyTitle")} +

                                                +

                                                + {t("settings.profiles.emptyDescription")} +

                                                +
                                                + )} +
                                                + + + + +
                                                + + + + { + if (!o) setEditTarget(null); + }} + initial={editTarget?.initial} + onSubmit={handleSave} + /> +
                                                + ); +} + +type ProfilesTableProps = { + ordered: Profile[]; + activeProfileId: string | undefined; + onSwitch: (id: string, name: string) => void; + onEdit: (id: string, name: string) => void; + onDeregister: (id: string, name: string) => void; + onDelete: (id: string, name: string) => void; +}; + +const ProfilesTable = ({ + ordered, + activeProfileId, + onSwitch, + onEdit, + onDeregister, + onDelete, +}: ProfilesTableProps) => { + const { t } = useTranslation(); + const [focusedIndex, setFocusedIndex] = useState(0); + const rowRefs = useRef>(new Map()); + + const focusRow = (index: number) => { + if (index < 0 || index >= ordered.length) return; + setFocusedIndex(index); + const el = rowRefs.current.get(ordered[index].id); + el?.focus(); + }; + + const actionButtonsIn = (row: HTMLTableRowElement | undefined) => + Array.from( + row?.querySelectorAll( + "button:not([aria-hidden='true']):not([aria-disabled='true'])", + ) ?? [], + ); + + const handleRowKey = (e: KeyboardEvent, index: number): boolean => { + switch (e.key) { + case "ArrowDown": + focusRow(Math.min(index + 1, ordered.length - 1)); + return true; + case "ArrowUp": + focusRow(Math.max(index - 1, 0)); + return true; + case "Home": + focusRow(0); + return true; + case "End": + focusRow(ordered.length - 1); + return true; + } + return false; + }; + + const handleButtonKey = ( + e: KeyboardEvent, + index: number, + row: HTMLTableRowElement, + ): boolean => { + const buttons = actionButtonsIn(row); + const current = buttons.indexOf(e.target as HTMLButtonElement); + if (current === -1) return false; + + switch (e.key) { + case "ArrowDown": + focusRow(Math.min(index + 1, ordered.length - 1)); + return true; + case "ArrowUp": + focusRow(Math.max(index - 1, 0)); + return true; + case "Escape": + row.focus(); + return true; + case "Tab": + // At the last button: jump to the next row instead of exiting the table. + // At the first button with Shift+Tab: jump back to the row. + if (!e.shiftKey && current === buttons.length - 1 && index < ordered.length - 1) { + focusRow(index + 1); + return true; + } + if (e.shiftKey && current === 0) { + row.focus(); + return true; + } + return false; + } + return false; + }; + + const handleRowKeyDown = (e: KeyboardEvent, index: number) => { + const row = rowRefs.current.get(ordered[index].id); + if (!row) return; + const onRow = e.target === row; + const handled = onRow ? handleRowKey(e, index) : handleButtonKey(e, index, row); + if (handled) e.preventDefault(); + }; + + const safeFocusedIndex = Math.min(focusedIndex, Math.max(0, ordered.length - 1)); + + return ( + + + {ordered.map((profile, index) => ( + { + if (el) rowRefs.current.set(profile.id, el); + else rowRefs.current.delete(profile.id); + }} + onKeyDown={(e) => handleRowKeyDown(e, index)} + onFocus={() => setFocusedIndex(index)} + onSwitch={() => onSwitch(profile.id, profile.name)} + onEdit={() => onEdit(profile.id, profile.name)} + onDeregister={() => onDeregister(profile.id, profile.name)} + onDelete={() => onDelete(profile.id, profile.name)} + /> + ))} + +
                                                + ); +}; + +type ProfileRowProps = { + profile: Profile; + isActive: boolean; + isFocused: boolean; + isFirst: boolean; + isLast: boolean; + rowRef: (el: HTMLTableRowElement | null) => void; + onKeyDown: (e: KeyboardEvent) => void; + onFocus: () => void; + onSwitch: () => void; + onEdit: () => void; + onDeregister: () => void; + onDelete: () => void; +}; + +const ProfileRow = ({ + profile, + isActive, + isFocused, + isFirst, + isLast, + rowRef, + onKeyDown, + onFocus, + onSwitch, + onEdit, + onDeregister, + onDelete, +}: ProfileRowProps) => { + const { t } = useTranslation(); + const Icon = pickProfileIcon(profile.name) ?? UserCircle; + const showEmail = !!profile.email; + + return ( + + + +
                                                +
                                                + + {profile.name} + + {isActive && {t("settings.profiles.active")}} +
                                                + {showEmail && } +
                                                + + + + + + ); +}; + +const TruncatedEmail = ({ email }: { email: string }) => { + const ref = useRef(null); + const [overflowing, setOverflowing] = useState(false); + + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + setOverflowing(el.scrollWidth > el.clientWidth); + }, [email]); + + const span = ( + + {email} + + ); + if (!overflowing) return span; + return {span}; +}; + +type RowActionsProps = { + canSwitch: boolean; + canDeregister: boolean; + isDefault: boolean; + isActive: boolean; + rowFocused: boolean; + onSwitch: () => void; + onEdit: () => void; + onDeregister: () => void; + onDelete: () => void; +}; + +const RowActions = ({ + canSwitch, + canDeregister, + isDefault, + isActive, + rowFocused, + onSwitch, + onEdit, + onDeregister, + onDelete, +}: RowActionsProps) => { + const { t } = useTranslation(); + const deleteDisabled = isDefault || isActive; + let deleteDisabledReason: string | null = null; + if (isDefault) deleteDisabledReason = t("profile.delete.disabledDefault"); + else if (isActive) deleteDisabledReason = t("profile.delete.disabledActive"); + return ( +
                                                +
                                                + ); +}; + +type RowMoreMenuProps = { + canDeregister: boolean; + deleteDisabled: boolean; + deleteDisabledReason: string | null; + rowFocused: boolean; + onEdit: () => void; + onDeregister: () => void; + onDelete: () => void; +}; + +const RowMoreMenu = ({ + canDeregister, + deleteDisabled, + deleteDisabledReason, + rowFocused, + onEdit, + onDeregister, + onDelete, +}: RowMoreMenuProps) => { + const { t } = useTranslation(); + const moreLabel = t("profile.selector.moreOptions"); + return ( + + + + + + +
                                                + + {t("profile.selector.edit")} +
                                                +
                                                + {canDeregister && ( + +
                                                + + {t("profile.selector.deregister")} +
                                                +
                                                + )} + +
                                                +
                                                + ); +}; + +type DeleteMenuItemProps = { + disabled: boolean; + disabledReason: string | null; + onDelete: () => void; +}; + +const DeleteMenuItem = ({ disabled, disabledReason, onDelete }: DeleteMenuItemProps) => { + const { t } = useTranslation(); + const item = ( + +
                                                + + {t("profile.selector.delete")} +
                                                +
                                                + ); + if (!disabled || !disabledReason) return item; + return ( + {disabledReason}} + side={"left"} + > + {item} + + ); +}; + +type ActionIconButtonProps = { + label: string; + icon: typeof CircleMinus; + onClick: () => void; + variant?: "default" | "danger"; + /** Occupies space but invisible and non-interactive (preserves row layout). */ + hidden?: boolean; + disabled?: boolean; + tabbable?: boolean; +}; + +const ActionIconButton = ({ + label, + icon: Icon, + onClick, + variant = "default", + hidden = false, + disabled = false, + tabbable = true, +}: ActionIconButtonProps) => { + const button = ( + + ); + if (hidden) return button; + return ( + {label}} + side={"top"} + > + {button} + + ); +}; diff --git a/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx new file mode 100644 index 000000000..e57040a7a --- /dev/null +++ b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx @@ -0,0 +1,255 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useSearchParams } from "react-router-dom"; +import { Events } from "@wailsio/runtime"; +import { AlertCircleIcon, ClockIcon } from "lucide-react"; +import { Button } from "@/components/buttons/Button"; +import { ConfirmDialog } from "@/components/dialog/ConfirmDialog"; +import { DialogActions } from "@/components/dialog/DialogActions"; +import { DialogDescription } from "@/components/dialog/DialogDescription"; +import { DialogHeading } from "@/components/dialog/DialogHeading"; +import { SquareIcon } from "@/components/SquareIcon"; +import { Connection, Profiles as ProfilesSvc, Session, WindowManager } from "@bindings/services"; +import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; +import { EVENT_BROWSER_LOGIN_CANCEL, EVENT_TRIGGER_LOGIN } from "@/lib/connection"; +import { errorDialog, formatErrorMessage } from "@/lib/errors.ts"; +import { formatRemaining } from "@/lib/formatters"; + +const DEFAULT_SECONDS = 360; +const WINDOW_WIDTH = 360; +const SOON_THRESHOLD_SECONDS = 60 * 60; +const DEADLINE_TOLERANCE_MS = 5 * 1000; +// The final-warning deadline reaches the Go side as RFC3339 truncated to whole +// seconds, while the status snapshot carries millisecond precision, so an +// unchanged deadline can look up to 999 ms newer than the exact URL value. +const EXACT_DEADLINE_TOLERANCE_MS = 999; + +export default function SessionExpirationDialog() { + const { t } = useTranslation(); + const contentRef = useAutoSizeWindow(WINDOW_WIDTH); + const [params] = useSearchParams(); + const initialSeconds = useMemo(() => { + const raw = params.get("seconds"); + if (!raw) return DEFAULT_SECONDS; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : DEFAULT_SECONDS; + }, [params]); + const initialDeadline = useMemo(() => { + const raw = params.get("deadline"); + if (!raw) return null; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : null; + }, [params]); + + const [remaining, setRemaining] = useState(initialSeconds); + const [busy, setBusy] = useState(false); + const busyRef = useRef(busy); + busyRef.current = busy; + const openedDeadlineRef = useRef(initialDeadline ?? Date.now() + initialSeconds * 1000); + const exactDeadlineRef = useRef(initialDeadline !== null); + const expired = remaining <= 0; + const expiredRef = useRef(expired); + expiredRef.current = expired; + const soon = remaining <= SOON_THRESHOLD_SECONDS; + const activeTitle = soon ? t("sessionExpiration.title") : t("sessionExpiration.titleLater"); + const activeDescription = soon + ? t("sessionExpiration.description") + : t("sessionExpiration.descriptionLater"); + + useEffect(() => { + setRemaining(initialSeconds); + openedDeadlineRef.current = initialDeadline ?? Date.now() + initialSeconds * 1000; + exactDeadlineRef.current = initialDeadline !== null; + }, [initialSeconds, initialDeadline]); + + // Recompute from the absolute deadline instead of decrementing per tick: webview + // timers get suspended for tens of seconds (App Nap / hidden-window throttling), + // so a tick counter drifts behind the wall clock by the suspended time. + useEffect(() => { + const id = globalThis.setInterval(() => { + setRemaining(Math.max(0, Math.ceil((openedDeadlineRef.current - Date.now()) / 1000))); + }, 1000); + return () => globalThis.clearInterval(id); + }, [initialSeconds]); + + // Auto-close only when the session was actually renewed elsewhere (tray action, CLI, + // main window): the daemon keeps emitting Connected snapshots regardless of session + // state, so the signal is the deadline jumping past the one this dialog was opened for. + // With the exact deadline from the URL any jump past its sub-second precision loss + // counts; the seconds-derived fallback needs a wider tolerance for the Go-side + // truncation and mount latency. + // Don't auto-close while busy (aborts our WaitExtend) or expired (hides the state). + useEffect(() => { + const off = Events.On( + "netbird:status", + (ev: { data: { status?: string; sessionExpiresAt?: string | null } }) => { + if (busyRef.current || expiredRef.current) return; + if (ev?.data?.status !== "Connected") return; + const raw = ev?.data?.sessionExpiresAt; + if (!raw) return; + const renewed = Date.parse(raw); + if (!Number.isFinite(renewed)) return; + const tolerance = exactDeadlineRef.current + ? EXACT_DEADLINE_TOLERANCE_MS + : DEADLINE_TOLERANCE_MS; + if (renewed - openedDeadlineRef.current > tolerance) { + WindowManager.CloseSessionExpiration().catch(console.error); + } + }, + ); + return () => { + off(); + }; + }, []); + + const stay = useCallback(async () => { + if (busy) return; + setBusy(true); + + let offCancel: (() => void) | undefined; + + // Return the dialog to its interactive state and dismiss the browser popup + const resetDialog = () => { + offCancel?.(); + WindowManager.CloseBrowserLogin().catch(console.error); + setBusy(false); + }; + + try { + const start = await Session.RequestExtend({ hint: "" }); + const uri = start.verificationUriComplete || start.verificationUri; + + // The popup opens the URL and (Go-side) hides this window, restoring it on close. + if (uri) { + try { + await WindowManager.OpenBrowserLogin(uri); + } catch (e) { + console.error(e); + } + } + + const cancelPromise = new Promise((resolve) => { + offCancel = Events.On(EVENT_BROWSER_LOGIN_CANCEL, () => { + resolve(); + }); + }); + + const waitPromise = Session.WaitExtend({ + deviceCode: start.deviceCode, + userCode: start.userCode, + }); + + const outcome = await Promise.race([ + waitPromise.then((r) => ({ kind: "done" as const, result: r })), + cancelPromise.then(() => ({ kind: "cancel" as const })), + ]); + + if (outcome.kind === "cancel") { + waitPromise.cancel?.(); + waitPromise.catch(() => {}); + resetDialog(); + return; + } + + // Another surface owns this flow; keep the dialog open to retry. + if (outcome.result.preempted) { + resetDialog(); + return; + } + WindowManager.CloseRenewFlow().catch(console.error); + } catch (e) { + resetDialog(); + await errorDialog({ + Title: t("sessionExpiration.extendFailedTitle"), + Message: formatErrorMessage(e), + }); + } + }, [busy, t]); + + const authenticate = useCallback(async () => { + if (busy) return; + setBusy(true); + try { + await Events.Emit(EVENT_TRIGGER_LOGIN); + await WindowManager.CloseSessionExpiration(); + } catch (e) { + setBusy(false); + await errorDialog({ + Title: t("connect.error.loginTitle"), + Message: formatErrorMessage(e), + }); + } + }, [busy, t]); + + const logout = useCallback(async () => { + if (busy) return; + setBusy(true); + try { + const username = await ProfilesSvc.Username(); + const active = await ProfilesSvc.GetActive(); + await Connection.Logout({ + profileName: active.id || "default", + username, + }); + WindowManager.CloseSessionExpiration().catch(console.error); + } catch (e) { + setBusy(false); + await errorDialog({ + Title: t("sessionExpiration.logoutFailedTitle"), + Message: formatErrorMessage(e), + }); + } + }, [busy, t]); + + const close = useCallback(() => { + WindowManager.CloseSessionExpiration().catch(console.error); + }, []); + + return ( + + + +
                                                + + {expired ? t("sessionExpiration.expired") : activeTitle} + + + {expired ? t("sessionExpiration.expiredDescription") : activeDescription} + +
                                                + + {!expired && ( +
                                                + {formatRemaining(remaining)} +
                                                + )} + + + + + +
                                                + ); +} diff --git a/client/ui/frontend/src/modules/settings/SettingsAbout.tsx b/client/ui/frontend/src/modules/settings/SettingsAbout.tsx new file mode 100644 index 000000000..8ba1009ba --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsAbout.tsx @@ -0,0 +1,169 @@ +import type { ComponentType, SVGProps } from "react"; +import { useTranslation } from "react-i18next"; +import { Browser } from "@wailsio/runtime"; +import { BookOpen, MessageSquareText, MessagesSquare } from "lucide-react"; +import netbirdFull from "@/assets/logos/netbird-full.svg"; + +// Brand glyphs from simpleicons.org (lucide deprecated its brand icons). +const GithubIcon = (props: SVGProps) => ( + + + +); +const SlackIcon = (props: SVGProps) => ( + + + +); +import { useSettings } from "@/contexts/SettingsContext.tsx"; +import { useStatus } from "@/contexts/StatusContext.tsx"; +import { UpdateVersionCard } from "@/modules/auto-update/UpdateVersionCard"; +import { useAccentTrigger } from "@/modules/settings/SettingsAccent"; + +function openUrl(url: string) { + Browser.OpenURL(url).catch(() => { + window.open(url, "_blank"); + }); +} + +export function SettingsAbout() { + const { t } = useTranslation(); + const { status } = useStatus(); + const { guiVersion } = useSettings(); + const daemonVersion = status?.daemonVersion ?? "—"; + + const handleVersionClick = useAccentTrigger(); + + const COMMUNITY_LINKS: { + label: string; + url: string; + Icon: ComponentType>; + iconClassName?: string; + }[] = [ + { + label: t("settings.about.community.github"), + url: "https://github.com/netbirdio/netbird", + Icon: GithubIcon, + iconClassName: "h-3 w-3", + }, + { + label: t("settings.about.community.slack"), + url: "https://docs.netbird.io/slack-url", + Icon: SlackIcon, + iconClassName: "h-3 w-3", + }, + { + label: t("settings.about.community.forum"), + url: "https://forum.netbird.io", + Icon: MessagesSquare, + }, + { + label: t("settings.about.community.documentation"), + url: "https://docs.netbird.io", + Icon: BookOpen, + }, + { + label: t("settings.about.community.feedback"), + url: "https://forms.gle/TeLw2zrXEdw6RcQ36", + Icon: MessageSquareText, + }, + ]; + + const LEGAL_LINKS: { label: string; url: string }[] = [ + { label: t("settings.about.links.imprint"), url: "https://netbird.io/imprint" }, + { label: t("settings.about.links.privacy"), url: "https://netbird.io/privacy" }, + { label: t("settings.about.links.cla"), url: "https://netbird.io/cla" }, + { label: t("settings.about.links.terms"), url: "https://netbird.io/terms" }, + ]; + + return ( +
                                                + {t("common.netbird")} +
                                                + +

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

                                                +
                                                + + + +

                                                + {t("settings.about.copyright", { year: new Date().getFullYear() })} +

                                                +
                                                + {COMMUNITY_LINKS.map(({ label, url, Icon, iconClassName }) => ( + + ))} +
                                                +
                                                + {LEGAL_LINKS.map((link) => ( + + ))} +
                                                +
                                                + ); +} diff --git a/client/ui/frontend/src/modules/settings/SettingsAccent.tsx b/client/ui/frontend/src/modules/settings/SettingsAccent.tsx new file mode 100644 index 000000000..b7d9c96c8 --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsAccent.tsx @@ -0,0 +1,116 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { createRoot } from "react-dom/client"; + +export function useAccentTrigger() { + const clicksRef = useRef(0); + const lastClickRef = useRef(0); + + return useCallback(() => { + const now = performance.now(); + if (now - lastClickRef.current > 400) { + clicksRef.current = 0; + } + lastClickRef.current = now; + clicksRef.current += 1; + if (clicksRef.current >= 10) { + clicksRef.current = 0; + triggerAccent(); + } + }, []); +} + +function triggerAccent() { + if (document.getElementById("nb-accent-root")) return; + + const container = document.createElement("div"); + container.id = "nb-accent-root"; + document.body.appendChild(container); + const root = createRoot(container); + + const cleanup = () => { + root.unmount(); + container.remove(); + }; + + root.render(); +} + +function Accent({ onDone }: Readonly<{ onDone: () => void }>) { + const canvasRef = useRef(null); + const [visible, setVisible] = useState(false); + + useEffect(() => { + const raf = requestAnimationFrame(() => setVisible(true)); + return () => cancelAnimationFrame(raf); + }, []); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + const resize = () => { + canvas.width = window.innerWidth * dpr; + canvas.height = window.innerHeight * dpr; + canvas.style.width = `${window.innerWidth}px`; + canvas.style.height = `${window.innerHeight}px`; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + }; + resize(); + window.addEventListener("resize", resize); + + const chars = "TEAMNETBIRD"; + const fontSize = 16; + const columns = Math.floor(window.innerWidth / fontSize); + const drops = Array.from({ length: columns }, () => Math.random() * -50); + + let raf = 0; + let last = 0; + const draw = (t: number) => { + if (t - last > 50) { + last = t; + + ctx.globalCompositeOperation = "destination-out"; + ctx.fillStyle = "rgba(0, 0, 0, 0.12)"; + ctx.fillRect(0, 0, window.innerWidth, window.innerHeight); + + ctx.globalCompositeOperation = "source-over"; + ctx.font = `${fontSize}px ui-monospace, monospace`; + ctx.fillStyle = "#f68330"; + + for (let i = 0; i < drops.length; i++) { + const ch = chars[Math.floor(Math.random() * chars.length)]; + const y = drops[i] * fontSize; + ctx.fillText(ch, i * fontSize, y); + if (y > window.innerHeight && Math.random() > 0.975) { + drops[i] = 0; + } + drops[i]++; + } + } + raf = requestAnimationFrame(draw); + }; + raf = requestAnimationFrame(draw); + + const timeout = globalThis.setTimeout(() => { + setVisible(false); + globalThis.setTimeout(onDone, 500); + }, 9000); + + return () => { + cancelAnimationFrame(raf); + globalThis.clearTimeout(timeout); + window.removeEventListener("resize", resize); + }; + }, [onDone]); + + return ( +
                                                + +
                                                + ); +} diff --git a/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx b/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx new file mode 100644 index 000000000..37b1932d9 --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx @@ -0,0 +1,179 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { System } from "@wailsio/runtime"; +import Button from "@/components/buttons/Button"; +import { HelpText } from "@/components/typography/HelpText"; +import { Input } from "@/components/inputs/Input"; +import { Label } from "@/components/typography/Label"; +import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx"; +import { useSettings } from "@/contexts/SettingsContext.tsx"; +import { useRestrictions } from "@/contexts/RestrictionsContext.tsx"; + +// macOS daemon/CLI only accept utun (Darwin parses digits as the utun unit); Linux caps at IFNAMSIZ-1 = 15 chars. +const IS_MAC = System.IsMac(); +const INTERFACE_NAME_RE = IS_MAC ? /^utun\d+$/ : /^[A-Za-z0-9._-]{1,15}$/; +const INTERFACE_NAME_ERROR_KEY = IS_MAC + ? "settings.advanced.interfaceName.errorMac" + : "settings.advanced.interfaceName.error"; + +// Port 0 lets the daemon pick a random free port. +const PORT_MIN = 0; +const PORT_MAX = 65535; + +// Mirrors client/iface/iface.go MinMTU / MaxMTU. +const MTU_MIN = 576; +const MTU_MAX = 8192; + +const PSK_MASK = "**********"; + +export function SettingsAdvanced() { + const { t } = useTranslation(); + const { config, saveFields } = useSettings(); + const { mdm } = useRestrictions(); + + const initialPsk = config.preSharedKeySet ? PSK_MASK : ""; + + const [values, setValues] = useState({ + interfaceName: config.interfaceName, + wireguardPort: config.wireguardPort, + mtu: config.mtu, + }); + + const [pskInputValue, setPskInputValue] = useState(initialPsk); + const [saving, setSaving] = useState(false); + + useEffect(() => { + setValues({ + interfaceName: config.interfaceName, + wireguardPort: config.wireguardPort, + mtu: config.mtu, + }); + setPskInputValue(config.preSharedKeySet ? PSK_MASK : ""); + }, [config.interfaceName, config.wireguardPort, config.mtu, config.preSharedKeySet]); + + const errors = useMemo(() => { + const out: { interfaceName?: string; wireguardPort?: string; mtu?: string } = {}; + if (!INTERFACE_NAME_RE.test(values.interfaceName)) { + out.interfaceName = t(INTERFACE_NAME_ERROR_KEY); + } + if ( + !Number.isInteger(values.wireguardPort) || + values.wireguardPort < PORT_MIN || + values.wireguardPort > PORT_MAX + ) { + out.wireguardPort = t("settings.advanced.port.error", { + min: PORT_MIN, + max: PORT_MAX, + }); + } + if (!Number.isInteger(values.mtu) || values.mtu < MTU_MIN || values.mtu > MTU_MAX) { + out.mtu = t("settings.advanced.mtu.error", { min: MTU_MIN, max: MTU_MAX }); + } + return out; + }, [values.interfaceName, values.wireguardPort, values.mtu, t]); + + const filteredErrors = mdm.wireguardPort ? { ...errors, wireguardPort: undefined } : errors; + const hasErrors = Object.values(filteredErrors).some((v) => v !== undefined); + const pskChanged = pskInputValue !== initialPsk; + const hasChanges = + values.interfaceName !== config.interfaceName || + (!mdm.wireguardPort && values.wireguardPort !== config.wireguardPort) || + values.mtu !== config.mtu || + (!mdm.preSharedKey && pskChanged); + + const handleSave = async () => { + if (!hasChanges || saving || hasErrors) return; + setSaving(true); + try { + const partial: typeof values = { ...values }; + if (mdm.wireguardPort) partial.wireguardPort = config.wireguardPort; + + const pskEdited = !mdm.preSharedKey && pskChanged && pskInputValue !== PSK_MASK; + const pskOpts = pskEdited ? { preSharedKey: pskInputValue } : undefined; + await saveFields(partial, pskOpts); + if (pskEdited) setPskInputValue(pskInputValue === "" ? "" : PSK_MASK); + } finally { + setSaving(false); + } + }; + + return ( + <> + + setValues((v) => ({ ...v, interfaceName: e.target.value }))} + spellCheck={false} + autoComplete={"off"} + autoCorrect={"off"} + autoCapitalize={"off"} + /> +
                                                + {!mdm.wireguardPort && ( +
                                                + + setValues((v) => ({ + ...v, + wireguardPort: Number(e.target.value), + })) + } + /> + + {t("settings.advanced.port.help")} + +
                                                + )} + setValues((v) => ({ ...v, mtu: Number(e.target.value) }))} + /> +
                                                +
                                                + + {!mdm.preSharedKey && ( + +
                                                + + {t("settings.advanced.psk.help")} + setPskInputValue(e.target.value)} + spellCheck={false} + autoComplete={"new-password"} + autoCorrect={"off"} + autoCapitalize={"off"} + /> +
                                                +
                                                + )} + + + + + + ); +} diff --git a/client/ui/frontend/src/modules/settings/SettingsGeneral.tsx b/client/ui/frontend/src/modules/settings/SettingsGeneral.tsx new file mode 100644 index 000000000..71720aebe --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsGeneral.tsx @@ -0,0 +1,124 @@ +import { useEffect, useId, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/buttons/Button"; +import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch"; +import { HelpText } from "@/components/typography/HelpText"; +import { Input } from "@/components/inputs/Input"; +import { Label } from "@/components/typography/Label"; +import { SectionGroup } from "@/modules/settings/SettingsSection.tsx"; +import { useAutostartSetting, useSettings } from "@/contexts/SettingsContext.tsx"; +import { ManagementServerSwitch } from "@/components/ManagementServerSwitch.tsx"; +import { ManagementMode, useManagementUrl } from "@/hooks/useManagementUrl.ts"; +import { LanguagePicker } from "@/components/LanguagePicker.tsx"; +import { useRestrictions } from "@/contexts/RestrictionsContext.tsx"; +import { useKeepConnectedOnQuit } from "@/hooks/useKeepConnectedOnQuit.ts"; + +export function SettingsGeneral() { + const { t } = useTranslation(); + const { config, setField } = useSettings(); + const { autostart, setAutostartEnabled } = useAutostartSetting(); + const { mode, setMode, setUrl, displayUrl, showError, canSave, save, checking, unreachable } = + useManagementUrl(); + const { mdm, features } = useRestrictions(); + const { keepConnected, setKeepConnectedOnQuit } = useKeepConnectedOnQuit(); + + const inputRef = useRef(null); + const managementUrlId = useId(); + const prevMode = useRef(mode); + useEffect(() => { + if (prevMode.current === ManagementMode.Cloud && mode === ManagementMode.SelfHosted) { + inputRef.current?.focus(); + } + prevMode.current = mode; + }, [mode]); + + return ( + <> + + + setField("disableNotifications", !v)} + label={t("settings.general.notifications.label")} + helpText={t("settings.general.notifications.help")} + /> + {!mdm.disableAutoConnect && !features.disableUpdateSettings && ( + setField("disableAutoConnect", !v)} + label={t("settings.general.connectOnStartup.label")} + helpText={t("settings.general.connectOnStartup.help")} + /> + )} + {(autostart === null || autostart.supported) && ( + + )} + { + void setKeepConnectedOnQuit(v); + }} + loading={keepConnected === null} + label={t("settings.general.keepConnectedOnQuit.label")} + helpText={t("settings.general.keepConnectedOnQuit.help")} + /> + + + {!mdm.managementURL && !features.disableUpdateSettings && ( + +
                                                +
                                                +
                                                + + {t("settings.general.management.help")} +
                                                + +
                                                + {mode === ManagementMode.SelfHosted && ( +
                                                + setUrl(e.target.value)} + placeholder={t("settings.general.management.urlPlaceholder")} + error={ + showError + ? t("settings.general.management.urlError") + : undefined + } + warning={ + unreachable + ? t("settings.general.management.urlUnreachable") + : undefined + } + spellCheck={false} + autoComplete={"off"} + autoCorrect={"off"} + autoCapitalize={"off"} + /> + +
                                                + )} +
                                                +
                                                + )} + + ); +} diff --git a/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx b/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx new file mode 100644 index 000000000..816dcb71f --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx @@ -0,0 +1,87 @@ +import { useTranslation } from "react-i18next"; +import { Tooltip } from "@/components/Tooltip.tsx"; +import { VerticalTabs } from "@/components/VerticalTabs.tsx"; +import { UpdateBadge } from "@/modules/auto-update/UpdateBadge.tsx"; +import { useClientVersion } from "@/contexts/ClientVersionContext.tsx"; +import { useRestrictions } from "@/contexts/RestrictionsContext.tsx"; +import { + BoltIcon, + InfoIcon, + LifeBuoyIcon, + NetworkIcon, + ShieldIcon, + SlidersHorizontalIcon, + SquareTerminalIcon, + UserCircleIcon, +} from "lucide-react"; + +export const SettingsNavigation = () => { + const { t } = useTranslation(); + const { updateAvailable } = useClientVersion(); + const { mdm, features } = useRestrictions(); + const showSsh = mdm.allowServerSSH ?? !features.disableUpdateSettings; + + const aboutAdornment = updateAvailable ? ( + + + + ) : undefined; + + return ( +
                                                + + + {!features.disableUpdateSettings && ( + <> + + + + )} + {!features.disableProfiles && ( + + )} + {showSsh && ( + + )} + {!features.disableUpdateSettings && ( + + )} + + + +
                                                + ); +}; diff --git a/client/ui/frontend/src/modules/settings/SettingsNetwork.tsx b/client/ui/frontend/src/modules/settings/SettingsNetwork.tsx new file mode 100644 index 000000000..7c903b634 --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsNetwork.tsx @@ -0,0 +1,55 @@ +import { useTranslation } from "react-i18next"; +import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch"; +import { SectionGroup } from "@/modules/settings/SettingsSection.tsx"; +import { useSettings } from "@/contexts/SettingsContext.tsx"; +import { useRestrictions } from "@/contexts/RestrictionsContext.tsx"; + +export function SettingsNetwork() { + const { t } = useTranslation(); + const { config, setField } = useSettings(); + const { mdm } = useRestrictions(); + + return ( + <> + + setField("networkMonitor", v)} + label={t("settings.network.monitor.label")} + helpText={t("settings.network.monitor.help")} + /> + + + + setField("disableDns", !v)} + label={t("settings.network.dns.label")} + helpText={t("settings.network.dns.help")} + /> + {!mdm.disableClientRoutes && ( + setField("disableClientRoutes", !v)} + label={t("settings.network.clientRoutes.label")} + helpText={t("settings.network.clientRoutes.help")} + /> + )} + {!mdm.disableServerRoutes && ( + setField("disableServerRoutes", !v)} + label={t("settings.network.serverRoutes.label")} + helpText={t("settings.network.serverRoutes.help")} + /> + )} + setField("disableIpv6", !v)} + label={t("settings.network.ipv6.label")} + helpText={t("settings.network.ipv6.help")} + /> + + + ); +} diff --git a/client/ui/frontend/src/modules/settings/SettingsPage.tsx b/client/ui/frontend/src/modules/settings/SettingsPage.tsx new file mode 100644 index 000000000..bf0db3bec --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsPage.tsx @@ -0,0 +1,131 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { useLocation } from "react-router-dom"; +import { Events } from "@wailsio/runtime"; +import * as ScrollArea from "@radix-ui/react-scroll-area"; +import { cn } from "@/lib/cn"; +import { isMacOS } from "@/lib/platform"; +import { AppRightPanel } from "@/layouts/AppRightPanel.tsx"; +import { VerticalTabs } from "@/components/VerticalTabs.tsx"; +import { SettingsNavigation } from "@/modules/settings/SettingsNavigation.tsx"; +import { AutostartSettingsProvider, SettingsProvider } from "@/contexts/SettingsContext.tsx"; +import { SettingsGeneral } from "@/modules/settings/SettingsGeneral.tsx"; +import { SettingsNetwork } from "@/modules/settings/SettingsNetwork.tsx"; +import { SettingsSecurity } from "@/modules/settings/SettingsSecurity.tsx"; +import { ProfilesTab } from "@/modules/profiles/ProfilesTab.tsx"; +import { SettingsSSH } from "@/modules/settings/SettingsSSH.tsx"; +import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx"; +import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx"; +import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx"; +import { useRestrictions } from "@/contexts/RestrictionsContext.tsx"; + +const EVENT_SETTINGS_OPEN = "netbird:settings:open"; + +const enum Tab { + General = "general", + Network = "network", + Security = "security", + Profiles = "profiles", + SSH = "ssh", + Advanced = "advanced", + Troubleshooting = "troubleshooting", + About = "about", +} + +const TAB_CONTENT: Record = { + [Tab.General]: , + [Tab.Network]: , + [Tab.Security]: , + [Tab.Profiles]: , + [Tab.SSH]: , + [Tab.Advanced]: , + [Tab.Troubleshooting]: , + [Tab.About]: , +}; + +export const SettingsPage = () => { + const location = useLocation(); + const navState = location.state as { tab?: string } | null; + const { mdm, features } = useRestrictions(); + + const visibleTabs = useMemo(() => { + const editable = !features.disableUpdateSettings; + const visibility: Record = { + [Tab.General]: true, + [Tab.Network]: editable, + [Tab.Security]: editable, + [Tab.Profiles]: !features.disableProfiles, + [Tab.SSH]: mdm.allowServerSSH ?? editable, + [Tab.Advanced]: editable, + [Tab.Troubleshooting]: true, + [Tab.About]: true, + }; + return (Object.keys(visibility) as Tab[]).filter((t) => visibility[t]); + }, [features.disableUpdateSettings, features.disableProfiles, mdm.allowServerSSH]); + + const defaultTab = visibleTabs[0]; + const [active, setActive] = useState(() => navState?.tab ?? defaultTab); + + useEffect(() => { + if (navState?.tab) setActive(navState.tab); + }, [navState?.tab, location.key]); + + useEffect(() => { + return Events.On(EVENT_SETTINGS_OPEN, (e: { data: string }) => { + setActive(e.data || defaultTab); + }); + }, [defaultTab]); + + // Reset active tab if it got disabled by any feature flag or mdm restrictions + useEffect(() => { + if (!visibleTabs.includes(active as Tab)) setActive(defaultTab); + }, [visibleTabs, active, defaultTab]); + + return ( + <> + {isMacOS() ? ( +
                                                + ) : ( +
                                                + )} +
                                                + + + + + + + +
                                                + {visibleTabs.map((tab) => ( + + {TAB_CONTENT[tab]} + + ))} +
                                                +
                                                + + + +
                                                +
                                                +
                                                +
                                                +
                                                +
                                                + + ); +}; diff --git a/client/ui/frontend/src/modules/settings/SettingsSSH.tsx b/client/ui/frontend/src/modules/settings/SettingsSSH.tsx new file mode 100644 index 000000000..d74afae73 --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsSSH.tsx @@ -0,0 +1,284 @@ +import { type TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; +import { CopyToClipboard } from "@/components/CopyToClipboard"; +import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch"; +import { HelpText } from "@/components/typography/HelpText"; +import { Input } from "@/components/inputs/Input"; +import { Label } from "@/components/typography/Label"; +import { cn } from "@/lib/cn"; +import { SectionGroup } from "@/modules/settings/SettingsSection.tsx"; +import { type GuardedField, useSettings } from "@/contexts/SettingsContext.tsx"; +import { usePrivilege } from "@/hooks/usePrivilege.ts"; +import type { Privilege } from "@bindings/services/models.js"; +import { type ChangeEvent, type ReactNode, useEffect, useId, useState } from "react"; + +export function SettingsSSH() { + const { t } = useTranslation(); + const { config, setField, saveGuardedField } = useSettings(); + const privilege = usePrivilege(); + // The field whose elevation prompt is currently up, if any. The prompt is + // modal to the operating system, not to us, so the guarded controls are held + // still meanwhile rather than allowed to stack a second one behind it. + const [authorizing, setAuthorizing] = useState(null); + const isSSHServerEnabled = config.serverSshAllowed; + + const authorize = async (field: GuardedField, value: boolean) => { + setAuthorizing(field); + try { + await saveGuardedField(field, value); + } finally { + setAuthorizing(null); + } + }; + + // The daemon restricts only the direction that hands out shells from a process + // running as root: for all three settings that is switching the field on. + // + // An unprivileged user gets that direction routed through the platform's + // elevation prompt where there is one to raise, and otherwise the old + // arrangement, where the control is either unavailable (it is off and only a + // privileged caller could turn it on) or a one-way switch (it is on, they may + // turn it off but not back on) with the command that does it. + // + // A null privilege means we could not determine it: leave the control alone + // rather than greying it out with nothing to explain why. The daemon enforces + // this regardless, and a rejected save reports its own guidance. + const guarded = ( + field: GuardedField, + command: (p: Privilege) => string, + // inverted marks a control whose guarded direction is switching it off, so + // the one-way warning has to read the other way round. + inverted = false, + ) => { + const plain = (value: boolean) => setField(field, value); + if (!privilege || privilege.privileged) { + return { apply: plain, disabled: false, hint: undefined }; + } + + const guardedDirectionActive = config[field]; + const hint = (pending: boolean, command?: string) => ( + + ); + + if (privilege.canElevate) { + return { + // Switching off is ours to do; only switching on is authorized. + apply: (value: boolean) => { + if (!value) { + plain(value); + return; + } + void authorize(field, value); + }, + disabled: authorizing !== null, + hint: hint(authorizing === field), + }; + } + return { + apply: plain, + disabled: !guardedDirectionActive, + hint: hint(false, command(privilege)), + }; + }; + + const sshServer = guarded("serverSshAllowed", (p) => p.allowSshServer); + const sshRoot = guarded("enableSshRoot", (p) => p.enableSshRoot); + // Inverted control: the guarded direction is switching authentication off, so + // it is the already-disabled state that is the one-way one. + const sshAuth = guarded("disableSshAuth", (p) => p.disableSshAuth, true); + const jwtTtlId = useId(); + const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl)); + + useEffect(() => { + setJwtTtlInput(String(config.sshJwtCacheTtl)); + }, [config.sshJwtCacheTtl]); + + const handleJwtTtlChange = (e: ChangeEvent) => { + const v = e.target.value; + setJwtTtlInput(v); + if (v === "") return; + const n = Number(v); + if (Number.isFinite(n) && n >= 0) { + setField("sshJwtCacheTtl", n); + } + }; + + const handleJwtTtlBlur = () => { + if (jwtTtlInput === "") { + setJwtTtlInput("0"); + setField("sshJwtCacheTtl", 0); + return; + } + const n = Number(jwtTtlInput); + if (!Number.isFinite(n) || n < 0) { + setJwtTtlInput(String(config.sshJwtCacheTtl)); + } + }; + return ( + <> + + + {sshServer.hint} + + + + + {sshRoot.hint} + setField("enableSshSftp", v)} + label={t("settings.ssh.sftp.label")} + helpText={t("settings.ssh.sftp.help")} + /> + setField("enableSshLocalPortForwarding", v)} + label={t("settings.ssh.localForward.label")} + helpText={t("settings.ssh.localForward.help")} + /> + setField("enableSshRemotePortForwarding", v)} + label={t("settings.ssh.remoteForward.label")} + helpText={t("settings.ssh.remoteForward.help")} + /> + + + + sshAuth.apply(!v)} + disabled={sshAuth.disabled} + label={t("settings.ssh.jwt.label")} + helpText={t("settings.ssh.jwt.help")} + /> + {sshAuth.hint} +
                                                +
                                                + + {t("settings.ssh.jwtTtl.help")} +
                                                +
                                                + +
                                                +
                                                +
                                                + + ); +} + +// actorLabel names the principal the daemon requires, in the user's language. The +// Go side reports which one it is rather than wording it, because "administrator +// privileges" is English and a translated sentence cannot borrow it. +function actorLabel(privilege: Privilege, t: TFunction): string { + return privilege.actorKey === "administrator" + ? t("settings.ssh.privilege.actorAdministrator") + : t("settings.ssh.privilege.actorRoot"); +} + +// GuardedHint is what a control the daemon guards says to an unprivileged user. +// There are three things worth saying, and it says at most one: +// +// - A prompt is open. Worth a line because it can take a few seconds to appear, +// long enough that a control which merely went inert would read as a hang. +// - The setting is in its guarded state already (oneWay), so the user may switch +// it back as they please and it is switching it away again that will ask. No +// command either way: the direction they can take is theirs to take. +// - Only a privileged caller can move it at all, and there is no prompt to +// raise: the command that does it belongs here, and nothing else will do. +// +// Which leaves the case of a control whose guarded direction is still ahead of the +// user and a prompt that can be raised for it: nothing to say, because clicking it +// raises the prompt and the prompt explains itself. +function GuardedHint({ + actor, + oneWay, + inverted, + pending, + command, +}: { + actor: string; + oneWay: boolean; + inverted: boolean; + pending: boolean; + command?: string; +}): ReactNode { + const { t } = useTranslation(); + + if (pending) { + return {t("settings.ssh.privilege.authorizePending")}; + } + if (oneWay) { + return ( + + + {inverted + ? t("settings.ssh.privilege.oneWayInverted", { actor }) + : t("settings.ssh.privilege.oneWay", { actor })} + + + ); + } + if (!command) return null; + return ( + + {t("settings.ssh.privilege.hint", { actor })} + + + {command} + + + + ); +} + +// HintBox is the box a guarded control puts its explanation in, directly under the +// control it belongs to. +function HintBox({ children }: { children: ReactNode }): ReactNode { + return ( +
                                                + {children} +
                                                + ); +} diff --git a/client/ui/frontend/src/modules/settings/SettingsSection.tsx b/client/ui/frontend/src/modules/settings/SettingsSection.tsx new file mode 100644 index 000000000..adba65cdc --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsSection.tsx @@ -0,0 +1,43 @@ +import type { ReactNode } from "react"; +import { cn } from "@/lib/cn"; + +export const SectionGroup = ({ + title, + children, + disabled = false, +}: { + title: string; + children: ReactNode; + disabled?: boolean; +}) => ( +
                                                +

                                                + {title} +

                                                +
                                                {children}
                                                +
                                                +); + +export const SettingsBottomBar = ({ children }: { children: ReactNode }) => ( + <> +
                                                +
                                                +
                                                + {children} +
                                                +
                                                + +); diff --git a/client/ui/frontend/src/modules/settings/SettingsSecurity.tsx b/client/ui/frontend/src/modules/settings/SettingsSecurity.tsx new file mode 100644 index 000000000..db1ad9c8f --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsSecurity.tsx @@ -0,0 +1,61 @@ +import { useTranslation } from "react-i18next"; +import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch"; +import { SectionGroup } from "@/modules/settings/SettingsSection.tsx"; +import { useSettings } from "@/contexts/SettingsContext.tsx"; +import { useRestrictions } from "@/contexts/RestrictionsContext.tsx"; + +export function SettingsSecurity() { + const { t } = useTranslation(); + const { config, setField } = useSettings(); + const { mdm } = useRestrictions(); + const hideRosenpassEnabled = mdm.rosenpassEnabled; + const hideRosenpassPermissive = + mdm.rosenpassPermissive || (mdm.rosenpassEnabled && !config.rosenpassEnabled); + const showEncryptionSection = !(hideRosenpassEnabled && hideRosenpassPermissive); + + return ( + <> + + {!mdm.blockInbound && ( + setField("blockInbound", v)} + label={t("settings.security.blockInbound.label")} + helpText={t("settings.security.blockInbound.help")} + /> + )} + setField("blockLanAccess", v)} + label={t("settings.security.blockLan.label")} + helpText={t("settings.security.blockLan.help")} + /> + + + {showEncryptionSection && ( + + {!hideRosenpassEnabled && ( + { + setField("rosenpassEnabled", v); + if (!v) setField("rosenpassPermissive", false); + }} + label={t("settings.security.rosenpass.label")} + helpText={t("settings.security.rosenpass.help")} + /> + )} + {!hideRosenpassPermissive && ( + setField("rosenpassPermissive", v)} + label={t("settings.security.rosenpassPermissive.label")} + helpText={t("settings.security.rosenpassPermissive.help")} + disabled={!config.rosenpassEnabled} + /> + )} + + )} + + ); +} diff --git a/client/ui/frontend/src/modules/settings/SettingsSkeleton.tsx b/client/ui/frontend/src/modules/settings/SettingsSkeleton.tsx new file mode 100644 index 000000000..afce192e6 --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsSkeleton.tsx @@ -0,0 +1,27 @@ +import Skeleton from "react-loading-skeleton"; + +export const SettingsSkeleton = () => { + return ( +
                                                +
                                                + +
                                                + + +
                                                +
                                                + + +
                                                +
                                                +
                                                + +
                                                + + + +
                                                +
                                                +
                                                + ); +}; diff --git a/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx b/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx new file mode 100644 index 000000000..8b1774ed6 --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx @@ -0,0 +1,405 @@ +import { useId, type ReactNode } from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { ChevronDown, CircleCheckBig, FolderOpen, Info, Loader2 } from "lucide-react"; +import { Browser } from "@wailsio/runtime"; +import { Debug as DebugSvc } from "@bindings/services"; +import type { DebugBundleResult } from "@bindings/services/models.js"; +import { Button } from "@/components/buttons/Button"; +import { DialogActions } from "@/components/dialog/DialogActions"; +import { DialogDescription } from "@/components/dialog/DialogDescription"; +import { DialogHeading } from "@/components/dialog/DialogHeading"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/DropdownMenu"; +import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch"; +import HelpText from "@/components/typography/HelpText.tsx"; +import { Input } from "@/components/inputs/Input"; +import { Label } from "@/components/typography/Label"; +import { SquareIcon } from "@/components/SquareIcon"; +import { Tooltip } from "@/components/Tooltip"; +import { cn } from "@/lib/cn"; +import { formatRemaining } from "@/lib/formatters"; +import type { AnonymizeLevel, DebugStage } from "@/contexts/DebugBundleContext"; +import { useDebugBundleContext } from "@/contexts/DebugBundleContext"; +import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx"; + +const SUPPORT_DOCS_URL = "https://docs.netbird.io/help/report-bug-issues"; + +export function SettingsTroubleshooting() { + const { t } = useTranslation(); + const durationId = useId(); + const { + anonymizeLevel, + setAnonymizeLevel, + systemInfo, + setSystemInfo, + upload, + setUpload, + trace, + setTrace, + capture, + setCapture, + traceMinutes, + setTraceMinutes, + capturePackets, + setCapturePackets, + run, + stage, + cancel, + reset, + } = useDebugBundleContext(); + + if (stage.kind === "done") { + return ( + + ); + } + if (stage.kind !== "idle") { + return ; + } + + return ( + +
                                                +
                                                +
                                                + } + > + + + + + + {t("settings.troubleshooting.anonymize.help")} + +
                                                +
                                                + + + + + + setAnonymizeLevel(v as AnonymizeLevel)} + > + + {t("settings.troubleshooting.anonymize.none")} + + + {t("settings.troubleshooting.anonymize.default")} + + + {t("settings.troubleshooting.anonymize.strict")} + + + + +
                                                +
                                                + + + + +
                                                + +
                                                +
                                                + + + {t("settings.troubleshooting.duration.help")} + +
                                                +
                                                + + setTraceMinutes( + Math.max(1, Math.min(30, Number(e.target.value) || 1)), + ) + } + customSuffix={t("settings.troubleshooting.duration.suffix")} + disabled={!capture} + /> +
                                                +
                                                +
                                                + + + + + + ); +} + +function CenteredPanel({ children }: Readonly<{ children: ReactNode }>) { + return ( +
                                                + {children} +
                                                + ); +} + +function ProgressSection({ + stage, + onCancel, +}: Readonly<{ stage: DebugStage; onCancel: () => void }>) { + const { t } = useTranslation(); + const cancelling = stage.kind === "cancelling"; + return ( + + + +
                                                + {stageLabel(stage, t)} + + {t("settings.troubleshooting.progress.description")} + +
                                                + + {stage.kind === "capturing" && ( +
                                                + {formatRemaining(stage.remainingSec)} +
                                                + )} + + + + +
                                                + ); +} + +function DoneResult({ + result, + uploaded, + onClose, +}: Readonly<{ + result: DebugBundleResult; + uploaded: boolean; + onClose: () => void; +}>) { + const { t } = useTranslation(); + const showKey = uploaded && Boolean(result.uploadedKey); + const uploadFailed = uploaded && !result.uploadedKey; + const onRevealPath = () => { + if (!result.path) return; + DebugSvc.RevealFile(result.path).catch((err: unknown) => + console.error("reveal debug bundle file", err), + ); + }; + return ( + + + +
                                                + + {showKey + ? t("settings.troubleshooting.done.uploadedTitle") + : t("settings.troubleshooting.done.savedTitle")} + + + {showKey ? ( + { + e.preventDefault(); + Browser.OpenURL(SUPPORT_DOCS_URL).catch(() => + globalThis.open(SUPPORT_DOCS_URL, "_blank"), + ); + }} + className={"text-netbird hover:underline"} + > + {/* content is provided by */} + + + ), + }} + /> + ) : ( + t("settings.troubleshooting.done.savedDescription") + )} + +
                                                + +
                                                + {showKey && } + + {result.path && !showKey && ( + + + + } + /> + )} + + {uploadFailed && ( +
                                                + {result.uploadFailureReason + ? t("settings.troubleshooting.uploadFailedWithReason", { + reason: result.uploadFailureReason, + }) + : t("settings.troubleshooting.uploadFailed")} +
                                                + )} +
                                                + + + {showKey ? ( + + ) : ( + result.path && ( + + ) + )} + + +
                                                + ); +} + +const stageLabel = ( + stage: DebugStage, + t: (key: string, options?: Record) => string, +): string => { + switch (stage.kind) { + case "reconnecting": + return t("settings.troubleshooting.stage.reconnecting"); + case "capturing": + return t("settings.troubleshooting.stage.capturing"); + case "bundling": + return t("settings.troubleshooting.stage.bundling"); + case "uploading": + return t("settings.troubleshooting.stage.uploading"); + case "cancelling": + return t("settings.troubleshooting.stage.cancelling"); + default: + return ""; + } +}; diff --git a/client/ui/frontend/src/modules/welcome/WelcomeDialog.tsx b/client/ui/frontend/src/modules/welcome/WelcomeDialog.tsx new file mode 100644 index 000000000..7be59f102 --- /dev/null +++ b/client/ui/frontend/src/modules/welcome/WelcomeDialog.tsx @@ -0,0 +1,170 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + Preferences, + Profiles as ProfilesSvc, + Settings as SettingsSvc, + WindowManager, +} from "@bindings/services"; +import { Restrictions, SetConfigParams } from "@bindings/services/models.js"; +import { ConfirmDialog } from "@/components/dialog/ConfirmDialog"; +import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; +import { errorDialog, formatErrorMessage } from "@/lib/errors"; +import i18next from "@/lib/i18n"; +import { isNetbirdCloud } from "@/hooks/useManagementUrl"; +import { WelcomeStepTray } from "./WelcomeStepTray"; +import { WelcomeStepManagement } from "./WelcomeStepManagement"; + +const WINDOW_WIDTH = 360; + +type WelcomeStep = "tray" | "management"; + +function shouldShowManagementStep( + activeProfileId: string, + email: string, + managementUrl: string, + managedManagementUrl: string, +): boolean { + if (managedManagementUrl) return false; + // The default profile's ID equals the literal "default", so this check + // holds whether we pass an ID or the legacy name. + if (activeProfileId !== "default") return false; + if (email.trim() !== "") return false; + return isNetbirdCloud(managementUrl); +} + +type InitialState = { + profileName: string; + username: string; + managementUrl: string; + needsManagementStep: boolean; +}; + +export default function WelcomeDialog() { + const [step, setStep] = useState("tray"); + const [initial, setInitial] = useState(null); + const [closing, setClosing] = useState(false); + const contentRef = useAutoSizeWindow(WINDOW_WIDTH, initial !== null); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const [username, active] = await Promise.all([ + ProfilesSvc.Username(), + ProfilesSvc.GetActive(), + ]); + const profileId = active.id || "default"; + const [config, list, restrictions] = await Promise.all([ + SettingsSvc.GetConfig({ profileName: profileId, username }), + ProfilesSvc.List(username), + SettingsSvc.GetRestrictions().catch(() => new Restrictions()), + ]); + const profile = list.find((p) => p.id === profileId); + const email = profile?.email ?? ""; + if (cancelled) return; + setInitial({ + profileName: profileId, + username, + managementUrl: config.managementUrl, + needsManagementStep: shouldShowManagementStep( + profileId, + email, + config.managementUrl, + restrictions.mdm.managementURL, + ), + }); + } catch (e) { + console.error("welcome: initial probe failed", e); + if (cancelled) return; + setInitial({ + profileName: "default", + username: "", + managementUrl: "", + needsManagementStep: false, + }); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const finish = useCallback(async () => { + if (closing) return; + setClosing(true); + try { + await Preferences.SetOnboardingCompleted(true); + } catch (e) { + console.error("persist onboarding flag:", e); + } + try { + await WindowManager.OpenMain(); + } catch (e) { + console.error("open main window:", e); + } + try { + await WindowManager.CloseWelcome(); + } catch (e) { + console.error("close welcome window:", e); + } + }, [closing]); + + const handleTrayContinue = useCallback(async () => { + if (initial?.needsManagementStep) { + setStep("management"); + } else { + await finish(); + } + }, [initial, finish]); + + const handleManagementContinue = useCallback( + async (url: string) => { + if (!initial) return; + try { + // SetConfig is a partial update — undefined fields are preserved Go-side. + await SettingsSvc.SetConfig( + new SetConfigParams({ + profileName: initial.profileName, + username: initial.username, + managementUrl: url, + }), + ); + } catch (e) { + await errorDialog({ + Title: i18next.t("settings.error.saveTitle"), + Message: formatErrorMessage(e), + }); + throw e; + } + setInitial((s) => (s ? { ...s, managementUrl: url } : s)); + await finish(); + }, + [initial, finish], + ); + + const content = useMemo(() => { + if (!initial) { + return null; + } + switch (step) { + case "tray": + return ; + case "management": + return ( + + ); + } + }, [initial, step, handleTrayContinue, handleManagementContinue]); + + return ( + + {content} + + ); +} diff --git a/client/ui/frontend/src/modules/welcome/WelcomeStepManagement.tsx b/client/ui/frontend/src/modules/welcome/WelcomeStepManagement.tsx new file mode 100644 index 000000000..5cb04d484 --- /dev/null +++ b/client/ui/frontend/src/modules/welcome/WelcomeStepManagement.tsx @@ -0,0 +1,135 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/buttons/Button"; +import { DialogActions } from "@/components/dialog/DialogActions"; +import { DialogDescription } from "@/components/dialog/DialogDescription"; +import { DialogHeading } from "@/components/dialog/DialogHeading"; +import { Input } from "@/components/inputs/Input"; +import { ManagementServerSwitch } from "@/components/ManagementServerSwitch"; +import { + CLOUD_MANAGEMENT_URL, + ManagementMode, + checkManagementUrlReachable, + isNetbirdCloud, + isValidManagementUrl, + normalizeManagementUrl, +} from "@/hooks/useManagementUrl"; +import { cn } from "@/lib/cn.ts"; +import { isMacOS } from "@/lib/platform.ts"; + +type WelcomeStepManagementProps = { + initialUrl: string; + onContinue: (url: string) => Promise; +}; + +export function WelcomeStepManagement({ + initialUrl, + onContinue, +}: Readonly) { + const { t } = useTranslation(); + const startsCloud = isNetbirdCloud(initialUrl); + const [mode, setMode] = useState( + startsCloud ? ManagementMode.Cloud : ManagementMode.SelfHosted, + ); + const [url, setUrl] = useState(startsCloud ? "" : initialUrl); + const [syntaxError, setSyntaxError] = useState(null); + const [unreachable, setUnreachable] = useState(false); + const [checking, setChecking] = useState(false); + + const trimmedUrl = url.trim(); + const syntaxValid = mode === ManagementMode.Cloud || isValidManagementUrl(trimmedUrl); + const inputRef = useRef(null); + const initialMountRef = useRef(true); + const initialSelfHostedRef = useRef(!startsCloud); + + useEffect(() => { + setSyntaxError(null); + setUnreachable(false); + }, [url, mode]); + + useEffect(() => { + if (initialMountRef.current && initialSelfHostedRef.current) { + inputRef.current?.focus(); + } + initialMountRef.current = false; + }, []); + + const handleContinue = useCallback(async () => { + if (checking) return; + if (mode === ManagementMode.SelfHosted && (!trimmedUrl || !syntaxValid)) { + setSyntaxError(t("welcome.management.urlInvalid")); + inputRef.current?.focus(); + return; + } + const target = + mode === ManagementMode.Cloud + ? CLOUD_MANAGEMENT_URL + : normalizeManagementUrl(trimmedUrl); + if (mode === ManagementMode.SelfHosted && !unreachable) { + setChecking(true); + const reachable = await checkManagementUrlReachable(target); + setChecking(false); + if (!reachable) { + setUnreachable(true); + return; + } + } + try { + await onContinue(target); + } catch (e) { + console.error("save management url:", e); + } + }, [checking, mode, syntaxValid, trimmedUrl, unreachable, onContinue, t]); + + const inputError = syntaxError ?? undefined; + const inputWarning = useMemo( + () => (!syntaxError && unreachable ? t("welcome.management.urlUnreachable") : undefined), + [syntaxError, unreachable, t], + ); + + return ( + <> +
                                                + + {t("welcome.management.title")} + + + {t("welcome.management.description")} + +
                                                + +
                                                + +
                                                + + {mode === ManagementMode.SelfHosted && ( +
                                                + setUrl(e.target.value)} + error={inputError} + warning={inputWarning} + spellCheck={false} + autoComplete={"off"} + autoCorrect={"off"} + autoCapitalize={"off"} + /> +
                                                + )} + + + + + + ); +} diff --git a/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx b/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx new file mode 100644 index 000000000..5a8b0d015 --- /dev/null +++ b/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx @@ -0,0 +1,61 @@ +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/buttons/Button"; +import { DialogActions } from "@/components/dialog/DialogActions"; +import { DialogDescription } from "@/components/dialog/DialogDescription"; +import { DialogHeading } from "@/components/dialog/DialogHeading"; +import { isMacOS, isWindows } from "@/lib/platform"; +import trayScreenshotDarwin from "@/assets/img/tray-darwin.png"; +import trayScreenshotWindows from "@/assets/img/tray-windows.png"; +import trayScreenshotLinux from "@/assets/img/tray-linux.png"; + +// Call at render time, not module scope: initPlatform() must run before isMacOS/isWindows. +function trayScreenshotForOS(): string { + if (isMacOS()) return trayScreenshotDarwin; + if (isWindows()) return trayScreenshotWindows; + return trayScreenshotLinux; +} + +type WelcomeStepTrayProps = { + onContinue: () => void; +}; + +export function WelcomeStepTray({ onContinue }: Readonly) { + const { t } = useTranslation(); + const trayScreenshot = trayScreenshotForOS(); + // macOS has no tray — the icon sits in the menu bar, so the copy says so. + const titleKey = isMacOS() ? "welcome.titleMac" : "welcome.title"; + const descriptionKey = isMacOS() ? "welcome.descriptionMac" : "welcome.description"; + + return ( + <> +
                                                + {""} +
                                                + +
                                                + + {t(titleKey)} + + {t(descriptionKey)} +
                                                + + + + + + ); +} diff --git a/client/ui/frontend/src/vite-env.d.ts b/client/ui/frontend/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/client/ui/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/client/ui/frontend/tailwind.config.ts b/client/ui/frontend/tailwind.config.ts new file mode 100644 index 000000000..93ff39eea --- /dev/null +++ b/client/ui/frontend/tailwind.config.ts @@ -0,0 +1,177 @@ +import type { Config } from "tailwindcss"; + +const config: Config = { + content: ["./index.html", "./src/**/*.{ts,tsx}"], + darkMode: "class", + theme: { + fontFamily: { + sans: ['"Inter Variable"', 'ui-sans-serif', 'system-ui', 'sans-serif'], + mono: ['"JetBrains Mono Variable"', 'ui-monospace', 'monospace'], + }, + extend: { + colors: { + "nb-gray": { + DEFAULT: "#181A1D", + 50: "#f4f6f7", + 100: "#e4e7e9", + 200: "#cbd2d6", + 250: "#b7c0c6", + 300: "#a3adb5", + 350: "#8f9ca8", + 400: "#7c8994", + 500: "#616e79", + 600: "#535d67", + 700: "#474e57", + 800: "#3f444b", + 850: "#363b40", + 900: "#2e3238", + 910: "#2b2f33", + 920: "#25282d", + 925: "#1e2123", + 930: "#25282c", + 935: "#1f2124", + 940: "#1c1e21", + 950: "#181a1d", + 960: "#16181b", + }, + gray: { + 50: "#F9FAFB", + 100: "#F3F4F6", + 200: "#E5E7EB", + 300: "#D1D5DB", + 400: "#9CA3AF", + 500: "#6B7280", + 600: "#4B5563", + 700: "#374151", + 800: "#1F2937", + 900: "#111827", + }, + red: { + 50: "#FDF2F2", + 100: "#FDE8E8", + 200: "#FBD5D5", + 300: "#F8B4B4", + 400: "#F98080", + 500: "#F05252", + 600: "#E02424", + 700: "#C81E1E", + 800: "#9B1C1C", + 900: "#771D1D", + }, + yellow: { + 50: "#FDFDEA", + 100: "#FDF6B2", + 200: "#FCE96A", + 300: "#FACA15", + 400: "#E3A008", + 500: "#C27803", + 600: "#9F580A", + 700: "#8E4B10", + 800: "#723B13", + 900: "#633112", + }, + green: { + 50: "#F3FAF7", + 100: "#DEF7EC", + 200: "#BCF0DA", + 300: "#84E1BC", + 400: "#31C48D", + 500: "#0E9F6E", + 600: "#057A55", + 700: "#046C4E", + 800: "#03543F", + 900: "#014737", + }, + blue: { + 50: "#EBF5FF", + 100: "#E1EFFE", + 200: "#C3DDFD", + 300: "#A4CAFE", + 400: "#76A9FA", + 500: "#3F83F8", + 600: "#1C64F2", + 700: "#1A56DB", + 800: "#1E429F", + 900: "#233876", + }, + indigo: { + 50: "#F0F5FF", + 100: "#E5EDFF", + 200: "#CDDBFE", + 300: "#B4C6FC", + 400: "#8DA2FB", + 500: "#6875F5", + 600: "#5850EC", + 700: "#5145CD", + 800: "#42389D", + 900: "#362F78", + }, + purple: { + 50: "#F6F5FF", + 100: "#EDEBFE", + 200: "#DCD7FE", + 300: "#CABFFD", + 400: "#AC94FA", + 500: "#9061F9", + 600: "#7E3AF2", + 700: "#6C2BD9", + 800: "#5521B5", + 900: "#4A1D96", + }, + pink: { + 50: "#FDF2F8", + 100: "#FCE8F3", + 200: "#FAD1E8", + 300: "#F8B4D9", + 400: "#F17EB8", + 500: "#E74694", + 600: "#D61F69", + 700: "#BF125D", + 800: "#99154B", + 900: "#751A3D", + }, + netbird: { + DEFAULT: "#f68330", + 50: "#fff6ed", + 100: "#feecd6", + 150: "#ffdfb8", + 200: "#ffd4a6", + 300: "#fab677", + 400: "#f68330", + 500: "#f46d1b", + 600: "#e55311", + 700: "#be3e10", + 800: "#973215", + 900: "#7a2b14", + 950: "#421308", + }, + }, + backgroundImage: { + "conic-netbird": "conic-gradient(from 0deg, #e55311 0%, #f68330 10%, #e55311 20%, #e55311 100%)", + }, + keyframes: { + "pulse-reverse": { + "0%, 100%": { opacity: "1" }, + "50%": { opacity: "0.4" }, + }, + "spin-slow": { + "0%": { transform: "rotate(0deg)" }, + "100%": { transform: "rotate(360deg)" }, + }, + "ping-slow": { + "0%": { transform: "scale(1)", opacity: "1" }, + "75%, 100%": { transform: "scale(2)", opacity: "0" }, + }, + }, + animation: { + "ping-slow": "ping-slow 2s cubic-bezier(0, 0, 0.2, 1) infinite", + "pulse-slow": "pulse-reverse 2s cubic-bezier(0.5, 0, 0.6, 1) infinite", + "pulse-slower": "pulse-reverse 3s cubic-bezier(0.5, 0, 0.6, 1) infinite", + "spin-slow": "spin-slow 2s linear infinite", + }, + }, + }, + plugins: [require("tailwindcss-animate")], +}; + +export default config; diff --git a/client/ui/frontend/tsconfig.json b/client/ui/frontend/tsconfig.json new file mode 100644 index 000000000..f95ce9015 --- /dev/null +++ b/client/ui/frontend/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": false, + "noImplicitAny": false, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@bindings/*": ["bindings/github.com/netbirdio/netbird/client/ui/*"] + } + }, + "include": ["src", "bindings"], +} diff --git a/client/ui/frontend/vite.config.ts b/client/ui/frontend/vite.config.ts new file mode 100644 index 000000000..48b1c5630 --- /dev/null +++ b/client/ui/frontend/vite.config.ts @@ -0,0 +1,28 @@ +import path from "path"; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import wails from "@wailsio/runtime/plugins/vite"; + +// https://vitejs.dev/config/ +export default defineConfig({ + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + "@bindings": path.resolve( + __dirname, + "./bindings/github.com/netbirdio/netbird/client/ui", + ), + }, + }, + plugins: [react(), wails("./bindings")], + server: { + host: "127.0.0.1", + port: 9245, + strictPort: true, + fs: { + // The i18n bundles live at ../i18n/locales (shared with the Go tray). + // Whitelist the parent dir so Vite's dev server serves them. + allow: [path.resolve(__dirname, ".."), __dirname], + }, + }, +}); diff --git a/client/ui/grpc.go b/client/ui/grpc.go new file mode 100644 index 000000000..5450e136d --- /dev/null +++ b/client/ui/grpc.go @@ -0,0 +1,71 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "fmt" + "runtime" + "sync" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/backoff" + + "github.com/netbirdio/netbird/client/internal/daemonaddr" + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/client/ui/desktop" +) + +// Conn is the lazy, lock-protected gRPC connection shared by all services so they reuse one channel. +type Conn struct { + addr string + + mu sync.Mutex + client proto.DaemonServiceClient +} + +func NewConn(addr string) *Conn { + return &Conn{addr: addr} +} + +func (c *Conn) Client() (proto.DaemonServiceClient, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.client != nil { + return c.client, nil + } + + // Lazy on purpose: grpc.NewClient does not connect here, so a daemon that + // is down surfaces as a per-RPC Unavailable instead of blocking the UI. + target, opts := daemonaddr.DialTarget(daemonaddr.ResolveDaemonAddr(c.addr)) + opts = append(opts, + grpc.WithUserAgent(desktop.GetUIUserAgent()), + // Cap reconnect backoff at 5s; gRPC's default 120s MaxDelay would + // leave the UI waiting 30-60s to notice a freshly-started daemon. + grpc.WithConnectParams(grpc.ConnectParams{ + Backoff: backoff.Config{ + BaseDelay: 1 * time.Second, + Multiplier: 1.6, + Jitter: 0.2, + MaxDelay: 5 * time.Second, + }, + }), + ) + + cc, err := grpc.NewClient(target, opts...) + if err != nil { + return nil, fmt.Errorf("dial daemon: %w", err) + } + c.client = proto.NewDaemonServiceClient(cc) + return c.client, nil +} + +// DaemonAddr returns the default daemon gRPC address: a Unix socket on +// Linux/macOS, a named pipe on Windows. The pipe carries the caller's token, +// which loopback TCP does not, so the daemon can tell who is calling. +func DaemonAddr() string { + if runtime.GOOS == "windows" { + return daemonaddr.WindowsPipeAddr + } + return "unix:///var/run/netbird.sock" +} diff --git a/client/ui/guilog/debuglog.go b/client/ui/guilog/debuglog.go new file mode 100644 index 000000000..3a25c26c8 --- /dev/null +++ b/client/ui/guilog/debuglog.go @@ -0,0 +1,77 @@ +//go:build !android && !ios && !freebsd && !js + +// Package guilog manages gui-client.log, which follows the daemon's log level: +// in debug/trace the GUI attaches a rotated file alongside the console so its +// (and the React frontend's forwarded) output is captured for the debug bundle. +package guilog + +import ( + "sync" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/util" +) + +// DebugLog attaches/detaches gui-client.log based on the daemon's log level, +// fed via Apply. The file is left on disk for the debug bundle to collect. +// Disabled (and never touches logging) when the user set --log-file explicitly. +type DebugLog struct { + uiPath string + enabled bool + + mu sync.Mutex + fileOn bool +} + +// NewDebugLog builds the GUI debug log. enabled is false when the user passed +// --log-file (manual override). +func NewDebugLog(uiPath string, enabled bool) *DebugLog { + return &DebugLog{uiPath: uiPath, enabled: enabled} +} + +// Path returns the GUI log path to register with the daemon, or "" when disabled +// so the daemon won't collect a file the GUI never writes. +func (d *DebugLog) Path() string { + if !d.enabled { + return "" + } + return d.uiPath +} + +// Apply reacts to a daemon log level (the logrus name, e.g. "debug"). +// Idempotent via the fileOn guard, so the startup replay plus a racing +// change-event are harmless. +func (d *DebugLog) Apply(level string) { + if !d.enabled { + return + } + + // Compared numerically so there are no hard-coded level-name literals. + lvl, err := log.ParseLevel(level) + if err != nil { + lvl = log.InfoLevel + } + debug := lvl >= log.DebugLevel + + d.mu.Lock() + defer d.mu.Unlock() + + switch { + case debug && !d.fileOn: + if err := util.SetLogOutputs(log.StandardLogger(), util.LogConsole, d.uiPath); err != nil { + log.Errorf("attach GUI file log %s: %v", d.uiPath, err) + return + } + log.SetLevel(lvl) + d.fileOn = true + log.Infof("GUI file logging enabled (daemon level %s), writing to %s", level, d.uiPath) + case !debug && d.fileOn: + if err := util.SetLogOutputs(log.StandardLogger(), util.LogConsole); err != nil { + log.Errorf("detach GUI file log: %v", err) + } + log.SetLevel(log.InfoLevel) + d.fileOn = false + log.Infof("GUI file logging disabled (daemon level: %s)", level) + } +} diff --git a/client/ui/i18n/TRANSLATING.md b/client/ui/i18n/TRANSLATING.md new file mode 100644 index 000000000..803e093c8 --- /dev/null +++ b/client/ui/i18n/TRANSLATING.md @@ -0,0 +1,130 @@ +# Translating the NetBird UI + +A short brief for translating the desktop UI — for any translator, human or AI agent (*"you"* = whoever's translating). + +**Translations are managed on Crowdin: .** Join the project, pick your language, and translate in the editor. Each string carries a context note (the `description` from the source file) telling you what it is and where it shows up, and the project's glossary, style guide, and QA checks mirror this document. + +> 💡 **The one habit that matters most:** read each string's context before translating it. Labels are terse and ambiguous on their own; the context tells you what the string is, where it shows up, what to keep verbatim, and what it actually means. + +--- + +## How contributions flow + +```text +i18n/locales/en/common.json ──sync──▶ Crowdin ──service PR──▶ i18n/locales//common.json +``` + +- `i18n/locales/en/common.json` is the source of truth. New and changed strings sync to Crowdin automatically (see `crowdin.yml` in the repository root). +- Crowdin opens and updates a service pull request with the translated bundles, keeping the source's file shape and key order. Keys nobody has translated yet are left out of the export; the app falls back to English for them at runtime. Maintainers review and merge that PR. +- Don't hand-edit `i18n/locales//common.json` in your own PRs: the next sync would conflict with or overwrite your changes. Translate on Crowdin instead. +- Missing your language? Request it on the Crowdin project page or in a [GitHub discussion](https://github.com/netbirdio/netbird/discussions). When a language first ships, a maintainer adds its row to `i18n/locales/_index.json` with `code`, `displayName` (the native name), and `englishName`, which puts it in the app's language picker. + +**Prefer translating with an AI agent?** That still works: drive it with *"Read `i18n/TRANSLATING.md` and translate the UI to Russian"* as before, but deliver the result to Crowdin instead of a pull request. Download your language's file from the Crowdin editor, let the agent translate it, and upload it back (the editor's offline translation flow). Crowdin runs its QA checks on upload, and the next service PR carries the strings into the repo. + +--- + +## What NetBird is + +A **business zero-trust VPN** — an encrypted **overlay mesh** between a company's devices, built on **WireGuard®**, connecting peers directly with a **relay** fallback. This is the **desktop client** (tray app + windows) someone runs to connect, switch profiles, browse peers, and pick an exit node — *not* the admin dashboard. + +**Audience:** IT-literate professionals. **Tone:** clear and professional, never consumer-cute. + +**The vocabulary you'll meet:** + +| Term | What it means here | +|---|---| +| **Peer** | A device on the network (laptop, server, phone) | +| **Resource / Network** | A routed network or service reachable through NetBird (UI calls these "Resources") | +| **Exit Node** | A peer that routes *all* internet traffic, like a full-tunnel gateway | +| **Profile** | A saved connection identity you can switch between | +| **Daemon** | The background service the UI talks to | +| **Management server** | The control plane — *Cloud* (hosted) or *self-hosted* (customer-run) | +| **Relay** | Forwards traffic when two peers can't connect directly | +| **Rosenpass** | Post-quantum security layered over WireGuard® | +| **Handshake** | The periodic WireGuard® key sync between peers | + +--- + +## Hard rules — get these exactly right + +These are the usual ways a translation *breaks the app*, not just reads oddly. + +| ✅ Do | ❌ Don't | +|---|---| +| Copy `{placeholders}` verbatim — `{version}`, `{count}`, `{name}`… | Translate the word inside the braces (`{verbleibend}` breaks it) | +| Reposition a placeholder so the sentence flows | Drop or duplicate a placeholder | +| Preserve every `\n`, leading/trailing space, and trailing `...` | Trim "invisible" spaces or the `...` (they're load-bearing) | +| Keep `®` in WireGuard® and quotes around `{name}` | Strip punctuation the context flags | + +**Plurals:** the app has only a *one / other* split — the singular key fires only when `count == 1`; the `{count}` key covers everything else (0, 2, 5, 100…). Languages with more than two forms (ru, pl, uk) can't be fully correct here — use the form that fits the widest range (Russian genitive plural: `минут` / `часов` / `дней`). Don't invent extra keys or cram multiple forms into one string. When no single form fits every value — a unit label after a number field, say — reach for a number-agnostic form (an abbreviation, or wording that reads the same for 1 and 100) instead of forcing a plural the *one / other* split can't supply. + +**Agreement:** a `{placeholder}` drops a value into a fixed frame, so the words around it must fit *every* value the app can supply. In inflected languages, write the frame in the case the surrounding preposition demands — German's duration fragments are **dative** because they land inside "…in {remaining}" (`in {count} Tagen`, `weniger als einer Minute`), not nominative `Tage`. Check the key that *consumes* the fragment (here `tray.session.expiresIn`) before choosing the form. + +--- + +## Glossary + +**Tier A — never translate (brands):** `NetBird` · `WireGuard®` · `Rosenpass` · `GitHub` · `ICE` · company/product names · sample URLs · version numbers. + +When a brand sits beside a common noun, keep its exact spelling but join them the way your language builds such phrases — a hyphen, a connector word, an inflected noun — rather than copying English's bare noun-stack. + +**Tier B — keep as-is (acronyms):** `SSO` · `MFA` · `DNS` · `IP`/`IPv6` · `ACL` · `SSH` · `GUI` · `P2P` · `URL` · `TCP`/`UDP`. + +**Tier C — judgment.** One rule decides every term: + +> **Use the word that language's IT users actually say.** Translate when a natural, common term exists; keep the English term *only* when the literal translation would be awkward or no one in that field really uses it. + +Apply each term **consistently** — same English term → same translation everywhere — and keep a term once you've settled it. Whether a term stays English or takes a native word is **language-dependent**: a technical loanword (e.g. *Daemon*, *Handshake*) often stays, an everyday word (e.g. *Latency*, *Public key*) usually localizes, and some (*Exit Node*, *Peer*) go either way depending on the language. Decide per term with the rule above — a foreign origin alone is no reason to keep English. **Your main reference is the existing translation:** match how a term was already rendered for your language rather than re-deciding it. + +Two checks before you commit a term: + +- **Prefer established localized wording.** If a widely used tool in this space (for example WireGuard) ships your language, its wording for a shared term such as *handshake* is what users already expect — look at the translated app, not just English docs. For generic UI verbs and formal address, follow your OS vendor's style guide (Microsoft / Apple / Google). +- **Watch for false friends.** A literal translation can collide with a *different* established term in your field — confirm your word doesn't already mean something else in this domain before using it. + +These tiers are mirrored in the Crowdin project glossary, so the editor highlights them inline. When you settle a new Tier C term for your language, add its translation to the glossary entry so it sticks for everyone who comes after you. + +--- + +## Style + +| ✅ Do | ❌ Don't | +|---|---| +| Use the **formal "you"** (de *Sie*, fr *vous*, ru *вы*, it *Lei*, zh 您) | Use casual/informal address | +| Keep **buttons, menu, and tray** items short, in your language's action form (de "Speichern", fr "Enregistrer") | Let a label run much longer than the English — space is tight | +| Follow **locale punctuation** (fr NBSP + « », de „…", zh full-width), including around a quoted UI label | Carry over English Title Case (use sentence case; German nouns excepted) | +| Translate a term the **same way everywhere** | Vary wording for the same concept across screens | + +Where it reads naturally, aim to keep each string **roughly the same length** as the English — the UI is tight and over-long strings can wrap or truncate. It's a soft preference, not a rule: if your language simply needs more words, use them. + +A few habits that keep a translation reading like one product rather than a word-for-word port: + +- **Translate meaning, not words.** Render what a string *does*. An idiom or an awkward source phrase should become natural in your language, not a literal calque. +- **Keep one voice within a family.** Sibling strings — the connection states, every settings *help* caption, every "… Failed" title — should share a grammatical form. If one member sounds wrong in that form, re-voice the whole family rather than leave one odd sibling. +- **Mirror opposites.** A status should read as the natural counterpart of its pair: translate *Disconnected* as the opposite of however you rendered *Connected*, not as an unrelated word. Same for Active/Inactive, Selected/Not selected. +- **Give a standalone label its subject.** A bare button or title can lose the context the surrounding English UI implied — add the noun back if it would otherwise read ambiguously. + +--- + +## Reviewing a language + +**On Crowdin:** proofread in the editor — context, glossary highlights, and QA flags sit inline next to each string. + +**In the repo** — e.g. driving an AI agent with *"Read `i18n/TRANSLATING.md` and review the existing German translation"* — read source and target side by side; for each key check glossary conformance (e.g. de `Exit-Node` → `Exit Node`, hu `Kilépő csomópont` → `Exit Node`), placeholder/`\n` integrity, consistency, tone, and that the meaning matches the English `description`. Report what you found, and apply the fixes **on Crowdin** — direct edits to the locale files are overwritten by the next sync. + +--- + +## QA before you finish + +- [ ] Every `{placeholder}`, `\n`, and intentional space preserved · `...` / `… Failed` / `{name}` quotes kept +- [ ] Tier A/B left intact · Tier C applied consistently (and matching the existing translation for your language) +- [ ] Buttons & tray short · locale punctuation and capitalization applied +- [ ] Crowdin QA flags resolved (variables, glossary terms, punctuation) +- [ ] **Tested in the running app** ↓ + +--- + +## Test it in the app + +A translation can pass every check above and still read wrong on screen. **Run the app, switch to your language, and click through the real surfaces** — tray menu, main window, every Settings tab, the dialogs. Watch for text overflow or truncation, labels that are technically right but wrong *for what the control does*, leaked placeholders, and terms that drift between screens. + +How to run the app and switch language: see the project README. Can't run it (e.g. a headless agent)? Say so in your summary — don't silently skip this step. diff --git a/client/ui/i18n/bundle.go b/client/ui/i18n/bundle.go new file mode 100644 index 000000000..892916999 --- /dev/null +++ b/client/ui/i18n/bundle.go @@ -0,0 +1,196 @@ +//go:build !android && !ios && !freebsd && !js + +// Package i18n loads and serves translation strings for both the tray (Go) +// and the React UI (via the services.I18n facade). +// +// The locale tree is passed in as an fs.FS so the embed directive can live in +// the main binary. +package i18n + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "path" + "sort" + "strings" + "sync" + + log "github.com/sirupsen/logrus" +) + +const ( + localeIndexFile = "_index.json" + + // commonBundleFile shape is Chrome-extension JSON (key -> "message" plus + // optional Crowdin "description"); loadBundle flattens to key->message. + commonBundleFile = "common.json" +) + +// LanguageCode is a BCP-47-ish locale identifier ("en", "hu", ...). +type LanguageCode string + +// DefaultLanguage is the fallback bundle for missing keys and the default +// when no preference is on disk. +const DefaultLanguage LanguageCode = "en" + +var ErrUnsupportedLanguage = errors.New("unsupported language") + +// Language describes one shipped UI locale. DisplayName is in the locale's +// own script (a Hungarian entry reads "Magyar" regardless of UI language). +type Language struct { + Code LanguageCode `json:"code"` + DisplayName string `json:"displayName"` + EnglishName string `json:"englishName"` +} + +type localeIndex struct { + Languages []Language `json:"languages"` +} + +// Bundle holds the parsed translation bundles. Loaded once at construction +// and never mutated. +type Bundle struct { + mu sync.RWMutex + languages []Language + bundles map[LanguageCode]map[string]string +} + +// NewBundle parses _index.json plus every /common.json in the locale +// tree. Hard-fails only when the default language is missing; other locales +// without a bundle are dropped with a warning. +func NewBundle(localesFS fs.FS) (*Bundle, error) { + idx, err := loadLocaleIndex(localesFS) + if err != nil { + return nil, fmt.Errorf("load locale index: %w", err) + } + + bundles := make(map[LanguageCode]map[string]string, len(idx.Languages)) + available := make([]Language, 0, len(idx.Languages)) + for _, l := range idx.Languages { + b, err := loadBundle(localesFS, l.Code) + if err != nil { + log.Warnf("skip language %q: %v", l.Code, err) + continue + } + bundles[l.Code] = b + available = append(available, l) + } + + if _, ok := bundles[DefaultLanguage]; !ok { + return nil, fmt.Errorf("default language %q bundle missing", DefaultLanguage) + } + + sort.Slice(available, func(i, j int) bool { return available[i].Code < available[j].Code }) + + return &Bundle{ + languages: available, + bundles: bundles, + }, nil +} + +// Languages returns a copy of the available locales. +func (b *Bundle) Languages() []Language { + b.mu.RLock() + defer b.mu.RUnlock() + out := make([]Language, len(b.languages)) + copy(out, b.languages) + return out +} + +func (b *Bundle) HasLanguage(code LanguageCode) bool { + b.mu.RLock() + defer b.mu.RUnlock() + _, ok := b.bundles[code] + return ok +} + +// BundleFor returns a copy of the full key->text map for one language. +func (b *Bundle) BundleFor(code LanguageCode) (map[string]string, error) { + b.mu.RLock() + defer b.mu.RUnlock() + + bundle, ok := b.bundles[code] + if !ok { + return nil, fmt.Errorf("%w: %q", ErrUnsupportedLanguage, code) + } + out := make(map[string]string, len(bundle)) + for k, v := range bundle { + out[k] = v + } + return out, nil +} + +// Translate resolves key for lang, substituting args given as name/value +// pairs ("version", "1.2.3" replaces "{version}"). Unknown keys fall back to +// the default language, then to the key itself so a miss is visible in the UI. +func (b *Bundle) Translate(lang LanguageCode, key string, args ...string) string { + b.mu.RLock() + defer b.mu.RUnlock() + + if v, ok := b.bundles[lang][key]; ok { + return applyPlaceholders(v, args) + } + if lang != DefaultLanguage { + if v, ok := b.bundles[DefaultLanguage][key]; ok { + return applyPlaceholders(v, args) + } + } + return key +} + +// applyPlaceholders substitutes {name} in s using args as flat name/value +// pairs. An odd-length args drops the trailing item. +func applyPlaceholders(s string, args []string) string { + if len(args) == 0 { + return s + } + if len(args)%2 != 0 { + log.Debugf("i18n placeholder args not paired: %d items, last dropped", len(args)) + args = args[:len(args)-1] + } + for j := 0; j < len(args); j += 2 { + s = strings.ReplaceAll(s, "{"+args[j]+"}", args[j+1]) + } + return s +} + +func loadLocaleIndex(localesFS fs.FS) (*localeIndex, error) { + data, err := fs.ReadFile(localesFS, localeIndexFile) + if err != nil { + return nil, err + } + var idx localeIndex + if err := json.Unmarshal(data, &idx); err != nil { + return nil, fmt.Errorf("parse %s: %w", localeIndexFile, err) + } + if len(idx.Languages) == 0 { + return nil, errors.New("no languages declared") + } + return &idx, nil +} + +// bundleEntry is one translation key on disk; Description is Crowdin context, +// ignored at runtime. +type bundleEntry struct { + Message string `json:"message"` + Description string `json:"description,omitempty"` +} + +func loadBundle(localesFS fs.FS, code LanguageCode) (map[string]string, error) { + p := path.Join(string(code), commonBundleFile) + data, err := fs.ReadFile(localesFS, p) + if err != nil { + return nil, err + } + var entries map[string]bundleEntry + if err := json.Unmarshal(data, &entries); err != nil { + return nil, fmt.Errorf("parse %s: %w", p, err) + } + bundle := make(map[string]string, len(entries)) + for k, e := range entries { + bundle[k] = e.Message + } + return bundle, nil +} diff --git a/client/ui/i18n/bundle_test.go b/client/ui/i18n/bundle_test.go new file mode 100644 index 000000000..d0b0d24d7 --- /dev/null +++ b/client/ui/i18n/bundle_test.go @@ -0,0 +1,156 @@ +//go:build !android && !ios && !freebsd && !js + +package i18n + +import ( + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeLocales returns an in-memory FS that mirrors the real +// client/ui/i18n/locales layout (root-level _index.json plus +// /common.json bundles). Used by every Bundle test so we don't +// depend on the embedded production bundles staying stable. +func fakeLocales() fstest.MapFS { + return fstest.MapFS{ + "_index.json": {Data: []byte(`{ + "languages": [ + {"code": "en", "displayName": "English", "englishName": "English"}, + {"code": "hu", "displayName": "Magyar", "englishName": "Hungarian"} + ] + }`)}, + "en/common.json": {Data: []byte(`{ + "tray.menu.connect": {"message": "Connect", "description": "Tray menu item"}, + "tray.menu.installVersion": {"message": "Install version {version}"}, + "notify.update.body": {"message": "NetBird {version} is available."} + }`)}, + "hu/common.json": {Data: []byte(`{ + "tray.menu.connect": {"message": "Csatlakozás"}, + "tray.menu.installVersion": {"message": "{version} telepítése"} + }`)}, + } +} + +func TestBundle_LoadsAllLanguages(t *testing.T) { + b, err := NewBundle(fakeLocales()) + require.NoError(t, err) + + langs := b.Languages() + require.Len(t, langs, 2) + codes := []LanguageCode{langs[0].Code, langs[1].Code} + assert.ElementsMatch(t, []LanguageCode{"en", "hu"}, codes, "Languages should list every bundle that loaded") +} + +func TestBundle_TranslateLooksUpKey(t *testing.T) { + b, err := NewBundle(fakeLocales()) + require.NoError(t, err) + + assert.Equal(t, "Csatlakozás", b.Translate("hu", "tray.menu.connect")) + assert.Equal(t, "Connect", b.Translate("en", "tray.menu.connect")) +} + +func TestBundle_TranslateSubstitutesPlaceholders(t *testing.T) { + b, err := NewBundle(fakeLocales()) + require.NoError(t, err) + + assert.Equal(t, "Install version 1.2.3", + b.Translate("en", "tray.menu.installVersion", "version", "1.2.3"), + "placeholders should substitute by name") + assert.Equal(t, "1.2.3 telepítése", + b.Translate("hu", "tray.menu.installVersion", "version", "1.2.3")) +} + +func TestBundle_TranslateFallsBackToEnglish(t *testing.T) { + b, err := NewBundle(fakeLocales()) + require.NoError(t, err) + + // notify.update.body is missing from the hu bundle; English fallback + // applies so the user always sees a populated label rather than the + // raw key. + got := b.Translate("hu", "notify.update.body", "version", "9.9.9") + assert.Equal(t, "NetBird 9.9.9 is available.", got, "missing hu key should fall back to en bundle") +} + +func TestBundle_TranslateUnknownKeyReturnsKey(t *testing.T) { + b, err := NewBundle(fakeLocales()) + require.NoError(t, err) + + assert.Equal(t, "tray.missing", b.Translate("en", "tray.missing"), + "unknown key should return the key itself for debugability") +} + +func TestBundle_BundleForReturnsCopy(t *testing.T) { + b, err := NewBundle(fakeLocales()) + require.NoError(t, err) + + m, err := b.BundleFor("en") + require.NoError(t, err) + require.NotEmpty(t, m, "BundleFor should return populated map for known language") + + m["tray.menu.connect"] = "Mutated" + assert.Equal(t, "Connect", b.Translate("en", "tray.menu.connect"), + "BundleFor must return a copy, not the live map") +} + +func TestBundle_BundleForUnknownLanguage(t *testing.T) { + b, err := NewBundle(fakeLocales()) + require.NoError(t, err) + + _, err = b.BundleFor("xx") + assert.ErrorIs(t, err, ErrUnsupportedLanguage) +} + +func TestBundle_HasLanguage(t *testing.T) { + b, err := NewBundle(fakeLocales()) + require.NoError(t, err) + + assert.True(t, b.HasLanguage("en")) + assert.True(t, b.HasLanguage("hu")) + assert.False(t, b.HasLanguage("de")) +} + +func TestBundle_MissingDefaultBundleFails(t *testing.T) { + // Without an en bundle we have nothing to fall back to, so construction + // must hard-fail. Catches packaging accidents where someone drops the + // English locale. + fs := fstest.MapFS{ + "_index.json": {Data: []byte(`{"languages":[{"code":"hu","displayName":"Magyar","englishName":"Hungarian"}]}`)}, + "hu/common.json": {Data: []byte(`{"k":{"message":"v"}}`)}, + } + _, err := NewBundle(fs) + require.Error(t, err) + assert.Contains(t, err.Error(), "default language") +} + +func TestBundle_MissingBundleSkipsLanguage(t *testing.T) { + // A language declared in the index but missing its bundle file is + // dropped from Languages with a warning — adding a new language must + // be a two-step process (declare + ship), not declare-only. + fs := fstest.MapFS{ + "_index.json": {Data: []byte(`{"languages":[ + {"code":"en","displayName":"English","englishName":"English"}, + {"code":"de","displayName":"Deutsch","englishName":"German"} + ]}`)}, + "en/common.json": {Data: []byte(`{"k":{"message":"v"}}`)}, + } + b, err := NewBundle(fs) + require.NoError(t, err) + + langs := b.Languages() + require.Len(t, langs, 1) + assert.Equal(t, LanguageCode("en"), langs[0].Code, "language without a bundle file must be dropped") + assert.False(t, b.HasLanguage("de")) +} + +func TestBundle_OddPlaceholderArgsDoNotPanic(t *testing.T) { + b, err := NewBundle(fakeLocales()) + require.NoError(t, err) + + // Trailing dangling arg should be dropped, not panic — preserves UI + // stability when a caller passes an unpaired placeholder by mistake. + got := b.Translate("en", "tray.menu.installVersion", "version", "1.2.3", "extra") + assert.Equal(t, "Install version 1.2.3", got) +} diff --git a/client/ui/i18n/check-translations.mjs b/client/ui/i18n/check-translations.mjs new file mode 100644 index 000000000..bd076e0e0 --- /dev/null +++ b/client/ui/i18n/check-translations.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +// Validates that every shipped translation bundle carries exactly the same set +// of keys as the English source of truth. English (en) defines the keys; every +// other locale declared in _index.json must match it 1:1: +// +// - no missing keys — a missing key silently falls back to English at runtime +// (see i18n bundle fallback), so the gap never surfaces to users or CI +// without this check; +// - no orphaned keys — keys left behind after an English key is renamed or +// removed are dead weight and a sign the locale is drifting. +// +// Pure Node, no dependencies, so it runs without installing the frontend +// toolchain. +// +// Local: node client/ui/i18n/check-translations.mjs (or: pnpm i18n:check) +// CI: .github/workflows/ui-translations.yml + +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SOURCE = "en"; +const localesDir = join(dirname(fileURLToPath(import.meta.url)), "locales"); +const isCI = Boolean(process.env.GITHUB_ACTIONS); + +function readJSON(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function keysOf(langCode) { + return Object.keys(readJSON(join(localesDir, langCode, "common.json"))); +} + +// Emit a GitHub Actions annotation so failures render inline on the PR diff. +function annotate(file, message) { + if (isCI) console.log(`::error file=${file}::${message}`); +} + +const index = readJSON(join(localesDir, "_index.json")); +const declared = index.languages.map((l) => l.code); + +if (!declared.includes(SOURCE)) { + console.error(`FATAL: source language "${SOURCE}" is not declared in _index.json`); + process.exit(1); +} + +const sourceKeys = keysOf(SOURCE); +const sourceSet = new Set(sourceKeys); +console.log(`Source of truth: ${SOURCE}/common.json — ${sourceKeys.length} keys\n`); + +let failed = false; + +for (const code of declared) { + if (code === SOURCE) continue; + const file = `client/ui/i18n/locales/${code}/common.json`; + + let keys; + try { + keys = keysOf(code); + } catch (e) { + failed = true; + const msg = `bundle is declared in _index.json but common.json is missing or invalid (${e.message})`; + console.error(`✗ ${code}: ${msg}`); + annotate("client/ui/i18n/locales/_index.json", `${code}: ${msg}`); + continue; + } + + const set = new Set(keys); + const missing = sourceKeys.filter((k) => !set.has(k)); + const extra = keys.filter((k) => !sourceSet.has(k)); + + if (missing.length === 0 && extra.length === 0) { + console.log(`✓ ${code}: ${keys.length} keys`); + continue; + } + + failed = true; + console.error(`✗ ${code}: ${keys.length} keys (expected ${sourceKeys.length})`); + if (missing.length) { + console.error(` missing ${missing.length}: ${missing.join(", ")}`); + annotate(file, `Missing ${missing.length} key(s) present in ${SOURCE}: ${missing.join(", ")}`); + } + if (extra.length) { + console.error(` extra ${extra.length}: ${extra.join(", ")}`); + annotate(file, `Has ${extra.length} key(s) not present in ${SOURCE}: ${extra.join(", ")}`); + } +} + +// Locale directories present on disk but not declared in _index.json are never +// loaded by the app — surface them so dead translation files don't rot silently. +const onDisk = readdirSync(localesDir, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name); +const undeclared = onDisk.filter((d) => !declared.includes(d)); +if (undeclared.length) { + console.warn(`\n⚠ locale directories not declared in _index.json (not shipped): ${undeclared.join(", ")}`); +} + +console.log(); +if (failed) { + console.error("Translation check FAILED — every locale must match the English key set."); + process.exit(1); +} +console.log("Translation check passed — all locales match the English key set."); diff --git a/client/ui/i18n/locales/_index.json b/client/ui/i18n/locales/_index.json new file mode 100644 index 000000000..17fb1d8ea --- /dev/null +++ b/client/ui/i18n/locales/_index.json @@ -0,0 +1,15 @@ +{ + "languages": [ + {"code": "en", "displayName": "English (US)", "englishName": "English (US)"}, + {"code": "uk", "displayName": "Українська", "englishName": "Ukrainian"}, + {"code": "de", "displayName": "Deutsch", "englishName": "German"}, + {"code": "hu", "displayName": "Magyar", "englishName": "Hungarian"}, + {"code": "ru", "displayName": "Русский", "englishName": "Russian"}, + {"code": "es", "displayName": "Español", "englishName": "Spanish"}, + {"code": "fr", "displayName": "Français", "englishName": "French"}, + {"code": "it", "displayName": "Italiano", "englishName": "Italian"}, + {"code": "pt", "displayName": "Português", "englishName": "Portuguese"}, + {"code": "zh-CN", "displayName": "简体中文", "englishName": "Simplified Chinese"}, + {"code": "ja", "displayName": "日本語", "englishName": "Japanese"} + ] +} diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json new file mode 100644 index 000000000..11e085927 --- /dev/null +++ b/client/ui/i18n/locales/de/common.json @@ -0,0 +1,1378 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Nicht verbunden" + }, + "tray.status.daemonUnavailable": { + "message": "Nicht aktiv" + }, + "tray.status.error": { + "message": "Fehler" + }, + "tray.status.connected": { + "message": "Verbunden" + }, + "tray.status.connecting": { + "message": "Wird verbunden" + }, + "tray.status.needsLogin": { + "message": "Anmeldung erforderlich" + }, + "tray.status.loginFailed": { + "message": "Anmeldung fehlgeschlagen" + }, + "tray.status.sessionExpired": { + "message": "Sitzung abgelaufen" + }, + "tray.session.expiresIn": { + "message": "Sitzung läuft ab in {remaining}" + }, + "tray.session.unit.lessThanMinute": { + "message": "weniger als einer Minute" + }, + "tray.session.unit.minute": { + "message": "1 Minute" + }, + "tray.session.unit.minutes": { + "message": "{count} Minuten" + }, + "tray.session.unit.hour": { + "message": "1 Stunde" + }, + "tray.session.unit.hours": { + "message": "{count} Stunden" + }, + "tray.session.unit.day": { + "message": "1 Tag" + }, + "tray.session.unit.days": { + "message": "{count} Tagen" + }, + "tray.menu.open": { + "message": "NetBird öffnen" + }, + "tray.menu.connect": { + "message": "Verbinden" + }, + "tray.menu.disconnect": { + "message": "Trennen" + }, + "tray.menu.exitNode": { + "message": "Exit Node" + }, + "tray.menu.networks": { + "message": "Ressourcen" + }, + "tray.menu.profiles": { + "message": "Profile" + }, + "tray.menu.manageProfiles": { + "message": "Profile verwalten" + }, + "tray.menu.settings": { + "message": "Einstellungen …" + }, + "tray.menu.debugBundle": { + "message": "Debug-Paket erstellen" + }, + "tray.menu.about": { + "message": "Hilfe & Support" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Dokumentation" + }, + "tray.menu.troubleshoot": { + "message": "Fehlerbehebung" + }, + "tray.menu.downloadLatest": { + "message": "Neueste Version herunterladen" + }, + "tray.menu.installVersion": { + "message": "Version {version} installieren" + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}" + }, + "tray.menu.daemonVersion": { + "message": "Daemon: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "NetBird beenden" + }, + "notify.daemonOutdated.title": { + "message": "NetBird-Dienst ist veraltet" + }, + "notify.daemonOutdated.body": { + "message": "Aktualisieren Sie den NetBird-Dienst, um diese App zu verwenden." + }, + "notify.update.title": { + "message": "NetBird-Update verfügbar" + }, + "notify.update.body": { + "message": "NetBird {version} ist verfügbar." + }, + "notify.update.enforcedSuffix": { + "message": " Ihr Administrator verlangt dieses Update." + }, + "notify.error.title": { + "message": "Fehler" + }, + "notify.error.connect": { + "message": "Verbindung fehlgeschlagen" + }, + "notify.error.disconnect": { + "message": "Trennen fehlgeschlagen" + }, + "notify.error.switchProfile": { + "message": "Wechsel zu {profile} fehlgeschlagen" + }, + "notify.error.exitNode": { + "message": "Exit Node {name} konnte nicht aktualisiert werden" + }, + "notify.sessionExpired.title": { + "message": "NetBird-Sitzung abgelaufen" + }, + "notify.sessionExpired.body": { + "message": "Ihre NetBird-Sitzung ist abgelaufen. Bitte melden Sie sich erneut an." + }, + "notify.sessionWarning.title": { + "message": "Sitzung läuft bald ab" + }, + "notify.sessionWarning.body": { + "message": "Ihre NetBird-Sitzung läuft in {remaining} ab. Klicken Sie auf Jetzt verlängern, um zu erneuern." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "Ihre NetBird-Sitzung läuft bald ab. Klicken Sie auf Jetzt verlängern, um zu erneuern." + }, + "notify.sessionWarning.extend": { + "message": "Jetzt verlängern" + }, + "notify.sessionWarning.dismiss": { + "message": "Verwerfen" + }, + "notify.sessionWarning.failed": { + "message": "NetBird-Sitzung konnte nicht verlängert werden" + }, + "notify.sessionWarning.successTitle": { + "message": "NetBird-Sitzung verlängert" + }, + "notify.sessionWarning.successBody": { + "message": "Ihre Sitzung wurde erneuert." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Ungültige Sitzungsablaufzeit" + }, + "notify.sessionDeadlineRejected.body": { + "message": "Der Server hat eine ungültige Sitzungsablaufzeit übermittelt. Bitte melden Sie sich erneut an." + }, + "notify.mdm.policyApplied.title": { + "message": "NetBird-Einstellungen aktualisiert" + }, + "notify.mdm.policyApplied.body": { + "message": "Ihre NetBird-Konfiguration wurde durch Ihre IT-Richtlinie aktualisiert." + }, + "common.cancel": { + "message": "Abbrechen" + }, + "common.save": { + "message": "Speichern" + }, + "common.saveChanges": { + "message": "Änderungen speichern" + }, + "common.saving": { + "message": "Speichert…" + }, + "common.close": { + "message": "Schließen" + }, + "common.copy": { + "message": "Kopieren" + }, + "common.togglePasswordVisibility": { + "message": "Passwortsichtbarkeit umschalten" + }, + "common.increase": { + "message": "Erhöhen" + }, + "common.decrease": { + "message": "Verringern" + }, + "common.delete": { + "message": "Löschen" + }, + "common.create": { + "message": "Erstellen" + }, + "common.add": { + "message": "Hinzufügen" + }, + "common.remove": { + "message": "Entfernen" + }, + "common.refresh": { + "message": "Aktualisieren" + }, + "common.loading": { + "message": "Lädt…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "Keine Ergebnisse gefunden" + }, + "common.noResults.description": { + "message": "Es konnten keine Ergebnisse gefunden werden. Bitte versuchen Sie es mit einem anderen Suchbegriff oder ändern Sie Ihre Filter." + }, + "notConnected.title": { + "message": "Nicht verbunden" + }, + "notConnected.description": { + "message": "Verbinden Sie sich zuerst mit NetBird, um detaillierte Informationen zu Ihren Peers, Netzwerkressourcen und Exit Nodes einzusehen." + }, + "connect.status.disconnected": { + "message": "Nicht verbunden" + }, + "connect.status.connecting": { + "message": "Wird verbunden…" + }, + "connect.status.connected": { + "message": "Verbunden" + }, + "connect.status.disconnecting": { + "message": "Wird getrennt…" + }, + "connect.status.daemonUnavailable": { + "message": "Daemon nicht verfügbar" + }, + "connect.status.loginRequired": { + "message": "Anmeldung erforderlich" + }, + "connect.error.loginTitle": { + "message": "Anmeldung fehlgeschlagen" + }, + "connect.error.connectTitle": { + "message": "Verbindung fehlgeschlagen" + }, + "connect.error.disconnectTitle": { + "message": "Trennen fehlgeschlagen" + }, + "nav.peers.title": { + "message": "Peers" + }, + "nav.peers.description": { + "message": "{connected} von {total} verbunden" + }, + "nav.resources.title": { + "message": "Ressourcen" + }, + "nav.resources.description": { + "message": "{active} von {total} aktiv" + }, + "nav.exitNode.title": { + "message": "Exit Nodes" + }, + "nav.exitNode.none": { + "message": "Nicht aktiv" + }, + "nav.exitNode.using": { + "message": "Über {name}" + }, + "header.openSettings": { + "message": "Einstellungen öffnen" + }, + "header.togglePanel": { + "message": "Seitenleiste umschalten" + }, + "profile.selector.loading": { + "message": "Lädt…" + }, + "profile.selector.noProfile": { + "message": "Kein Profil" + }, + "profile.selector.searchPlaceholder": { + "message": "Profil nach Namen suchen…" + }, + "profile.selector.emptyTitle": { + "message": "Keine Profile gefunden" + }, + "profile.selector.emptyDescription": { + "message": "Versuchen Sie einen anderen Suchbegriff oder erstellen Sie ein neues Profil." + }, + "profile.selector.newProfile": { + "message": "Neues Profil" + }, + "profile.selector.moreOptions": { + "message": "Weitere Optionen" + }, + "profile.selector.deregister": { + "message": "Abmelden" + }, + "profile.selector.delete": { + "message": "Profil löschen" + }, + "profile.selector.switchTo": { + "message": "Zu diesem Profil wechseln" + }, + "profile.selector.edit": { + "message": "Bearbeiten" + }, + "profile.edit.title": { + "message": "Profil bearbeiten" + }, + "profile.edit.submit": { + "message": "Änderungen speichern" + }, + "profile.dialog.title": { + "message": "Neues Profil" + }, + "profile.dialog.nameLabel": { + "message": "Profilname" + }, + "profile.dialog.description": { + "message": "Legen Sie einen leicht erkennbaren Namen für Ihr Profil fest." + }, + "profile.dialog.placeholder": { + "message": "z. B. Arbeit" + }, + "profile.dialog.submit": { + "message": "Profil hinzufügen" + }, + "profile.dialog.required": { + "message": "Bitte geben Sie einen Profilnamen ein, z. B. Arbeit, Privat" + }, + "profile.dialog.managementHelp": { + "message": "NetBird Cloud oder Ihr eigener Server." + }, + "profile.dialog.urlUnreachable": { + "message": "Server nicht erreichbar. Überprüfen Sie die URL, oder fügen Sie das Profil trotzdem hinzu, wenn Sie sicher sind, dass sie korrekt ist." + }, + "header.menu.settings": { + "message": "Einstellungen …" + }, + "header.menu.defaultView": { + "message": "Standardansicht" + }, + "header.menu.advancedView": { + "message": "Erweiterte Ansicht" + }, + "header.menu.updateAvailable": { + "message": "Update verfügbar" + }, + "header.menu.open": { + "message": "Menü öffnen" + }, + "header.profile.switch": { + "message": "Profil wechseln" + }, + "connect.toggle.label": { + "message": "NetBird-Verbindung umschalten" + }, + "connect.localIp.label": { + "message": "Lokale IP-Adressen" + }, + "common.search": { + "message": "Suchen" + }, + "common.filter": { + "message": "Filtern" + }, + "exitNodes.dropdown.trigger": { + "message": "Exit-Node auswählen" + }, + "peers.row.label": { + "message": "Details öffnen für {name}, {status}" + }, + "peers.dialog.title": { + "message": "Peer-Details" + }, + "networks.row.toggle": { + "message": "{name} umschalten" + }, + "networks.bulk.label": { + "message": "Alle sichtbaren Ressourcen umschalten" + }, + "profile.switch.title": { + "message": "Zu Profil \"{name}\" wechseln?" + }, + "profile.switch.message": { + "message": "Sind Sie sicher, dass Sie das Profil wechseln möchten?\nIhr aktuelles Profil wird getrennt." + }, + "profile.switch.confirm": { + "message": "Bestätigen" + }, + "profile.deregister.title": { + "message": "Profil \"{name}\" abmelden?" + }, + "profile.deregister.message": { + "message": "Sind Sie sicher, dass Sie dieses Profil abmelden möchten?\nSie müssen sich erneut anmelden, um es zu nutzen." + }, + "profile.deregister.confirm": { + "message": "Abmelden" + }, + "profile.delete.title": { + "message": "Profil \"{name}\" löschen?" + }, + "profile.delete.message": { + "message": "Sind Sie sicher, dass Sie dieses Profil löschen möchten?\nDiese Aktion kann nicht rückgängig gemacht werden." + }, + "profile.delete.disabledActive": { + "message": "Aktive Profile können nicht gelöscht werden. Wechseln Sie zu einem anderen Profil, bevor Sie dieses löschen." + }, + "profile.delete.disabledDefault": { + "message": "Das Standardprofil kann nicht gelöscht werden." + }, + "profile.error.switchTitle": { + "message": "Profilwechsel fehlgeschlagen" + }, + "profile.error.deregisterTitle": { + "message": "Abmeldung fehlgeschlagen" + }, + "profile.error.deleteTitle": { + "message": "Löschen des Profils fehlgeschlagen" + }, + "profile.error.createTitle": { + "message": "Erstellen des Profils fehlgeschlagen" + }, + "profile.error.editTitle": { + "message": "Bearbeiten des Profils fehlgeschlagen" + }, + "profile.error.loadTitle": { + "message": "Laden der Profile fehlgeschlagen" + }, + "profile.dropdown.activeProfile": { + "message": "Aktives Profil" + }, + "profile.dropdown.switchProfile": { + "message": "Profil wechseln" + }, + "profile.dropdown.noEmail": { + "message": "Andere" + }, + "profile.dropdown.addProfile": { + "message": "Profil hinzufügen" + }, + "profile.dropdown.manageProfiles": { + "message": "Profile verwalten" + }, + "profile.dropdown.settings": { + "message": "Einstellungen" + }, + "settings.profiles.section.profiles": { + "message": "Profile" + }, + "settings.profiles.intro": { + "message": "Verwalten Sie mehrere NetBird-Profile parallel, zum Beispiel berufliche und private Konten oder verschiedene Management-Server. Fügen Sie unten Profile hinzu, melden Sie sie ab oder löschen Sie sie." + }, + "settings.profiles.addProfile": { + "message": "Profil hinzufügen" + }, + "settings.profiles.active": { + "message": "Aktiv" + }, + "settings.profiles.emptyTitle": { + "message": "Keine Profile" + }, + "settings.profiles.emptyDescription": { + "message": "Erstellen Sie ein Profil, um sich mit einem NetBird-Management-Server zu verbinden." + }, + "settings.error.loadTitle": { + "message": "Laden der Einstellungen fehlgeschlagen" + }, + "settings.error.saveTitle": { + "message": "Speichern der Einstellungen fehlgeschlagen" + }, + "settings.error.debugBundleTitle": { + "message": "Debug-Paket fehlgeschlagen" + }, + "settings.nav.label": { + "message": "Einstellungsbereiche" + }, + "settings.tabs.general": { + "message": "Allgemein" + }, + "settings.tabs.network": { + "message": "Netzwerk" + }, + "settings.tabs.security": { + "message": "Sicherheit" + }, + "settings.tabs.profiles": { + "message": "Profile" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "Erweitert" + }, + "settings.tabs.troubleshooting": { + "message": "Fehlerbehebung" + }, + "settings.tabs.about": { + "message": "Über" + }, + "settings.tabs.updateAvailable": { + "message": "Update verfügbar" + }, + "settings.general.section.general": { + "message": "Allgemein" + }, + "settings.general.section.connection": { + "message": "Verbindung" + }, + "settings.general.connectOnStartup.label": { + "message": "Beim Start verbinden" + }, + "settings.general.connectOnStartup.help": { + "message": "Beim Start des Dienstes automatisch eine Verbindung herstellen." + }, + "settings.general.notifications.label": { + "message": "Desktop-Benachrichtigungen" + }, + "settings.general.notifications.help": { + "message": "Desktop-Benachrichtigungen für neue Updates und Verbindungsereignisse anzeigen." + }, + "settings.general.autostart.label": { + "message": "NetBird-UI beim Anmelden starten" + }, + "settings.general.autostart.help": { + "message": "Die NetBird-Oberfläche beim Anmelden automatisch starten. Dies betrifft nur die grafische Oberfläche, nicht den Hintergrunddienst." + }, + "settings.general.autostart.errorTitle": { + "message": "Ändern des Autostarts fehlgeschlagen" + }, + "settings.general.keepConnectedOnQuit.label": { + "message": "Nach dem Beenden verbunden bleiben", + "description": "Toggle label: keep the VPN connection up after quitting the UI." + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "Die Verbindung bleibt im Hintergrund bestehen, nachdem Sie NetBird schließen. Sie endet erst, wenn Sie sie selbst trennen.", + "description": "Helper text for the stay-connected-after-quitting toggle." + }, + "settings.general.language.label": { + "message": "Anzeigesprache" + }, + "settings.general.language.help": { + "message": "Wählen Sie die Sprache der NetBird-Oberfläche." + }, + "settings.general.language.search": { + "message": "Sprache suchen…" + }, + "settings.general.language.empty": { + "message": "Keine Sprachen gefunden." + }, + "settings.general.management.label": { + "message": "Management-Server" + }, + "settings.general.management.help": { + "message": "Mit NetBird Cloud oder Ihrem eigenen self-hosted Management-Server verbinden. Änderungen lösen eine Neuverbindung des Clients aus." + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "Self-hosted" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Bitte geben Sie eine gültige URL ein, z. B. https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "Server nicht erreichbar. Überprüfen Sie die URL, oder speichern Sie trotzdem, wenn Sie sicher sind, dass sie korrekt ist." + }, + "settings.general.management.switchCloudTitle": { + "message": "Zu NetBird Cloud wechseln?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Dies trennt die Verbindung zu Ihrem self-hosted Server.\nMöglicherweise müssen Sie sich erneut anmelden." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Zu Cloud wechseln" + }, + "settings.network.section.connectivity": { + "message": "Konnektivität" + }, + "settings.network.section.routingDns": { + "message": "Routing & DNS" + }, + "settings.network.monitor.label": { + "message": "Bei Netzwerkwechsel neu verbinden" + }, + "settings.network.monitor.help": { + "message": "Das Netzwerk überwachen und bei Änderungen (z. B. WLAN-Wechsel, Ethernet-Änderungen oder Rückkehr aus dem Ruhezustand) automatisch neu verbinden." + }, + "settings.network.dns.label": { + "message": "DNS aktivieren" + }, + "settings.network.dns.help": { + "message": "NetBird-verwaltete DNS-Einstellungen auf den Host-Resolver anwenden." + }, + "settings.network.clientRoutes.label": { + "message": "Client-Routen aktivieren" + }, + "settings.network.clientRoutes.help": { + "message": "Routen von anderen Peers übernehmen, um deren Netzwerke zu erreichen." + }, + "settings.network.serverRoutes.label": { + "message": "Server-Routen aktivieren" + }, + "settings.network.serverRoutes.help": { + "message": "Lokale Routen dieses Hosts an andere Peers ankündigen." + }, + "settings.network.ipv6.label": { + "message": "IPv6 aktivieren" + }, + "settings.network.ipv6.help": { + "message": "IPv6-Adressierung für das NetBird-Overlay-Netzwerk verwenden." + }, + "settings.security.section.firewall": { + "message": "Firewall" + }, + "settings.security.section.encryption": { + "message": "Verschlüsselung" + }, + "settings.security.blockInbound.label": { + "message": "Eingehenden Verkehr blockieren" + }, + "settings.security.blockInbound.help": { + "message": "Unaufgeforderte Verbindungen von Peers zu diesem Gerät und den von ihm gerouteten Netzwerken ablehnen. Ausgehender Verkehr ist nicht betroffen." + }, + "settings.security.blockLan.label": { + "message": "LAN-Zugriff blockieren" + }, + "settings.security.blockLan.help": { + "message": "Verhindert, dass Peers Ihr lokales Netzwerk oder dessen Geräte erreichen, wenn dieses Gerät deren Verkehr routet." + }, + "settings.security.rosenpass.label": { + "message": "Quantenresistenz aktivieren" + }, + "settings.security.rosenpass.help": { + "message": "Einen Post-Quanten-Schlüsselaustausch über Rosenpass zusätzlich zu WireGuard® hinzufügen." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Permissiven Modus aktivieren" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Verbindungen zu Peers ohne Quantenresistenz-Unterstützung erlauben." + }, + "settings.ssh.section.server": { + "message": "Server" + }, + "settings.ssh.section.capabilities": { + "message": "Funktionen" + }, + "settings.ssh.section.authentication": { + "message": "Authentifizierung" + }, + "settings.ssh.server.label": { + "message": "SSH-Server aktivieren" + }, + "settings.ssh.server.help": { + "message": "Den NetBird SSH-Server auf diesem Host ausführen, damit andere Peers sich verbinden können." + }, + "settings.ssh.root.label": { + "message": "Root-Login erlauben" + }, + "settings.ssh.root.help": { + "message": "Peers dürfen sich als root anmelden. Deaktivieren, um ein nicht-privilegiertes Konto zu erfordern." + }, + "settings.ssh.sftp.label": { + "message": "SFTP erlauben" + }, + "settings.ssh.sftp.help": { + "message": "Dateien sicher über native SFTP- oder SCP-Clients übertragen." + }, + "settings.ssh.localForward.label": { + "message": "Lokale Portweiterleitung" + }, + "settings.ssh.localForward.help": { + "message": "Verbundene Peers können lokale Ports zu von diesem Host erreichbaren Diensten tunneln." + }, + "settings.ssh.remoteForward.label": { + "message": "Remote-Portweiterleitung" + }, + "settings.ssh.remoteForward.help": { + "message": "Verbundene Peers können Ports dieses Hosts an ihren eigenen Rechner weitergeben." + }, + "settings.ssh.jwt.label": { + "message": "JWT-Authentifizierung aktivieren" + }, + "settings.ssh.jwt.help": { + "message": "Jede SSH-Sitzung gegen Ihren IdP für Identität und Audit prüfen. Deaktivieren, um sich nur auf Netzwerk-ACL-Richtlinien zu verlassen — sinnvoll, wenn kein IdP verfügbar ist." + }, + "settings.ssh.jwtTtl.label": { + "message": "JWT-Cache-TTL" + }, + "settings.ssh.jwtTtl.help": { + "message": "Wie lange dieser Client ein JWT zwischenspeichert, bevor bei ausgehenden SSH-Verbindungen erneut nachgefragt wird. Auf 0 setzen, um den Cache zu deaktivieren und bei jeder Verbindung zu authentifizieren." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "Sekunde(n)" + }, + "settings.advanced.section.interface": { + "message": "Schnittstelle" + }, + "settings.advanced.section.security": { + "message": "Sicherheit" + }, + "settings.advanced.interfaceName.label": { + "message": "Name" + }, + "settings.advanced.interfaceName.error": { + "message": "Verwenden Sie 1–15 Buchstaben, Ziffern, Punkte, Bindestriche oder Unterstriche." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Muss mit „utun“ und einer Zahl beginnen (z. B. utun100)." + }, + "settings.advanced.port.label": { + "message": "Port" + }, + "settings.advanced.port.error": { + "message": "Geben Sie einen Port zwischen {min} und {max} ein." + }, + "settings.advanced.port.help": { + "message": "Wenn auf 0 gesetzt, wird ein zufälliger freier Port verwendet." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Geben Sie einen MTU-Wert zwischen {min} und {max} ein." + }, + "settings.advanced.psk.label": { + "message": "Pre-shared Key" + }, + "settings.advanced.psk.help": { + "message": "Optionaler WireGuard-PSK für zusätzliche symmetrische Verschlüsselung. Nicht identisch mit einem NetBird Setup-Key. Sie kommunizieren nur mit Peers, die denselben Pre-shared Key verwenden." + }, + "settings.troubleshooting.section.title": { + "message": "Debug-Paket" + }, + "settings.troubleshooting.anonymize.label": { + "message": "Sensible Informationen anonymisieren" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Verbirgt IP-Adressen, Domains und andere sensible Werte." + }, + "settings.troubleshooting.anonymize.info": { + "message": "Der Standardmodus lässt interne IPv4-Adressen und Peer-Namen für den Support lesbar. Der strikte Modus anonymisiert zusätzlich private (RFC 1918), CGNAT- und Link-Local-IP-Adressen, Peer-Namen und öffentliche WireGuard-Schlüssel. Wiederkehrende Werte erhalten denselben Platzhalter, sodass Peers unterscheidbar bleiben. Verwenden Sie den strikten Modus, wenn Sie das Debug-Paket außerhalb Ihrer Organisation weitergeben." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Keine" + }, + "settings.troubleshooting.anonymize.default": { + "message": "Standard" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Strikt" + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Systeminformationen einschließen" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "OS, Kernel, Netzwerkschnittstellen und Routing-Tabellen einschließen." + }, + "settings.troubleshooting.upload.label": { + "message": "Paket an NetBird-Server hochladen" + }, + "settings.troubleshooting.upload.help": { + "message": "Gibt einen Upload-Schlüssel zurück, den Sie mit dem NetBird-Support teilen können." + }, + "settings.troubleshooting.trace.label": { + "message": "Trace-Logs aktivieren" + }, + "settings.troubleshooting.trace.help": { + "message": "Hebt das Log-Level auf TRACE an und stellt es danach wieder her." + }, + "settings.troubleshooting.capture.label": { + "message": "Aufzeichnungssitzung" + }, + "settings.troubleshooting.capture.help": { + "message": "Stellt die Verbindung neu her und wartet, damit Sie das Problem reproduzieren können." + }, + "settings.troubleshooting.packets.label": { + "message": "Netzwerkpakete aufzeichnen" + }, + "settings.troubleshooting.packets.help": { + "message": "Speichert eine .pcap-Datei des Netzwerkverkehrs während der Aufzeichnung." + }, + "settings.troubleshooting.duration.label": { + "message": "Aufzeichnungsdauer" + }, + "settings.troubleshooting.duration.help": { + "message": "Wie lange die Aufzeichnungssitzung läuft." + }, + "settings.troubleshooting.duration.suffix": { + "message": "Minute(n)" + }, + "settings.troubleshooting.create": { + "message": "Debug-Paket erstellen" + }, + "settings.troubleshooting.progress.description": { + "message": "Logs, Systemdetails und Verbindungszustand werden gesammelt. Dies dauert in der Regel einen Moment — lassen Sie dieses Fenster geöffnet, bis es abgeschlossen ist." + }, + "settings.troubleshooting.cancelling": { + "message": "Wird abgebrochen…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "Debug-Paket erfolgreich hochgeladen!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Paket gespeichert" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Teilen Sie den unten angezeigten Upload-Schlüssel mit dem NetBird-Support. Eine lokale Kopie wurde ebenfalls auf Ihrem Gerät gespeichert." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "Ihr Debug-Paket wurde lokal gespeichert." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Schlüssel kopieren" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Ordner öffnen" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Speicherort öffnen" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Upload fehlgeschlagen: {reason} Das Paket wurde trotzdem lokal gespeichert." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Upload fehlgeschlagen. Das Paket wurde trotzdem lokal gespeichert." + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "NetBird wird neu verbunden…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Debug-Logs werden erfasst" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Debug-Paket wird erstellt…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Wird zu NetBird hochgeladen…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Wird abgebrochen…" + }, + "settings.about.client": { + "message": "NetBird Client v{version}" + }, + "settings.about.clientName": { + "message": "NetBird Client" + }, + "settings.about.development": { + "message": "[Entwicklung]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Alle Rechte vorbehalten." + }, + "settings.about.links.imprint": { + "message": "Impressum" + }, + "settings.about.links.privacy": { + "message": "Datenschutz" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Nutzungsbedingungen" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Forum" + }, + "settings.about.community.documentation": { + "message": "Dokumentation" + }, + "settings.about.community.feedback": { + "message": "Feedback" + }, + "update.banner.message": { + "message": "NetBird {version} ist installationsbereit." + }, + "update.banner.later": { + "message": "Später" + }, + "update.banner.installNow": { + "message": "Jetzt installieren" + }, + "update.card.versionAvailableDownload": { + "message": "Version {version} ist zum Herunterladen verfügbar." + }, + "update.card.versionAvailableInstall": { + "message": "Version {version} ist zur Installation verfügbar." + }, + "update.card.whatsNew": { + "message": "Was ist neu?" + }, + "update.card.installNow": { + "message": "Jetzt installieren" + }, + "update.card.getInstaller": { + "message": "Herunterladen" + }, + "update.card.autoCheckInterval": { + "message": "NetBird sucht im Hintergrund nach Updates." + }, + "update.card.changelog": { + "message": "Changelog" + }, + "update.card.onLatestVersion": { + "message": "Sie verwenden die neueste Version" + }, + "update.header.tooltip": { + "message": "Update verfügbar" + }, + "update.overlay.updatingVersion": { + "message": "NetBird wird auf v{version} aktualisiert" + }, + "update.overlay.updating": { + "message": "NetBird wird aktualisiert" + }, + "update.overlay.description": { + "message": "Eine neuere Version ist verfügbar und wird installiert. NetBird startet nach Abschluss des Updates automatisch neu." + }, + "update.overlay.error.timeoutTitle": { + "message": "Update dauert zu lange" + }, + "update.overlay.error.timeoutDescription": { + "message": "Die Installation von {target} hat zu lange gedauert und wurde nicht abgeschlossen." + }, + "update.overlay.error.canceledTitle": { + "message": "Update wurde abgebrochen" + }, + "update.overlay.error.canceledDescription": { + "message": "Das Update auf {target} wurde vor dem Abschluss abgebrochen." + }, + "update.overlay.error.failTitle": { + "message": "Update konnte nicht installiert werden" + }, + "update.overlay.error.failDescription": { + "message": "{target} konnte nicht installiert werden." + }, + "update.overlay.error.unknownMessage": { + "message": "unbekannter Fehler" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "die neue Version" + }, + "update.error.loadStateTitle": { + "message": "Laden des Update-Status fehlgeschlagen" + }, + "update.error.triggerTitle": { + "message": "Update-Start fehlgeschlagen" + }, + "update.page.versionLine": { + "message": "Client wird aktualisiert auf: {version}." + }, + "update.page.versionLineGeneric": { + "message": "Client wird aktualisiert." + }, + "update.page.outdated": { + "message": "Ihre Client-Version ist älter als die im Management eingestellte Auto-Update-Version." + }, + "update.page.status.running": { + "message": "Wird aktualisiert" + }, + "update.page.status.timeout": { + "message": "Zeitüberschreitung beim Update. Bitte erneut versuchen." + }, + "update.page.status.canceled": { + "message": "Update abgebrochen." + }, + "update.page.status.failed": { + "message": "Update fehlgeschlagen: {message}" + }, + "update.page.status.unknownError": { + "message": "unbekannter Update-Fehler" + }, + "update.page.failedTitle": { + "message": "Update fehlgeschlagen" + }, + "update.page.timeoutMessage": { + "message": "Zeitüberschreitung beim Update." + }, + "update.page.dontClose": { + "message": "Bitte schließen Sie dieses Fenster nicht." + }, + "update.page.updating": { + "message": "Wird aktualisiert…" + }, + "update.page.complete": { + "message": "Update abgeschlossen" + }, + "update.page.failed": { + "message": "Update fehlgeschlagen" + }, + "window.title.settings": { + "message": "Einstellungen" + }, + "window.title.signIn": { + "message": "Anmeldung" + }, + "window.title.sessionExpiration": { + "message": "Sitzung läuft ab" + }, + "window.title.updating": { + "message": "Aktualisierung" + }, + "window.title.welcome": { + "message": "Willkommen bei NetBird" + }, + "window.title.error": { + "message": "Fehler" + }, + "welcome.title": { + "message": "Suchen Sie NetBird in der Taskleiste" + }, + "welcome.titleMac": { + "message": "Suchen Sie NetBird in der Menüleiste" + }, + "welcome.description": { + "message": "NetBird läuft in Ihrer Taskleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen." + }, + "welcome.descriptionMac": { + "message": "NetBird läuft in Ihrer Menüleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen." + }, + "welcome.continue": { + "message": "Weiter" + }, + "welcome.back": { + "message": "Zurück" + }, + "welcome.management.title": { + "message": "NetBird einrichten" + }, + "welcome.management.description": { + "message": "Klicken Sie auf „Weiter“, um loszulegen, oder wählen Sie „Self-hosted“, wenn Sie einen eigenen NetBird-Server haben." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Nutzen Sie unseren gehosteten Dienst. Keine Einrichtung nötig." + }, + "welcome.management.selfHosted.title": { + "message": "Self-hosted" + }, + "welcome.management.selfHosted.description": { + "message": "Verbindung zu Ihrem eigenen Management-Server." + }, + "welcome.management.urlLabel": { + "message": "URL des Management-Servers" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Bitte geben Sie eine gültige URL ein, z. B. https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "Server nicht erreichbar. Überprüfen Sie die URL oder Ihr Netzwerk und fahren Sie fort, wenn Sie sicher sind, dass sie korrekt ist." + }, + "welcome.management.checking": { + "message": "Wird geprüft …" + }, + "browserLogin.title": { + "message": "Anmeldung im Browser abschließen" + }, + "browserLogin.notSeeing": { + "message": "Sehen Sie den Browser-Tab nicht?" + }, + "browserLogin.tryAgain": { + "message": "Erneut versuchen" + }, + "browserLogin.openFailedTitle": { + "message": "Browser konnte nicht geöffnet werden" + }, + "sessionExpiration.title": { + "message": "Sitzung läuft bald ab" + }, + "sessionExpiration.titleLater": { + "message": "Ihre Sitzung läuft ab" + }, + "sessionExpiration.description": { + "message": "Dieses Gerät wird bald getrennt. Browser-Anmeldung zum Erneuern erforderlich." + }, + "sessionExpiration.descriptionLater": { + "message": "Eine Browser-Anmeldung hält dieses Gerät mit Ihrem Netzwerk verbunden." + }, + "sessionExpiration.stay": { + "message": "Sitzung erneuern" + }, + "sessionExpiration.authenticate": { + "message": "Anmelden" + }, + "sessionExpiration.logout": { + "message": "Abmelden" + }, + "sessionExpiration.expired": { + "message": "Sitzung abgelaufen" + }, + "sessionExpiration.expiredDescription": { + "message": "Gerät getrennt. Mit Browser-Anmeldung authentifizieren, um erneut zu verbinden." + }, + "sessionExpiration.close": { + "message": "Schließen" + }, + "sessionExpiration.extendFailedTitle": { + "message": "Sitzungsverlängerung fehlgeschlagen" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Abmeldung fehlgeschlagen" + }, + "peers.search.placeholder": { + "message": "Nach Name oder IP suchen" + }, + "peers.filter.all": { + "message": "Alle" + }, + "peers.filter.online": { + "message": "Online" + }, + "peers.filter.offline": { + "message": "Offline" + }, + "peers.empty.title": { + "message": "Keine Peers verfügbar" + }, + "peers.empty.description": { + "message": "Sie haben entweder keine Peers verfügbar oder keinen Zugriff auf einen davon." + }, + "peers.details.domain": { + "message": "Domain" + }, + "peers.details.netbirdIp": { + "message": "NetBird-IP" + }, + "peers.details.netbirdIpv6": { + "message": "NetBird-IPv6" + }, + "peers.details.publicKey": { + "message": "Öffentlicher Schlüssel" + }, + "peers.details.connection": { + "message": "Verbindung" + }, + "peers.details.latency": { + "message": "Latenz" + }, + "peers.details.lastHandshake": { + "message": "Letzter Handshake" + }, + "peers.details.statusSince": { + "message": "Letzte Verbindungsaktualisierung" + }, + "peers.details.bytes": { + "message": "Bytes" + }, + "peers.details.bytesSent": { + "message": "Gesendet" + }, + "peers.details.bytesReceived": { + "message": "Empfangen" + }, + "peers.details.localIce": { + "message": "Lokales ICE" + }, + "peers.details.remoteIce": { + "message": "Remote ICE" + }, + "peers.details.never": { + "message": "Nie" + }, + "peers.details.justNow": { + "message": "Gerade eben" + }, + "peers.details.refresh": { + "message": "Aktualisieren" + }, + "peers.status.connected": { + "message": "Verbunden" + }, + "peers.status.connecting": { + "message": "Wird verbunden" + }, + "peers.status.disconnected": { + "message": "Nicht verbunden" + }, + "peers.details.relayAddress": { + "message": "Relay" + }, + "peers.details.networks": { + "message": "Ressourcen" + }, + "peers.details.relayed": { + "message": "Relayed" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass aktiviert" + }, + "networks.search.placeholder": { + "message": "Nach Netzwerk oder Domain suchen" + }, + "networks.filter.all": { + "message": "Alle" + }, + "networks.filter.active": { + "message": "Aktiv" + }, + "networks.filter.overlapping": { + "message": "Überlappend" + }, + "networks.empty.title": { + "message": "Keine Ressourcen verfügbar" + }, + "networks.empty.description": { + "message": "Sie haben entweder keine Netzwerkressourcen verfügbar oder keinen Zugriff auf eine davon." + }, + "networks.selected": { + "message": "Ausgewählt" + }, + "networks.unselected": { + "message": "Nicht ausgewählt" + }, + "networks.ips.heading": { + "message": "Aufgelöste IPs" + }, + "networks.bulk.selectionCount": { + "message": "{selected} von {total} aktiv" + }, + "networks.bulk.enableAll": { + "message": "Alle aktivieren" + }, + "networks.bulk.disableAll": { + "message": "Alle deaktivieren" + }, + "exitNodes.search.placeholder": { + "message": "Exit Nodes suchen" + }, + "exitNodes.none": { + "message": "Keiner" + }, + "exitNodes.empty.title": { + "message": "Keine Exit Nodes verfügbar" + }, + "exitNodes.empty.description": { + "message": "Für diesen Peer wurden keine Exit Nodes freigegeben." + }, + "exitNodes.card.title": { + "message": "Exit Node" + }, + "exitNodes.card.statusActive": { + "message": "Aktiv" + }, + "exitNodes.card.statusInactive": { + "message": "Inaktiv" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Keiner" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Direkte Verbindung ohne Exit Node" + }, + "quickActions.connect": { + "message": "Verbinden" + }, + "quickActions.disconnect": { + "message": "Trennen" + }, + "daemon.unavailable.title": { + "message": "NetBird-Dienst läuft nicht" + }, + "daemon.unavailable.description": { + "message": "Die App stellt automatisch die Verbindung wieder her, sobald der Dienst läuft." + }, + "daemon.unavailable.docsLink": { + "message": "Dokumentation" + }, + "daemon.outdated.title": { + "message": "NetBird Client ist veraltet" + }, + "daemon.outdated.description": { + "message": "Die neue GUI ist nicht mit Ihrem älteren Client kompatibel. Aktualisieren Sie Ihren Client, um die neue Anwendung zu verwenden." + }, + "daemon.outdated.download": { + "message": "Neueste Version herunterladen" + }, + "error.jwt_clock_skew": { + "message": "Anmeldung fehlgeschlagen: Die Uhr dieses Geräts ist nicht mit dem Server synchron. Bitte synchronisieren Sie die Systemuhr und versuchen Sie es erneut." + }, + "error.jwt_expired": { + "message": "Ihr Anmeldetoken ist abgelaufen. Bitte melden Sie sich erneut an." + }, + "error.jwt_signature_invalid": { + "message": "Anmeldung fehlgeschlagen: Die Token-Signatur ist ungültig. Bitte wenden Sie sich an Ihren Administrator." + }, + "error.session_expired": { + "message": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an." + }, + "error.invalid_setup_key": { + "message": "Der Setup-Key fehlt oder ist ungültig." + }, + "error.permission_denied": { + "message": "Die Anmeldung wurde vom Server abgelehnt." + }, + "error.daemon_unreachable": { + "message": "Der NetBird-Dienst antwortet nicht. Bitte prüfen Sie, ob der Dienst läuft." + }, + "error.unknown": { + "message": "Vorgang fehlgeschlagen." + }, + "error.elevation_unavailable": { + "message": "NetBird konnte auf diesem System nicht die nötigen Rechte anfordern. Führen Sie stattdessen dies aus:" + }, + "error.elevation_failed": { + "message": "Die Änderung konnte mit erhöhten Rechten nicht angewendet werden. Führen Sie stattdessen dies aus:" + }, + "settings.ssh.privilege.actorRoot": { + "message": "root-Rechte" + }, + "settings.ssh.privilege.actorAdministrator": { + "message": "Administratorrechte" + }, + "settings.ssh.privilege.hint": { + "message": "Erfordert {actor}. Führen Sie stattdessen dies aus:" + }, + "settings.ssh.privilege.oneWay": { + "message": "Sie können dies deaktivieren, zum erneuten Aktivieren sind {actor} erforderlich." + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Sie können dies aktivieren, zum erneuten Deaktivieren sind {actor} erforderlich." + }, + "settings.ssh.privilege.authorizePending": { + "message": "Warten auf Autorisierung…" + } +} diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json new file mode 100644 index 000000000..36f00e4bd --- /dev/null +++ b/client/ui/i18n/locales/en/common.json @@ -0,0 +1,1834 @@ +{ + "tray.tooltip": { + "message": "NetBird", + "description": "Hover tooltip on the system-tray / menu-bar icon. Brand name — do not translate." + }, + "tray.status.disconnected": { + "message": "Disconnected", + "description": "Connection status surfaced through the tray icon: the client is not connected to the network." + }, + "tray.status.daemonUnavailable": { + "message": "Not running", + "description": "Tray status: the background NetBird service (daemon) is not running." + }, + "tray.status.error": { + "message": "Error", + "description": "Tray status: the client is in an error state." + }, + "tray.status.connected": { + "message": "Connected", + "description": "Tray status: connected to the NetBird network." + }, + "tray.status.connecting": { + "message": "Connecting", + "description": "Tray status: a connection is being established." + }, + "tray.status.needsLogin": { + "message": "Login required", + "description": "Tray status: the user must sign in before connecting." + }, + "tray.status.loginFailed": { + "message": "Login failed", + "description": "Tray status: the last sign-in attempt failed." + }, + "tray.status.sessionExpired": { + "message": "Session expired", + "description": "Tray status: the authenticated session expired; the user must sign in again." + }, + "tray.session.expiresIn": { + "message": "Session expires in {remaining}", + "description": "Tray row showing time left before the session expires. {remaining} is a human-readable duration such as '5 minutes', built from the tray.session.unit.* strings. Keep {remaining} unchanged." + }, + "tray.session.unit.lessThanMinute": { + "message": "less than a minute", + "description": "Duration fragment substituted into {remaining} (see tray.session.expiresIn). Used when under one minute remains." + }, + "tray.session.unit.minute": { + "message": "1 minute", + "description": "Duration fragment for exactly one minute, substituted into {remaining}." + }, + "tray.session.unit.minutes": { + "message": "{count} minutes", + "description": "Duration fragment for several minutes, substituted into {remaining}. {count} is the number of minutes; keep {count}." + }, + "tray.session.unit.hour": { + "message": "1 hour", + "description": "Duration fragment for exactly one hour, substituted into {remaining}." + }, + "tray.session.unit.hours": { + "message": "{count} hours", + "description": "Duration fragment for several hours. {count} is the number of hours; keep {count}." + }, + "tray.session.unit.day": { + "message": "1 day", + "description": "Duration fragment for exactly one day, substituted into {remaining}." + }, + "tray.session.unit.days": { + "message": "{count} days", + "description": "Duration fragment for several days. {count} is the number of days; keep {count}." + }, + "tray.menu.open": { + "message": "Open NetBird", + "description": "Tray menu item that opens the main NetBird window. Keep short." + }, + "tray.menu.connect": { + "message": "Connect", + "description": "Tray menu item that connects to the network. Keep short." + }, + "tray.menu.disconnect": { + "message": "Disconnect", + "description": "Tray menu item that disconnects from the network. Keep short." + }, + "tray.menu.exitNode": { + "message": "Exit Node", + "description": "Tray submenu title for choosing an exit node (route all traffic through another peer)." + }, + "tray.menu.networks": { + "message": "Resources", + "description": "Tray submenu title listing network resources the user can reach. Labelled 'Resources' in the UI even though the key says networks." + }, + "tray.menu.profiles": { + "message": "Profiles", + "description": "Tray submenu title listing connection profiles." + }, + "tray.menu.manageProfiles": { + "message": "Manage Profiles", + "description": "Tray menu item that opens Settings → Profiles." + }, + "tray.menu.settings": { + "message": "Settings...", + "description": "Tray menu item that opens the Settings window. The trailing '...' signals that a window opens; keep it." + }, + "tray.menu.debugBundle": { + "message": "Create Debug Bundle", + "description": "Tray menu item that generates a diagnostic log bundle for support." + }, + "tray.menu.about": { + "message": "Help & Support", + "description": "Tray submenu title for help and support links." + }, + "tray.menu.github": { + "message": "GitHub", + "description": "Tray menu link to the GitHub repository. Brand name — do not translate." + }, + "tray.menu.documentation": { + "message": "Documentation", + "description": "Tray menu link to the online documentation." + }, + "tray.menu.troubleshoot": { + "message": "Troubleshoot", + "description": "Tray menu link to troubleshooting help." + }, + "tray.menu.downloadLatest": { + "message": "Download latest version", + "description": "Tray menu item to download the latest available version." + }, + "tray.menu.installVersion": { + "message": "Install version {version}", + "description": "Tray menu item to install a specific update. {version} is a version number like 0.30.1; keep {version}." + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}", + "description": "Tray menu row showing the installed UI version. 'GUI' = the graphical app. {version} is a version number; keep it." + }, + "tray.menu.daemonVersion": { + "message": "Daemon: {version}", + "description": "Tray menu row showing the background-service version. 'Daemon' = the background NetBird service. {version} is a version number; keep it." + }, + "tray.menu.versionUnknown": { + "message": "—", + "description": "Placeholder shown in version rows when the version can't be determined. It is an em dash — leave as-is." + }, + "tray.menu.quit": { + "message": "Quit NetBird", + "description": "Tray menu item that fully exits the app and removes the tray icon. Keep short." + }, + "notify.daemonOutdated.title": { + "message": "NetBird service is outdated", + "description": "Title of the OS desktop notification shown when the running daemon is too old for this UI." + }, + "notify.daemonOutdated.body": { + "message": "Update the NetBird service to use this app.", + "description": "Body of the desktop notification telling the user to upgrade the daemon." + }, + "notify.update.title": { + "message": "NetBird update available", + "description": "Title of the OS desktop notification shown when an app update is available." + }, + "notify.update.body": { + "message": "NetBird {version} is available.", + "description": "Body of the update-available desktop notification. {version} is the new version number; keep it." + }, + "notify.update.enforcedSuffix": { + "message": " Your administrator requires this update.", + "description": "Sentence appended to the update notification body when the admin has made the update mandatory. Note the leading space — keep it." + }, + "notify.error.title": { + "message": "Error", + "description": "Title of a generic error desktop notification." + }, + "notify.error.connect": { + "message": "Failed to connect", + "description": "Error notification body shown when connecting failed." + }, + "notify.error.disconnect": { + "message": "Failed to disconnect", + "description": "Error notification body shown when disconnecting failed." + }, + "notify.error.switchProfile": { + "message": "Failed to switch to {profile}", + "description": "Error notification shown when switching profiles failed. {profile} is the target profile name; keep it." + }, + "notify.error.exitNode": { + "message": "Failed to update exit node {name}", + "description": "Error notification shown when updating the exit node failed. {name} is the exit node name; keep it." + }, + "notify.sessionExpired.title": { + "message": "NetBird session expired", + "description": "Title of the desktop notification shown when the session has expired." + }, + "notify.sessionExpired.body": { + "message": "Your NetBird session has expired. Please log in again.", + "description": "Body of the session-expired desktop notification." + }, + "notify.sessionWarning.title": { + "message": "Session expires soon", + "description": "Title of the desktop notification warning that the session will expire soon." + }, + "notify.sessionWarning.body": { + "message": "Your NetBird session expires in {remaining}. Click Extend now to renew.", + "description": "Body of the session-expiry warning notification. {remaining} is a human-readable duration (see tray.session.unit.*); keep it. 'Extend now' refers to the action button notify.sessionWarning.extend." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "Your NetBird session is about to expire. Click Extend now to renew.", + "description": "Generic session-expiry warning body used when the exact remaining time isn't known." + }, + "notify.sessionWarning.extend": { + "message": "Extend now", + "description": "Action button on the session-expiry notification that renews the session. Keep short." + }, + "notify.sessionWarning.dismiss": { + "message": "Dismiss", + "description": "Action button on the session-expiry notification that dismisses it. Keep short." + }, + "notify.sessionWarning.failed": { + "message": "Failed to extend NetBird session", + "description": "Notification shown when renewing the session failed." + }, + "notify.sessionWarning.successTitle": { + "message": "NetBird session extended", + "description": "Title of the notification confirming the session was renewed." + }, + "notify.sessionWarning.successBody": { + "message": "Your session has been refreshed.", + "description": "Body of the notification confirming the session was renewed." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Session deadline rejected", + "description": "Title of the notification shown when the server sent an invalid session deadline." + }, + "notify.sessionDeadlineRejected.body": { + "message": "The server sent an invalid session deadline. Please sign in again.", + "description": "Body explaining the server sent an invalid session deadline and the user must sign in again." + }, + "notify.mdm.policyApplied.title": { + "message": "NetBird settings updated", + "description": "Title of the desktop notification shown when an MDM (IT-managed) policy changed the daemon configuration at runtime." + }, + "notify.mdm.policyApplied.body": { + "message": "Your NetBird configuration was updated by your IT policy.", + "description": "Body of the MDM policy-applied notification, telling the user their settings were changed by their organization's device-management policy." + }, + "common.cancel": { + "message": "Cancel", + "description": "Generic Cancel button label, reused across dialogs. Keep short." + }, + "common.save": { + "message": "Save", + "description": "Generic Save button label. Keep short." + }, + "common.saveChanges": { + "message": "Save Changes", + "description": "Button label to save edited settings. Keep short." + }, + "common.saving": { + "message": "Saving…", + "description": "Button label / status shown while a save is in progress. Ends with an ellipsis." + }, + "common.close": { + "message": "Close", + "description": "Generic Close button label. Keep short." + }, + "common.copy": { + "message": "Copy", + "description": "Generic Copy button label (copy to clipboard). Keep short." + }, + "common.togglePasswordVisibility": { + "message": "Toggle password visibility", + "description": "Accessibility label for the show/hide button inside a password field." + }, + "common.increase": { + "message": "Increase", + "description": "Accessibility label for the increment (+) button on a numeric stepper input." + }, + "common.decrease": { + "message": "Decrease", + "description": "Accessibility label for the decrement (−) button on a numeric stepper input." + }, + "common.delete": { + "message": "Delete", + "description": "Generic Delete button label. Keep short." + }, + "common.create": { + "message": "Create", + "description": "Generic Create button label. Keep short." + }, + "common.add": { + "message": "Add", + "description": "Generic Add button label. Keep short." + }, + "common.remove": { + "message": "Remove", + "description": "Generic Remove button label. Keep short." + }, + "common.refresh": { + "message": "Refresh", + "description": "Generic Refresh button label. Keep short." + }, + "common.loading": { + "message": "Loading…", + "description": "Generic loading indicator text. Ends with an ellipsis." + }, + "common.netbird": { + "message": "NetBird", + "description": "The product name. Brand — do not translate." + }, + "common.noResults.title": { + "message": "Could not find any results", + "description": "Title of the empty state shown when a search or filter returns nothing." + }, + "common.noResults.description": { + "message": "We couldn't find any results. Please try a different search term or change your filters.", + "description": "Body of the no-results empty state, suggesting a different search term or filters." + }, + "notConnected.title": { + "message": "Disconnected", + "description": "Title of the placeholder shown on data screens while disconnected." + }, + "notConnected.description": { + "message": "Connect to NetBird first to view detailed information about your peers, network resources, and exit nodes.", + "description": "Body explaining the user must connect to NetBird first to see peer, resource, and exit-node details." + }, + "connect.status.disconnected": { + "message": "Disconnected", + "description": "Label on the main-window connection toggle: not connected." + }, + "connect.status.connecting": { + "message": "Connecting...", + "description": "Connection toggle label while connecting. Ends with an ellipsis." + }, + "connect.status.connected": { + "message": "Connected", + "description": "Connection toggle label when connected." + }, + "connect.status.disconnecting": { + "message": "Disconnecting...", + "description": "Connection toggle label while disconnecting. Ends with an ellipsis." + }, + "connect.status.daemonUnavailable": { + "message": "Daemon unavailable", + "description": "Connection toggle label when the background service is unavailable. 'Daemon' = background service." + }, + "connect.status.loginRequired": { + "message": "Login required", + "description": "Connection toggle label when sign-in is required before connecting." + }, + "connect.error.loginTitle": { + "message": "Login Failed", + "description": "Error-dialog title shown when sign-in fails. The action-named '… Failed' style is intentional — keep it." + }, + "connect.error.connectTitle": { + "message": "Connect Failed", + "description": "Error-dialog title shown when connecting fails." + }, + "connect.error.disconnectTitle": { + "message": "Disconnect Failed", + "description": "Error-dialog title shown when disconnecting fails." + }, + "nav.peers.title": { + "message": "Peers", + "description": "Navigation label for the Peers section (other devices in the network)." + }, + "nav.peers.description": { + "message": "{connected} of {total} connected", + "description": "Sub-label under Peers showing how many are connected. {connected} and {total} are numbers; keep both." + }, + "nav.resources.title": { + "message": "Resources", + "description": "Navigation label for the Resources section (routed networks)." + }, + "nav.resources.description": { + "message": "{active} of {total} active", + "description": "Sub-label under Resources showing how many are active. {active} and {total} are numbers; keep both." + }, + "nav.exitNode.title": { + "message": "Exit Nodes", + "description": "Navigation label for the Exit Nodes section." + }, + "nav.exitNode.none": { + "message": "Not active", + "description": "Sub-label under Exit Nodes when no exit node is in use." + }, + "nav.exitNode.using": { + "message": "Via {name}", + "description": "Sub-label under Exit Nodes when one is active. {name} is the exit node's name; keep it." + }, + "header.openSettings": { + "message": "Open settings", + "description": "Accessibility label / tooltip for the gear icon that opens Settings." + }, + "header.togglePanel": { + "message": "Toggle side panel", + "description": "Accessibility label / tooltip for the button that shows or hides the side panel." + }, + "profile.selector.loading": { + "message": "Loading...", + "description": "Shown in the profile picker while profiles load. Ends with an ellipsis." + }, + "profile.selector.noProfile": { + "message": "No profile", + "description": "Shown in the profile picker when no profile is selected." + }, + "profile.selector.searchPlaceholder": { + "message": "Search profile by name...", + "description": "Placeholder text in the profile picker's search field." + }, + "profile.selector.emptyTitle": { + "message": "No Profiles Found", + "description": "Title shown in the profile picker when a search matches no profiles." + }, + "profile.selector.emptyDescription": { + "message": "Try a different search term or create a new profile.", + "description": "Body shown when no profiles match the search, suggesting a different term or creating one." + }, + "profile.selector.newProfile": { + "message": "New Profile", + "description": "Button in the profile picker to create a new profile. Keep short." + }, + "profile.selector.moreOptions": { + "message": "More options", + "description": "Accessibility label for the per-profile kebab (⋯) menu." + }, + "profile.selector.deregister": { + "message": "Deregister", + "description": "Per-profile menu action: deregister (sign out of the profile but keep it)." + }, + "profile.selector.delete": { + "message": "Delete", + "description": "Per-profile menu action: delete the profile." + }, + "profile.selector.switchTo": { + "message": "Switch to this profile", + "description": "Tooltip / label for the action that switches to a profile." + }, + "profile.selector.edit": { + "message": "Edit", + "description": "Per-profile menu action: open the edit dialog to rename or change the management server." + }, + "profile.edit.title": { + "message": "Edit Profile", + "description": "Title of the dialog for editing an existing profile." + }, + "profile.edit.submit": { + "message": "Save Changes", + "description": "Submit button on the edit-profile dialog. Keep short." + }, + "profile.dialog.title": { + "message": "Enter Profile Name", + "description": "Title of the dialog for naming a new profile." + }, + "profile.dialog.nameLabel": { + "message": "Profile Name", + "description": "Field label for the profile name." + }, + "profile.dialog.description": { + "message": "Set an easily identifiable name for your profile.", + "description": "Helper text under the profile-name field." + }, + "profile.dialog.placeholder": { + "message": "e.g. Work", + "description": "Example placeholder shown in the profile-name field. 'Work' is a sample value; translate it to a natural example." + }, + "profile.dialog.submit": { + "message": "Add Profile", + "description": "Submit button on the add-profile dialog. Keep short." + }, + "profile.dialog.required": { + "message": "Please enter a profile name, e.g. work, home", + "description": "Validation message shown when the profile name is empty. The examples (work, home) may be localized." + }, + "profile.dialog.managementHelp": { + "message": "Use NetBird Cloud or your own server.", + "description": "Helper text noting the profile can use NetBird Cloud or a self-hosted server." + }, + "profile.dialog.urlUnreachable": { + "message": "Couldn't reach this server. Check the URL, or add the profile anyway if you're sure it's correct.", + "description": "Soft warning when the entered server URL couldn't be reached; the user may add the profile anyway." + }, + "header.menu.settings": { + "message": "Settings...", + "description": "'More' menu item in the header that opens Settings. The trailing '...' signals a window opens." + }, + "header.menu.defaultView": { + "message": "Default View", + "description": "'More' menu item that switches the main window to the compact default view." + }, + "header.menu.advancedView": { + "message": "Advanced View", + "description": "'More' menu item that switches the main window to the wider advanced view." + }, + "header.menu.updateAvailable": { + "message": "Update Available", + "description": "'More' menu item / badge shown when an update is available." + }, + "header.menu.open": { + "message": "Open menu", + "description": "Accessibility label for the header's more (⋮) button that opens the menu." + }, + "header.profile.switch": { + "message": "Switch profile", + "description": "Accessibility label for the header's profile selector button." + }, + "connect.toggle.label": { + "message": "Toggle NetBird connection", + "description": "Accessibility label for the large connect/disconnect toggle on the main page." + }, + "connect.localIp.label": { + "message": "Local IP addresses", + "description": "Accessibility label for the local IP selector that toggles between IPv4 and IPv6 on the main page." + }, + "common.search": { + "message": "Search", + "description": "Accessibility label for a generic search input." + }, + "common.filter": { + "message": "Filter", + "description": "Accessibility label for a generic filter control." + }, + "exitNodes.dropdown.trigger": { + "message": "Select exit node", + "description": "Accessibility label for the exit-node picker button at the bottom of the main page." + }, + "peers.row.label": { + "message": "Open details for {name}, {status}", + "description": "Accessibility label for a peer row in the list. {name} is the peer name and {status} is the connection status; keep both placeholders." + }, + "peers.dialog.title": { + "message": "Peer details", + "description": "Accessibility title (announced to screen readers) for the peer details dialog/panel." + }, + "networks.row.toggle": { + "message": "Toggle {name}", + "description": "Accessibility label for the row-wide toggle on a network/resource. {name} is the resource id; keep the placeholder." + }, + "networks.bulk.label": { + "message": "Toggle all visible resources", + "description": "Accessibility label for the bulk enable/disable button at the bottom of the resources list." + }, + "profile.switch.title": { + "message": "Switch Profile to \"{name}\"?", + "description": "Confirmation-dialog title for switching profiles. {name} is the target profile name, shown in quotes; keep {name} and the surrounding quotes." + }, + "profile.switch.message": { + "message": "Are you sure you want to switch profiles?\nYour current profile will be disconnected.", + "description": "Confirmation body for switching profiles. Contains a line break (\\n) — keep it." + }, + "profile.switch.confirm": { + "message": "Confirm", + "description": "Confirm button on the switch-profile dialog. Keep short." + }, + "profile.deregister.title": { + "message": "Deregister Profile \"{name}\"?", + "description": "Confirmation-dialog title for deregistering a profile. {name} is the profile name; keep it and the quotes." + }, + "profile.deregister.message": { + "message": "Are you sure you want to deregister this profile?\nYou will need to log in again to use it.", + "description": "Confirmation body for deregistering; warns the user must sign in again. Contains a line break (\\n) — keep it." + }, + "profile.deregister.confirm": { + "message": "Deregister", + "description": "Confirm button on the deregister dialog. Keep short." + }, + "profile.delete.title": { + "message": "Delete Profile \"{name}\"?", + "description": "Confirmation-dialog title for deleting a profile. {name} is the profile name; keep it and the quotes." + }, + "profile.delete.message": { + "message": "Are you sure you want to delete this profile?\nThis action cannot be undone.", + "description": "Confirmation body for deleting; warns the action can't be undone. Contains a line break (\\n) — keep it." + }, + "profile.delete.disabledActive": { + "message": "Active profiles cannot be deleted. Switch to a different one before deleting this profile.", + "description": "Tooltip explaining the active profile can't be deleted until the user switches away." + }, + "profile.delete.disabledDefault": { + "message": "The default profile cannot be deleted.", + "description": "Tooltip explaining the default profile can't be deleted." + }, + "profile.error.switchTitle": { + "message": "Switch Profile Failed", + "description": "Error-dialog title when switching profiles fails." + }, + "profile.error.deregisterTitle": { + "message": "Deregister Profile Failed", + "description": "Error-dialog title when deregistering a profile fails." + }, + "profile.error.deleteTitle": { + "message": "Delete Profile Failed", + "description": "Error-dialog title when deleting a profile fails." + }, + "profile.error.createTitle": { + "message": "Create Profile Failed", + "description": "Error-dialog title when creating a profile fails." + }, + "profile.error.editTitle": { + "message": "Edit Profile Failed", + "description": "Error-dialog title when editing a profile (rename or management URL change) fails." + }, + "profile.error.loadTitle": { + "message": "Load Profiles Failed", + "description": "Error-dialog title when loading profiles fails." + }, + "profile.dropdown.activeProfile": { + "message": "Active profile", + "description": "Section heading in the header profile dropdown for the current profile." + }, + "profile.dropdown.switchProfile": { + "message": "Switch Profile", + "description": "Section heading / action in the profile dropdown to switch profiles." + }, + "profile.dropdown.noEmail": { + "message": "Other", + "description": "Label shown for a profile that has no associated email address." + }, + "profile.dropdown.addProfile": { + "message": "Add Profile", + "description": "Profile dropdown action to add a profile." + }, + "profile.dropdown.manageProfiles": { + "message": "Manage Profiles", + "description": "Profile dropdown action that opens Settings → Profiles." + }, + "profile.dropdown.settings": { + "message": "Settings", + "description": "Profile dropdown action that opens Settings." + }, + "settings.profiles.section.profiles": { + "message": "Profiles", + "description": "Section heading on the Profiles settings tab." + }, + "settings.profiles.intro": { + "message": "Keep separate NetBird identities side by side, for example work and personal accounts, or different management servers. Add, deregister, or delete profiles below.", + "description": "Intro paragraph on the Profiles settings tab explaining what profiles are for." + }, + "settings.profiles.addProfile": { + "message": "Add Profile", + "description": "Button on the Profiles settings tab to add a profile." + }, + "settings.profiles.active": { + "message": "Active", + "description": "Badge marking the currently active profile in the profiles table." + }, + "settings.profiles.emptyTitle": { + "message": "No Profiles", + "description": "Title of the empty state when there are no profiles." + }, + "settings.profiles.emptyDescription": { + "message": "Create a profile to connect to a NetBird management server.", + "description": "Body of the no-profiles empty state." + }, + "settings.error.loadTitle": { + "message": "Load Settings Failed", + "description": "Error-dialog title when loading settings fails." + }, + "settings.error.saveTitle": { + "message": "Save Settings Failed", + "description": "Error-dialog title when saving settings fails." + }, + "settings.error.debugBundleTitle": { + "message": "Debug Bundle Failed", + "description": "Error-dialog title when creating the debug bundle fails." + }, + "settings.nav.label": { + "message": "Settings sections", + "description": "Accessibility label for the Settings page's side navigation (list of section tabs)." + }, + "settings.tabs.general": { + "message": "General", + "description": "Settings tab label: General. Keep short." + }, + "settings.tabs.network": { + "message": "Network", + "description": "Settings tab label: Network. Keep short." + }, + "settings.tabs.security": { + "message": "Security", + "description": "Settings tab label: Security. Keep short." + }, + "settings.tabs.profiles": { + "message": "Profiles", + "description": "Settings tab label: Profiles. Keep short." + }, + "settings.tabs.ssh": { + "message": "SSH", + "description": "Settings tab label: SSH. Acronym — keep as-is." + }, + "settings.tabs.advanced": { + "message": "Advanced", + "description": "Settings tab label: Advanced. Keep short." + }, + "settings.tabs.troubleshooting": { + "message": "Troubleshoot", + "description": "Settings tab label: Troubleshoot. Keep short." + }, + "settings.tabs.about": { + "message": "About", + "description": "Settings tab label: About. Keep short." + }, + "settings.tabs.updateAvailable": { + "message": "Update Available", + "description": "Settings tab label / badge shown when an update is available." + }, + "settings.general.section.general": { + "message": "General", + "description": "Section heading on the General settings tab." + }, + "settings.general.section.connection": { + "message": "Connection", + "description": "Section heading for connection-related options on the General tab." + }, + "settings.general.connectOnStartup.label": { + "message": "Connect on Startup", + "description": "Toggle label: connect automatically when the service starts." + }, + "settings.general.connectOnStartup.help": { + "message": "Automatically establish a connection when the service starts.", + "description": "Helper text for the connect-on-startup toggle." + }, + "settings.general.notifications.label": { + "message": "Desktop Notifications", + "description": "Toggle label: enable desktop notifications." + }, + "settings.general.notifications.help": { + "message": "Show desktop notifications for new updates and connection events.", + "description": "Helper text for the desktop-notifications toggle." + }, + "settings.general.autostart.label": { + "message": "Launch NetBird UI at Login", + "description": "Toggle label: launch the NetBird UI at login." + }, + "settings.general.autostart.help": { + "message": "Start the NetBird interface automatically when you log in. This affects the graphical interface only, not the background service.", + "description": "Helper text clarifying autostart affects only the UI, not the background service." + }, + "settings.general.autostart.errorTitle": { + "message": "Autostart Change Failed", + "description": "Error-dialog title when changing the autostart setting fails." + }, + "settings.general.keepConnectedOnQuit.label": { + "message": "Stay Connected After Quitting", + "description": "Toggle label: keep the VPN connection up after quitting the UI." + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "The connection stays up in the background after you close NetBird. It only stops when you disconnect it yourself.", + "description": "Helper text for the stay-connected-after-quitting toggle." + }, + "settings.general.language.label": { + "message": "Display Language", + "description": "Label for the display-language picker." + }, + "settings.general.language.help": { + "message": "Choose the language for the NetBird interface.", + "description": "Helper text for the language picker." + }, + "settings.general.language.search": { + "message": "Search language…", + "description": "Placeholder in the language picker's search field." + }, + "settings.general.language.empty": { + "message": "No languages match.", + "description": "Shown when no languages match the search." + }, + "settings.general.management.label": { + "message": "Management Server", + "description": "Label for the management-server selector." + }, + "settings.general.management.help": { + "message": "Connect to NetBird Cloud or your own self-hosted management server. Changes will reconnect the client.", + "description": "Helper text explaining Cloud vs self-hosted management server; warns that changes reconnect the client." + }, + "settings.general.management.cloud": { + "message": "Cloud", + "description": "Option label for using NetBird Cloud as the management server." + }, + "settings.general.management.selfHosted": { + "message": "Self-hosted", + "description": "Option label for using a self-hosted management server. 'Self-hosted' is a common technical term." + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443", + "description": "Example URL placeholder for the self-hosted server field. It is a sample URL — do not translate the URL itself." + }, + "settings.general.management.urlError": { + "message": "Please enter a valid URL, e.g., https://netbird.selfhosted.com:443", + "description": "Validation message for an invalid management-server URL. The example URL stays as-is." + }, + "settings.general.management.urlUnreachable": { + "message": "Couldn't reach this server. Check the URL, or save anyway if you're sure it's correct.", + "description": "Soft warning when the management URL couldn't be reached; the user may save anyway." + }, + "settings.general.management.switchCloudTitle": { + "message": "Switch to NetBird Cloud?", + "description": "Confirmation-dialog title for switching to NetBird Cloud." + }, + "settings.general.management.switchCloudMessage": { + "message": "This disconnects your self-hosted server.\nYou may need to log in again.", + "description": "Confirmation body warning the self-hosted server will be disconnected. Contains a line break (\\n) — keep it." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Switch to Cloud", + "description": "Confirm button for switching to Cloud. Keep short." + }, + "settings.network.section.connectivity": { + "message": "Connectivity", + "description": "Section heading for connectivity options on the Network tab." + }, + "settings.network.section.routingDns": { + "message": "Routing & DNS", + "description": "Section heading for routing and DNS options. 'DNS' is an acronym — keep it." + }, + "settings.network.monitor.label": { + "message": "Reconnect on Network Change", + "description": "Toggle label: reconnect automatically on network change." + }, + "settings.network.monitor.help": { + "message": "Monitor the network and automatically reconnect on changes such as Wi-Fi switching, Ethernet changes, or resume from sleep.", + "description": "Helper text for the network-change reconnect toggle." + }, + "settings.network.dns.label": { + "message": "Enable DNS", + "description": "Toggle label: enable DNS. 'DNS' — keep acronym." + }, + "settings.network.dns.help": { + "message": "Apply NetBird-managed DNS settings to the host resolver.", + "description": "Helper text for the enable-DNS toggle." + }, + "settings.network.clientRoutes.label": { + "message": "Enable Client Routes", + "description": "Toggle label: enable client routes." + }, + "settings.network.clientRoutes.help": { + "message": "Accept routes from other peers to reach their networks.", + "description": "Helper text for client routes (accept routes from other peers)." + }, + "settings.network.serverRoutes.label": { + "message": "Enable Server Routes", + "description": "Toggle label: enable server routes." + }, + "settings.network.serverRoutes.help": { + "message": "Advertise this host's local routes to other peers.", + "description": "Helper text for server routes (advertise this host's local routes to other peers)." + }, + "settings.network.ipv6.label": { + "message": "Enable IPv6", + "description": "Toggle label: enable IPv6. 'IPv6' — keep as-is." + }, + "settings.network.ipv6.help": { + "message": "Use IPv6 addressing for the NetBird overlay network.", + "description": "Helper text for the IPv6 toggle." + }, + "settings.security.section.firewall": { + "message": "Firewall", + "description": "Section heading: Firewall." + }, + "settings.security.section.encryption": { + "message": "Encryption", + "description": "Section heading: Encryption." + }, + "settings.security.blockInbound.label": { + "message": "Block Inbound Traffic", + "description": "Toggle label: block inbound traffic." + }, + "settings.security.blockInbound.help": { + "message": "Reject unsolicited connections from peers to this device and any networks it routes. Outbound traffic is unaffected.", + "description": "Helper text for blocking inbound traffic." + }, + "settings.security.blockLan.label": { + "message": "Block LAN Access", + "description": "Toggle label: block LAN access. 'LAN' — keep acronym." + }, + "settings.security.blockLan.help": { + "message": "Prevent peers from reaching your local network or its devices when this device routes their traffic.", + "description": "Helper text for blocking LAN access." + }, + "settings.security.rosenpass.label": { + "message": "Enable Quantum-Resistance", + "description": "Toggle label: enable quantum-resistance." + }, + "settings.security.rosenpass.help": { + "message": "Add a post-quantum key exchange via Rosenpass on top of WireGuard®.", + "description": "Helper text: adds a post-quantum key exchange via Rosenpass on top of WireGuard®. 'Rosenpass' and 'WireGuard®' are product names — do not translate; keep the ® symbol." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Enable Permissive Mode", + "description": "Toggle label: enable permissive mode (for quantum-resistance)." + }, + "settings.security.rosenpassPermissive.help": { + "message": "Allow connections to peers without quantum-resistance support.", + "description": "Helper text for permissive mode (allow peers without quantum-resistance support)." + }, + "settings.ssh.section.server": { + "message": "Server", + "description": "Section heading: Server (SSH settings)." + }, + "settings.ssh.section.capabilities": { + "message": "Capabilities", + "description": "Section heading: Capabilities (SSH features)." + }, + "settings.ssh.section.authentication": { + "message": "Authentication", + "description": "Section heading: Authentication (SSH)." + }, + "settings.ssh.server.label": { + "message": "Enable SSH Server", + "description": "Toggle label: enable the SSH server." + }, + "settings.ssh.server.help": { + "message": "Run the NetBird SSH server on this host so other peers can connect to it.", + "description": "Helper text for the SSH server toggle." + }, + "settings.ssh.root.label": { + "message": "Allow Root Login", + "description": "Toggle label: allow root login over SSH. 'root' is the Unix superuser account — keep as-is." + }, + "settings.ssh.root.help": { + "message": "Let peers sign in as the root user. Disable to require a non-privileged account.", + "description": "Helper text for allowing root login." + }, + "settings.ssh.sftp.label": { + "message": "Allow SFTP", + "description": "Toggle label: allow SFTP. 'SFTP' — keep acronym." + }, + "settings.ssh.sftp.help": { + "message": "Transfer files securely using native SFTP or SCP clients.", + "description": "Helper text about SFTP/SCP file transfer. Keep the acronyms." + }, + "settings.ssh.localForward.label": { + "message": "Local Port Forwarding", + "description": "Toggle label: local port forwarding." + }, + "settings.ssh.localForward.help": { + "message": "Let connecting peers tunnel local ports to services reachable from this host.", + "description": "Helper text for local port forwarding." + }, + "settings.ssh.remoteForward.label": { + "message": "Remote Port Forwarding", + "description": "Toggle label: remote port forwarding." + }, + "settings.ssh.remoteForward.help": { + "message": "Let connecting peers expose ports on this host back to their own machine.", + "description": "Helper text for remote port forwarding." + }, + "settings.ssh.jwt.label": { + "message": "Enable JWT Authentication", + "description": "Toggle label: enable JWT authentication. 'JWT' — keep acronym." + }, + "settings.ssh.jwt.help": { + "message": "Verify each SSH session against your IdP for user identity and audit. Disable to rely on network ACL policies only, useful when no IdP is available.", + "description": "Helper text for JWT auth. 'IdP' (identity provider) and 'ACL' are acronyms — keep them." + }, + "settings.ssh.jwtTtl.label": { + "message": "JWT Cache TTL", + "description": "Label for the JWT cache time-to-live field. 'JWT' and 'TTL' — keep acronyms." + }, + "settings.ssh.jwtTtl.help": { + "message": "How long this client caches a JWT before prompting again on outgoing SSH connections. Set to 0 to disable caching and authenticate on every connection.", + "description": "Helper text for the JWT cache TTL; mentions setting 0 to disable caching." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "Second(s)", + "description": "Unit suffix shown after the JWT TTL number field. The '(s)' marks an optional plural." + }, + "settings.advanced.section.interface": { + "message": "Interface", + "description": "Section heading: Interface (network-interface settings)." + }, + "settings.advanced.section.security": { + "message": "Security", + "description": "Section heading: Security (advanced)." + }, + "settings.advanced.interfaceName.label": { + "message": "Name", + "description": "Field label for the WireGuard interface name." + }, + "settings.advanced.interfaceName.error": { + "message": "Use 1-15 letters, digits, dots, hyphens, or underscores.", + "description": "Validation message for the interface name (allowed characters)." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Must start with \"utun\" followed by a number (e.g. utun100).", + "description": "Validation message specific to macOS, where the name must start with 'utun' followed by a number. Keep 'utun' and the example." + }, + "settings.advanced.port.label": { + "message": "Port", + "description": "Field label: Port." + }, + "settings.advanced.port.error": { + "message": "Enter a port between {min} and {max}.", + "description": "Validation message for the port range. {min} and {max} are numbers; keep them." + }, + "settings.advanced.port.help": { + "message": "If set to 0, a random free port will be used.", + "description": "Helper text: 0 means a random free port is used." + }, + "settings.advanced.mtu.label": { + "message": "MTU", + "description": "Field label: MTU. 'MTU' — keep acronym." + }, + "settings.advanced.mtu.error": { + "message": "Enter an MTU value between {min} and {max}.", + "description": "Validation message for the MTU range. {min} and {max} are numbers; keep them." + }, + "settings.advanced.psk.label": { + "message": "Pre-shared Key", + "description": "Field label: Pre-shared Key." + }, + "settings.advanced.psk.help": { + "message": "Optional WireGuard PSK for extra symmetric encryption. Not the same as a NetBird Setup Key. You will only communicate with peers that use the same pre-shared key.", + "description": "Helper text for the WireGuard PSK. 'WireGuard', 'PSK', and 'NetBird Setup Key' are product/technical terms — keep them." + }, + "settings.troubleshooting.section.title": { + "message": "Debug bundle", + "description": "Section heading: Debug bundle." + }, + "settings.troubleshooting.anonymize.label": { + "message": "Anonymize Sensitive Information", + "description": "Label for the anonymization level dropdown (None, Default, Strict)." + }, + "settings.troubleshooting.anonymize.help": { + "message": "Hides IP addresses, domains, and other sensitive values.", + "description": "Helper text under the anonymization dropdown. The level details live in the info tooltip." + }, + "settings.troubleshooting.anonymize.info": { + "message": "Default keeps internal IPv4 addresses and peer names readable for support. Strict additionally anonymizes private (RFC 1918), CGNAT, and link-local IP addresses, peer names, and WireGuard public keys. Recurring values map to the same placeholder, so peers stay distinguishable. Use Strict when sharing the bundle outside your organization.", + "description": "Info tooltip explaining the anonymization levels. 'RFC 1918', 'CGNAT', 'link-local', and 'WireGuard' are technical terms — keep them." + }, + "settings.troubleshooting.anonymize.none": { + "message": "None", + "description": "Dropdown option: no anonymization." + }, + "settings.troubleshooting.anonymize.default": { + "message": "Default", + "description": "Dropdown option: default anonymization level." + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Strict", + "description": "Dropdown option: strict anonymization level." + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Include System Information", + "description": "Toggle label: include system information in the bundle." + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Include OS, kernel, network interfaces, and routing tables.", + "description": "Helper text listing the system info included (OS, kernel, interfaces, routing tables)." + }, + "settings.troubleshooting.upload.label": { + "message": "Upload Bundle to NetBird Servers", + "description": "Toggle label: upload the bundle to NetBird servers." + }, + "settings.troubleshooting.upload.help": { + "message": "Returns an upload key to share with NetBird support.", + "description": "Helper text for uploading the bundle." + }, + "settings.troubleshooting.trace.label": { + "message": "Enable Trace Logs", + "description": "Toggle label: raise daemon log level to TRACE while the bundle is built. 'TRACE' is a log level." + }, + "settings.troubleshooting.trace.help": { + "message": "Raises the log level to TRACE and restores it after.", + "description": "Helper text for the trace toggle. 'TRACE' is a log level — keep as-is." + }, + "settings.troubleshooting.capture.label": { + "message": "Capture Session", + "description": "Toggle label: open a capture session — reconnect NetBird, wait a duration, optionally record packets." + }, + "settings.troubleshooting.capture.help": { + "message": "Reconnects and waits so you can reproduce the issue.", + "description": "Helper text for the master Capture Session toggle." + }, + "settings.troubleshooting.packets.label": { + "message": "Capture Network Packets", + "description": "Toggle label: capture packets to a .pcap during the capture session." + }, + "settings.troubleshooting.packets.help": { + "message": "Saves a .pcap of network traffic during the capture window.", + "description": "Helper text for the packet recording toggle. '.pcap' is a file extension — keep it." + }, + "settings.troubleshooting.duration.label": { + "message": "Capture Duration", + "description": "Label for the trace-capture duration field." + }, + "settings.troubleshooting.duration.help": { + "message": "How long the capture session runs.", + "description": "Helper text for the capture duration." + }, + "settings.troubleshooting.duration.suffix": { + "message": "Minute(s)", + "description": "Unit suffix after the duration field. The '(s)' marks an optional plural." + }, + "settings.troubleshooting.create": { + "message": "Create Bundle", + "description": "Button to create the debug bundle. Keep short." + }, + "settings.troubleshooting.progress.description": { + "message": "Collecting logs, system details, and connection state. This usually takes a moment. You can keep using NetBird or close Settings while it finishes.", + "description": "Status text shown while the debug bundle is being collected; reassures the user they can keep using NetBird or close Settings meanwhile." + }, + "settings.troubleshooting.cancelling": { + "message": "Canceling…", + "description": "Status shown while cancelling bundle creation. Ends with an ellipsis." + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "Debug bundle successfully uploaded!", + "description": "Success title after the bundle was uploaded." + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Bundle saved", + "description": "Title shown when the bundle was saved locally (not uploaded)." + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Share the upload key below with NetBird support. A local copy was also saved on your device.", + "description": "Body after upload, telling the user to share the upload key with support. '' wraps the inline link to the NetBird support docs — keep the tags exactly and wrap the phrase that means 'NetBird support'." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "Your debug bundle has been saved locally.", + "description": "Body shown when the bundle was only saved locally." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Copy Key", + "description": "Button to copy the upload key. Keep short." + }, + "settings.troubleshooting.done.openFolder": { + "message": "Open Folder", + "description": "Button to open the folder containing the bundle. Keep short." + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Open file location", + "description": "Button to reveal the bundle file in the OS file manager." + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Upload failed: {reason} The bundle is still saved locally.", + "description": "Shown when upload failed but the bundle was saved locally. {reason} is the server error text; keep it." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Upload failed. The bundle is still saved locally.", + "description": "Shown when upload failed (no specific reason) but the bundle was saved locally." + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "Reconnecting NetBird…", + "description": "Progress stage: reconnecting NetBird. Ends with an ellipsis." + }, + "settings.troubleshooting.stage.capturing": { + "message": "Capturing debug logs", + "description": "Progress stage: capturing logs. {elapsed} and {total} are time values (e.g. 0:30 / 2:00); keep both." + }, + "settings.troubleshooting.stage.bundling": { + "message": "Generating debug bundle…", + "description": "Progress stage: generating the debug bundle. Ends with an ellipsis." + }, + "settings.troubleshooting.stage.uploading": { + "message": "Uploading to NetBird…", + "description": "Progress stage: uploading to NetBird. Ends with an ellipsis." + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Canceling…", + "description": "Progress stage: cancelling. Ends with an ellipsis." + }, + "settings.about.client": { + "message": "NetBird Client v{version}", + "description": "About tab: client product name and version. {version} is a version number; keep it. 'NetBird Client' — keep brand." + }, + "settings.about.clientName": { + "message": "NetBird Client", + "description": "About tab: the client product name shown when no version is available. Brand — do not translate." + }, + "settings.about.development": { + "message": "[Development]", + "description": "Badge shown next to the version on development builds. Keep the square brackets." + }, + "settings.about.gui": { + "message": "GUI v{version}", + "description": "About tab: UI component name and version. {version} is a version number; keep it. 'GUI' = the graphical app." + }, + "settings.about.guiName": { + "message": "GUI", + "description": "About tab: the UI component name shown without a version. 'GUI' = the graphical app." + }, + "settings.about.copyright": { + "message": "© {year} NetBird. All Rights Reserved.", + "description": "Copyright line. {year} is the current year; keep it. 'NetBird' — brand." + }, + "settings.about.links.imprint": { + "message": "Imprint", + "description": "Footer link: Imprint (legal notice / Impressum)." + }, + "settings.about.links.privacy": { + "message": "Privacy", + "description": "Footer link: Privacy policy." + }, + "settings.about.links.cla": { + "message": "CLA", + "description": "Footer link: CLA (Contributor License Agreement). Acronym — keep as-is." + }, + "settings.about.links.terms": { + "message": "Terms of Service", + "description": "Footer link: Terms of Service." + }, + "settings.about.community.github": { + "message": "GitHub", + "description": "Community link: GitHub. Brand — do not translate." + }, + "settings.about.community.slack": { + "message": "Slack", + "description": "Community link: Slack. Brand — do not translate." + }, + "settings.about.community.forum": { + "message": "Forum", + "description": "Community link: Forum." + }, + "settings.about.community.documentation": { + "message": "Documentation", + "description": "Community link: Documentation." + }, + "settings.about.community.feedback": { + "message": "Feedback", + "description": "Community link: Feedback." + }, + "update.banner.message": { + "message": "NetBird {version} is ready to install.", + "description": "Update banner text when a new version is ready to install. {version} is the version number; keep it." + }, + "update.banner.later": { + "message": "Later", + "description": "Banner button to postpone the update. Keep short." + }, + "update.banner.installNow": { + "message": "Install now", + "description": "Banner button to install the update now. Keep short." + }, + "update.card.versionAvailableDownload": { + "message": "Version {version} is available for download.", + "description": "Update card text when a version is available to download. {version} is the version number; keep it." + }, + "update.card.versionAvailableInstall": { + "message": "Version {version} is available for install.", + "description": "Update card text when a version is available to install. {version} is the version number; keep it." + }, + "update.card.whatsNew": { + "message": "What's new?", + "description": "Link / label that opens the release notes." + }, + "update.card.installNow": { + "message": "Install Now", + "description": "Update card button: install now. Keep short." + }, + "update.card.getInstaller": { + "message": "Download", + "description": "Update card button: download the installer." + }, + "update.card.autoCheckInterval": { + "message": "NetBird checks for updates in the background.", + "description": "Note that NetBird checks for updates in the background." + }, + "update.card.changelog": { + "message": "Changelog", + "description": "Link / label: Changelog." + }, + "update.card.onLatestVersion": { + "message": "You're on the latest version", + "description": "Shown when the client is already up to date." + }, + "update.header.tooltip": { + "message": "Update Available", + "description": "Tooltip on the header update badge." + }, + "update.overlay.updatingVersion": { + "message": "Updating NetBird to v{version}", + "description": "Heading in the install-progress window while updating to a specific version. {version} is the version number; keep it. The 'v' prefix is part of the version display." + }, + "update.overlay.updating": { + "message": "Updating NetBird", + "description": "Heading in the install-progress window while updating when the target version isn't known." + }, + "update.overlay.description": { + "message": "A newer version is available and is being installed. NetBird will restart automatically once the update is finished.", + "description": "Install-progress window body explaining NetBird will restart automatically after the update." + }, + "update.overlay.error.timeoutTitle": { + "message": "Update Is Taking Too Long", + "description": "Install-progress window error title: the update took too long." + }, + "update.overlay.error.timeoutDescription": { + "message": "Installing {target} took too long and didn't finish.", + "description": "Install-progress window error body for a timeout. {target} is the target-version label (see update.overlay.error.targetVersion / targetFallback); keep it." + }, + "update.overlay.error.canceledTitle": { + "message": "Update Was Stopped", + "description": "Install-progress window error title: the update was stopped / canceled." + }, + "update.overlay.error.canceledDescription": { + "message": "The update to {target} was canceled before it finished.", + "description": "Install-progress window error body for a canceled update. {target} is the target-version label; keep it." + }, + "update.overlay.error.failTitle": { + "message": "Couldn't Install the Update", + "description": "Install-progress window error title: the update couldn't be installed." + }, + "update.overlay.error.failDescription": { + "message": "{target} couldn't be installed.", + "description": "Install-progress window error body for a failed install. {target} is the target-version label; keep it." + }, + "update.overlay.error.unknownMessage": { + "message": "unknown error", + "description": "Fallback text for an unspecified error, used inside other messages." + }, + "update.overlay.error.targetVersion": { + "message": "v{version}", + "description": "The target-version label substituted into {target}. {version} is a number; keep the 'v' prefix." + }, + "update.overlay.error.targetFallback": { + "message": "the new version", + "description": "Fallback target label used when the version isn't known, substituted into {target}." + }, + "update.error.loadStateTitle": { + "message": "Load Update State Failed", + "description": "Error-dialog title when loading update state fails." + }, + "update.error.triggerTitle": { + "message": "Start Update Failed", + "description": "Error-dialog title when starting the update fails." + }, + "update.page.versionLine": { + "message": "Updating client to: {version}.", + "description": "Install-progress window text naming the target version. {version} is the version number; keep it." + }, + "update.page.versionLineGeneric": { + "message": "Updating client.", + "description": "Install-progress text used when the target version isn't known." + }, + "update.page.outdated": { + "message": "Your client version is older than the auto-update version set in Management.", + "description": "Note that the client is older than the auto-update version set in Management. 'Management' = the NetBird management server / console." + }, + "update.page.status.running": { + "message": "Updating", + "description": "Install status: updating." + }, + "update.page.status.timeout": { + "message": "Update timed out. Please try again.", + "description": "Install status: timed out; asks the user to retry." + }, + "update.page.status.canceled": { + "message": "Update canceled.", + "description": "Install status: canceled." + }, + "update.page.status.failed": { + "message": "Update failed: {message}", + "description": "Install status: failed. {message} is the error detail; keep it." + }, + "update.page.status.unknownError": { + "message": "unknown update error", + "description": "Fallback text for an unknown update error, used inside update.page.status.failed." + }, + "update.page.failedTitle": { + "message": "Update Failed", + "description": "Heading shown when the install failed." + }, + "update.page.timeoutMessage": { + "message": "Update timed out.", + "description": "Message shown when the install timed out." + }, + "update.page.dontClose": { + "message": "Please don't close this window.", + "description": "Warning asking the user not to close the install window." + }, + "update.page.updating": { + "message": "Updating…", + "description": "Short status: updating. Ends with an ellipsis." + }, + "update.page.complete": { + "message": "Update complete", + "description": "Short status: update complete." + }, + "update.page.failed": { + "message": "Update failed", + "description": "Short status: update failed." + }, + "window.title.settings": { + "message": "Settings", + "description": "OS window-chrome title for the Settings window. The app prefixes it with 'NetBird - '." + }, + "window.title.signIn": { + "message": "Sign-in", + "description": "OS window-chrome title for the sign-in window." + }, + "window.title.sessionExpiration": { + "message": "Session Expiring", + "description": "OS window-chrome title for the session-expiration window." + }, + "window.title.updating": { + "message": "Updating", + "description": "OS window-chrome title for the update / install window." + }, + "window.title.welcome": { + "message": "Welcome to NetBird", + "description": "OS window-chrome title for the first-launch welcome window. 'NetBird' — brand." + }, + "window.title.error": { + "message": "Error", + "description": "OS window-chrome title for the error window." + }, + "welcome.title": { + "message": "Look for NetBird in your tray", + "description": "Heading on the first onboarding step, pointing the user to the tray icon. Shown on Windows and Linux; macOS uses welcome.titleMac." + }, + "welcome.titleMac": { + "message": "Look for NetBird in your menu bar", + "description": "Heading on the first onboarding step on macOS, pointing the user to the menu bar icon. Use your language's Apple term for the macOS menu bar." + }, + "welcome.description": { + "message": "NetBird lives in your tray. Click the icon to connect, switch profiles, or open settings.", + "description": "Body of the first onboarding step explaining the tray icon. Shown on Windows and Linux; macOS uses welcome.descriptionMac." + }, + "welcome.descriptionMac": { + "message": "NetBird lives in your menu bar. Click the icon to connect, switch profiles, or open settings.", + "description": "Body of the first onboarding step on macOS explaining the menu bar icon. Use your language's Apple term for the macOS menu bar." + }, + "welcome.continue": { + "message": "Continue", + "description": "Primary button to advance the onboarding. Keep short." + }, + "welcome.back": { + "message": "Back", + "description": "Button to go back a step in onboarding. Keep short." + }, + "welcome.management.title": { + "message": "Set up NetBird", + "description": "Heading on the onboarding step for choosing a management server." + }, + "welcome.management.description": { + "message": "Click Continue to get started, or pick Self-hosted if you have your own NetBird server.", + "description": "Body of the management step; mentions choosing Self-hosted if the user runs their own server." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud", + "description": "Option title: NetBird Cloud. Brand — do not translate 'NetBird Cloud'." + }, + "welcome.management.cloud.description": { + "message": "Use our hosted service. No setup required.", + "description": "Option body for NetBird Cloud (hosted service, no setup required)." + }, + "welcome.management.selfHosted.title": { + "message": "Self-hosted", + "description": "Option title: Self-hosted." + }, + "welcome.management.selfHosted.description": { + "message": "Connect to your own management server.", + "description": "Option body for connecting to your own management server." + }, + "welcome.management.urlLabel": { + "message": "Management server URL", + "description": "Field label for the management-server URL." + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443", + "description": "Example URL placeholder. Sample URL — do not translate it." + }, + "welcome.management.urlInvalid": { + "message": "Please enter a valid URL, e.g., https://netbird.selfhosted.com:443", + "description": "Validation message for an invalid URL. The example URL stays as-is." + }, + "welcome.management.urlUnreachable": { + "message": "Couldn't reach this server. Check the URL or your network, then continue if you're sure it's correct.", + "description": "Soft warning when the server couldn't be reached during onboarding; the user may continue anyway." + }, + "welcome.management.checking": { + "message": "Checking…", + "description": "Status shown while checking the server URL. Ends with an ellipsis." + }, + "browserLogin.title": { + "message": "Complete login in your browser", + "description": "Heading telling the user to finish signing in via their browser." + }, + "browserLogin.notSeeing": { + "message": "We opened a browser tab so you can finish signing in. Not seeing it?", + "description": "Prompt asking whether the user doesn't see the opened browser tab." + }, + "browserLogin.tryAgain": { + "message": "Try again", + "description": "Button to re-open the browser sign-in page. Keep short." + }, + "browserLogin.openFailedTitle": { + "message": "Open Browser Failed", + "description": "Error-dialog title when the browser couldn't be opened." + }, + "sessionExpiration.title": { + "message": "Session expiring soon", + "description": "Heading warning the session will expire soon." + }, + "sessionExpiration.titleLater": { + "message": "Your session will expire", + "description": "Alternate, less-urgent heading: the session will expire." + }, + "sessionExpiration.description": { + "message": "This device will disconnect soon. Renew with a browser sign-in.", + "description": "Body warning the device will disconnect soon; renew via a browser sign-in." + }, + "sessionExpiration.descriptionLater": { + "message": "A browser sign-in keeps this device connected to your network.", + "description": "Body for the less-urgent variant; a browser sign-in keeps the device connected." + }, + "sessionExpiration.stay": { + "message": "Renew session", + "description": "Button to renew the session (keep connected). Keep short." + }, + "sessionExpiration.authenticate": { + "message": "Authenticate", + "description": "Button to authenticate / sign in. Keep short." + }, + "sessionExpiration.logout": { + "message": "Logout", + "description": "Button to log out. Keep short." + }, + "sessionExpiration.expired": { + "message": "Session expired", + "description": "Heading shown once the session has actually expired." + }, + "sessionExpiration.expiredDescription": { + "message": "Device disconnected. Authenticate with a browser sign-in to reconnect.", + "description": "Body shown after expiry; authenticate via a browser sign-in to reconnect." + }, + "sessionExpiration.close": { + "message": "Close", + "description": "Close button on the session window. Keep short." + }, + "sessionExpiration.extendFailedTitle": { + "message": "Extend Session Failed", + "description": "Error-dialog title when extending the session fails." + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Logout Failed", + "description": "Error-dialog title when logging out fails." + }, + "peers.search.placeholder": { + "message": "Search by name or IP", + "description": "Placeholder in the peers search field." + }, + "peers.filter.all": { + "message": "All", + "description": "Peers filter option: All. Keep short." + }, + "peers.filter.online": { + "message": "Online", + "description": "Peers filter option: Online. Keep short." + }, + "peers.filter.offline": { + "message": "Offline", + "description": "Peers filter option: Offline. Keep short." + }, + "peers.empty.title": { + "message": "No peers available", + "description": "Title of the empty state when no peers are available." + }, + "peers.empty.description": { + "message": "You either don't have any peers available or have no access to any of them.", + "description": "Body of the no-peers empty state." + }, + "peers.details.domain": { + "message": "Domain", + "description": "Peer detail label: Domain." + }, + "peers.details.netbirdIp": { + "message": "NetBird IP", + "description": "Peer detail label: NetBird IP address. 'NetBird' — brand; 'IP' — keep acronym." + }, + "peers.details.netbirdIpv6": { + "message": "NetBird IPv6", + "description": "Peer detail label: NetBird IPv6 address. Keep 'NetBird' and 'IPv6'." + }, + "peers.details.publicKey": { + "message": "Public key", + "description": "Peer detail label: WireGuard public key." + }, + "peers.details.connection": { + "message": "Connection", + "description": "Peer detail label: Connection." + }, + "peers.details.latency": { + "message": "Latency", + "description": "Peer detail label: Latency." + }, + "peers.details.lastHandshake": { + "message": "Last handshake", + "description": "Peer detail label: time of the last WireGuard handshake." + }, + "peers.details.statusSince": { + "message": "Last connection update", + "description": "Peer detail label: time since the last connection-status update." + }, + "peers.details.bytes": { + "message": "Bytes", + "description": "Peer detail label: Bytes (data transferred)." + }, + "peers.details.bytesSent": { + "message": "Sent", + "description": "Peer detail label: bytes sent." + }, + "peers.details.bytesReceived": { + "message": "Received", + "description": "Peer detail label: bytes received." + }, + "peers.details.localIce": { + "message": "Local ICE", + "description": "Peer detail label: local ICE candidate. 'ICE' — keep acronym." + }, + "peers.details.remoteIce": { + "message": "Remote ICE", + "description": "Peer detail label: remote ICE candidate. 'ICE' — keep acronym." + }, + "peers.details.never": { + "message": "Never", + "description": "Value shown when an event has never happened (e.g. last handshake = Never)." + }, + "peers.details.justNow": { + "message": "Just now", + "description": "Relative-time value meaning a moment ago." + }, + "peers.details.refresh": { + "message": "Refresh", + "description": "Button to refresh peer details. Keep short." + }, + "peers.status.connected": { + "message": "Connected", + "description": "Peer status: connected." + }, + "peers.status.connecting": { + "message": "Connecting", + "description": "Peer status: connecting." + }, + "peers.status.disconnected": { + "message": "Disconnected", + "description": "Peer status: disconnected." + }, + "peers.details.relayAddress": { + "message": "Relay", + "description": "Peer detail label: Relay (relay-server address)." + }, + "peers.details.networks": { + "message": "Resources", + "description": "Peer detail label for the peer's network resources. Labelled 'Resources' in the UI." + }, + "peers.details.relayed": { + "message": "Relayed", + "description": "Connection-type value: the connection is relayed (not direct). Technical term." + }, + "peers.details.p2p": { + "message": "P2P", + "description": "Connection-type value: peer-to-peer (direct). 'P2P' — keep as-is." + }, + "peers.details.rosenpass": { + "message": "Rosenpass enabled", + "description": "Peer detail indicating Rosenpass (quantum-resistance) is enabled. 'Rosenpass' — product name, do not translate." + }, + "networks.search.placeholder": { + "message": "Search by network or domain", + "description": "Placeholder in the resources search field." + }, + "networks.filter.all": { + "message": "All", + "description": "Resources filter: All. Keep short." + }, + "networks.filter.active": { + "message": "Active", + "description": "Resources filter: Active. Keep short." + }, + "networks.filter.overlapping": { + "message": "Overlapping", + "description": "Resources filter: Overlapping (overlapping IP ranges). Keep short." + }, + "networks.empty.title": { + "message": "No resources available", + "description": "Title of the empty state when no resources are available." + }, + "networks.empty.description": { + "message": "You either don't have any network resources available or have no access to any of them.", + "description": "Body of the no-resources empty state." + }, + "networks.selected": { + "message": "Selected", + "description": "Label / badge: the resource is selected (enabled)." + }, + "networks.unselected": { + "message": "Not selected", + "description": "Label / badge: the resource is not selected." + }, + "networks.ips.heading": { + "message": "Resolved IPs", + "description": "Heading for the list of a resource's resolved IP addresses." + }, + "networks.bulk.selectionCount": { + "message": "{selected} of {total} Active", + "description": "Header showing how many resources are active. {selected} and {total} are numbers; keep both." + }, + "networks.bulk.enableAll": { + "message": "Enable all", + "description": "Button to enable all resources. Keep short." + }, + "networks.bulk.disableAll": { + "message": "Disable all", + "description": "Button to disable all resources. Keep short." + }, + "exitNodes.search.placeholder": { + "message": "Search exit nodes", + "description": "Placeholder in the exit-nodes search field." + }, + "exitNodes.none": { + "message": "None", + "description": "Option meaning no exit node. Keep short." + }, + "exitNodes.empty.title": { + "message": "No exit nodes available", + "description": "Title of the empty state when no exit nodes are available." + }, + "exitNodes.empty.description": { + "message": "No exit nodes have been shared with this peer.", + "description": "Body of the no-exit-nodes empty state." + }, + "exitNodes.card.title": { + "message": "Exit Node", + "description": "Card heading: Exit Node." + }, + "exitNodes.card.statusActive": { + "message": "Active", + "description": "Exit node card status: Active." + }, + "exitNodes.card.statusInactive": { + "message": "Inactive", + "description": "Exit node card status: Inactive." + }, + "exitNodes.dropdown.noneTitle": { + "message": "None", + "description": "Dropdown option title: None (no exit node)." + }, + "exitNodes.dropdown.noneDescription": { + "message": "Direct connection without an exit node", + "description": "Dropdown option body for None: direct connection without an exit node." + }, + "quickActions.connect": { + "message": "Connect", + "description": "Quick-action button: Connect. Keep short." + }, + "quickActions.disconnect": { + "message": "Disconnect", + "description": "Quick-action button: Disconnect. Keep short." + }, + "daemon.unavailable.title": { + "message": "NetBird Service Is Not Running", + "description": "Title of the overlay shown when the NetBird background service isn't running." + }, + "daemon.unavailable.description": { + "message": "The app will reconnect automatically once the service is running.", + "description": "Body reassuring the user the app will reconnect once the service is running." + }, + "daemon.unavailable.docsLink": { + "message": "Documentation", + "description": "Documentation link on the daemon-unavailable overlay." + }, + "daemon.outdated.title": { + "message": "NetBird Client Is Outdated", + "description": "Title of the overlay shown when the NetBird client (daemon) is too old to drive this UI." + }, + "daemon.outdated.description": { + "message": "The new GUI isn't compatible with the older NetBird client. Update your client to use the new application.", + "description": "Body of the daemon-outdated overlay explaining that the GUI is newer than the client and the client must be updated." + }, + "daemon.outdated.download": { + "message": "Download Latest", + "description": "Button on the daemon-outdated overlay that opens the download page for the latest release." + }, + "error.jwt_clock_skew": { + "message": "Sign-in failed: this device's clock is out of sync with the server. Please sync your system clock and try again.", + "description": "Sign-in error shown to the user: the device clock is out of sync with the server." + }, + "error.jwt_expired": { + "message": "Your sign-in token has expired. Please sign in again.", + "description": "Sign-in error: the sign-in token expired; sign in again." + }, + "error.jwt_signature_invalid": { + "message": "Sign-in failed: the token signature is invalid. Please contact your administrator.", + "description": "Sign-in error: the token signature is invalid; contact the administrator." + }, + "error.session_expired": { + "message": "Your session has expired. Please sign in again.", + "description": "Error: the session expired; sign in again." + }, + "error.invalid_setup_key": { + "message": "The setup key is missing or invalid.", + "description": "Error: the setup key is missing or invalid. 'setup key' is a NetBird term." + }, + "error.permission_denied": { + "message": "Sign-in was rejected by the server.", + "description": "Error: the server rejected the sign-in." + }, + "error.daemon_unreachable": { + "message": "The NetBird daemon is not responding. Please check that the service is running.", + "description": "Error: the NetBird background service isn't responding. 'daemon' = the background service." + }, + "error.unknown": { + "message": "Operation failed.", + "description": "Generic fallback error message used when no specific error applies." + }, + "error.elevation_unavailable": { + "message": "NetBird could not ask this system for the privileges the change needs. Run this instead:", + "description": "Error: this computer has no way to prompt for elevated privileges. Followed by a copyable command that applies the setting from a terminal." + }, + "error.elevation_failed": { + "message": "The change could not be applied with elevated privileges. Run this instead:", + "description": "Error: the authorization succeeded but applying the setting afterwards failed. Followed by a copyable command that applies the setting from a terminal." + }, + "settings.ssh.privilege.actorRoot": { + "message": "root", + "description": "Fills {actor} in the settings.ssh.privilege.* messages on Linux, macOS and BSD, where the daemon requires the root account. 'root' is an account name and stays as it is; add the word for privileges or rights around it if the sentence needs one to read naturally." + }, + "settings.ssh.privilege.actorAdministrator": { + "message": "administrator privileges", + "description": "Fills {actor} in the settings.ssh.privilege.* messages on Windows, where the daemon requires an elevated administrator. The Windows term for the rights an account is asked to elevate to." + }, + "settings.ssh.privilege.hint": { + "message": "Requires {actor}. Run this instead:", + "description": "Help text under an SSH setting the user cannot change: it needs elevated privileges. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command." + }, + "settings.ssh.privilege.oneWay": { + "message": "You can switch this off, but switching it back on needs {actor}.", + "description": "Help text under an SSH setting that is already on: an unprivileged user may switch it off freely, and switching it on again is what needs the privileges. No command follows, since the direction they can take is theirs to take. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows." + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "You can switch this on, but switching it back off needs {actor}.", + "description": "Same as settings.ssh.privilege.oneWay, for the SSH authentication setting once it has been switched off: switching it off again is what needs the privileges." + }, + "settings.ssh.privilege.authorizePending": { + "message": "Waiting for authorization…", + "description": "Replaces the help text under a guarded SSH setting while the authorization prompt is open, which can take a few seconds to appear. Keep the trailing ellipsis." + } +} diff --git a/client/ui/i18n/locales/es/common.json b/client/ui/i18n/locales/es/common.json new file mode 100644 index 000000000..41872d7a0 --- /dev/null +++ b/client/ui/i18n/locales/es/common.json @@ -0,0 +1,1378 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Desconectado" + }, + "tray.status.daemonUnavailable": { + "message": "No está en ejecución" + }, + "tray.status.error": { + "message": "Error" + }, + "tray.status.connected": { + "message": "Conectado" + }, + "tray.status.connecting": { + "message": "Conectando" + }, + "tray.status.needsLogin": { + "message": "Inicio de sesión requerido" + }, + "tray.status.loginFailed": { + "message": "Error al iniciar sesión" + }, + "tray.status.sessionExpired": { + "message": "Sesión expirada" + }, + "tray.session.expiresIn": { + "message": "La sesión expira en {remaining}" + }, + "tray.session.unit.lessThanMinute": { + "message": "menos de un minuto" + }, + "tray.session.unit.minute": { + "message": "1 minuto" + }, + "tray.session.unit.minutes": { + "message": "{count} minutos" + }, + "tray.session.unit.hour": { + "message": "1 hora" + }, + "tray.session.unit.hours": { + "message": "{count} horas" + }, + "tray.session.unit.day": { + "message": "1 día" + }, + "tray.session.unit.days": { + "message": "{count} días" + }, + "tray.menu.open": { + "message": "Abrir NetBird" + }, + "tray.menu.connect": { + "message": "Conectar" + }, + "tray.menu.disconnect": { + "message": "Desconectar" + }, + "tray.menu.exitNode": { + "message": "Nodo de salida" + }, + "tray.menu.networks": { + "message": "Recursos" + }, + "tray.menu.profiles": { + "message": "Perfiles" + }, + "tray.menu.manageProfiles": { + "message": "Gestionar perfiles" + }, + "tray.menu.settings": { + "message": "Configuración..." + }, + "tray.menu.debugBundle": { + "message": "Crear paquete de diagnóstico" + }, + "tray.menu.about": { + "message": "Ayuda y soporte" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Documentación" + }, + "tray.menu.troubleshoot": { + "message": "Solucionar problemas" + }, + "tray.menu.downloadLatest": { + "message": "Descargar la última versión" + }, + "tray.menu.installVersion": { + "message": "Instalar la versión {version}" + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}" + }, + "tray.menu.daemonVersion": { + "message": "Daemon: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "Salir de NetBird" + }, + "notify.daemonOutdated.title": { + "message": "El servicio de NetBird está desactualizado" + }, + "notify.daemonOutdated.body": { + "message": "Actualice el servicio de NetBird para usar esta aplicación." + }, + "notify.update.title": { + "message": "Actualización de NetBird disponible" + }, + "notify.update.body": { + "message": "NetBird {version} está disponible." + }, + "notify.update.enforcedSuffix": { + "message": " Su administrador requiere esta actualización." + }, + "notify.error.title": { + "message": "Error" + }, + "notify.error.connect": { + "message": "Error al conectar" + }, + "notify.error.disconnect": { + "message": "Error al desconectar" + }, + "notify.error.switchProfile": { + "message": "Error al cambiar a {profile}" + }, + "notify.error.exitNode": { + "message": "Error al actualizar el nodo de salida {name}" + }, + "notify.sessionExpired.title": { + "message": "Sesión de NetBird expirada" + }, + "notify.sessionExpired.body": { + "message": "Su sesión de NetBird ha expirado. Inicie sesión de nuevo." + }, + "notify.sessionWarning.title": { + "message": "La sesión expira pronto" + }, + "notify.sessionWarning.body": { + "message": "Su sesión de NetBird expira en {remaining}. Haga clic en Renovar ahora para renovarla." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "Su sesión de NetBird está a punto de expirar. Haga clic en Renovar ahora para renovarla." + }, + "notify.sessionWarning.extend": { + "message": "Renovar ahora" + }, + "notify.sessionWarning.dismiss": { + "message": "Descartar" + }, + "notify.sessionWarning.failed": { + "message": "Error al renovar la sesión de NetBird" + }, + "notify.sessionWarning.successTitle": { + "message": "Sesión de NetBird renovada" + }, + "notify.sessionWarning.successBody": { + "message": "Su sesión se ha renovado." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Plazo de sesión rechazado" + }, + "notify.sessionDeadlineRejected.body": { + "message": "El servidor envió un plazo de sesión no válido. Inicie sesión de nuevo." + }, + "notify.mdm.policyApplied.title": { + "message": "Configuración de NetBird actualizada" + }, + "notify.mdm.policyApplied.body": { + "message": "Su configuración de NetBird fue actualizada por su política de TI." + }, + "common.cancel": { + "message": "Cancelar" + }, + "common.save": { + "message": "Guardar" + }, + "common.saveChanges": { + "message": "Guardar cambios" + }, + "common.saving": { + "message": "Guardando…" + }, + "common.close": { + "message": "Cerrar" + }, + "common.copy": { + "message": "Copiar" + }, + "common.togglePasswordVisibility": { + "message": "Mostrar u ocultar la contraseña" + }, + "common.increase": { + "message": "Aumentar" + }, + "common.decrease": { + "message": "Disminuir" + }, + "common.delete": { + "message": "Eliminar" + }, + "common.create": { + "message": "Crear" + }, + "common.add": { + "message": "Añadir" + }, + "common.remove": { + "message": "Quitar" + }, + "common.refresh": { + "message": "Actualizar" + }, + "common.loading": { + "message": "Cargando…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "No se encontraron resultados" + }, + "common.noResults.description": { + "message": "No se encontraron resultados. Pruebe con otro término de búsqueda o cambie los filtros." + }, + "notConnected.title": { + "message": "Desconectado" + }, + "notConnected.description": { + "message": "Conéctese primero a NetBird para ver información detallada sobre sus peers, recursos de red y nodos de salida." + }, + "connect.status.disconnected": { + "message": "Desconectado" + }, + "connect.status.connecting": { + "message": "Conectando..." + }, + "connect.status.connected": { + "message": "Conectado" + }, + "connect.status.disconnecting": { + "message": "Desconectando..." + }, + "connect.status.daemonUnavailable": { + "message": "Daemon no disponible" + }, + "connect.status.loginRequired": { + "message": "Inicio de sesión requerido" + }, + "connect.error.loginTitle": { + "message": "Error al iniciar sesión" + }, + "connect.error.connectTitle": { + "message": "Error al conectar" + }, + "connect.error.disconnectTitle": { + "message": "Error al desconectar" + }, + "nav.peers.title": { + "message": "Peers" + }, + "nav.peers.description": { + "message": "{connected} de {total} conectados" + }, + "nav.resources.title": { + "message": "Recursos" + }, + "nav.resources.description": { + "message": "{active} de {total} activos" + }, + "nav.exitNode.title": { + "message": "Nodos de salida" + }, + "nav.exitNode.none": { + "message": "Inactivo" + }, + "nav.exitNode.using": { + "message": "Vía {name}" + }, + "header.openSettings": { + "message": "Abrir configuración" + }, + "header.togglePanel": { + "message": "Mostrar u ocultar el panel lateral" + }, + "profile.selector.loading": { + "message": "Cargando..." + }, + "profile.selector.noProfile": { + "message": "Sin perfil" + }, + "profile.selector.searchPlaceholder": { + "message": "Buscar perfil por nombre..." + }, + "profile.selector.emptyTitle": { + "message": "No se encontraron perfiles" + }, + "profile.selector.emptyDescription": { + "message": "Pruebe con otro término de búsqueda o cree un perfil nuevo." + }, + "profile.selector.newProfile": { + "message": "Nuevo perfil" + }, + "profile.selector.moreOptions": { + "message": "Más opciones" + }, + "profile.selector.deregister": { + "message": "Anular registro" + }, + "profile.selector.delete": { + "message": "Eliminar" + }, + "profile.selector.switchTo": { + "message": "Cambiar a este perfil" + }, + "profile.selector.edit": { + "message": "Editar" + }, + "profile.edit.title": { + "message": "Editar perfil" + }, + "profile.edit.submit": { + "message": "Guardar cambios" + }, + "profile.dialog.title": { + "message": "Introduzca el nombre del perfil" + }, + "profile.dialog.nameLabel": { + "message": "Nombre del perfil" + }, + "profile.dialog.description": { + "message": "Asigne un nombre fácil de identificar a su perfil." + }, + "profile.dialog.placeholder": { + "message": "p. ej. trabajo" + }, + "profile.dialog.submit": { + "message": "Añadir perfil" + }, + "profile.dialog.required": { + "message": "Introduzca un nombre de perfil, p. ej. trabajo, casa" + }, + "profile.dialog.managementHelp": { + "message": "Use NetBird Cloud o su propio servidor." + }, + "profile.dialog.urlUnreachable": { + "message": "No se pudo contactar con este servidor. Compruebe la URL o añada el perfil de todos modos si está seguro de que es correcta." + }, + "header.menu.settings": { + "message": "Configuración..." + }, + "header.menu.defaultView": { + "message": "Vista predeterminada" + }, + "header.menu.advancedView": { + "message": "Vista avanzada" + }, + "header.menu.updateAvailable": { + "message": "Actualización disponible" + }, + "header.menu.open": { + "message": "Abrir menú" + }, + "header.profile.switch": { + "message": "Cambiar de perfil" + }, + "connect.toggle.label": { + "message": "Conmutar conexión NetBird" + }, + "connect.localIp.label": { + "message": "Direcciones IP locales" + }, + "common.search": { + "message": "Buscar" + }, + "common.filter": { + "message": "Filtrar" + }, + "exitNodes.dropdown.trigger": { + "message": "Seleccionar nodo de salida" + }, + "peers.row.label": { + "message": "Abrir detalles de {name}, {status}" + }, + "peers.dialog.title": { + "message": "Detalles del par" + }, + "networks.row.toggle": { + "message": "Conmutar {name}" + }, + "networks.bulk.label": { + "message": "Conmutar todos los recursos visibles" + }, + "profile.switch.title": { + "message": "¿Cambiar el perfil a «{name}»?" + }, + "profile.switch.message": { + "message": "¿Seguro que desea cambiar de perfil?\nSu perfil actual se desconectará." + }, + "profile.switch.confirm": { + "message": "Confirmar" + }, + "profile.deregister.title": { + "message": "¿Anular el registro del perfil «{name}»?" + }, + "profile.deregister.message": { + "message": "¿Seguro que desea anular el registro de este perfil?\nDeberá iniciar sesión de nuevo para usarlo." + }, + "profile.deregister.confirm": { + "message": "Anular registro" + }, + "profile.delete.title": { + "message": "¿Eliminar el perfil «{name}»?" + }, + "profile.delete.message": { + "message": "¿Seguro que desea eliminar este perfil?\nEsta acción no se puede deshacer." + }, + "profile.delete.disabledActive": { + "message": "Los perfiles activos no se pueden eliminar. Cambie a otro antes de eliminar este perfil." + }, + "profile.delete.disabledDefault": { + "message": "El perfil predeterminado no se puede eliminar." + }, + "profile.error.switchTitle": { + "message": "Error al cambiar de perfil" + }, + "profile.error.deregisterTitle": { + "message": "Error al anular el registro del perfil" + }, + "profile.error.deleteTitle": { + "message": "Error al eliminar el perfil" + }, + "profile.error.createTitle": { + "message": "Error al crear el perfil" + }, + "profile.error.editTitle": { + "message": "Error al editar el perfil" + }, + "profile.error.loadTitle": { + "message": "Error al cargar los perfiles" + }, + "profile.dropdown.activeProfile": { + "message": "Perfil activo" + }, + "profile.dropdown.switchProfile": { + "message": "Cambiar de perfil" + }, + "profile.dropdown.noEmail": { + "message": "Otro" + }, + "profile.dropdown.addProfile": { + "message": "Añadir perfil" + }, + "profile.dropdown.manageProfiles": { + "message": "Gestionar perfiles" + }, + "profile.dropdown.settings": { + "message": "Configuración" + }, + "settings.profiles.section.profiles": { + "message": "Perfiles" + }, + "settings.profiles.intro": { + "message": "Mantenga identidades de NetBird independientes en paralelo, por ejemplo cuentas de trabajo y personales, o distintos servidores de gestión. Añada, anule el registro o elimine perfiles a continuación." + }, + "settings.profiles.addProfile": { + "message": "Añadir perfil" + }, + "settings.profiles.active": { + "message": "Activo" + }, + "settings.profiles.emptyTitle": { + "message": "Sin perfiles" + }, + "settings.profiles.emptyDescription": { + "message": "Cree un perfil para conectarse a un servidor de gestión de NetBird." + }, + "settings.error.loadTitle": { + "message": "Error al cargar la configuración" + }, + "settings.error.saveTitle": { + "message": "Error al guardar la configuración" + }, + "settings.error.debugBundleTitle": { + "message": "Error en el paquete de diagnóstico" + }, + "settings.nav.label": { + "message": "Secciones de configuración" + }, + "settings.tabs.general": { + "message": "General" + }, + "settings.tabs.network": { + "message": "Red" + }, + "settings.tabs.security": { + "message": "Seguridad" + }, + "settings.tabs.profiles": { + "message": "Perfiles" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "Avanzado" + }, + "settings.tabs.troubleshooting": { + "message": "Solución de problemas" + }, + "settings.tabs.about": { + "message": "Acerca de" + }, + "settings.tabs.updateAvailable": { + "message": "Actualización disponible" + }, + "settings.general.section.general": { + "message": "General" + }, + "settings.general.section.connection": { + "message": "Conexión" + }, + "settings.general.connectOnStartup.label": { + "message": "Conectar al iniciar" + }, + "settings.general.connectOnStartup.help": { + "message": "Establece una conexión automáticamente cuando se inicia el servicio." + }, + "settings.general.notifications.label": { + "message": "Notificaciones de escritorio" + }, + "settings.general.notifications.help": { + "message": "Muestra notificaciones de escritorio para nuevas actualizaciones y eventos de conexión." + }, + "settings.general.autostart.label": { + "message": "Iniciar la interfaz de NetBird al iniciar sesión" + }, + "settings.general.autostart.help": { + "message": "Inicia la interfaz de NetBird automáticamente al iniciar sesión. Esto afecta solo a la interfaz gráfica, no al servicio en segundo plano." + }, + "settings.general.autostart.errorTitle": { + "message": "Error al cambiar el inicio automático" + }, + "settings.general.keepConnectedOnQuit.label": { + "message": "Permanecer conectado al salir", + "description": "Toggle label: keep the VPN connection up after quitting the UI." + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "La conexión sigue activa en segundo plano después de cerrar NetBird. Solo se detiene cuando la desconectas tú.", + "description": "Helper text for the stay-connected-after-quitting toggle." + }, + "settings.general.language.label": { + "message": "Idioma de la interfaz" + }, + "settings.general.language.help": { + "message": "Elija el idioma de la interfaz de NetBird." + }, + "settings.general.language.search": { + "message": "Buscar idioma…" + }, + "settings.general.language.empty": { + "message": "Ningún idioma coincide." + }, + "settings.general.management.label": { + "message": "Servidor de gestión" + }, + "settings.general.management.help": { + "message": "Conéctese a NetBird Cloud o a su propio servidor de gestión autoalojado. Los cambios reconectarán el cliente." + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "Autoalojado" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Introduzca una URL válida, p. ej., https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "No se pudo contactar con este servidor. Compruebe la URL o guarde de todos modos si está seguro de que es correcta." + }, + "settings.general.management.switchCloudTitle": { + "message": "¿Cambiar a NetBird Cloud?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Esto desconecta su servidor autoalojado.\nEs posible que deba iniciar sesión de nuevo." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Cambiar a Cloud" + }, + "settings.network.section.connectivity": { + "message": "Conectividad" + }, + "settings.network.section.routingDns": { + "message": "Enrutamiento y DNS" + }, + "settings.network.monitor.label": { + "message": "Reconectar al cambiar de red" + }, + "settings.network.monitor.help": { + "message": "Monitoriza la red y reconecta automáticamente ante cambios como cambios de Wi-Fi, cambios de Ethernet o la reanudación tras la suspensión." + }, + "settings.network.dns.label": { + "message": "Habilitar DNS" + }, + "settings.network.dns.help": { + "message": "Aplica la configuración de DNS gestionada por NetBird al resolutor del host." + }, + "settings.network.clientRoutes.label": { + "message": "Habilitar rutas de cliente" + }, + "settings.network.clientRoutes.help": { + "message": "Acepta rutas de otros peers para alcanzar sus redes." + }, + "settings.network.serverRoutes.label": { + "message": "Habilitar rutas de servidor" + }, + "settings.network.serverRoutes.help": { + "message": "Anuncia las rutas locales de este host a otros peers." + }, + "settings.network.ipv6.label": { + "message": "Habilitar IPv6" + }, + "settings.network.ipv6.help": { + "message": "Usa direccionamiento IPv6 para la red superpuesta de NetBird." + }, + "settings.security.section.firewall": { + "message": "Cortafuegos" + }, + "settings.security.section.encryption": { + "message": "Cifrado" + }, + "settings.security.blockInbound.label": { + "message": "Bloquear tráfico entrante" + }, + "settings.security.blockInbound.help": { + "message": "Rechaza conexiones no solicitadas de peers hacia este dispositivo y cualquier red que enrute. El tráfico saliente no se ve afectado." + }, + "settings.security.blockLan.label": { + "message": "Bloquear acceso a la LAN" + }, + "settings.security.blockLan.help": { + "message": "Impide que los peers alcancen su red local o sus dispositivos cuando este dispositivo enruta su tráfico." + }, + "settings.security.rosenpass.label": { + "message": "Habilitar resistencia cuántica" + }, + "settings.security.rosenpass.help": { + "message": "Añade un intercambio de claves poscuántico mediante Rosenpass sobre WireGuard®." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Habilitar modo permisivo" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Permite conexiones con peers sin compatibilidad con resistencia cuántica." + }, + "settings.ssh.section.server": { + "message": "Servidor" + }, + "settings.ssh.section.capabilities": { + "message": "Capacidades" + }, + "settings.ssh.section.authentication": { + "message": "Autenticación" + }, + "settings.ssh.server.label": { + "message": "Habilitar el servidor SSH" + }, + "settings.ssh.server.help": { + "message": "Ejecuta el servidor SSH de NetBird en este host para que otros peers puedan conectarse a él." + }, + "settings.ssh.root.label": { + "message": "Permitir inicio de sesión como root" + }, + "settings.ssh.root.help": { + "message": "Permite que los peers inicien sesión como usuario root. Desactívelo para exigir una cuenta sin privilegios." + }, + "settings.ssh.sftp.label": { + "message": "Permitir SFTP" + }, + "settings.ssh.sftp.help": { + "message": "Transfiere archivos de forma segura mediante clientes SFTP o SCP nativos." + }, + "settings.ssh.localForward.label": { + "message": "Reenvío de puertos local" + }, + "settings.ssh.localForward.help": { + "message": "Permite que los peers conectados tunelicen puertos locales hacia servicios accesibles desde este host." + }, + "settings.ssh.remoteForward.label": { + "message": "Reenvío de puertos remoto" + }, + "settings.ssh.remoteForward.help": { + "message": "Permite que los peers conectados expongan puertos de este host de vuelta a su propia máquina." + }, + "settings.ssh.jwt.label": { + "message": "Habilitar autenticación JWT" + }, + "settings.ssh.jwt.help": { + "message": "Verifica cada sesión SSH contra su IdP para la identidad del usuario y la auditoría. Desactívelo para basarse solo en las políticas de ACL de red, útil cuando no hay ningún IdP disponible." + }, + "settings.ssh.jwtTtl.label": { + "message": "TTL de la caché JWT" + }, + "settings.ssh.jwtTtl.help": { + "message": "Cuánto tiempo almacena en caché este cliente un JWT antes de volver a solicitarlo en conexiones SSH salientes. Establézcalo en 0 para desactivar la caché y autenticar en cada conexión." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "s" + }, + "settings.advanced.section.interface": { + "message": "Interfaz" + }, + "settings.advanced.section.security": { + "message": "Seguridad" + }, + "settings.advanced.interfaceName.label": { + "message": "Nombre" + }, + "settings.advanced.interfaceName.error": { + "message": "Use de 1 a 15 letras, dígitos, puntos, guiones o guiones bajos." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Debe empezar por \"utun\" seguido de un número (p. ej. utun100)." + }, + "settings.advanced.port.label": { + "message": "Puerto" + }, + "settings.advanced.port.error": { + "message": "Introduzca un puerto entre {min} y {max}." + }, + "settings.advanced.port.help": { + "message": "Si se establece en 0, se usará un puerto libre aleatorio." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Introduzca un valor de MTU entre {min} y {max}." + }, + "settings.advanced.psk.label": { + "message": "Clave precompartida" + }, + "settings.advanced.psk.help": { + "message": "PSK de WireGuard opcional para cifrado simétrico adicional. No es lo mismo que una clave de instalación de NetBird. Solo se comunicará con peers que usen la misma clave precompartida." + }, + "settings.troubleshooting.section.title": { + "message": "Paquete de diagnóstico" + }, + "settings.troubleshooting.anonymize.label": { + "message": "Anonimizar información sensible" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Oculta direcciones IP, dominios y otros valores sensibles." + }, + "settings.troubleshooting.anonymize.info": { + "message": "El modo predeterminado mantiene legibles las direcciones IPv4 internas y los nombres de los peers para el soporte. El modo estricto anonimiza además las direcciones IP privadas (RFC 1918), CGNAT y de enlace local, los nombres de los peers y las claves públicas de WireGuard. Los valores recurrentes se asignan al mismo marcador de posición, por lo que los peers siguen siendo distinguibles. Use el modo estricto cuando comparta el paquete de diagnóstico fuera de su organización." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Ninguno" + }, + "settings.troubleshooting.anonymize.default": { + "message": "Predeterminado" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Estricto" + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Incluir información del sistema" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Incluye el OS, el kernel, las interfaces de red y las tablas de enrutamiento." + }, + "settings.troubleshooting.upload.label": { + "message": "Subir el paquete a los servidores de NetBird" + }, + "settings.troubleshooting.upload.help": { + "message": "Devuelve una clave de subida para compartir con el soporte de NetBird." + }, + "settings.troubleshooting.trace.label": { + "message": "Activar registros de seguimiento" + }, + "settings.troubleshooting.trace.help": { + "message": "Eleva el nivel de registro a TRACE y lo restaura después." + }, + "settings.troubleshooting.capture.label": { + "message": "Sesión de captura" + }, + "settings.troubleshooting.capture.help": { + "message": "Vuelve a conectar y espera para que pueda reproducir el problema." + }, + "settings.troubleshooting.packets.label": { + "message": "Capturar paquetes de red" + }, + "settings.troubleshooting.packets.help": { + "message": "Guarda un .pcap del tráfico de red durante la sesión de captura." + }, + "settings.troubleshooting.duration.label": { + "message": "Duración de la captura" + }, + "settings.troubleshooting.duration.help": { + "message": "Cuánto tiempo se ejecuta la sesión de captura." + }, + "settings.troubleshooting.duration.suffix": { + "message": "min" + }, + "settings.troubleshooting.create": { + "message": "Crear paquete" + }, + "settings.troubleshooting.progress.description": { + "message": "Recopilando registros, detalles del sistema y estado de la conexión. Esto suele tardar un momento; mantenga esta ventana abierta hasta que termine." + }, + "settings.troubleshooting.cancelling": { + "message": "Cancelando…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "¡Paquete de diagnóstico subido correctamente!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Paquete guardado" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Comparta la clave de subida de abajo con el soporte de NetBird. También se guardó una copia local en su dispositivo." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "Su paquete de diagnóstico se ha guardado localmente." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Copiar clave" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Abrir carpeta" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Abrir ubicación del archivo" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Error en la subida: {reason} El paquete sigue guardado localmente." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Error en la subida. El paquete sigue guardado localmente." + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "Reconectando NetBird…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Capturando registros de depuración" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Generando el paquete de diagnóstico…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Subiendo a NetBird…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Cancelando…" + }, + "settings.about.client": { + "message": "NetBird Client v{version}" + }, + "settings.about.clientName": { + "message": "NetBird Client" + }, + "settings.about.development": { + "message": "[Desarrollo]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Todos los derechos reservados." + }, + "settings.about.links.imprint": { + "message": "Aviso legal" + }, + "settings.about.links.privacy": { + "message": "Privacidad" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Términos del servicio" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Foro" + }, + "settings.about.community.documentation": { + "message": "Documentación" + }, + "settings.about.community.feedback": { + "message": "Comentarios" + }, + "update.banner.message": { + "message": "NetBird {version} está listo para instalar." + }, + "update.banner.later": { + "message": "Más tarde" + }, + "update.banner.installNow": { + "message": "Instalar ahora" + }, + "update.card.versionAvailableDownload": { + "message": "La versión {version} está disponible para descargar." + }, + "update.card.versionAvailableInstall": { + "message": "La versión {version} está disponible para instalar." + }, + "update.card.whatsNew": { + "message": "¿Qué hay de nuevo?" + }, + "update.card.installNow": { + "message": "Instalar ahora" + }, + "update.card.getInstaller": { + "message": "Descargar" + }, + "update.card.autoCheckInterval": { + "message": "NetBird busca actualizaciones en segundo plano." + }, + "update.card.changelog": { + "message": "Registro de cambios" + }, + "update.card.onLatestVersion": { + "message": "Tiene la última versión" + }, + "update.header.tooltip": { + "message": "Actualización disponible" + }, + "update.overlay.updatingVersion": { + "message": "Actualizando NetBird a v{version}" + }, + "update.overlay.updating": { + "message": "Actualizando NetBird" + }, + "update.overlay.description": { + "message": "Hay una versión más reciente disponible y se está instalando. NetBird se reiniciará automáticamente cuando finalice la actualización." + }, + "update.overlay.error.timeoutTitle": { + "message": "La actualización está tardando demasiado" + }, + "update.overlay.error.timeoutDescription": { + "message": "La instalación de {target} tardó demasiado y no finalizó." + }, + "update.overlay.error.canceledTitle": { + "message": "La actualización se detuvo" + }, + "update.overlay.error.canceledDescription": { + "message": "La actualización a {target} se canceló antes de finalizar." + }, + "update.overlay.error.failTitle": { + "message": "No se pudo instalar la actualización" + }, + "update.overlay.error.failDescription": { + "message": "No se pudo instalar {target}." + }, + "update.overlay.error.unknownMessage": { + "message": "error desconocido" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "la nueva versión" + }, + "update.error.loadStateTitle": { + "message": "Error al cargar el estado de la actualización" + }, + "update.error.triggerTitle": { + "message": "Error al iniciar la actualización" + }, + "update.page.versionLine": { + "message": "Actualizando el cliente a: {version}." + }, + "update.page.versionLineGeneric": { + "message": "Actualizando el cliente." + }, + "update.page.outdated": { + "message": "La versión de su cliente es anterior a la versión de actualización automática configurada en Management." + }, + "update.page.status.running": { + "message": "Actualizando" + }, + "update.page.status.timeout": { + "message": "Se agotó el tiempo de la actualización. Inténtelo de nuevo." + }, + "update.page.status.canceled": { + "message": "Actualización cancelada." + }, + "update.page.status.failed": { + "message": "Error en la actualización: {message}" + }, + "update.page.status.unknownError": { + "message": "error de actualización desconocido" + }, + "update.page.failedTitle": { + "message": "Error en la actualización" + }, + "update.page.timeoutMessage": { + "message": "Se agotó el tiempo de la actualización." + }, + "update.page.dontClose": { + "message": "No cierre esta ventana." + }, + "update.page.updating": { + "message": "Actualizando…" + }, + "update.page.complete": { + "message": "Actualización completada" + }, + "update.page.failed": { + "message": "Error en la actualización" + }, + "window.title.settings": { + "message": "Configuración" + }, + "window.title.signIn": { + "message": "Inicio de sesión" + }, + "window.title.sessionExpiration": { + "message": "Sesión a punto de expirar" + }, + "window.title.updating": { + "message": "Actualizando" + }, + "window.title.welcome": { + "message": "Bienvenido a NetBird" + }, + "window.title.error": { + "message": "Error" + }, + "welcome.title": { + "message": "Busque NetBird en su bandeja del sistema" + }, + "welcome.titleMac": { + "message": "Busque NetBird en su barra de menús" + }, + "welcome.description": { + "message": "NetBird reside en su bandeja del sistema. Haga clic en el icono para conectarse, cambiar de perfil o abrir la configuración." + }, + "welcome.descriptionMac": { + "message": "NetBird reside en su barra de menús. Haga clic en el icono para conectarse, cambiar de perfil o abrir la configuración." + }, + "welcome.continue": { + "message": "Continuar" + }, + "welcome.back": { + "message": "Atrás" + }, + "welcome.management.title": { + "message": "Configurar NetBird" + }, + "welcome.management.description": { + "message": "Haga clic en Continuar para empezar, o elija Autoalojado si tiene su propio servidor de NetBird." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Use nuestro servicio alojado. No requiere configuración." + }, + "welcome.management.selfHosted.title": { + "message": "Autoalojado" + }, + "welcome.management.selfHosted.description": { + "message": "Conéctese a su propio servidor de gestión." + }, + "welcome.management.urlLabel": { + "message": "URL del servidor de gestión" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Introduzca una URL válida, p. ej., https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "No se pudo contactar con este servidor. Compruebe la URL o su red y, a continuación, continúe si está seguro de que es correcta." + }, + "welcome.management.checking": { + "message": "Comprobando…" + }, + "browserLogin.title": { + "message": "Continúe en su navegador para completar el inicio de sesión" + }, + "browserLogin.notSeeing": { + "message": "¿No ve la pestaña del navegador?" + }, + "browserLogin.tryAgain": { + "message": "Reintentar" + }, + "browserLogin.openFailedTitle": { + "message": "Error al abrir el navegador" + }, + "sessionExpiration.title": { + "message": "La sesión expira pronto" + }, + "sessionExpiration.titleLater": { + "message": "Su sesión expirará" + }, + "sessionExpiration.description": { + "message": "Este dispositivo se desconectará pronto. Renuévela iniciando sesión en el navegador." + }, + "sessionExpiration.descriptionLater": { + "message": "Iniciar sesión en el navegador mantiene este dispositivo conectado a su red." + }, + "sessionExpiration.stay": { + "message": "Renovar sesión" + }, + "sessionExpiration.authenticate": { + "message": "Autenticar" + }, + "sessionExpiration.logout": { + "message": "Cerrar sesión" + }, + "sessionExpiration.expired": { + "message": "Sesión expirada" + }, + "sessionExpiration.expiredDescription": { + "message": "Dispositivo desconectado. Autentíquese iniciando sesión en el navegador para reconectar." + }, + "sessionExpiration.close": { + "message": "Cerrar" + }, + "sessionExpiration.extendFailedTitle": { + "message": "Error al renovar la sesión" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Error al cerrar sesión" + }, + "peers.search.placeholder": { + "message": "Buscar por nombre o IP" + }, + "peers.filter.all": { + "message": "Todos" + }, + "peers.filter.online": { + "message": "En línea" + }, + "peers.filter.offline": { + "message": "Sin conexión" + }, + "peers.empty.title": { + "message": "No hay peers disponibles" + }, + "peers.empty.description": { + "message": "No tiene ningún peer disponible o no tiene acceso a ninguno de ellos." + }, + "peers.details.domain": { + "message": "Dominio" + }, + "peers.details.netbirdIp": { + "message": "IP de NetBird" + }, + "peers.details.netbirdIpv6": { + "message": "IPv6 de NetBird" + }, + "peers.details.publicKey": { + "message": "Clave pública" + }, + "peers.details.connection": { + "message": "Conexión" + }, + "peers.details.latency": { + "message": "Latencia" + }, + "peers.details.lastHandshake": { + "message": "Último handshake" + }, + "peers.details.statusSince": { + "message": "Última actualización de la conexión" + }, + "peers.details.bytes": { + "message": "Bytes" + }, + "peers.details.bytesSent": { + "message": "Enviados" + }, + "peers.details.bytesReceived": { + "message": "Recibidos" + }, + "peers.details.localIce": { + "message": "ICE local" + }, + "peers.details.remoteIce": { + "message": "ICE remoto" + }, + "peers.details.never": { + "message": "Nunca" + }, + "peers.details.justNow": { + "message": "Justo ahora" + }, + "peers.details.refresh": { + "message": "Actualizar" + }, + "peers.status.connected": { + "message": "Conectado" + }, + "peers.status.connecting": { + "message": "Conectando" + }, + "peers.status.disconnected": { + "message": "Desconectado" + }, + "peers.details.relayAddress": { + "message": "Relay" + }, + "peers.details.networks": { + "message": "Recursos" + }, + "peers.details.relayed": { + "message": "Retransmitida" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass habilitado" + }, + "networks.search.placeholder": { + "message": "Buscar por red o dominio" + }, + "networks.filter.all": { + "message": "Todos" + }, + "networks.filter.active": { + "message": "Activos" + }, + "networks.filter.overlapping": { + "message": "Solapados" + }, + "networks.empty.title": { + "message": "No hay recursos disponibles" + }, + "networks.empty.description": { + "message": "No tiene ningún recurso de red disponible o no tiene acceso a ninguno de ellos." + }, + "networks.selected": { + "message": "Seleccionado" + }, + "networks.unselected": { + "message": "No seleccionado" + }, + "networks.ips.heading": { + "message": "IP resueltas" + }, + "networks.bulk.selectionCount": { + "message": "{selected} de {total} activos" + }, + "networks.bulk.enableAll": { + "message": "Habilitar todos" + }, + "networks.bulk.disableAll": { + "message": "Deshabilitar todos" + }, + "exitNodes.search.placeholder": { + "message": "Buscar nodos de salida" + }, + "exitNodes.none": { + "message": "Ninguno" + }, + "exitNodes.empty.title": { + "message": "No hay nodos de salida disponibles" + }, + "exitNodes.empty.description": { + "message": "No se ha compartido ningún nodo de salida con este peer." + }, + "exitNodes.card.title": { + "message": "Nodo de salida" + }, + "exitNodes.card.statusActive": { + "message": "Activo" + }, + "exitNodes.card.statusInactive": { + "message": "Inactivo" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Ninguno" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Conexión directa sin nodo de salida" + }, + "quickActions.connect": { + "message": "Conectar" + }, + "quickActions.disconnect": { + "message": "Desconectar" + }, + "daemon.unavailable.title": { + "message": "El servicio de NetBird no está en ejecución" + }, + "daemon.unavailable.description": { + "message": "La aplicación se reconectará automáticamente cuando el servicio esté en ejecución." + }, + "daemon.unavailable.docsLink": { + "message": "Documentación" + }, + "daemon.outdated.title": { + "message": "NetBird Client está desactualizado" + }, + "daemon.outdated.description": { + "message": "La nueva GUI no es compatible con su cliente anterior. Actualice su cliente para usar la nueva aplicación." + }, + "daemon.outdated.download": { + "message": "Descargar la última versión" + }, + "error.jwt_clock_skew": { + "message": "Error al iniciar sesión: el reloj de este dispositivo no está sincronizado con el servidor. Sincronice el reloj del sistema e inténtelo de nuevo." + }, + "error.jwt_expired": { + "message": "Su token de inicio de sesión ha expirado. Inicie sesión de nuevo." + }, + "error.jwt_signature_invalid": { + "message": "Error al iniciar sesión: la firma del token no es válida. Póngase en contacto con su administrador." + }, + "error.session_expired": { + "message": "Su sesión ha expirado. Inicie sesión de nuevo." + }, + "error.invalid_setup_key": { + "message": "La clave de instalación falta o no es válida." + }, + "error.permission_denied": { + "message": "El servidor rechazó el inicio de sesión." + }, + "error.daemon_unreachable": { + "message": "El daemon de NetBird no responde. Compruebe que el servicio esté en ejecución." + }, + "error.unknown": { + "message": "La operación falló." + }, + "error.elevation_unavailable": { + "message": "NetBird no pudo solicitar a este sistema los privilegios necesarios. Ejecute esto en su lugar:" + }, + "error.elevation_failed": { + "message": "No se pudo aplicar el cambio con privilegios elevados. Ejecute esto en su lugar:" + }, + "settings.ssh.privilege.actorRoot": { + "message": "privilegios de root" + }, + "settings.ssh.privilege.actorAdministrator": { + "message": "privilegios de administrador" + }, + "settings.ssh.privilege.hint": { + "message": "Requiere {actor}. Ejecute esto en su lugar:" + }, + "settings.ssh.privilege.oneWay": { + "message": "Puede desactivarlo, pero volver a activarlo requiere {actor}." + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Puede activarlo, pero volver a desactivarlo requiere {actor}." + }, + "settings.ssh.privilege.authorizePending": { + "message": "Esperando la autorización…" + } +} diff --git a/client/ui/i18n/locales/fr/common.json b/client/ui/i18n/locales/fr/common.json new file mode 100644 index 000000000..920ef8343 --- /dev/null +++ b/client/ui/i18n/locales/fr/common.json @@ -0,0 +1,1378 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Déconnecté" + }, + "tray.status.daemonUnavailable": { + "message": "Non démarré" + }, + "tray.status.error": { + "message": "Erreur" + }, + "tray.status.connected": { + "message": "Connecté" + }, + "tray.status.connecting": { + "message": "Connexion" + }, + "tray.status.needsLogin": { + "message": "Connexion requise" + }, + "tray.status.loginFailed": { + "message": "Échec de la connexion" + }, + "tray.status.sessionExpired": { + "message": "Session expirée" + }, + "tray.session.expiresIn": { + "message": "La session expire dans {remaining}" + }, + "tray.session.unit.lessThanMinute": { + "message": "moins d’une minute" + }, + "tray.session.unit.minute": { + "message": "1 minute" + }, + "tray.session.unit.minutes": { + "message": "{count} minutes" + }, + "tray.session.unit.hour": { + "message": "1 heure" + }, + "tray.session.unit.hours": { + "message": "{count} heures" + }, + "tray.session.unit.day": { + "message": "1 jour" + }, + "tray.session.unit.days": { + "message": "{count} jours" + }, + "tray.menu.open": { + "message": "Ouvrir NetBird" + }, + "tray.menu.connect": { + "message": "Se connecter" + }, + "tray.menu.disconnect": { + "message": "Se déconnecter" + }, + "tray.menu.exitNode": { + "message": "Nœud de sortie" + }, + "tray.menu.networks": { + "message": "Ressources" + }, + "tray.menu.profiles": { + "message": "Profils" + }, + "tray.menu.manageProfiles": { + "message": "Gérer les profils" + }, + "tray.menu.settings": { + "message": "Paramètres..." + }, + "tray.menu.debugBundle": { + "message": "Créer un lot de diagnostic" + }, + "tray.menu.about": { + "message": "Aide et assistance" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Documentation" + }, + "tray.menu.troubleshoot": { + "message": "Dépannage" + }, + "tray.menu.downloadLatest": { + "message": "Télécharger la dernière version" + }, + "tray.menu.installVersion": { + "message": "Installer la version {version}" + }, + "tray.menu.guiVersion": { + "message": "GUI : {version}" + }, + "tray.menu.daemonVersion": { + "message": "Daemon : {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "Quitter NetBird" + }, + "notify.daemonOutdated.title": { + "message": "Le service NetBird est obsolète" + }, + "notify.daemonOutdated.body": { + "message": "Mettez à jour le service NetBird pour utiliser cette application." + }, + "notify.update.title": { + "message": "Mise à jour de NetBird disponible" + }, + "notify.update.body": { + "message": "NetBird {version} est disponible." + }, + "notify.update.enforcedSuffix": { + "message": " Votre administrateur impose cette mise à jour." + }, + "notify.error.title": { + "message": "Erreur" + }, + "notify.error.connect": { + "message": "Échec de la connexion" + }, + "notify.error.disconnect": { + "message": "Échec de la déconnexion" + }, + "notify.error.switchProfile": { + "message": "Échec du passage à {profile}" + }, + "notify.error.exitNode": { + "message": "Échec de la mise à jour du nœud de sortie {name}" + }, + "notify.sessionExpired.title": { + "message": "Session NetBird expirée" + }, + "notify.sessionExpired.body": { + "message": "Votre session NetBird a expiré. Veuillez vous reconnecter." + }, + "notify.sessionWarning.title": { + "message": "La session expire bientôt" + }, + "notify.sessionWarning.body": { + "message": "Votre session NetBird expire dans {remaining}. Cliquez sur Prolonger pour la renouveler." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "Votre session NetBird est sur le point d’expirer. Cliquez sur Prolonger pour la renouveler." + }, + "notify.sessionWarning.extend": { + "message": "Prolonger" + }, + "notify.sessionWarning.dismiss": { + "message": "Ignorer" + }, + "notify.sessionWarning.failed": { + "message": "Échec de la prolongation de la session NetBird" + }, + "notify.sessionWarning.successTitle": { + "message": "Session NetBird prolongée" + }, + "notify.sessionWarning.successBody": { + "message": "Votre session a été renouvelée." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Échéance de session rejetée" + }, + "notify.sessionDeadlineRejected.body": { + "message": "Le serveur a envoyé une échéance de session invalide. Veuillez vous reconnecter." + }, + "notify.mdm.policyApplied.title": { + "message": "Paramètres NetBird mis à jour" + }, + "notify.mdm.policyApplied.body": { + "message": "Votre configuration NetBird a été mise à jour par votre politique informatique." + }, + "common.cancel": { + "message": "Annuler" + }, + "common.save": { + "message": "Enregistrer" + }, + "common.saveChanges": { + "message": "Enregistrer les modifications" + }, + "common.saving": { + "message": "Enregistrement…" + }, + "common.close": { + "message": "Fermer" + }, + "common.copy": { + "message": "Copier" + }, + "common.togglePasswordVisibility": { + "message": "Afficher ou masquer le mot de passe" + }, + "common.increase": { + "message": "Augmenter" + }, + "common.decrease": { + "message": "Diminuer" + }, + "common.delete": { + "message": "Supprimer" + }, + "common.create": { + "message": "Créer" + }, + "common.add": { + "message": "Ajouter" + }, + "common.remove": { + "message": "Retirer" + }, + "common.refresh": { + "message": "Actualiser" + }, + "common.loading": { + "message": "Chargement…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "Aucun résultat trouvé" + }, + "common.noResults.description": { + "message": "Nous n’avons trouvé aucun résultat. Essayez un autre terme de recherche ou modifiez vos filtres." + }, + "notConnected.title": { + "message": "Déconnecté" + }, + "notConnected.description": { + "message": "Connectez-vous d’abord à NetBird pour consulter les informations détaillées sur vos pairs, vos ressources réseau et vos nœuds de sortie." + }, + "connect.status.disconnected": { + "message": "Déconnecté" + }, + "connect.status.connecting": { + "message": "Connexion..." + }, + "connect.status.connected": { + "message": "Connecté" + }, + "connect.status.disconnecting": { + "message": "Déconnexion..." + }, + "connect.status.daemonUnavailable": { + "message": "Daemon indisponible" + }, + "connect.status.loginRequired": { + "message": "Connexion requise" + }, + "connect.error.loginTitle": { + "message": "Échec de la connexion" + }, + "connect.error.connectTitle": { + "message": "Échec de la connexion" + }, + "connect.error.disconnectTitle": { + "message": "Échec de la déconnexion" + }, + "nav.peers.title": { + "message": "Pairs" + }, + "nav.peers.description": { + "message": "{connected} sur {total} connectés" + }, + "nav.resources.title": { + "message": "Ressources" + }, + "nav.resources.description": { + "message": "{active} sur {total} actives" + }, + "nav.exitNode.title": { + "message": "Nœuds de sortie" + }, + "nav.exitNode.none": { + "message": "Inactif" + }, + "nav.exitNode.using": { + "message": "Via {name}" + }, + "header.openSettings": { + "message": "Ouvrir les paramètres" + }, + "header.togglePanel": { + "message": "Afficher ou masquer le panneau latéral" + }, + "profile.selector.loading": { + "message": "Chargement..." + }, + "profile.selector.noProfile": { + "message": "Aucun profil" + }, + "profile.selector.searchPlaceholder": { + "message": "Rechercher un profil par nom..." + }, + "profile.selector.emptyTitle": { + "message": "Aucun profil trouvé" + }, + "profile.selector.emptyDescription": { + "message": "Essayez un autre terme de recherche ou créez un nouveau profil." + }, + "profile.selector.newProfile": { + "message": "Nouveau profil" + }, + "profile.selector.moreOptions": { + "message": "Plus d’options" + }, + "profile.selector.deregister": { + "message": "Désinscrire" + }, + "profile.selector.delete": { + "message": "Supprimer" + }, + "profile.selector.switchTo": { + "message": "Basculer vers ce profil" + }, + "profile.selector.edit": { + "message": "Modifier" + }, + "profile.edit.title": { + "message": "Modifier le profil" + }, + "profile.edit.submit": { + "message": "Enregistrer les modifications" + }, + "profile.dialog.title": { + "message": "Saisir le nom du profil" + }, + "profile.dialog.nameLabel": { + "message": "Nom du profil" + }, + "profile.dialog.description": { + "message": "Choisissez un nom facilement identifiable pour votre profil." + }, + "profile.dialog.placeholder": { + "message": "ex. travail" + }, + "profile.dialog.submit": { + "message": "Ajouter un profil" + }, + "profile.dialog.required": { + "message": "Veuillez saisir un nom de profil, ex. travail, domicile" + }, + "profile.dialog.managementHelp": { + "message": "Utilisez NetBird Cloud ou votre propre serveur." + }, + "profile.dialog.urlUnreachable": { + "message": "Impossible de joindre ce serveur. Vérifiez l’URL, ou ajoutez le profil quand même si vous êtes sûr qu’elle est correcte." + }, + "header.menu.settings": { + "message": "Paramètres..." + }, + "header.menu.defaultView": { + "message": "Vue par défaut" + }, + "header.menu.advancedView": { + "message": "Vue avancée" + }, + "header.menu.updateAvailable": { + "message": "Mise à jour disponible" + }, + "header.menu.open": { + "message": "Ouvrir le menu" + }, + "header.profile.switch": { + "message": "Changer de profil" + }, + "connect.toggle.label": { + "message": "Activer/désactiver la connexion NetBird" + }, + "connect.localIp.label": { + "message": "Adresses IP locales" + }, + "common.search": { + "message": "Rechercher" + }, + "common.filter": { + "message": "Filtrer" + }, + "exitNodes.dropdown.trigger": { + "message": "Sélectionner un nœud de sortie" + }, + "peers.row.label": { + "message": "Ouvrir les détails pour {name}, {status}" + }, + "peers.dialog.title": { + "message": "Détails du pair" + }, + "networks.row.toggle": { + "message": "Activer/désactiver {name}" + }, + "networks.bulk.label": { + "message": "Activer/désactiver toutes les ressources visibles" + }, + "profile.switch.title": { + "message": "Basculer vers le profil « {name} » ?" + }, + "profile.switch.message": { + "message": "Voulez-vous vraiment changer de profil ?\nVotre profil actuel sera déconnecté." + }, + "profile.switch.confirm": { + "message": "Confirmer" + }, + "profile.deregister.title": { + "message": "Désinscrire le profil « {name} » ?" + }, + "profile.deregister.message": { + "message": "Voulez-vous vraiment désinscrire ce profil ?\nVous devrez vous reconnecter pour l’utiliser." + }, + "profile.deregister.confirm": { + "message": "Désinscrire" + }, + "profile.delete.title": { + "message": "Supprimer le profil « {name} » ?" + }, + "profile.delete.message": { + "message": "Voulez-vous vraiment supprimer ce profil ?\nCette action est irréversible." + }, + "profile.delete.disabledActive": { + "message": "Les profils actifs ne peuvent pas être supprimés. Basculez vers un autre profil avant de supprimer celui-ci." + }, + "profile.delete.disabledDefault": { + "message": "Le profil par défaut ne peut pas être supprimé." + }, + "profile.error.switchTitle": { + "message": "Échec du changement de profil" + }, + "profile.error.deregisterTitle": { + "message": "Échec de la désinscription du profil" + }, + "profile.error.deleteTitle": { + "message": "Échec de la suppression du profil" + }, + "profile.error.createTitle": { + "message": "Échec de la création du profil" + }, + "profile.error.editTitle": { + "message": "Échec de la modification du profil" + }, + "profile.error.loadTitle": { + "message": "Échec du chargement des profils" + }, + "profile.dropdown.activeProfile": { + "message": "Profil actif" + }, + "profile.dropdown.switchProfile": { + "message": "Changer de profil" + }, + "profile.dropdown.noEmail": { + "message": "Autre" + }, + "profile.dropdown.addProfile": { + "message": "Ajouter un profil" + }, + "profile.dropdown.manageProfiles": { + "message": "Gérer les profils" + }, + "profile.dropdown.settings": { + "message": "Paramètres" + }, + "settings.profiles.section.profiles": { + "message": "Profils" + }, + "settings.profiles.intro": { + "message": "Conservez plusieurs identités NetBird côte à côte, par exemple des comptes professionnels et personnels, ou différents serveurs de gestion. Ajoutez, désinscrivez ou supprimez des profils ci-dessous." + }, + "settings.profiles.addProfile": { + "message": "Ajouter un profil" + }, + "settings.profiles.active": { + "message": "Actif" + }, + "settings.profiles.emptyTitle": { + "message": "Aucun profil" + }, + "settings.profiles.emptyDescription": { + "message": "Créez un profil pour vous connecter à un serveur de gestion NetBird." + }, + "settings.error.loadTitle": { + "message": "Échec du chargement des paramètres" + }, + "settings.error.saveTitle": { + "message": "Échec de l’enregistrement des paramètres" + }, + "settings.error.debugBundleTitle": { + "message": "Échec du lot de diagnostic" + }, + "settings.nav.label": { + "message": "Sections des paramètres" + }, + "settings.tabs.general": { + "message": "Général" + }, + "settings.tabs.network": { + "message": "Réseau" + }, + "settings.tabs.security": { + "message": "Sécurité" + }, + "settings.tabs.profiles": { + "message": "Profils" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "Avancé" + }, + "settings.tabs.troubleshooting": { + "message": "Dépannage" + }, + "settings.tabs.about": { + "message": "À propos" + }, + "settings.tabs.updateAvailable": { + "message": "Mise à jour disponible" + }, + "settings.general.section.general": { + "message": "Général" + }, + "settings.general.section.connection": { + "message": "Connexion" + }, + "settings.general.connectOnStartup.label": { + "message": "Se connecter au démarrage" + }, + "settings.general.connectOnStartup.help": { + "message": "Établir automatiquement une connexion au démarrage du service." + }, + "settings.general.notifications.label": { + "message": "Notifications du bureau" + }, + "settings.general.notifications.help": { + "message": "Afficher des notifications du bureau pour les nouvelles mises à jour et les événements de connexion." + }, + "settings.general.autostart.label": { + "message": "Lancer l’interface NetBird à l’ouverture de session" + }, + "settings.general.autostart.help": { + "message": "Démarrer automatiquement l’interface NetBird à l’ouverture de votre session. Cela n’affecte que l’interface graphique, pas le service en arrière-plan." + }, + "settings.general.autostart.errorTitle": { + "message": "Échec de la modification du démarrage automatique" + }, + "settings.general.keepConnectedOnQuit.label": { + "message": "Rester connecté après la fermeture", + "description": "Toggle label: keep the VPN connection up after quitting the UI." + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "La connexion reste active en arrière-plan après la fermeture de NetBird. Elle ne s'arrête que si vous la coupez vous-même.", + "description": "Helper text for the stay-connected-after-quitting toggle." + }, + "settings.general.language.label": { + "message": "Langue d’affichage" + }, + "settings.general.language.help": { + "message": "Choisissez la langue de l’interface NetBird." + }, + "settings.general.language.search": { + "message": "Rechercher une langue…" + }, + "settings.general.language.empty": { + "message": "Aucune langue ne correspond." + }, + "settings.general.management.label": { + "message": "Serveur de gestion" + }, + "settings.general.management.help": { + "message": "Connectez-vous à NetBird Cloud ou à votre propre serveur de gestion auto-hébergé. Les modifications reconnecteront le client." + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "Auto-hébergé" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Veuillez saisir une URL valide, ex. https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "Impossible de joindre ce serveur. Vérifiez l’URL, ou enregistrez quand même si vous êtes sûr qu’elle est correcte." + }, + "settings.general.management.switchCloudTitle": { + "message": "Basculer vers NetBird Cloud ?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Cela déconnecte votre serveur auto-hébergé.\nVous devrez peut-être vous reconnecter." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Basculer vers Cloud" + }, + "settings.network.section.connectivity": { + "message": "Connectivité" + }, + "settings.network.section.routingDns": { + "message": "Routage et DNS" + }, + "settings.network.monitor.label": { + "message": "Reconnecter en cas de changement de réseau" + }, + "settings.network.monitor.help": { + "message": "Surveiller le réseau et se reconnecter automatiquement lors de changements tels qu’un basculement Wi-Fi, une modification Ethernet ou une reprise après la mise en veille." + }, + "settings.network.dns.label": { + "message": "Activer le DNS" + }, + "settings.network.dns.help": { + "message": "Appliquer les paramètres DNS gérés par NetBird au résolveur de l’hôte." + }, + "settings.network.clientRoutes.label": { + "message": "Activer les routes client" + }, + "settings.network.clientRoutes.help": { + "message": "Accepter les routes d’autres pairs pour atteindre leurs réseaux." + }, + "settings.network.serverRoutes.label": { + "message": "Activer les routes serveur" + }, + "settings.network.serverRoutes.help": { + "message": "Annoncer les routes locales de cet hôte aux autres pairs." + }, + "settings.network.ipv6.label": { + "message": "Activer IPv6" + }, + "settings.network.ipv6.help": { + "message": "Utiliser l’adressage IPv6 pour le réseau overlay NetBird." + }, + "settings.security.section.firewall": { + "message": "Pare-feu" + }, + "settings.security.section.encryption": { + "message": "Chiffrement" + }, + "settings.security.blockInbound.label": { + "message": "Bloquer le trafic entrant" + }, + "settings.security.blockInbound.help": { + "message": "Rejeter les connexions non sollicitées des pairs vers cet appareil et les réseaux qu’il route. Le trafic sortant n’est pas affecté." + }, + "settings.security.blockLan.label": { + "message": "Bloquer l’accès au LAN" + }, + "settings.security.blockLan.help": { + "message": "Empêcher les pairs d’atteindre votre réseau local ou ses appareils lorsque cet appareil route leur trafic." + }, + "settings.security.rosenpass.label": { + "message": "Activer la résistance quantique" + }, + "settings.security.rosenpass.help": { + "message": "Ajouter un échange de clés post-quantique via Rosenpass au-dessus de WireGuard®." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Activer le mode permissif" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Autoriser les connexions vers des pairs sans prise en charge de la résistance quantique." + }, + "settings.ssh.section.server": { + "message": "Serveur" + }, + "settings.ssh.section.capabilities": { + "message": "Fonctionnalités" + }, + "settings.ssh.section.authentication": { + "message": "Authentification" + }, + "settings.ssh.server.label": { + "message": "Activer le serveur SSH" + }, + "settings.ssh.server.help": { + "message": "Exécuter le serveur SSH NetBird sur cet hôte afin que d’autres pairs puissent s’y connecter." + }, + "settings.ssh.root.label": { + "message": "Autoriser la connexion en root" + }, + "settings.ssh.root.help": { + "message": "Permettre aux pairs de se connecter en tant qu’utilisateur root. Désactivez pour exiger un compte non privilégié." + }, + "settings.ssh.sftp.label": { + "message": "Autoriser SFTP" + }, + "settings.ssh.sftp.help": { + "message": "Transférer des fichiers en toute sécurité à l’aide de clients SFTP ou SCP natifs." + }, + "settings.ssh.localForward.label": { + "message": "Redirection de port locale" + }, + "settings.ssh.localForward.help": { + "message": "Permettre aux pairs connectés de tunneliser des ports locaux vers des services accessibles depuis cet hôte." + }, + "settings.ssh.remoteForward.label": { + "message": "Redirection de port distante" + }, + "settings.ssh.remoteForward.help": { + "message": "Permettre aux pairs connectés d’exposer des ports de cet hôte vers leur propre machine." + }, + "settings.ssh.jwt.label": { + "message": "Activer l’authentification JWT" + }, + "settings.ssh.jwt.help": { + "message": "Vérifier chaque session SSH auprès de votre IdP pour l’identité de l’utilisateur et l’audit. Désactivez pour vous appuyer uniquement sur les politiques ACL réseau, utile lorsqu’aucun IdP n’est disponible." + }, + "settings.ssh.jwtTtl.label": { + "message": "TTL du cache JWT" + }, + "settings.ssh.jwtTtl.help": { + "message": "Durée pendant laquelle ce client met en cache un JWT avant de redemander lors des connexions SSH sortantes. Définissez 0 pour désactiver la mise en cache et vous authentifier à chaque connexion." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "s" + }, + "settings.advanced.section.interface": { + "message": "Interface" + }, + "settings.advanced.section.security": { + "message": "Sécurité" + }, + "settings.advanced.interfaceName.label": { + "message": "Nom" + }, + "settings.advanced.interfaceName.error": { + "message": "Utilisez 1 à 15 lettres, chiffres, points, tirets ou traits de soulignement." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Doit commencer par « utun » suivi d’un nombre (ex. utun100)." + }, + "settings.advanced.port.label": { + "message": "Port" + }, + "settings.advanced.port.error": { + "message": "Saisissez un port compris entre {min} et {max}." + }, + "settings.advanced.port.help": { + "message": "Si la valeur est 0, un port libre aléatoire sera utilisé." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Saisissez une valeur MTU comprise entre {min} et {max}." + }, + "settings.advanced.psk.label": { + "message": "Clé pré-partagée" + }, + "settings.advanced.psk.help": { + "message": "PSK WireGuard facultative pour un chiffrement symétrique supplémentaire. Différente d’une clé d’installation NetBird. Vous ne communiquerez qu’avec les pairs utilisant la même clé pré-partagée." + }, + "settings.troubleshooting.section.title": { + "message": "Lot de diagnostic" + }, + "settings.troubleshooting.anonymize.label": { + "message": "Anonymiser les informations sensibles" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Masque les adresses IP, les domaines et d'autres valeurs sensibles." + }, + "settings.troubleshooting.anonymize.info": { + "message": "Le mode par défaut garde les adresses IPv4 internes et les noms des pairs lisibles pour le support. Le mode strict anonymise en plus les adresses IP privées (RFC 1918), CGNAT et de lien local, les noms des pairs et les clés publiques WireGuard. Les valeurs récurrentes reçoivent le même espace réservé, les pairs restent donc distinguables. Utilisez le mode strict lorsque vous partagez le lot de diagnostic en dehors de votre organisation." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Aucune" + }, + "settings.troubleshooting.anonymize.default": { + "message": "Par défaut" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Strict" + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Inclure les informations système" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Inclure l’OS, le noyau, les interfaces réseau et les tables de routage." + }, + "settings.troubleshooting.upload.label": { + "message": "Envoyer le lot aux serveurs NetBird" + }, + "settings.troubleshooting.upload.help": { + "message": "Renvoie une clé d’envoi à partager avec l’assistance NetBird." + }, + "settings.troubleshooting.trace.label": { + "message": "Activer les journaux trace" + }, + "settings.troubleshooting.trace.help": { + "message": "Élève le niveau de journalisation à TRACE, puis le rétablit ensuite." + }, + "settings.troubleshooting.capture.label": { + "message": "Session de capture" + }, + "settings.troubleshooting.capture.help": { + "message": "Se reconnecte et attend pour que vous puissiez reproduire le problème." + }, + "settings.troubleshooting.packets.label": { + "message": "Capturer les paquets réseau" + }, + "settings.troubleshooting.packets.help": { + "message": "Enregistre un .pcap du trafic réseau pendant la session de capture." + }, + "settings.troubleshooting.duration.label": { + "message": "Durée de capture" + }, + "settings.troubleshooting.duration.help": { + "message": "Durée d’exécution de la session de capture." + }, + "settings.troubleshooting.duration.suffix": { + "message": "min" + }, + "settings.troubleshooting.create": { + "message": "Créer le lot" + }, + "settings.troubleshooting.progress.description": { + "message": "Collecte des journaux, des détails système et de l’état de connexion. Cela prend généralement un instant — gardez cette fenêtre ouverte jusqu’à la fin." + }, + "settings.troubleshooting.cancelling": { + "message": "Annulation…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "Lot de diagnostic envoyé avec succès !" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Lot enregistré" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Partagez la clé d’envoi ci-dessous avec l’assistance NetBird. Une copie locale a également été enregistrée sur votre appareil." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "Votre lot de diagnostic a été enregistré localement." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Copier la clé" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Ouvrir le dossier" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Ouvrir l’emplacement du fichier" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Échec de l’envoi : {reason} Le lot reste enregistré localement." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Échec de l’envoi. Le lot reste enregistré localement." + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "Reconnexion de NetBird…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Capture des journaux de débogage" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Génération du lot de diagnostic…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Envoi vers NetBird…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Annulation…" + }, + "settings.about.client": { + "message": "Client NetBird v{version}" + }, + "settings.about.clientName": { + "message": "Client NetBird" + }, + "settings.about.development": { + "message": "[Développement]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Tous droits réservés." + }, + "settings.about.links.imprint": { + "message": "Mentions légales" + }, + "settings.about.links.privacy": { + "message": "Confidentialité" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Conditions d’utilisation" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Forum" + }, + "settings.about.community.documentation": { + "message": "Documentation" + }, + "settings.about.community.feedback": { + "message": "Retour d’expérience" + }, + "update.banner.message": { + "message": "NetBird {version} est prêt à être installé." + }, + "update.banner.later": { + "message": "Plus tard" + }, + "update.banner.installNow": { + "message": "Installer maintenant" + }, + "update.card.versionAvailableDownload": { + "message": "La version {version} est disponible au téléchargement." + }, + "update.card.versionAvailableInstall": { + "message": "La version {version} est disponible à l’installation." + }, + "update.card.whatsNew": { + "message": "Quoi de neuf ?" + }, + "update.card.installNow": { + "message": "Installer maintenant" + }, + "update.card.getInstaller": { + "message": "Télécharger" + }, + "update.card.autoCheckInterval": { + "message": "NetBird vérifie les mises à jour en arrière-plan." + }, + "update.card.changelog": { + "message": "Journal des modifications" + }, + "update.card.onLatestVersion": { + "message": "Vous utilisez la dernière version" + }, + "update.header.tooltip": { + "message": "Mise à jour disponible" + }, + "update.overlay.updatingVersion": { + "message": "Mise à jour de NetBird vers v{version}" + }, + "update.overlay.updating": { + "message": "Mise à jour de NetBird" + }, + "update.overlay.description": { + "message": "Une version plus récente est disponible et en cours d’installation. NetBird redémarrera automatiquement une fois la mise à jour terminée." + }, + "update.overlay.error.timeoutTitle": { + "message": "La mise à jour prend trop de temps" + }, + "update.overlay.error.timeoutDescription": { + "message": "L’installation de {target} a pris trop de temps et ne s’est pas terminée." + }, + "update.overlay.error.canceledTitle": { + "message": "La mise à jour a été interrompue" + }, + "update.overlay.error.canceledDescription": { + "message": "La mise à jour vers {target} a été annulée avant de se terminer." + }, + "update.overlay.error.failTitle": { + "message": "Impossible d’installer la mise à jour" + }, + "update.overlay.error.failDescription": { + "message": "Impossible d’installer {target}." + }, + "update.overlay.error.unknownMessage": { + "message": "erreur inconnue" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "la nouvelle version" + }, + "update.error.loadStateTitle": { + "message": "Échec du chargement de l’état de mise à jour" + }, + "update.error.triggerTitle": { + "message": "Échec du démarrage de la mise à jour" + }, + "update.page.versionLine": { + "message": "Mise à jour du client vers : {version}." + }, + "update.page.versionLineGeneric": { + "message": "Mise à jour du client." + }, + "update.page.outdated": { + "message": "La version de votre client est antérieure à la version de mise à jour automatique définie dans Management." + }, + "update.page.status.running": { + "message": "Mise à jour en cours" + }, + "update.page.status.timeout": { + "message": "La mise à jour a expiré. Veuillez réessayer." + }, + "update.page.status.canceled": { + "message": "Mise à jour annulée." + }, + "update.page.status.failed": { + "message": "Échec de la mise à jour : {message}" + }, + "update.page.status.unknownError": { + "message": "erreur de mise à jour inconnue" + }, + "update.page.failedTitle": { + "message": "Échec de la mise à jour" + }, + "update.page.timeoutMessage": { + "message": "La mise à jour a expiré." + }, + "update.page.dontClose": { + "message": "Veuillez ne pas fermer cette fenêtre." + }, + "update.page.updating": { + "message": "Mise à jour…" + }, + "update.page.complete": { + "message": "Mise à jour terminée" + }, + "update.page.failed": { + "message": "Échec de la mise à jour" + }, + "window.title.settings": { + "message": "Paramètres" + }, + "window.title.signIn": { + "message": "Connexion" + }, + "window.title.sessionExpiration": { + "message": "Expiration de session" + }, + "window.title.updating": { + "message": "Mise à jour" + }, + "window.title.welcome": { + "message": "Bienvenue dans NetBird" + }, + "window.title.error": { + "message": "Erreur" + }, + "welcome.title": { + "message": "Cherchez NetBird dans votre barre d’état système" + }, + "welcome.titleMac": { + "message": "Cherchez NetBird dans votre barre des menus" + }, + "welcome.description": { + "message": "NetBird se trouve dans votre barre d’état système. Cliquez sur l’icône pour vous connecter, changer de profil ou ouvrir les paramètres." + }, + "welcome.descriptionMac": { + "message": "NetBird se trouve dans votre barre des menus. Cliquez sur l’icône pour vous connecter, changer de profil ou ouvrir les paramètres." + }, + "welcome.continue": { + "message": "Continuer" + }, + "welcome.back": { + "message": "Retour" + }, + "welcome.management.title": { + "message": "Configurer NetBird" + }, + "welcome.management.description": { + "message": "Cliquez sur Continuer pour commencer, ou choisissez Auto-hébergé si vous disposez de votre propre serveur NetBird." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Utilisez notre service hébergé. Aucune configuration requise." + }, + "welcome.management.selfHosted.title": { + "message": "Auto-hébergé" + }, + "welcome.management.selfHosted.description": { + "message": "Connectez-vous à votre propre serveur de gestion." + }, + "welcome.management.urlLabel": { + "message": "URL du serveur de gestion" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Veuillez saisir une URL valide, ex. https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "Impossible de joindre ce serveur. Vérifiez l’URL ou votre réseau, puis continuez si vous êtes sûr qu’elle est correcte." + }, + "welcome.management.checking": { + "message": "Vérification…" + }, + "browserLogin.title": { + "message": "Continuez dans votre navigateur pour terminer la connexion" + }, + "browserLogin.notSeeing": { + "message": "Vous ne voyez pas l’onglet du navigateur ?" + }, + "browserLogin.tryAgain": { + "message": "Réessayer" + }, + "browserLogin.openFailedTitle": { + "message": "Échec de l’ouverture du navigateur" + }, + "sessionExpiration.title": { + "message": "La session expire bientôt" + }, + "sessionExpiration.titleLater": { + "message": "Votre session va expirer" + }, + "sessionExpiration.description": { + "message": "Cet appareil sera bientôt déconnecté. Renouvelez via une connexion dans le navigateur." + }, + "sessionExpiration.descriptionLater": { + "message": "Une connexion dans le navigateur maintient cet appareil connecté à votre réseau." + }, + "sessionExpiration.stay": { + "message": "Renouveler la session" + }, + "sessionExpiration.authenticate": { + "message": "S’authentifier" + }, + "sessionExpiration.logout": { + "message": "Se déconnecter" + }, + "sessionExpiration.expired": { + "message": "Session expirée" + }, + "sessionExpiration.expiredDescription": { + "message": "Appareil déconnecté. Authentifiez-vous via une connexion dans le navigateur pour vous reconnecter." + }, + "sessionExpiration.close": { + "message": "Fermer" + }, + "sessionExpiration.extendFailedTitle": { + "message": "Échec de la prolongation de la session" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Échec de la déconnexion" + }, + "peers.search.placeholder": { + "message": "Rechercher par nom ou IP" + }, + "peers.filter.all": { + "message": "Tous" + }, + "peers.filter.online": { + "message": "En ligne" + }, + "peers.filter.offline": { + "message": "Hors ligne" + }, + "peers.empty.title": { + "message": "Aucun pair disponible" + }, + "peers.empty.description": { + "message": "Soit vous n’avez aucun pair disponible, soit vous n’y avez pas accès." + }, + "peers.details.domain": { + "message": "Domaine" + }, + "peers.details.netbirdIp": { + "message": "IP NetBird" + }, + "peers.details.netbirdIpv6": { + "message": "IPv6 NetBird" + }, + "peers.details.publicKey": { + "message": "Clé publique" + }, + "peers.details.connection": { + "message": "Connexion" + }, + "peers.details.latency": { + "message": "Latence" + }, + "peers.details.lastHandshake": { + "message": "Dernier handshake" + }, + "peers.details.statusSince": { + "message": "Dernière mise à jour de connexion" + }, + "peers.details.bytes": { + "message": "Octets" + }, + "peers.details.bytesSent": { + "message": "Envoyés" + }, + "peers.details.bytesReceived": { + "message": "Reçus" + }, + "peers.details.localIce": { + "message": "ICE local" + }, + "peers.details.remoteIce": { + "message": "ICE distant" + }, + "peers.details.never": { + "message": "Jamais" + }, + "peers.details.justNow": { + "message": "À l’instant" + }, + "peers.details.refresh": { + "message": "Actualiser" + }, + "peers.status.connected": { + "message": "Connecté" + }, + "peers.status.connecting": { + "message": "Connexion" + }, + "peers.status.disconnected": { + "message": "Déconnecté" + }, + "peers.details.relayAddress": { + "message": "Relais" + }, + "peers.details.networks": { + "message": "Ressources" + }, + "peers.details.relayed": { + "message": "Relayée" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass activé" + }, + "networks.search.placeholder": { + "message": "Rechercher par réseau ou domaine" + }, + "networks.filter.all": { + "message": "Toutes" + }, + "networks.filter.active": { + "message": "Actives" + }, + "networks.filter.overlapping": { + "message": "Chevauchantes" + }, + "networks.empty.title": { + "message": "Aucune ressource disponible" + }, + "networks.empty.description": { + "message": "Soit vous n’avez aucune ressource réseau disponible, soit vous n’y avez pas accès." + }, + "networks.selected": { + "message": "Sélectionnée" + }, + "networks.unselected": { + "message": "Non sélectionnée" + }, + "networks.ips.heading": { + "message": "IP résolues" + }, + "networks.bulk.selectionCount": { + "message": "{selected} sur {total} actives" + }, + "networks.bulk.enableAll": { + "message": "Tout activer" + }, + "networks.bulk.disableAll": { + "message": "Tout désactiver" + }, + "exitNodes.search.placeholder": { + "message": "Rechercher des nœuds de sortie" + }, + "exitNodes.none": { + "message": "Aucun" + }, + "exitNodes.empty.title": { + "message": "Aucun nœud de sortie disponible" + }, + "exitNodes.empty.description": { + "message": "Aucun nœud de sortie n’a été partagé avec ce pair." + }, + "exitNodes.card.title": { + "message": "Nœud de sortie" + }, + "exitNodes.card.statusActive": { + "message": "Actif" + }, + "exitNodes.card.statusInactive": { + "message": "Inactif" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Aucun" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Connexion directe sans nœud de sortie" + }, + "quickActions.connect": { + "message": "Se connecter" + }, + "quickActions.disconnect": { + "message": "Se déconnecter" + }, + "daemon.unavailable.title": { + "message": "Le service NetBird n’est pas en cours d’exécution" + }, + "daemon.unavailable.description": { + "message": "L’application se reconnectera automatiquement une fois le service en cours d’exécution." + }, + "daemon.unavailable.docsLink": { + "message": "Documentation" + }, + "daemon.outdated.title": { + "message": "Le Client NetBird est obsolète" + }, + "daemon.outdated.description": { + "message": "La nouvelle GUI n'est pas compatible avec votre ancien client. Mettez à jour votre client pour utiliser la nouvelle application." + }, + "daemon.outdated.download": { + "message": "Télécharger la dernière version" + }, + "error.jwt_clock_skew": { + "message": "Échec de la connexion : l’horloge de cet appareil n’est pas synchronisée avec le serveur. Veuillez synchroniser l’horloge de votre système et réessayer." + }, + "error.jwt_expired": { + "message": "Votre jeton de connexion a expiré. Veuillez vous reconnecter." + }, + "error.jwt_signature_invalid": { + "message": "Échec de la connexion : la signature du jeton est invalide. Veuillez contacter votre administrateur." + }, + "error.session_expired": { + "message": "Votre session a expiré. Veuillez vous reconnecter." + }, + "error.invalid_setup_key": { + "message": "La clé d’installation est manquante ou invalide." + }, + "error.permission_denied": { + "message": "La connexion a été rejetée par le serveur." + }, + "error.daemon_unreachable": { + "message": "Le daemon NetBird ne répond pas. Veuillez vérifier que le service est en cours d’exécution." + }, + "error.unknown": { + "message": "L’opération a échoué." + }, + "error.elevation_unavailable": { + "message": "NetBird n’a pas pu demander à ce système les privilèges nécessaires. Exécutez plutôt ceci :" + }, + "error.elevation_failed": { + "message": "La modification n’a pas pu être appliquée avec des privilèges élevés. Exécutez plutôt ceci :" + }, + "settings.ssh.privilege.actorRoot": { + "message": "les privilèges root" + }, + "settings.ssh.privilege.actorAdministrator": { + "message": "les privilèges administrateur" + }, + "settings.ssh.privilege.hint": { + "message": "Nécessite {actor}. Exécutez plutôt ceci :" + }, + "settings.ssh.privilege.oneWay": { + "message": "Vous pouvez le désactiver, mais le réactiver nécessite {actor}." + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Vous pouvez l’activer, mais le désactiver de nouveau nécessite {actor}." + }, + "settings.ssh.privilege.authorizePending": { + "message": "En attente de l’autorisation…" + } +} diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json new file mode 100644 index 000000000..82996e3d3 --- /dev/null +++ b/client/ui/i18n/locales/hu/common.json @@ -0,0 +1,1378 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Lecsatlakozva" + }, + "tray.status.daemonUnavailable": { + "message": "Nem fut" + }, + "tray.status.error": { + "message": "Hiba" + }, + "tray.status.connected": { + "message": "Csatlakozva" + }, + "tray.status.connecting": { + "message": "Csatlakozás" + }, + "tray.status.needsLogin": { + "message": "Bejelentkezés szükséges" + }, + "tray.status.loginFailed": { + "message": "Sikertelen bejelentkezés" + }, + "tray.status.sessionExpired": { + "message": "Munkamenet lejárt" + }, + "tray.session.expiresIn": { + "message": "Munkamenet lejár {remaining} múlva" + }, + "tray.session.unit.lessThanMinute": { + "message": "egy percnél kevesebb" + }, + "tray.session.unit.minute": { + "message": "1 perc" + }, + "tray.session.unit.minutes": { + "message": "{count} perc" + }, + "tray.session.unit.hour": { + "message": "1 óra" + }, + "tray.session.unit.hours": { + "message": "{count} óra" + }, + "tray.session.unit.day": { + "message": "1 nap" + }, + "tray.session.unit.days": { + "message": "{count} nap" + }, + "tray.menu.open": { + "message": "NetBird megnyitása" + }, + "tray.menu.connect": { + "message": "Csatlakozás" + }, + "tray.menu.disconnect": { + "message": "Bontás" + }, + "tray.menu.exitNode": { + "message": "Exit Node" + }, + "tray.menu.networks": { + "message": "Erőforrások" + }, + "tray.menu.profiles": { + "message": "Profilok" + }, + "tray.menu.manageProfiles": { + "message": "Profilok kezelése" + }, + "tray.menu.settings": { + "message": "Beállítások…" + }, + "tray.menu.debugBundle": { + "message": "Hibakeresési csomag készítése" + }, + "tray.menu.about": { + "message": "Súgó és támogatás" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Dokumentáció" + }, + "tray.menu.troubleshoot": { + "message": "Hibakeresés" + }, + "tray.menu.downloadLatest": { + "message": "Legfrissebb verzió letöltése" + }, + "tray.menu.installVersion": { + "message": "{version} verzió telepítése" + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}" + }, + "tray.menu.daemonVersion": { + "message": "Daemon: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "NetBird bezárása" + }, + "notify.daemonOutdated.title": { + "message": "A NetBird szolgáltatás elavult" + }, + "notify.daemonOutdated.body": { + "message": "Frissítsd a NetBird szolgáltatást az alkalmazás használatához." + }, + "notify.update.title": { + "message": "NetBird frissítés elérhető" + }, + "notify.update.body": { + "message": "Elérhető a NetBird {version}." + }, + "notify.update.enforcedSuffix": { + "message": " A rendszergazda kötelezővé tette ezt a frissítést." + }, + "notify.error.title": { + "message": "Hiba" + }, + "notify.error.connect": { + "message": "Csatlakozás sikertelen" + }, + "notify.error.disconnect": { + "message": "Bontás sikertelen" + }, + "notify.error.switchProfile": { + "message": "Átváltás sikertelen erre: {profile}" + }, + "notify.error.exitNode": { + "message": "Az Exit Node frissítése sikertelen: {name}" + }, + "notify.sessionExpired.title": { + "message": "NetBird munkamenet lejárt" + }, + "notify.sessionExpired.body": { + "message": "A NetBird munkamenet lejárt. Kérjük, jelentkezzen be újra." + }, + "notify.sessionWarning.title": { + "message": "Munkamenet hamarosan lejár" + }, + "notify.sessionWarning.body": { + "message": "A NetBird munkamenet {remaining} múlva lejár. Kattintson a Meghosszabbítás gombra a megújításhoz." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "A NetBird munkamenet hamarosan lejár. Kattintson a Meghosszabbítás gombra a megújításhoz." + }, + "notify.sessionWarning.extend": { + "message": "Meghosszabbítás" + }, + "notify.sessionWarning.dismiss": { + "message": "Elvetés" + }, + "notify.sessionWarning.failed": { + "message": "A NetBird munkamenet meghosszabbítása sikertelen" + }, + "notify.sessionWarning.successTitle": { + "message": "NetBird munkamenet meghosszabbítva" + }, + "notify.sessionWarning.successBody": { + "message": "A munkamenet frissítve." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Munkamenet-határidő elutasítva" + }, + "notify.sessionDeadlineRejected.body": { + "message": "A szerver érvénytelen munkamenet-határidőt küldött. Kérjük, jelentkezzen be újra." + }, + "notify.mdm.policyApplied.title": { + "message": "NetBird beállítások frissítve" + }, + "notify.mdm.policyApplied.body": { + "message": "A NetBird konfigurációt az IT-szabályzat frissítette." + }, + "common.cancel": { + "message": "Mégse" + }, + "common.save": { + "message": "Mentés" + }, + "common.saveChanges": { + "message": "Módosítások mentése" + }, + "common.saving": { + "message": "Mentés…" + }, + "common.close": { + "message": "Bezárás" + }, + "common.copy": { + "message": "Másolás" + }, + "common.togglePasswordVisibility": { + "message": "Jelszó láthatóságának váltása" + }, + "common.increase": { + "message": "Növelés" + }, + "common.decrease": { + "message": "Csökkentés" + }, + "common.delete": { + "message": "Törlés" + }, + "common.create": { + "message": "Létrehozás" + }, + "common.add": { + "message": "Hozzáadás" + }, + "common.remove": { + "message": "Eltávolítás" + }, + "common.refresh": { + "message": "Frissítés" + }, + "common.loading": { + "message": "Betöltés…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "Nincs találat" + }, + "common.noResults.description": { + "message": "Nem találtunk eredményt. Próbáljon meg másik keresési kifejezést, vagy módosítsa a szűrőket." + }, + "notConnected.title": { + "message": "Lecsatlakozva" + }, + "notConnected.description": { + "message": "Csatlakozzon először a NetBirdhöz, hogy részletes információkat láthasson a Peerekről, a hálózati erőforrásokról és az Exit Node-okról." + }, + "connect.status.disconnected": { + "message": "Lecsatlakozva" + }, + "connect.status.connecting": { + "message": "Csatlakozás…" + }, + "connect.status.connected": { + "message": "Csatlakozva" + }, + "connect.status.disconnecting": { + "message": "Lecsatlakozás…" + }, + "connect.status.daemonUnavailable": { + "message": "Daemon nem elérhető" + }, + "connect.status.loginRequired": { + "message": "Bejelentkezés szükséges" + }, + "connect.error.loginTitle": { + "message": "Bejelentkezés sikertelen" + }, + "connect.error.connectTitle": { + "message": "Csatlakozás sikertelen" + }, + "connect.error.disconnectTitle": { + "message": "Bontás sikertelen" + }, + "nav.peers.title": { + "message": "Peerek" + }, + "nav.peers.description": { + "message": "{connected} / {total} csatlakoztatva" + }, + "nav.resources.title": { + "message": "Erőforrások" + }, + "nav.resources.description": { + "message": "{active} / {total} aktív" + }, + "nav.exitNode.title": { + "message": "Exit Node-ok" + }, + "nav.exitNode.none": { + "message": "Nem aktív" + }, + "nav.exitNode.using": { + "message": "Ezen át: {name}" + }, + "header.openSettings": { + "message": "Beállítások megnyitása" + }, + "header.togglePanel": { + "message": "Oldalsó panel váltása" + }, + "profile.selector.loading": { + "message": "Betöltés…" + }, + "profile.selector.noProfile": { + "message": "Nincs profil" + }, + "profile.selector.searchPlaceholder": { + "message": "Profil keresése név alapján…" + }, + "profile.selector.emptyTitle": { + "message": "Nem található profil" + }, + "profile.selector.emptyDescription": { + "message": "Próbáljon más keresőkifejezést, vagy hozzon létre új profilt." + }, + "profile.selector.newProfile": { + "message": "Új profil" + }, + "profile.selector.moreOptions": { + "message": "További műveletek" + }, + "profile.selector.deregister": { + "message": "Leválasztás" + }, + "profile.selector.delete": { + "message": "Profil törlése" + }, + "profile.selector.switchTo": { + "message": "Váltás erre a profilra" + }, + "profile.selector.edit": { + "message": "Szerkesztés" + }, + "profile.edit.title": { + "message": "Profil szerkesztése" + }, + "profile.edit.submit": { + "message": "Módosítások mentése" + }, + "profile.dialog.title": { + "message": "Új profil" + }, + "profile.dialog.nameLabel": { + "message": "Profilnév" + }, + "profile.dialog.description": { + "message": "Adjon profiljának egy könnyen azonosítható nevet." + }, + "profile.dialog.placeholder": { + "message": "pl. Munka" + }, + "profile.dialog.submit": { + "message": "Profil hozzáadása" + }, + "profile.dialog.required": { + "message": "Adjon meg egy profilnevet, pl. Munka, Otthon" + }, + "profile.dialog.managementHelp": { + "message": "NetBird Cloud vagy saját kiszolgáló." + }, + "profile.dialog.urlUnreachable": { + "message": "A szerver nem érhető el. Ellenőrizze az URL-t, vagy adja hozzá a profilt, ha biztos benne, hogy helyes." + }, + "header.menu.settings": { + "message": "Beállítások…" + }, + "header.menu.defaultView": { + "message": "Alapnézet" + }, + "header.menu.advancedView": { + "message": "Speciális nézet" + }, + "header.menu.updateAvailable": { + "message": "Frissítés elérhető" + }, + "header.menu.open": { + "message": "Menü megnyitása" + }, + "header.profile.switch": { + "message": "Profilváltás" + }, + "connect.toggle.label": { + "message": "NetBird-kapcsolat be/ki" + }, + "connect.localIp.label": { + "message": "Helyi IP-címek" + }, + "common.search": { + "message": "Keresés" + }, + "common.filter": { + "message": "Szűrés" + }, + "exitNodes.dropdown.trigger": { + "message": "Kilépőcsomópont kiválasztása" + }, + "peers.row.label": { + "message": "Részletek megnyitása: {name}, {status}" + }, + "peers.dialog.title": { + "message": "Társ részletei" + }, + "networks.row.toggle": { + "message": "{name} be/ki" + }, + "networks.bulk.label": { + "message": "Összes látható erőforrás be/ki" + }, + "profile.switch.title": { + "message": "Váltás a(z) \"{name}\" profilra?" + }, + "profile.switch.message": { + "message": "Biztosan profilt szeretne váltani?\nAz aktuális profilja le lesz választva." + }, + "profile.switch.confirm": { + "message": "Megerősítés" + }, + "profile.deregister.title": { + "message": "\"{name}\" profil leválasztása?" + }, + "profile.deregister.message": { + "message": "Biztosan le szeretné választani ezt a profilt?\nÚjra be kell jelentkeznie a használatához." + }, + "profile.deregister.confirm": { + "message": "Leválasztás" + }, + "profile.delete.title": { + "message": "\"{name}\" profil törlése?" + }, + "profile.delete.message": { + "message": "Biztosan törölni szeretné ezt a profilt?\nEz a művelet nem vonható vissza." + }, + "profile.delete.disabledActive": { + "message": "Aktív profilok nem törölhetők. Váltson másik profilra, mielőtt törölné ezt." + }, + "profile.delete.disabledDefault": { + "message": "Az alapértelmezett profil nem törölhető." + }, + "profile.error.switchTitle": { + "message": "Profilváltás sikertelen" + }, + "profile.error.deregisterTitle": { + "message": "Leválasztás sikertelen" + }, + "profile.error.deleteTitle": { + "message": "Profil törlése sikertelen" + }, + "profile.error.createTitle": { + "message": "Profil létrehozása sikertelen" + }, + "profile.error.editTitle": { + "message": "Profil szerkesztése sikertelen" + }, + "profile.error.loadTitle": { + "message": "Profilok betöltése sikertelen" + }, + "profile.dropdown.activeProfile": { + "message": "Aktív profil" + }, + "profile.dropdown.switchProfile": { + "message": "Profilváltás" + }, + "profile.dropdown.noEmail": { + "message": "Egyéb" + }, + "profile.dropdown.addProfile": { + "message": "Profil hozzáadása" + }, + "profile.dropdown.manageProfiles": { + "message": "Profilok kezelése" + }, + "profile.dropdown.settings": { + "message": "Beállítások" + }, + "settings.profiles.section.profiles": { + "message": "Profilok" + }, + "settings.profiles.intro": { + "message": "Kezeljen több NetBird-profilt párhuzamosan, például munkahelyi és személyes fiókokat, vagy különböző felügyeleti szervereket. Lent hozzáadhat, leválaszthat vagy törölhet profilokat." + }, + "settings.profiles.addProfile": { + "message": "Profil hozzáadása" + }, + "settings.profiles.active": { + "message": "Aktív" + }, + "settings.profiles.emptyTitle": { + "message": "Nincsenek profilok" + }, + "settings.profiles.emptyDescription": { + "message": "Hozzon létre egy profilt a NetBird felügyeleti szerverhez való csatlakozáshoz." + }, + "settings.error.loadTitle": { + "message": "Beállítások betöltése sikertelen" + }, + "settings.error.saveTitle": { + "message": "Beállítások mentése sikertelen" + }, + "settings.error.debugBundleTitle": { + "message": "Hibakeresési csomag sikertelen" + }, + "settings.nav.label": { + "message": "Beállítások szakaszai" + }, + "settings.tabs.general": { + "message": "Általános" + }, + "settings.tabs.network": { + "message": "Hálózat" + }, + "settings.tabs.security": { + "message": "Biztonság" + }, + "settings.tabs.profiles": { + "message": "Profilok" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "Speciális" + }, + "settings.tabs.troubleshooting": { + "message": "Hibaelhárítás" + }, + "settings.tabs.about": { + "message": "Névjegy" + }, + "settings.tabs.updateAvailable": { + "message": "Frissítés elérhető" + }, + "settings.general.section.general": { + "message": "Általános" + }, + "settings.general.section.connection": { + "message": "Kapcsolat" + }, + "settings.general.connectOnStartup.label": { + "message": "Csatlakozás indításkor" + }, + "settings.general.connectOnStartup.help": { + "message": "A szolgáltatás indulásakor automatikusan kapcsolatot létesít." + }, + "settings.general.notifications.label": { + "message": "Asztali értesítések" + }, + "settings.general.notifications.help": { + "message": "Asztali értesítések megjelenítése új frissítésekről és kapcsolati eseményekről." + }, + "settings.general.autostart.label": { + "message": "NetBird UI indítása bejelentkezéskor" + }, + "settings.general.autostart.help": { + "message": "A NetBird felület automatikus indítása bejelentkezéskor. Ez csak a grafikus felületet érinti, a háttérszolgáltatást nem." + }, + "settings.general.autostart.errorTitle": { + "message": "Az automatikus indítás módosítása sikertelen" + }, + "settings.general.keepConnectedOnQuit.label": { + "message": "Kapcsolat megtartása kilépéskor", + "description": "Toggle label: keep the VPN connection up after quitting the UI." + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "A kapcsolat a háttérben megmarad, miután bezárod a NetBirdöt. Csak akkor szakad meg, ha te magad bontod.", + "description": "Helper text for the stay-connected-after-quitting toggle." + }, + "settings.general.language.label": { + "message": "Megjelenítési nyelv" + }, + "settings.general.language.help": { + "message": "Válassza ki a NetBird felület nyelvét." + }, + "settings.general.language.search": { + "message": "Nyelv keresése…" + }, + "settings.general.language.empty": { + "message": "Nincs találat." + }, + "settings.general.management.label": { + "message": "Felügyeleti szerver" + }, + "settings.general.management.help": { + "message": "Csatlakozás a NetBird Cloudhoz vagy saját üzemeltetésű felügyeleti szerverhez. A módosítások újracsatlakozást váltanak ki." + }, + "settings.general.management.cloud": { + "message": "Felhő" + }, + "settings.general.management.selfHosted": { + "message": "Saját üzemeltetésű" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Adjon meg egy érvényes URL-t, pl. https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "A szerver nem érhető el. Ellenőrizze az URL-t, vagy mentse el, ha biztos benne, hogy helyes." + }, + "settings.general.management.switchCloudTitle": { + "message": "Átváltás a NetBird Cloudra?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Ez leválasztja a saját üzemeltetésű kiszolgálót.\nLehet, hogy újra be kell jelentkeznie." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Váltás a felhőre" + }, + "settings.network.section.connectivity": { + "message": "Kapcsolódás" + }, + "settings.network.section.routingDns": { + "message": "Útválasztás és DNS" + }, + "settings.network.monitor.label": { + "message": "Újracsatlakozás hálózatváltáskor" + }, + "settings.network.monitor.help": { + "message": "A hálózat figyelése és automatikus újracsatlakozás változások (pl. Wi-Fi-váltás, Ethernet-változás vagy alvó állapotból való visszatérés) esetén." + }, + "settings.network.dns.label": { + "message": "DNS engedélyezése" + }, + "settings.network.dns.help": { + "message": "A NetBird által kezelt DNS-beállítások alkalmazása a gazda DNS-feloldójára." + }, + "settings.network.clientRoutes.label": { + "message": "Kliens útvonalak engedélyezése" + }, + "settings.network.clientRoutes.help": { + "message": "Más Peerek útvonalainak elfogadása a hálózataik eléréséhez." + }, + "settings.network.serverRoutes.label": { + "message": "Szerver útvonalak engedélyezése" + }, + "settings.network.serverRoutes.help": { + "message": "Ennek a gazdának a helyi útvonalainak meghirdetése más Peerek számára." + }, + "settings.network.ipv6.label": { + "message": "IPv6 engedélyezése" + }, + "settings.network.ipv6.help": { + "message": "IPv6-címzés használata a NetBird overlay hálózathoz." + }, + "settings.security.section.firewall": { + "message": "Tűzfal" + }, + "settings.security.section.encryption": { + "message": "Titkosítás" + }, + "settings.security.blockInbound.label": { + "message": "Bejövő forgalom blokkolása" + }, + "settings.security.blockInbound.help": { + "message": "Visszautasítja a Peerektől érkező nem kért kapcsolatokat ezen eszközhöz és az általa irányított hálózatokhoz. A kimenő forgalmat nem érinti." + }, + "settings.security.blockLan.label": { + "message": "LAN-hozzáférés blokkolása" + }, + "settings.security.blockLan.help": { + "message": "Megakadályozza, hogy a Peerek elérjék a helyi hálózatot vagy annak eszközeit, amikor ez az eszköz irányítja a forgalmukat." + }, + "settings.security.rosenpass.label": { + "message": "Kvantumellenálló titkosítás engedélyezése" + }, + "settings.security.rosenpass.help": { + "message": "Post-kvantum kulcscsere hozzáadása Rosenpass segítségével a WireGuard® tetejére." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Engedékeny mód engedélyezése" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Kapcsolatok engedélyezése kvantumellenálló titkosítás nélküli Peerekkel." + }, + "settings.ssh.section.server": { + "message": "Szerver" + }, + "settings.ssh.section.capabilities": { + "message": "Képességek" + }, + "settings.ssh.section.authentication": { + "message": "Hitelesítés" + }, + "settings.ssh.server.label": { + "message": "SSH szerver engedélyezése" + }, + "settings.ssh.server.help": { + "message": "Futtassa a NetBird SSH szervert ezen a gazdán, hogy más Peerek csatlakozhassanak." + }, + "settings.ssh.root.label": { + "message": "Root bejelentkezés engedélyezése" + }, + "settings.ssh.root.help": { + "message": "A Peerek bejelentkezhetnek root felhasználóként. Tiltsa le, ha nem privilegizált fiók szükséges." + }, + "settings.ssh.sftp.label": { + "message": "SFTP engedélyezése" + }, + "settings.ssh.sftp.help": { + "message": "Fájlok biztonságos átvitele natív SFTP- vagy SCP-kliensekkel." + }, + "settings.ssh.localForward.label": { + "message": "Helyi porttovábbítás" + }, + "settings.ssh.localForward.help": { + "message": "A csatlakozó Peerek helyi portokat alagútba helyezhetnek erről a gazdáról elérhető szolgáltatásokhoz." + }, + "settings.ssh.remoteForward.label": { + "message": "Távoli porttovábbítás" + }, + "settings.ssh.remoteForward.help": { + "message": "A csatlakozó Peerek ezen a gazdán lévő portokat tehetnek elérhetővé a saját gépük számára." + }, + "settings.ssh.jwt.label": { + "message": "JWT-hitelesítés engedélyezése" + }, + "settings.ssh.jwt.help": { + "message": "Minden SSH-munkamenet ellenőrzése az IdP-vel a felhasználói identitás és audit céljából. Tiltsa le, ha csak a hálózati ACL-szabályokra kíván támaszkodni — hasznos, ha nincs elérhető IdP." + }, + "settings.ssh.jwtTtl.label": { + "message": "JWT gyorsítótár TTL" + }, + "settings.ssh.jwtTtl.help": { + "message": "Mennyi ideig őrzi meg a kliens a JWT-t, mielőtt újra kérné a kimenő SSH-kapcsolatoknál. 0 érték esetén a gyorsítótárazás kikapcsol, és minden kapcsolatnál hitelesít." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "másodperc" + }, + "settings.advanced.section.interface": { + "message": "Interfész" + }, + "settings.advanced.section.security": { + "message": "Biztonság" + }, + "settings.advanced.interfaceName.label": { + "message": "Név" + }, + "settings.advanced.interfaceName.error": { + "message": "Használjon 1–15 betűt, számot, pontot, kötőjelet vagy aláhúzást." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "„utun” után számmal kezdődjön (pl. utun100)." + }, + "settings.advanced.port.label": { + "message": "Port" + }, + "settings.advanced.port.error": { + "message": "Adjon meg egy portot {min} és {max} között." + }, + "settings.advanced.port.help": { + "message": "Ha 0-ra állítja, egy véletlenszerű szabad portot használ." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Adjon meg egy MTU értéket {min} és {max} között." + }, + "settings.advanced.psk.label": { + "message": "Pre-shared kulcs" + }, + "settings.advanced.psk.help": { + "message": "Opcionális WireGuard PSK további szimmetrikus titkosításhoz. Nem azonos a NetBird telepítőkulccsal. Csak olyan Peerekkel kommunikál, akik ugyanazt a pre-shared kulcsot használják." + }, + "settings.troubleshooting.section.title": { + "message": "Hibakeresési csomag" + }, + "settings.troubleshooting.anonymize.label": { + "message": "Érzékeny információk anonimizálása" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Elrejti az IP-címeket, a tartományokat és más érzékeny értékeket." + }, + "settings.troubleshooting.anonymize.info": { + "message": "Az Alapértelmezett szint a belső IPv4-címeket és a peer-neveket olvashatóan hagyja a támogatás számára. A Szigorú ezen felül anonimizálja a privát (RFC 1918), CGNAT és link-local IP-címeket, a peer-neveket és a WireGuard nyilvános kulcsokat. Az ismétlődő értékek ugyanazt a helyettesítőt kapják, így a peerek megkülönböztethetők maradnak. Használja a Szigorú szintet, ha a hibakeresési csomagot a szervezetén kívül osztja meg." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Nincs" + }, + "settings.troubleshooting.anonymize.default": { + "message": "Alapértelmezett" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Szigorú" + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Rendszerinformációk beillesztése" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Tartalmazza az OS-t, a kernelt, a hálózati interfészeket és az útválasztási táblákat." + }, + "settings.troubleshooting.upload.label": { + "message": "Csomag feltöltése a NetBird szerverekre" + }, + "settings.troubleshooting.upload.help": { + "message": "Egy feltöltési kulcsot ad vissza, amelyet megoszthat a NetBird támogatással." + }, + "settings.troubleshooting.trace.label": { + "message": "Trace naplók engedélyezése" + }, + "settings.troubleshooting.trace.help": { + "message": "TRACE szintre emeli a naplózást, majd utána visszaállítja." + }, + "settings.troubleshooting.capture.label": { + "message": "Rögzítési munkamenet" + }, + "settings.troubleshooting.capture.help": { + "message": "Újra csatlakozik és vár, hogy reprodukálhassa a problémát." + }, + "settings.troubleshooting.packets.label": { + "message": "Hálózati csomagok rögzítése" + }, + "settings.troubleshooting.packets.help": { + "message": "A rögzítés ideje alatt elmenti a hálózati forgalom .pcap fájlját." + }, + "settings.troubleshooting.duration.label": { + "message": "Rögzítés időtartama" + }, + "settings.troubleshooting.duration.help": { + "message": "Mennyi ideig fusson a rögzítési munkamenet." + }, + "settings.troubleshooting.duration.suffix": { + "message": "perc" + }, + "settings.troubleshooting.create": { + "message": "Hibakeresési csomag létrehozása" + }, + "settings.troubleshooting.progress.description": { + "message": "Naplók, rendszerinformációk és kapcsolati állapot gyűjtése folyamatban. Általában néhány pillanatot vesz igénybe — tartsa nyitva ezt az ablakot a befejezésig." + }, + "settings.troubleshooting.cancelling": { + "message": "Megszakítás…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "A hibakeresési csomag feltöltése sikeres!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Csomag elmentve" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Ossza meg az alábbi feltöltési kulcsot a NetBird támogatással. A helyi másolat is elmentve van az eszközén." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "A hibakeresési csomag helyileg elmentve." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Kulcs másolása" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Mappa megnyitása" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Fájl helyének megnyitása" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Feltöltés sikertelen: {reason} A csomag továbbra is el van mentve helyileg." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Feltöltés sikertelen. A csomag továbbra is el van mentve helyileg." + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "NetBird újracsatlakoztatása…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Hibakeresési naplók rögzítése" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Hibakeresési csomag generálása…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Feltöltés a NetBirdhöz…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Megszakítás…" + }, + "settings.about.client": { + "message": "NetBird Kliens v{version}" + }, + "settings.about.clientName": { + "message": "NetBird Kliens" + }, + "settings.about.development": { + "message": "[Fejlesztés]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Minden jog fenntartva." + }, + "settings.about.links.imprint": { + "message": "Impresszum" + }, + "settings.about.links.privacy": { + "message": "Adatvédelem" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Felhasználási feltételek" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Fórum" + }, + "settings.about.community.documentation": { + "message": "Dokumentáció" + }, + "settings.about.community.feedback": { + "message": "Visszajelzés" + }, + "update.banner.message": { + "message": "A NetBird {version} telepítésre kész." + }, + "update.banner.later": { + "message": "Később" + }, + "update.banner.installNow": { + "message": "Telepítés most" + }, + "update.card.versionAvailableDownload": { + "message": "A {version} verzió letöltésre elérhető." + }, + "update.card.versionAvailableInstall": { + "message": "A {version} verzió telepítésre elérhető." + }, + "update.card.whatsNew": { + "message": "Mi az újdonság?" + }, + "update.card.installNow": { + "message": "Telepítés most" + }, + "update.card.getInstaller": { + "message": "Letöltés" + }, + "update.card.autoCheckInterval": { + "message": "A NetBird a háttérben keres frissítéseket." + }, + "update.card.changelog": { + "message": "Változásnapló" + }, + "update.card.onLatestVersion": { + "message": "A legfrissebb verziót használja" + }, + "update.header.tooltip": { + "message": "Frissítés elérhető" + }, + "update.overlay.updatingVersion": { + "message": "NetBird frissítése a következőre: v{version}" + }, + "update.overlay.updating": { + "message": "NetBird frissítése" + }, + "update.overlay.description": { + "message": "Egy újabb verzió elérhető és települ. A NetBird automatikusan újraindul a frissítés befejeztével." + }, + "update.overlay.error.timeoutTitle": { + "message": "A frissítés túl sokáig tart" + }, + "update.overlay.error.timeoutDescription": { + "message": "A(z) {target} telepítése túl sokáig tartott, és nem fejeződött be." + }, + "update.overlay.error.canceledTitle": { + "message": "A frissítés megszakítva" + }, + "update.overlay.error.canceledDescription": { + "message": "A(z) {target} frissítését megszakították a befejezés előtt." + }, + "update.overlay.error.failTitle": { + "message": "A frissítés nem telepíthető" + }, + "update.overlay.error.failDescription": { + "message": "A(z) {target} nem volt telepíthető." + }, + "update.overlay.error.unknownMessage": { + "message": "ismeretlen hiba" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "az új verzió" + }, + "update.error.loadStateTitle": { + "message": "Frissítési állapot betöltése sikertelen" + }, + "update.error.triggerTitle": { + "message": "Frissítés indítása sikertelen" + }, + "update.page.versionLine": { + "message": "Kliens frissítése erre: {version}." + }, + "update.page.versionLineGeneric": { + "message": "Kliens frissítése." + }, + "update.page.outdated": { + "message": "Az Ön kliensverziója régebbi, mint a Managementben beállított automatikus frissítési verzió." + }, + "update.page.status.running": { + "message": "Frissítés" + }, + "update.page.status.timeout": { + "message": "A frissítés időtúllépés miatt megszakadt. Kérjük, próbálja újra." + }, + "update.page.status.canceled": { + "message": "Frissítés megszakítva." + }, + "update.page.status.failed": { + "message": "Frissítés sikertelen: {message}" + }, + "update.page.status.unknownError": { + "message": "ismeretlen frissítési hiba" + }, + "update.page.failedTitle": { + "message": "Frissítés sikertelen" + }, + "update.page.timeoutMessage": { + "message": "Frissítés időtúllépés." + }, + "update.page.dontClose": { + "message": "Kérjük, ne zárja be ezt az ablakot." + }, + "update.page.updating": { + "message": "Frissítés…" + }, + "update.page.complete": { + "message": "Frissítés kész" + }, + "update.page.failed": { + "message": "Frissítés sikertelen" + }, + "window.title.settings": { + "message": "Beállítások" + }, + "window.title.signIn": { + "message": "Bejelentkezés" + }, + "window.title.sessionExpiration": { + "message": "Munkamenet lejár" + }, + "window.title.updating": { + "message": "Frissítés" + }, + "window.title.welcome": { + "message": "Üdvözli a NetBird" + }, + "window.title.error": { + "message": "Hiba" + }, + "welcome.title": { + "message": "Keresse a NetBirdöt a tálcán" + }, + "welcome.titleMac": { + "message": "Keresse a NetBirdöt a menüsorban" + }, + "welcome.description": { + "message": "A NetBird a tálcán fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához." + }, + "welcome.descriptionMac": { + "message": "A NetBird a menüsorban fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához." + }, + "welcome.continue": { + "message": "Folytatás" + }, + "welcome.back": { + "message": "Vissza" + }, + "welcome.management.title": { + "message": "NetBird beállítása" + }, + "welcome.management.description": { + "message": "Kattintson a Folytatás gombra a kezdéshez, vagy válassza a „Saját üzemeltetésű” lehetőséget, ha saját NetBird-szervere van." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Használja az általunk üzemeltetett szolgáltatást. Nincs szükség beállításra." + }, + "welcome.management.selfHosted.title": { + "message": "Saját üzemeltetésű" + }, + "welcome.management.selfHosted.description": { + "message": "Csatlakozás a saját felügyeleti szerveréhez." + }, + "welcome.management.urlLabel": { + "message": "Felügyeleti szerver URL" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Adjon meg egy érvényes URL-t, pl. https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "A szerver nem érhető el. Ellenőrizze az URL-t vagy a hálózatot, majd folytassa, ha biztos benne, hogy helyes." + }, + "welcome.management.checking": { + "message": "Ellenőrzés…" + }, + "browserLogin.title": { + "message": "Folytassa a böngészőben a bejelentkezés befejezéséhez" + }, + "browserLogin.notSeeing": { + "message": "Nem látja a böngésző fülét?" + }, + "browserLogin.tryAgain": { + "message": "Próbálja újra" + }, + "browserLogin.openFailedTitle": { + "message": "A böngésző megnyitása sikertelen" + }, + "sessionExpiration.title": { + "message": "A munkamenet hamarosan lejár" + }, + "sessionExpiration.titleLater": { + "message": "A munkamenete lejár" + }, + "sessionExpiration.description": { + "message": "Az eszköz hamarosan lecsatlakozik. Megújításhoz böngészős bejelentkezés kell." + }, + "sessionExpiration.descriptionLater": { + "message": "Egy böngészős bejelentkezés a hálózaton tartja az eszközt." + }, + "sessionExpiration.stay": { + "message": "Munkamenet megújítása" + }, + "sessionExpiration.authenticate": { + "message": "Bejelentkezés" + }, + "sessionExpiration.logout": { + "message": "Kijelentkezés" + }, + "sessionExpiration.expired": { + "message": "Munkamenet lejárt" + }, + "sessionExpiration.expiredDescription": { + "message": "Eszköz lecsatlakozott. Hitelesítés böngészős bejelentkezéssel az újracsatlakozáshoz." + }, + "sessionExpiration.close": { + "message": "Bezárás" + }, + "sessionExpiration.extendFailedTitle": { + "message": "A munkamenet meghosszabbítása sikertelen" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Kijelentkezés sikertelen" + }, + "peers.search.placeholder": { + "message": "Keresés név vagy IP alapján" + }, + "peers.filter.all": { + "message": "Összes" + }, + "peers.filter.online": { + "message": "Online" + }, + "peers.filter.offline": { + "message": "Offline" + }, + "peers.empty.title": { + "message": "Nincs elérhető Peer" + }, + "peers.empty.description": { + "message": "Önnek vagy nincsenek elérhető Peerei, vagy nincs hozzáférése egyikhez sem." + }, + "peers.details.domain": { + "message": "Domain" + }, + "peers.details.netbirdIp": { + "message": "NetBird IP" + }, + "peers.details.netbirdIpv6": { + "message": "NetBird IPv6" + }, + "peers.details.publicKey": { + "message": "Nyilvános kulcs" + }, + "peers.details.connection": { + "message": "Kapcsolat" + }, + "peers.details.latency": { + "message": "Késleltetés" + }, + "peers.details.lastHandshake": { + "message": "Utolsó Handshake" + }, + "peers.details.statusSince": { + "message": "Utolsó kapcsolati frissítés" + }, + "peers.details.bytes": { + "message": "Bájtok" + }, + "peers.details.bytesSent": { + "message": "Küldve" + }, + "peers.details.bytesReceived": { + "message": "Fogadva" + }, + "peers.details.localIce": { + "message": "Helyi ICE" + }, + "peers.details.remoteIce": { + "message": "Távoli ICE" + }, + "peers.details.never": { + "message": "Soha" + }, + "peers.details.justNow": { + "message": "Épp most" + }, + "peers.details.refresh": { + "message": "Frissítés" + }, + "peers.status.connected": { + "message": "Csatlakozva" + }, + "peers.status.connecting": { + "message": "Csatlakozás" + }, + "peers.status.disconnected": { + "message": "Lecsatlakozva" + }, + "peers.details.relayAddress": { + "message": "Relay" + }, + "peers.details.networks": { + "message": "Erőforrások" + }, + "peers.details.relayed": { + "message": "Relayed" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass engedélyezve" + }, + "networks.search.placeholder": { + "message": "Keresés hálózat vagy domain alapján" + }, + "networks.filter.all": { + "message": "Összes" + }, + "networks.filter.active": { + "message": "Aktív" + }, + "networks.filter.overlapping": { + "message": "Átfedő" + }, + "networks.empty.title": { + "message": "Nincs elérhető erőforrás" + }, + "networks.empty.description": { + "message": "Önnek vagy nincsenek elérhető hálózati erőforrásai vagy nincs hozzáférése egyikhez sem." + }, + "networks.selected": { + "message": "Kiválasztva" + }, + "networks.unselected": { + "message": "Nincs kiválasztva" + }, + "networks.ips.heading": { + "message": "Feloldott IP-címek" + }, + "networks.bulk.selectionCount": { + "message": "{selected} / {total} aktív" + }, + "networks.bulk.enableAll": { + "message": "Összes engedélyezése" + }, + "networks.bulk.disableAll": { + "message": "Összes letiltása" + }, + "exitNodes.search.placeholder": { + "message": "Keresés az Exit Node-ok között" + }, + "exitNodes.none": { + "message": "Egyik sem" + }, + "exitNodes.empty.title": { + "message": "Nincs elérhető Exit Node" + }, + "exitNodes.empty.description": { + "message": "Ehhez a Peerhez nem osztottak meg Exit Node-okat." + }, + "exitNodes.card.title": { + "message": "Exit Node" + }, + "exitNodes.card.statusActive": { + "message": "Aktív" + }, + "exitNodes.card.statusInactive": { + "message": "Inaktív" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Egyik sem" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Közvetlen kapcsolat Exit Node nélkül" + }, + "quickActions.connect": { + "message": "Csatlakozás" + }, + "quickActions.disconnect": { + "message": "Bontás" + }, + "daemon.unavailable.title": { + "message": "A NetBird szolgáltatás nem fut" + }, + "daemon.unavailable.description": { + "message": "Az alkalmazás automatikusan újracsatlakozik, amint a szolgáltatás újra elérhető." + }, + "daemon.unavailable.docsLink": { + "message": "Dokumentáció" + }, + "daemon.outdated.title": { + "message": "A NetBird Kliens elavult" + }, + "daemon.outdated.description": { + "message": "Az új GUI nem kompatibilis a régebbi klienseddel. Frissítsd a klienst az új alkalmazás használatához." + }, + "daemon.outdated.download": { + "message": "Legújabb letöltése" + }, + "error.jwt_clock_skew": { + "message": "A bejelentkezés sikertelen: az eszköz órája eltér a szerverétől. Kérjük, szinkronizálja a rendszer óráját, majd próbálja újra." + }, + "error.jwt_expired": { + "message": "A bejelentkezési token lejárt. Kérjük, jelentkezzen be újra." + }, + "error.jwt_signature_invalid": { + "message": "A bejelentkezés sikertelen: a token aláírása érvénytelen. Kérjük, lépjen kapcsolatba a rendszergazdával." + }, + "error.session_expired": { + "message": "A munkamenet lejárt. Kérjük, jelentkezzen be újra." + }, + "error.invalid_setup_key": { + "message": "A telepítőkulcs hiányzik vagy érvénytelen." + }, + "error.permission_denied": { + "message": "A szerver elutasította a bejelentkezést." + }, + "error.daemon_unreachable": { + "message": "A NetBird szolgáltatás nem válaszol. Kérjük, ellenőrizze, hogy fut-e a szolgáltatás." + }, + "error.unknown": { + "message": "A művelet meghiúsult." + }, + "error.elevation_unavailable": { + "message": "A NetBird nem tudta bekérni a rendszertől a szükséges jogosultságokat. Futtassa inkább ezt:" + }, + "error.elevation_failed": { + "message": "A módosítást emelt szintű jogosultságokkal sem sikerült alkalmazni. Futtassa inkább ezt:" + }, + "settings.ssh.privilege.actorRoot": { + "message": "root jogosultság" + }, + "settings.ssh.privilege.actorAdministrator": { + "message": "rendszergazdai jogosultság" + }, + "settings.ssh.privilege.hint": { + "message": "{actor} szükséges hozzá. Futtassa inkább ezt:" + }, + "settings.ssh.privilege.oneWay": { + "message": "Kikapcsolhatja, de a visszakapcsolásához {actor} szükséges." + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Bekapcsolhatja, de az ismételt kikapcsolásához {actor} szükséges." + }, + "settings.ssh.privilege.authorizePending": { + "message": "Várakozás az engedélyezésre…" + } +} diff --git a/client/ui/i18n/locales/it/common.json b/client/ui/i18n/locales/it/common.json new file mode 100644 index 000000000..b8166aa6e --- /dev/null +++ b/client/ui/i18n/locales/it/common.json @@ -0,0 +1,1378 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Disconnesso" + }, + "tray.status.daemonUnavailable": { + "message": "Non in esecuzione" + }, + "tray.status.error": { + "message": "Errore" + }, + "tray.status.connected": { + "message": "Connesso" + }, + "tray.status.connecting": { + "message": "Connessione" + }, + "tray.status.needsLogin": { + "message": "Accesso richiesto" + }, + "tray.status.loginFailed": { + "message": "Accesso non riuscito" + }, + "tray.status.sessionExpired": { + "message": "Sessione scaduta" + }, + "tray.session.expiresIn": { + "message": "La sessione scade tra {remaining}" + }, + "tray.session.unit.lessThanMinute": { + "message": "meno di un minuto" + }, + "tray.session.unit.minute": { + "message": "1 minuto" + }, + "tray.session.unit.minutes": { + "message": "{count} minuti" + }, + "tray.session.unit.hour": { + "message": "1 ora" + }, + "tray.session.unit.hours": { + "message": "{count} ore" + }, + "tray.session.unit.day": { + "message": "1 giorno" + }, + "tray.session.unit.days": { + "message": "{count} giorni" + }, + "tray.menu.open": { + "message": "Apri NetBird" + }, + "tray.menu.connect": { + "message": "Connetti" + }, + "tray.menu.disconnect": { + "message": "Disconnetti" + }, + "tray.menu.exitNode": { + "message": "Nodo di uscita" + }, + "tray.menu.networks": { + "message": "Risorse" + }, + "tray.menu.profiles": { + "message": "Profili" + }, + "tray.menu.manageProfiles": { + "message": "Gestisci profili" + }, + "tray.menu.settings": { + "message": "Impostazioni..." + }, + "tray.menu.debugBundle": { + "message": "Crea pacchetto di debug" + }, + "tray.menu.about": { + "message": "Guida e supporto" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Documentazione" + }, + "tray.menu.troubleshoot": { + "message": "Risoluzione dei problemi" + }, + "tray.menu.downloadLatest": { + "message": "Scarica l'ultima versione" + }, + "tray.menu.installVersion": { + "message": "Installa la versione {version}" + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}" + }, + "tray.menu.daemonVersion": { + "message": "Daemon: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "Esci da NetBird" + }, + "notify.daemonOutdated.title": { + "message": "Il servizio NetBird è obsoleto" + }, + "notify.daemonOutdated.body": { + "message": "Aggiorna il servizio NetBird per usare questa app." + }, + "notify.update.title": { + "message": "Aggiornamento NetBird disponibile" + }, + "notify.update.body": { + "message": "NetBird {version} è disponibile." + }, + "notify.update.enforcedSuffix": { + "message": " L'amministratore richiede questo aggiornamento." + }, + "notify.error.title": { + "message": "Errore" + }, + "notify.error.connect": { + "message": "Connessione non riuscita" + }, + "notify.error.disconnect": { + "message": "Disconnessione non riuscita" + }, + "notify.error.switchProfile": { + "message": "Impossibile passare a {profile}" + }, + "notify.error.exitNode": { + "message": "Impossibile aggiornare il nodo di uscita {name}" + }, + "notify.sessionExpired.title": { + "message": "Sessione NetBird scaduta" + }, + "notify.sessionExpired.body": { + "message": "La sessione NetBird è scaduta. Effettui di nuovo l'accesso." + }, + "notify.sessionWarning.title": { + "message": "La sessione sta per scadere" + }, + "notify.sessionWarning.body": { + "message": "La sessione NetBird scade tra {remaining}. Clicchi su Rinnova ora per rinnovarla." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "La sessione NetBird sta per scadere. Clicchi su Rinnova ora per rinnovarla." + }, + "notify.sessionWarning.extend": { + "message": "Rinnova ora" + }, + "notify.sessionWarning.dismiss": { + "message": "Ignora" + }, + "notify.sessionWarning.failed": { + "message": "Impossibile rinnovare la sessione NetBird" + }, + "notify.sessionWarning.successTitle": { + "message": "Sessione NetBird rinnovata" + }, + "notify.sessionWarning.successBody": { + "message": "La sessione è stata rinnovata." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Scadenza sessione rifiutata" + }, + "notify.sessionDeadlineRejected.body": { + "message": "Il server ha inviato una scadenza di sessione non valida. Effettui di nuovo l'accesso." + }, + "notify.mdm.policyApplied.title": { + "message": "Impostazioni NetBird aggiornate" + }, + "notify.mdm.policyApplied.body": { + "message": "La configurazione di NetBird è stata aggiornata dalla policy IT." + }, + "common.cancel": { + "message": "Annulla" + }, + "common.save": { + "message": "Salva" + }, + "common.saveChanges": { + "message": "Salva modifiche" + }, + "common.saving": { + "message": "Salvataggio…" + }, + "common.close": { + "message": "Chiudi" + }, + "common.copy": { + "message": "Copia" + }, + "common.togglePasswordVisibility": { + "message": "Mostra/nascondi password" + }, + "common.increase": { + "message": "Aumenta" + }, + "common.decrease": { + "message": "Diminuisci" + }, + "common.delete": { + "message": "Elimina" + }, + "common.create": { + "message": "Crea" + }, + "common.add": { + "message": "Aggiungi" + }, + "common.remove": { + "message": "Rimuovi" + }, + "common.refresh": { + "message": "Aggiorna" + }, + "common.loading": { + "message": "Caricamento…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "Nessun risultato trovato" + }, + "common.noResults.description": { + "message": "Non è stato trovato alcun risultato. Provi un altro termine di ricerca o modifichi i filtri." + }, + "notConnected.title": { + "message": "Disconnesso" + }, + "notConnected.description": { + "message": "Si connetta prima a NetBird per visualizzare informazioni dettagliate su peer, risorse di rete e nodi di uscita." + }, + "connect.status.disconnected": { + "message": "Disconnesso" + }, + "connect.status.connecting": { + "message": "Connessione..." + }, + "connect.status.connected": { + "message": "Connesso" + }, + "connect.status.disconnecting": { + "message": "Disconnessione..." + }, + "connect.status.daemonUnavailable": { + "message": "Daemon non disponibile" + }, + "connect.status.loginRequired": { + "message": "Accesso richiesto" + }, + "connect.error.loginTitle": { + "message": "Accesso non riuscito" + }, + "connect.error.connectTitle": { + "message": "Connessione non riuscita" + }, + "connect.error.disconnectTitle": { + "message": "Disconnessione non riuscita" + }, + "nav.peers.title": { + "message": "Peer" + }, + "nav.peers.description": { + "message": "{connected} di {total} connessi" + }, + "nav.resources.title": { + "message": "Risorse" + }, + "nav.resources.description": { + "message": "{active} di {total} attive" + }, + "nav.exitNode.title": { + "message": "Nodi di uscita" + }, + "nav.exitNode.none": { + "message": "Non attivo" + }, + "nav.exitNode.using": { + "message": "Tramite {name}" + }, + "header.openSettings": { + "message": "Apri impostazioni" + }, + "header.togglePanel": { + "message": "Mostra/nascondi pannello laterale" + }, + "profile.selector.loading": { + "message": "Caricamento..." + }, + "profile.selector.noProfile": { + "message": "Nessun profilo" + }, + "profile.selector.searchPlaceholder": { + "message": "Cerca profilo per nome..." + }, + "profile.selector.emptyTitle": { + "message": "Nessun profilo trovato" + }, + "profile.selector.emptyDescription": { + "message": "Provi un altro termine di ricerca o crei un nuovo profilo." + }, + "profile.selector.newProfile": { + "message": "Nuovo profilo" + }, + "profile.selector.moreOptions": { + "message": "Altre opzioni" + }, + "profile.selector.deregister": { + "message": "Annulla registrazione" + }, + "profile.selector.delete": { + "message": "Elimina" + }, + "profile.selector.switchTo": { + "message": "Passa a questo profilo" + }, + "profile.selector.edit": { + "message": "Modifica" + }, + "profile.edit.title": { + "message": "Modifica profilo" + }, + "profile.edit.submit": { + "message": "Salva modifiche" + }, + "profile.dialog.title": { + "message": "Inserisci il nome del profilo" + }, + "profile.dialog.nameLabel": { + "message": "Nome del profilo" + }, + "profile.dialog.description": { + "message": "Imposti un nome facilmente riconoscibile per il profilo." + }, + "profile.dialog.placeholder": { + "message": "es. lavoro" + }, + "profile.dialog.submit": { + "message": "Aggiungi profilo" + }, + "profile.dialog.required": { + "message": "Inserisca un nome per il profilo, es. lavoro, casa" + }, + "profile.dialog.managementHelp": { + "message": "Usi NetBird Cloud o il suo server." + }, + "profile.dialog.urlUnreachable": { + "message": "Impossibile raggiungere questo server. Controlli l'URL, oppure aggiunga comunque il profilo se è certo che sia corretto." + }, + "header.menu.settings": { + "message": "Impostazioni..." + }, + "header.menu.defaultView": { + "message": "Vista predefinita" + }, + "header.menu.advancedView": { + "message": "Vista avanzata" + }, + "header.menu.updateAvailable": { + "message": "Aggiornamento disponibile" + }, + "header.menu.open": { + "message": "Apri menu" + }, + "header.profile.switch": { + "message": "Cambia profilo" + }, + "connect.toggle.label": { + "message": "Attiva/disattiva connessione NetBird" + }, + "connect.localIp.label": { + "message": "Indirizzi IP locali" + }, + "common.search": { + "message": "Cerca" + }, + "common.filter": { + "message": "Filtra" + }, + "exitNodes.dropdown.trigger": { + "message": "Seleziona nodo di uscita" + }, + "peers.row.label": { + "message": "Apri dettagli di {name}, {status}" + }, + "peers.dialog.title": { + "message": "Dettagli peer" + }, + "networks.row.toggle": { + "message": "Attiva/disattiva {name}" + }, + "networks.bulk.label": { + "message": "Attiva/disattiva tutte le risorse visibili" + }, + "profile.switch.title": { + "message": "Passare al profilo «{name}»?" + }, + "profile.switch.message": { + "message": "Vuole davvero cambiare profilo?\nIl profilo attuale verrà disconnesso." + }, + "profile.switch.confirm": { + "message": "Conferma" + }, + "profile.deregister.title": { + "message": "Annullare la registrazione del profilo «{name}»?" + }, + "profile.deregister.message": { + "message": "Vuole davvero annullare la registrazione di questo profilo?\nDovrà accedere di nuovo per usarlo." + }, + "profile.deregister.confirm": { + "message": "Annulla registrazione" + }, + "profile.delete.title": { + "message": "Eliminare il profilo «{name}»?" + }, + "profile.delete.message": { + "message": "Vuole davvero eliminare questo profilo?\nQuesta azione non può essere annullata." + }, + "profile.delete.disabledActive": { + "message": "I profili attivi non possono essere eliminati. Passi a un altro profilo prima di eliminare questo." + }, + "profile.delete.disabledDefault": { + "message": "Il profilo predefinito non può essere eliminato." + }, + "profile.error.switchTitle": { + "message": "Cambio profilo non riuscito" + }, + "profile.error.deregisterTitle": { + "message": "Annullamento registrazione non riuscito" + }, + "profile.error.deleteTitle": { + "message": "Eliminazione profilo non riuscita" + }, + "profile.error.createTitle": { + "message": "Creazione profilo non riuscita" + }, + "profile.error.editTitle": { + "message": "Modifica del profilo non riuscita" + }, + "profile.error.loadTitle": { + "message": "Caricamento profili non riuscito" + }, + "profile.dropdown.activeProfile": { + "message": "Profilo attivo" + }, + "profile.dropdown.switchProfile": { + "message": "Cambia profilo" + }, + "profile.dropdown.noEmail": { + "message": "Altro" + }, + "profile.dropdown.addProfile": { + "message": "Aggiungi profilo" + }, + "profile.dropdown.manageProfiles": { + "message": "Gestisci profili" + }, + "profile.dropdown.settings": { + "message": "Impostazioni" + }, + "settings.profiles.section.profiles": { + "message": "Profili" + }, + "settings.profiles.intro": { + "message": "Mantenga affiancate identità NetBird separate, ad esempio account di lavoro e personali, oppure server di gestione diversi. Aggiunga, annulli la registrazione o elimini i profili qui sotto." + }, + "settings.profiles.addProfile": { + "message": "Aggiungi profilo" + }, + "settings.profiles.active": { + "message": "Attivo" + }, + "settings.profiles.emptyTitle": { + "message": "Nessun profilo" + }, + "settings.profiles.emptyDescription": { + "message": "Crei un profilo per connettersi a un server di gestione NetBird." + }, + "settings.error.loadTitle": { + "message": "Caricamento impostazioni non riuscito" + }, + "settings.error.saveTitle": { + "message": "Salvataggio impostazioni non riuscito" + }, + "settings.error.debugBundleTitle": { + "message": "Pacchetto di debug non riuscito" + }, + "settings.nav.label": { + "message": "Sezioni delle impostazioni" + }, + "settings.tabs.general": { + "message": "Generale" + }, + "settings.tabs.network": { + "message": "Rete" + }, + "settings.tabs.security": { + "message": "Sicurezza" + }, + "settings.tabs.profiles": { + "message": "Profili" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "Avanzate" + }, + "settings.tabs.troubleshooting": { + "message": "Risoluzione problemi" + }, + "settings.tabs.about": { + "message": "Informazioni" + }, + "settings.tabs.updateAvailable": { + "message": "Aggiornamento disponibile" + }, + "settings.general.section.general": { + "message": "Generale" + }, + "settings.general.section.connection": { + "message": "Connessione" + }, + "settings.general.connectOnStartup.label": { + "message": "Connetti all'avvio" + }, + "settings.general.connectOnStartup.help": { + "message": "Stabilisce automaticamente una connessione all'avvio del servizio." + }, + "settings.general.notifications.label": { + "message": "Notifiche desktop" + }, + "settings.general.notifications.help": { + "message": "Mostra notifiche desktop per nuovi aggiornamenti ed eventi di connessione." + }, + "settings.general.autostart.label": { + "message": "Avvia l'interfaccia NetBird all'accesso" + }, + "settings.general.autostart.help": { + "message": "Avvia automaticamente l'interfaccia NetBird quando effettua l'accesso. Riguarda solo l'interfaccia grafica, non il servizio in background." + }, + "settings.general.autostart.errorTitle": { + "message": "Modifica avvio automatico non riuscita" + }, + "settings.general.keepConnectedOnQuit.label": { + "message": "Resta connesso dopo la chiusura", + "description": "Toggle label: keep the VPN connection up after quitting the UI." + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "La connessione resta attiva in background dopo la chiusura di NetBird. Si interrompe solo quando la disconnetti tu.", + "description": "Helper text for the stay-connected-after-quitting toggle." + }, + "settings.general.language.label": { + "message": "Lingua dell'interfaccia" + }, + "settings.general.language.help": { + "message": "Scelga la lingua dell'interfaccia NetBird." + }, + "settings.general.language.search": { + "message": "Cerca lingua…" + }, + "settings.general.language.empty": { + "message": "Nessuna lingua corrisponde." + }, + "settings.general.management.label": { + "message": "Server di gestione" + }, + "settings.general.management.help": { + "message": "Si connetta a NetBird Cloud o al suo server di gestione self-hosted. Le modifiche riconnettono il client." + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "Self-hosted" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Inserisca un URL valido, es. https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "Impossibile raggiungere questo server. Controlli l'URL, oppure salvi comunque se è certo che sia corretto." + }, + "settings.general.management.switchCloudTitle": { + "message": "Passare a NetBird Cloud?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Questo disconnette il suo server self-hosted.\nPotrebbe dover accedere di nuovo." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Passa a Cloud" + }, + "settings.network.section.connectivity": { + "message": "Connettività" + }, + "settings.network.section.routingDns": { + "message": "Routing e DNS" + }, + "settings.network.monitor.label": { + "message": "Riconnetti al cambio di rete" + }, + "settings.network.monitor.help": { + "message": "Monitora la rete e si riconnette automaticamente ai cambiamenti, come il passaggio di Wi-Fi, le modifiche Ethernet o la ripresa dalla sospensione." + }, + "settings.network.dns.label": { + "message": "Abilita DNS" + }, + "settings.network.dns.help": { + "message": "Applica le impostazioni DNS gestite da NetBird al resolver dell'host." + }, + "settings.network.clientRoutes.label": { + "message": "Abilita route client" + }, + "settings.network.clientRoutes.help": { + "message": "Accetta le route da altri peer per raggiungere le loro reti." + }, + "settings.network.serverRoutes.label": { + "message": "Abilita route server" + }, + "settings.network.serverRoutes.help": { + "message": "Annuncia le route locali di questo host agli altri peer." + }, + "settings.network.ipv6.label": { + "message": "Abilita IPv6" + }, + "settings.network.ipv6.help": { + "message": "Usa l'indirizzamento IPv6 per la rete overlay NetBird." + }, + "settings.security.section.firewall": { + "message": "Firewall" + }, + "settings.security.section.encryption": { + "message": "Crittografia" + }, + "settings.security.blockInbound.label": { + "message": "Blocca traffico in entrata" + }, + "settings.security.blockInbound.help": { + "message": "Rifiuta le connessioni non richieste dai peer verso questo dispositivo e le reti che instrada. Il traffico in uscita non è interessato." + }, + "settings.security.blockLan.label": { + "message": "Blocca accesso alla LAN" + }, + "settings.security.blockLan.help": { + "message": "Impedisce ai peer di raggiungere la sua rete locale o i suoi dispositivi quando questo dispositivo instrada il loro traffico." + }, + "settings.security.rosenpass.label": { + "message": "Abilita resistenza quantistica" + }, + "settings.security.rosenpass.help": { + "message": "Aggiunge uno scambio di chiavi post-quantistico tramite Rosenpass sopra WireGuard®." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Abilita modalità permissiva" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Consente le connessioni con peer privi del supporto alla resistenza quantistica." + }, + "settings.ssh.section.server": { + "message": "Server" + }, + "settings.ssh.section.capabilities": { + "message": "Funzionalità" + }, + "settings.ssh.section.authentication": { + "message": "Autenticazione" + }, + "settings.ssh.server.label": { + "message": "Abilita server SSH" + }, + "settings.ssh.server.help": { + "message": "Esegue il server SSH di NetBird su questo host in modo che altri peer possano connettersi." + }, + "settings.ssh.root.label": { + "message": "Consenti accesso come root" + }, + "settings.ssh.root.help": { + "message": "Permette ai peer di accedere come utente root. Disabiliti per richiedere un account senza privilegi." + }, + "settings.ssh.sftp.label": { + "message": "Consenti SFTP" + }, + "settings.ssh.sftp.help": { + "message": "Trasferisca file in modo sicuro usando client SFTP o SCP nativi." + }, + "settings.ssh.localForward.label": { + "message": "Inoltro porte locale" + }, + "settings.ssh.localForward.help": { + "message": "Permette ai peer in connessione di inoltrare porte locali verso servizi raggiungibili da questo host." + }, + "settings.ssh.remoteForward.label": { + "message": "Inoltro porte remoto" + }, + "settings.ssh.remoteForward.help": { + "message": "Permette ai peer in connessione di esporre porte di questo host verso la loro macchina." + }, + "settings.ssh.jwt.label": { + "message": "Abilita autenticazione JWT" + }, + "settings.ssh.jwt.help": { + "message": "Verifica ogni sessione SSH con il suo IdP per l'identità utente e l'audit. Disabiliti per basarsi solo sulle policy ACL di rete, utile quando non è disponibile un IdP." + }, + "settings.ssh.jwtTtl.label": { + "message": "TTL cache JWT" + }, + "settings.ssh.jwtTtl.help": { + "message": "Per quanto tempo questo client memorizza un JWT prima di richiederlo di nuovo sulle connessioni SSH in uscita. Imposti 0 per disabilitare la cache e autenticarsi a ogni connessione." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "sec." + }, + "settings.advanced.section.interface": { + "message": "Interfaccia" + }, + "settings.advanced.section.security": { + "message": "Sicurezza" + }, + "settings.advanced.interfaceName.label": { + "message": "Nome" + }, + "settings.advanced.interfaceName.error": { + "message": "Usi da 1 a 15 lettere, cifre, punti, trattini o trattini bassi." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Deve iniziare con \"utun\" seguito da un numero (es. utun100)." + }, + "settings.advanced.port.label": { + "message": "Porta" + }, + "settings.advanced.port.error": { + "message": "Inserisca una porta compresa tra {min} e {max}." + }, + "settings.advanced.port.help": { + "message": "Se impostata su 0, verrà usata una porta libera casuale." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Inserisca un valore MTU compreso tra {min} e {max}." + }, + "settings.advanced.psk.label": { + "message": "Chiave pre-condivisa" + }, + "settings.advanced.psk.help": { + "message": "PSK WireGuard opzionale per una crittografia simmetrica aggiuntiva. Non è la stessa cosa di una chiave di configurazione NetBird. Comunicherà solo con i peer che usano la stessa chiave pre-condivisa." + }, + "settings.troubleshooting.section.title": { + "message": "Pacchetto di debug" + }, + "settings.troubleshooting.anonymize.label": { + "message": "Anonimizza informazioni sensibili" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Nasconde indirizzi IP, domini e altri valori sensibili." + }, + "settings.troubleshooting.anonymize.info": { + "message": "La modalità predefinita mantiene leggibili gli indirizzi IPv4 interni e i nomi dei peer per il supporto. La modalità rigorosa anonimizza inoltre gli indirizzi IP privati (RFC 1918), CGNAT e link-local, i nomi dei peer e le chiavi pubbliche WireGuard. I valori ricorrenti vengono associati allo stesso segnaposto, quindi i peer restano distinguibili. Usa la modalità rigorosa quando condividi il pacchetto di debug al di fuori della tua organizzazione." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Nessuna" + }, + "settings.troubleshooting.anonymize.default": { + "message": "Predefinito" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Rigoroso" + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Includi informazioni di sistema" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Include OS, kernel, interfacce di rete e tabelle di routing." + }, + "settings.troubleshooting.upload.label": { + "message": "Carica il pacchetto sui server NetBird" + }, + "settings.troubleshooting.upload.help": { + "message": "Restituisce una chiave di caricamento da condividere con il supporto NetBird." + }, + "settings.troubleshooting.trace.label": { + "message": "Abilita log di traccia" + }, + "settings.troubleshooting.trace.help": { + "message": "Aumenta il livello di log a TRACE e lo ripristina al termine." + }, + "settings.troubleshooting.capture.label": { + "message": "Sessione di acquisizione" + }, + "settings.troubleshooting.capture.help": { + "message": "Si riconnette e attende per consentirle di riprodurre il problema." + }, + "settings.troubleshooting.packets.label": { + "message": "Acquisisci pacchetti di rete" + }, + "settings.troubleshooting.packets.help": { + "message": "Salva un .pcap del traffico di rete durante la sessione di acquisizione." + }, + "settings.troubleshooting.duration.label": { + "message": "Durata acquisizione" + }, + "settings.troubleshooting.duration.help": { + "message": "Per quanto tempo viene eseguita la sessione di acquisizione." + }, + "settings.troubleshooting.duration.suffix": { + "message": "min." + }, + "settings.troubleshooting.create": { + "message": "Crea pacchetto" + }, + "settings.troubleshooting.progress.description": { + "message": "Raccolta di log, dettagli di sistema e stato della connessione. Di solito richiede un momento: tenga aperta questa finestra fino al completamento." + }, + "settings.troubleshooting.cancelling": { + "message": "Annullamento…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "Pacchetto di debug caricato con successo!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Pacchetto salvato" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Condivida la chiave di caricamento qui sotto con il supporto NetBird. Una copia locale è stata salvata anche sul suo dispositivo." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "Il pacchetto di debug è stato salvato localmente." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Copia chiave" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Apri cartella" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Apri posizione file" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Caricamento non riuscito: {reason} Il pacchetto è comunque salvato localmente." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Caricamento non riuscito. Il pacchetto è comunque salvato localmente." + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "Riconnessione di NetBird…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Acquisizione log di debug" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Generazione del pacchetto di debug…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Caricamento su NetBird…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Annullamento…" + }, + "settings.about.client": { + "message": "NetBird Client v{version}" + }, + "settings.about.clientName": { + "message": "NetBird Client" + }, + "settings.about.development": { + "message": "[Development]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Tutti i diritti riservati." + }, + "settings.about.links.imprint": { + "message": "Note legali" + }, + "settings.about.links.privacy": { + "message": "Privacy" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Termini di servizio" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Forum" + }, + "settings.about.community.documentation": { + "message": "Documentazione" + }, + "settings.about.community.feedback": { + "message": "Feedback" + }, + "update.banner.message": { + "message": "NetBird {version} è pronto per l'installazione." + }, + "update.banner.later": { + "message": "Più tardi" + }, + "update.banner.installNow": { + "message": "Installa ora" + }, + "update.card.versionAvailableDownload": { + "message": "La versione {version} è disponibile per il download." + }, + "update.card.versionAvailableInstall": { + "message": "La versione {version} è disponibile per l'installazione." + }, + "update.card.whatsNew": { + "message": "Novità?" + }, + "update.card.installNow": { + "message": "Installa ora" + }, + "update.card.getInstaller": { + "message": "Scarica" + }, + "update.card.autoCheckInterval": { + "message": "NetBird verifica gli aggiornamenti in background." + }, + "update.card.changelog": { + "message": "Changelog" + }, + "update.card.onLatestVersion": { + "message": "Sta usando l'ultima versione" + }, + "update.header.tooltip": { + "message": "Aggiornamento disponibile" + }, + "update.overlay.updatingVersion": { + "message": "Aggiornamento di NetBird alla v{version}" + }, + "update.overlay.updating": { + "message": "Aggiornamento di NetBird" + }, + "update.overlay.description": { + "message": "È disponibile una versione più recente ed è in corso l'installazione. NetBird si riavvierà automaticamente al termine dell'aggiornamento." + }, + "update.overlay.error.timeoutTitle": { + "message": "L'aggiornamento richiede troppo tempo" + }, + "update.overlay.error.timeoutDescription": { + "message": "L'installazione di {target} ha richiesto troppo tempo e non è stata completata." + }, + "update.overlay.error.canceledTitle": { + "message": "Aggiornamento interrotto" + }, + "update.overlay.error.canceledDescription": { + "message": "L'aggiornamento a {target} è stato annullato prima del completamento." + }, + "update.overlay.error.failTitle": { + "message": "Impossibile installare l'aggiornamento" + }, + "update.overlay.error.failDescription": { + "message": "Impossibile installare {target}." + }, + "update.overlay.error.unknownMessage": { + "message": "errore sconosciuto" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "la nuova versione" + }, + "update.error.loadStateTitle": { + "message": "Caricamento stato aggiornamento non riuscito" + }, + "update.error.triggerTitle": { + "message": "Avvio aggiornamento non riuscito" + }, + "update.page.versionLine": { + "message": "Aggiornamento del client a: {version}." + }, + "update.page.versionLineGeneric": { + "message": "Aggiornamento del client." + }, + "update.page.outdated": { + "message": "La versione del client è precedente alla versione di aggiornamento automatico impostata in Management." + }, + "update.page.status.running": { + "message": "Aggiornamento in corso" + }, + "update.page.status.timeout": { + "message": "Aggiornamento scaduto. Riprovi." + }, + "update.page.status.canceled": { + "message": "Aggiornamento annullato." + }, + "update.page.status.failed": { + "message": "Aggiornamento non riuscito: {message}" + }, + "update.page.status.unknownError": { + "message": "errore di aggiornamento sconosciuto" + }, + "update.page.failedTitle": { + "message": "Aggiornamento non riuscito" + }, + "update.page.timeoutMessage": { + "message": "Aggiornamento scaduto." + }, + "update.page.dontClose": { + "message": "Non chiuda questa finestra." + }, + "update.page.updating": { + "message": "Aggiornamento…" + }, + "update.page.complete": { + "message": "Aggiornamento completato" + }, + "update.page.failed": { + "message": "Aggiornamento non riuscito" + }, + "window.title.settings": { + "message": "Impostazioni" + }, + "window.title.signIn": { + "message": "Accesso" + }, + "window.title.sessionExpiration": { + "message": "Sessione in scadenza" + }, + "window.title.updating": { + "message": "Aggiornamento" + }, + "window.title.welcome": { + "message": "Benvenuto in NetBird" + }, + "window.title.error": { + "message": "Errore" + }, + "welcome.title": { + "message": "Cerchi NetBird nella tray" + }, + "welcome.titleMac": { + "message": "Cerchi NetBird nella barra dei menu" + }, + "welcome.description": { + "message": "NetBird risiede nella tray. Clicchi sull'icona per connettersi, cambiare profilo o aprire le impostazioni." + }, + "welcome.descriptionMac": { + "message": "NetBird risiede nella barra dei menu. Clicchi sull'icona per connettersi, cambiare profilo o aprire le impostazioni." + }, + "welcome.continue": { + "message": "Continua" + }, + "welcome.back": { + "message": "Indietro" + }, + "welcome.management.title": { + "message": "Configura NetBird" + }, + "welcome.management.description": { + "message": "Clicchi su Continua per iniziare, oppure scelga Self-hosted se dispone di un proprio server NetBird." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Usi il nostro servizio gestito. Nessuna configurazione necessaria." + }, + "welcome.management.selfHosted.title": { + "message": "Self-hosted" + }, + "welcome.management.selfHosted.description": { + "message": "Si connetta al suo server di gestione." + }, + "welcome.management.urlLabel": { + "message": "URL del server di gestione" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Inserisca un URL valido, es. https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "Impossibile raggiungere questo server. Controlli l'URL o la sua rete, poi prosegua se è certo che sia corretto." + }, + "welcome.management.checking": { + "message": "Verifica…" + }, + "browserLogin.title": { + "message": "Continui nel browser per completare l'accesso" + }, + "browserLogin.notSeeing": { + "message": "Non vede la scheda del browser?" + }, + "browserLogin.tryAgain": { + "message": "Riprova" + }, + "browserLogin.openFailedTitle": { + "message": "Apertura browser non riuscita" + }, + "sessionExpiration.title": { + "message": "La sessione sta per scadere" + }, + "sessionExpiration.titleLater": { + "message": "La sessione scadrà" + }, + "sessionExpiration.description": { + "message": "Questo dispositivo verrà disconnesso a breve. Rinnovi con un accesso dal browser." + }, + "sessionExpiration.descriptionLater": { + "message": "Un accesso dal browser mantiene questo dispositivo connesso alla sua rete." + }, + "sessionExpiration.stay": { + "message": "Rinnova sessione" + }, + "sessionExpiration.authenticate": { + "message": "Autenticati" + }, + "sessionExpiration.logout": { + "message": "Esci" + }, + "sessionExpiration.expired": { + "message": "Sessione scaduta" + }, + "sessionExpiration.expiredDescription": { + "message": "Dispositivo disconnesso. Si autentichi con un accesso dal browser per riconnettersi." + }, + "sessionExpiration.close": { + "message": "Chiudi" + }, + "sessionExpiration.extendFailedTitle": { + "message": "Rinnovo sessione non riuscito" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Disconnessione non riuscita" + }, + "peers.search.placeholder": { + "message": "Cerca per nome o IP" + }, + "peers.filter.all": { + "message": "Tutti" + }, + "peers.filter.online": { + "message": "Online" + }, + "peers.filter.offline": { + "message": "Offline" + }, + "peers.empty.title": { + "message": "Nessun peer disponibile" + }, + "peers.empty.description": { + "message": "Non ha peer disponibili oppure non ha accesso a nessuno di essi." + }, + "peers.details.domain": { + "message": "Dominio" + }, + "peers.details.netbirdIp": { + "message": "IP NetBird" + }, + "peers.details.netbirdIpv6": { + "message": "IPv6 NetBird" + }, + "peers.details.publicKey": { + "message": "Chiave pubblica" + }, + "peers.details.connection": { + "message": "Connessione" + }, + "peers.details.latency": { + "message": "Latenza" + }, + "peers.details.lastHandshake": { + "message": "Ultimo handshake" + }, + "peers.details.statusSince": { + "message": "Ultimo aggiornamento connessione" + }, + "peers.details.bytes": { + "message": "Byte" + }, + "peers.details.bytesSent": { + "message": "Inviati" + }, + "peers.details.bytesReceived": { + "message": "Ricevuti" + }, + "peers.details.localIce": { + "message": "ICE locale" + }, + "peers.details.remoteIce": { + "message": "ICE remoto" + }, + "peers.details.never": { + "message": "Mai" + }, + "peers.details.justNow": { + "message": "Proprio ora" + }, + "peers.details.refresh": { + "message": "Aggiorna" + }, + "peers.status.connected": { + "message": "Connesso" + }, + "peers.status.connecting": { + "message": "Connessione" + }, + "peers.status.disconnected": { + "message": "Disconnesso" + }, + "peers.details.relayAddress": { + "message": "Relay" + }, + "peers.details.networks": { + "message": "Risorse" + }, + "peers.details.relayed": { + "message": "Tramite relay" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass abilitato" + }, + "networks.search.placeholder": { + "message": "Cerca per rete o dominio" + }, + "networks.filter.all": { + "message": "Tutte" + }, + "networks.filter.active": { + "message": "Attive" + }, + "networks.filter.overlapping": { + "message": "Sovrapposte" + }, + "networks.empty.title": { + "message": "Nessuna risorsa disponibile" + }, + "networks.empty.description": { + "message": "Non ha risorse di rete disponibili oppure non ha accesso a nessuna di esse." + }, + "networks.selected": { + "message": "Selezionata" + }, + "networks.unselected": { + "message": "Non selezionata" + }, + "networks.ips.heading": { + "message": "IP risolti" + }, + "networks.bulk.selectionCount": { + "message": "{selected} di {total} attive" + }, + "networks.bulk.enableAll": { + "message": "Abilita tutte" + }, + "networks.bulk.disableAll": { + "message": "Disabilita tutte" + }, + "exitNodes.search.placeholder": { + "message": "Cerca nodi di uscita" + }, + "exitNodes.none": { + "message": "Nessuno" + }, + "exitNodes.empty.title": { + "message": "Nessun nodo di uscita disponibile" + }, + "exitNodes.empty.description": { + "message": "Nessun nodo di uscita è stato condiviso con questo peer." + }, + "exitNodes.card.title": { + "message": "Nodo di uscita" + }, + "exitNodes.card.statusActive": { + "message": "Attivo" + }, + "exitNodes.card.statusInactive": { + "message": "Inattivo" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Nessuno" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Connessione diretta senza nodo di uscita" + }, + "quickActions.connect": { + "message": "Connetti" + }, + "quickActions.disconnect": { + "message": "Disconnetti" + }, + "daemon.unavailable.title": { + "message": "Il servizio NetBird non è in esecuzione" + }, + "daemon.unavailable.description": { + "message": "L'app si riconnetterà automaticamente non appena il servizio sarà in esecuzione." + }, + "daemon.unavailable.docsLink": { + "message": "Documentazione" + }, + "daemon.outdated.title": { + "message": "NetBird Client è obsoleto" + }, + "daemon.outdated.description": { + "message": "La nuova GUI non è compatibile con il tuo client precedente. Aggiorna il client per usare la nuova applicazione." + }, + "daemon.outdated.download": { + "message": "Scarica l'ultima versione" + }, + "error.jwt_clock_skew": { + "message": "Accesso non riuscito: l'orologio di questo dispositivo non è sincronizzato con il server. Sincronizzi l'orologio di sistema e riprovi." + }, + "error.jwt_expired": { + "message": "Il token di accesso è scaduto. Effettui di nuovo l'accesso." + }, + "error.jwt_signature_invalid": { + "message": "Accesso non riuscito: la firma del token non è valida. Contatti il suo amministratore." + }, + "error.session_expired": { + "message": "La sessione è scaduta. Effettui di nuovo l'accesso." + }, + "error.invalid_setup_key": { + "message": "La chiave di configurazione è mancante o non valida." + }, + "error.permission_denied": { + "message": "L'accesso è stato rifiutato dal server." + }, + "error.daemon_unreachable": { + "message": "Il daemon NetBird non risponde. Verifichi che il servizio sia in esecuzione." + }, + "error.unknown": { + "message": "Operazione non riuscita." + }, + "error.elevation_unavailable": { + "message": "NetBird non ha potuto richiedere a questo sistema i privilegi necessari. Esegua invece questo:" + }, + "error.elevation_failed": { + "message": "Non è stato possibile applicare la modifica con privilegi elevati. Esegua invece questo:" + }, + "settings.ssh.privilege.actorRoot": { + "message": "i privilegi di root" + }, + "settings.ssh.privilege.actorAdministrator": { + "message": "i privilegi di amministratore" + }, + "settings.ssh.privilege.hint": { + "message": "Richiede {actor}. Esegua invece questo:" + }, + "settings.ssh.privilege.oneWay": { + "message": "Può disabilitarlo, ma riabilitarlo richiede {actor}." + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Può abilitarlo, ma disabilitarlo di nuovo richiede {actor}." + }, + "settings.ssh.privilege.authorizePending": { + "message": "In attesa dell'autorizzazione…" + } +} diff --git a/client/ui/i18n/locales/ja/common.json b/client/ui/i18n/locales/ja/common.json new file mode 100644 index 000000000..6ffe05e1c --- /dev/null +++ b/client/ui/i18n/locales/ja/common.json @@ -0,0 +1,1378 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "未接続" + }, + "tray.status.daemonUnavailable": { + "message": "実行されていません" + }, + "tray.status.error": { + "message": "エラー" + }, + "tray.status.connected": { + "message": "接続済み" + }, + "tray.status.connecting": { + "message": "接続中" + }, + "tray.status.needsLogin": { + "message": "ログインが必要" + }, + "tray.status.loginFailed": { + "message": "ログインに失敗しました" + }, + "tray.status.sessionExpired": { + "message": "セッションが期限切れ" + }, + "tray.session.expiresIn": { + "message": "セッションはあと{remaining}で期限切れ" + }, + "tray.session.unit.lessThanMinute": { + "message": "1分未満" + }, + "tray.session.unit.minute": { + "message": "1分" + }, + "tray.session.unit.minutes": { + "message": "{count}分" + }, + "tray.session.unit.hour": { + "message": "1時間" + }, + "tray.session.unit.hours": { + "message": "{count}時間" + }, + "tray.session.unit.day": { + "message": "1日" + }, + "tray.session.unit.days": { + "message": "{count}日" + }, + "tray.menu.open": { + "message": "NetBird を開く" + }, + "tray.menu.connect": { + "message": "接続" + }, + "tray.menu.disconnect": { + "message": "切断" + }, + "tray.menu.exitNode": { + "message": "出口ノード" + }, + "tray.menu.networks": { + "message": "リソース" + }, + "tray.menu.profiles": { + "message": "プロファイル" + }, + "tray.menu.manageProfiles": { + "message": "プロファイルの管理" + }, + "tray.menu.settings": { + "message": "設定..." + }, + "tray.menu.debugBundle": { + "message": "デバッグバンドルを作成" + }, + "tray.menu.about": { + "message": "ヘルプとサポート" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "ドキュメント" + }, + "tray.menu.troubleshoot": { + "message": "トラブルシューティング" + }, + "tray.menu.downloadLatest": { + "message": "最新バージョンをダウンロード" + }, + "tray.menu.installVersion": { + "message": "バージョン {version} をインストール" + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}" + }, + "tray.menu.daemonVersion": { + "message": "デーモン: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "NetBird を終了" + }, + "notify.daemonOutdated.title": { + "message": "NetBird サービスが古くなっています" + }, + "notify.daemonOutdated.body": { + "message": "このアプリを使用するには NetBird サービスを更新してください。" + }, + "notify.update.title": { + "message": "NetBird の更新が利用可能" + }, + "notify.update.body": { + "message": "NetBird {version} が利用可能です。" + }, + "notify.update.enforcedSuffix": { + "message": "管理者がこの更新を必須にしています。" + }, + "notify.error.title": { + "message": "エラー" + }, + "notify.error.connect": { + "message": "接続に失敗しました" + }, + "notify.error.disconnect": { + "message": "切断に失敗しました" + }, + "notify.error.switchProfile": { + "message": "{profile} への切り替えに失敗しました" + }, + "notify.error.exitNode": { + "message": "出口ノード {name} の更新に失敗しました" + }, + "notify.sessionExpired.title": { + "message": "NetBird セッションが期限切れ" + }, + "notify.sessionExpired.body": { + "message": "NetBird セッションの有効期限が切れました。もう一度ログインしてください。" + }, + "notify.sessionWarning.title": { + "message": "まもなくセッションが期限切れ" + }, + "notify.sessionWarning.body": { + "message": "NetBird セッションはあと{remaining}で期限切れになります。更新するには「今すぐ延長」をクリックしてください。" + }, + "notify.sessionWarning.bodyGeneric": { + "message": "NetBird セッションはまもなく期限切れになります。更新するには「今すぐ延長」をクリックしてください。" + }, + "notify.sessionWarning.extend": { + "message": "今すぐ延長" + }, + "notify.sessionWarning.dismiss": { + "message": "閉じる" + }, + "notify.sessionWarning.failed": { + "message": "NetBird セッションの延長に失敗しました" + }, + "notify.sessionWarning.successTitle": { + "message": "NetBird セッションを延長しました" + }, + "notify.sessionWarning.successBody": { + "message": "セッションが更新されました。" + }, + "notify.sessionDeadlineRejected.title": { + "message": "セッション期限が拒否されました" + }, + "notify.sessionDeadlineRejected.body": { + "message": "サーバーが無効なセッション期限を送信しました。もう一度サインインしてください。" + }, + "notify.mdm.policyApplied.title": { + "message": "NetBird 設定が更新されました" + }, + "notify.mdm.policyApplied.body": { + "message": "NetBird の構成が IT ポリシーによって更新されました。" + }, + "common.cancel": { + "message": "キャンセル" + }, + "common.save": { + "message": "保存" + }, + "common.saveChanges": { + "message": "変更を保存" + }, + "common.saving": { + "message": "保存中…" + }, + "common.close": { + "message": "閉じる" + }, + "common.copy": { + "message": "コピー" + }, + "common.togglePasswordVisibility": { + "message": "パスワードの表示を切り替え" + }, + "common.increase": { + "message": "増やす" + }, + "common.decrease": { + "message": "減らす" + }, + "common.delete": { + "message": "削除" + }, + "common.create": { + "message": "作成" + }, + "common.add": { + "message": "追加" + }, + "common.remove": { + "message": "削除" + }, + "common.refresh": { + "message": "更新" + }, + "common.loading": { + "message": "読み込み中…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "結果が見つかりませんでした" + }, + "common.noResults.description": { + "message": "結果が見つかりませんでした。別の検索語を試すか、フィルターを変更してください。" + }, + "notConnected.title": { + "message": "未接続" + }, + "notConnected.description": { + "message": "ピア、ネットワークリソース、出口ノードの詳細情報を表示するには、まず NetBird に接続してください。" + }, + "connect.status.disconnected": { + "message": "未接続" + }, + "connect.status.connecting": { + "message": "接続中..." + }, + "connect.status.connected": { + "message": "接続済み" + }, + "connect.status.disconnecting": { + "message": "切断中..." + }, + "connect.status.daemonUnavailable": { + "message": "デーモンが利用できません" + }, + "connect.status.loginRequired": { + "message": "ログインが必要" + }, + "connect.error.loginTitle": { + "message": "ログインに失敗しました" + }, + "connect.error.connectTitle": { + "message": "接続に失敗しました" + }, + "connect.error.disconnectTitle": { + "message": "切断に失敗しました" + }, + "nav.peers.title": { + "message": "ピア" + }, + "nav.peers.description": { + "message": "{total}台中{connected}台接続中" + }, + "nav.resources.title": { + "message": "リソース" + }, + "nav.resources.description": { + "message": "{total}件中{active}件有効" + }, + "nav.exitNode.title": { + "message": "出口ノード" + }, + "nav.exitNode.none": { + "message": "未使用" + }, + "nav.exitNode.using": { + "message": "{name} 経由" + }, + "header.openSettings": { + "message": "設定を開く" + }, + "header.togglePanel": { + "message": "サイドパネルを切り替え" + }, + "profile.selector.loading": { + "message": "読み込み中..." + }, + "profile.selector.noProfile": { + "message": "プロファイルなし" + }, + "profile.selector.searchPlaceholder": { + "message": "名前でプロファイルを検索..." + }, + "profile.selector.emptyTitle": { + "message": "プロファイルが見つかりません" + }, + "profile.selector.emptyDescription": { + "message": "別の検索語を試すか、新しいプロファイルを作成してください。" + }, + "profile.selector.newProfile": { + "message": "新しいプロファイル" + }, + "profile.selector.moreOptions": { + "message": "その他のオプション" + }, + "profile.selector.deregister": { + "message": "登録解除" + }, + "profile.selector.delete": { + "message": "削除" + }, + "profile.selector.switchTo": { + "message": "このプロファイルに切り替え" + }, + "profile.selector.edit": { + "message": "編集" + }, + "profile.edit.title": { + "message": "プロファイルを編集" + }, + "profile.edit.submit": { + "message": "変更を保存" + }, + "profile.dialog.title": { + "message": "プロファイル名を入力" + }, + "profile.dialog.nameLabel": { + "message": "プロファイル名" + }, + "profile.dialog.description": { + "message": "分かりやすいプロファイル名を設定してください。" + }, + "profile.dialog.placeholder": { + "message": "例: 仕事" + }, + "profile.dialog.submit": { + "message": "プロファイルを追加" + }, + "profile.dialog.required": { + "message": "プロファイル名を入力してください(例: 仕事、自宅)" + }, + "profile.dialog.managementHelp": { + "message": "NetBird Cloud または独自のサーバーを使用します。" + }, + "profile.dialog.urlUnreachable": { + "message": "このサーバーに到達できませんでした。URLを確認するか、正しいことが確実な場合はそのままプロファイルを追加してください。" + }, + "header.menu.settings": { + "message": "設定..." + }, + "header.menu.defaultView": { + "message": "デフォルト表示" + }, + "header.menu.advancedView": { + "message": "詳細表示" + }, + "header.menu.updateAvailable": { + "message": "更新が利用可能" + }, + "header.menu.open": { + "message": "メニューを開く" + }, + "header.profile.switch": { + "message": "プロファイルを切り替え" + }, + "connect.toggle.label": { + "message": "NetBird 接続を切り替え" + }, + "connect.localIp.label": { + "message": "ローカル IP アドレス" + }, + "common.search": { + "message": "検索" + }, + "common.filter": { + "message": "フィルター" + }, + "exitNodes.dropdown.trigger": { + "message": "出口ノードを選択" + }, + "peers.row.label": { + "message": "{name} の詳細を開く、{status}" + }, + "peers.dialog.title": { + "message": "ピアの詳細" + }, + "networks.row.toggle": { + "message": "{name} を切り替え" + }, + "networks.bulk.label": { + "message": "表示中のすべてのリソースを切り替え" + }, + "profile.switch.title": { + "message": "プロファイルを「{name}」に切り替えますか?" + }, + "profile.switch.message": { + "message": "プロファイルを切り替えてもよろしいですか?\n現在のプロファイルは切断されます。" + }, + "profile.switch.confirm": { + "message": "確認" + }, + "profile.deregister.title": { + "message": "プロファイル「{name}」の登録を解除しますか?" + }, + "profile.deregister.message": { + "message": "このプロファイルの登録を解除してもよろしいですか?\n再度使用するにはログインが必要になります。" + }, + "profile.deregister.confirm": { + "message": "登録解除" + }, + "profile.delete.title": { + "message": "プロファイル「{name}」を削除しますか?" + }, + "profile.delete.message": { + "message": "このプロファイルを削除してもよろしいですか?\nこの操作は取り消せません。" + }, + "profile.delete.disabledActive": { + "message": "使用中のプロファイルは削除できません。削除する前に別のプロファイルに切り替えてください。" + }, + "profile.delete.disabledDefault": { + "message": "デフォルトのプロファイルは削除できません。" + }, + "profile.error.switchTitle": { + "message": "プロファイルの切り替えに失敗しました" + }, + "profile.error.deregisterTitle": { + "message": "プロファイルの登録解除に失敗しました" + }, + "profile.error.deleteTitle": { + "message": "プロファイルの削除に失敗しました" + }, + "profile.error.createTitle": { + "message": "プロファイルの作成に失敗しました" + }, + "profile.error.editTitle": { + "message": "プロファイルの編集に失敗しました" + }, + "profile.error.loadTitle": { + "message": "プロファイルの読み込みに失敗しました" + }, + "profile.dropdown.activeProfile": { + "message": "使用中のプロファイル" + }, + "profile.dropdown.switchProfile": { + "message": "プロファイルを切り替え" + }, + "profile.dropdown.noEmail": { + "message": "その他" + }, + "profile.dropdown.addProfile": { + "message": "プロファイルを追加" + }, + "profile.dropdown.manageProfiles": { + "message": "プロファイルの管理" + }, + "profile.dropdown.settings": { + "message": "設定" + }, + "settings.profiles.section.profiles": { + "message": "プロファイル" + }, + "settings.profiles.intro": { + "message": "仕事用と個人用のアカウント、あるいは異なる管理サーバーなど、複数の NetBird ID を並行して管理できます。以下でプロファイルの追加、登録解除、削除ができます。" + }, + "settings.profiles.addProfile": { + "message": "プロファイルを追加" + }, + "settings.profiles.active": { + "message": "使用中" + }, + "settings.profiles.emptyTitle": { + "message": "プロファイルがありません" + }, + "settings.profiles.emptyDescription": { + "message": "NetBird 管理サーバーに接続するプロファイルを作成してください。" + }, + "settings.error.loadTitle": { + "message": "設定の読み込みに失敗しました" + }, + "settings.error.saveTitle": { + "message": "設定の保存に失敗しました" + }, + "settings.error.debugBundleTitle": { + "message": "デバッグバンドルの作成に失敗しました" + }, + "settings.nav.label": { + "message": "設定セクション" + }, + "settings.tabs.general": { + "message": "一般" + }, + "settings.tabs.network": { + "message": "ネットワーク" + }, + "settings.tabs.security": { + "message": "セキュリティ" + }, + "settings.tabs.profiles": { + "message": "プロファイル" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "詳細設定" + }, + "settings.tabs.troubleshooting": { + "message": "トラブルシューティング" + }, + "settings.tabs.about": { + "message": "情報" + }, + "settings.tabs.updateAvailable": { + "message": "更新が利用可能" + }, + "settings.general.section.general": { + "message": "一般" + }, + "settings.general.section.connection": { + "message": "接続" + }, + "settings.general.connectOnStartup.label": { + "message": "起動時に接続" + }, + "settings.general.connectOnStartup.help": { + "message": "サービスの起動時に自動的に接続を確立します。" + }, + "settings.general.notifications.label": { + "message": "デスクトップ通知" + }, + "settings.general.notifications.help": { + "message": "新しい更新や接続イベントに関するデスクトップ通知を表示します。" + }, + "settings.general.autostart.label": { + "message": "ログイン時に NetBird UI を起動" + }, + "settings.general.autostart.help": { + "message": "ログイン時に NetBird インターフェースを自動的に起動します。これはグラフィカルインターフェースにのみ影響し、バックグラウンドサービスには影響しません。" + }, + "settings.general.autostart.errorTitle": { + "message": "自動起動の変更に失敗しました" + }, + "settings.general.keepConnectedOnQuit.label": { + "message": "終了後も接続を維持", + "description": "Toggle label: keep the VPN connection up after quitting the UI." + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "NetBird を閉じたあとも接続はバックグラウンドで維持されます。自分で切断したときにだけ停止します。", + "description": "Helper text for the stay-connected-after-quitting toggle." + }, + "settings.general.language.label": { + "message": "表示言語" + }, + "settings.general.language.help": { + "message": "NetBird インターフェースの言語を選択します。" + }, + "settings.general.language.search": { + "message": "言語を検索…" + }, + "settings.general.language.empty": { + "message": "一致する言語がありません。" + }, + "settings.general.management.label": { + "message": "管理サーバー" + }, + "settings.general.management.help": { + "message": "NetBird Cloud または自身のセルフホスト管理サーバーに接続します。変更するとクライアントが再接続します。" + }, + "settings.general.management.cloud": { + "message": "クラウド" + }, + "settings.general.management.selfHosted": { + "message": "セルフホスト" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "有効なURLを入力してください(例: https://netbird.selfhosted.com:443)" + }, + "settings.general.management.urlUnreachable": { + "message": "このサーバーに到達できませんでした。URLを確認するか、正しいことが確実な場合はそのまま保存してください。" + }, + "settings.general.management.switchCloudTitle": { + "message": "NetBird Cloud に切り替えますか?" + }, + "settings.general.management.switchCloudMessage": { + "message": "セルフホストサーバーが切断されます。\n再度ログインが必要になる場合があります。" + }, + "settings.general.management.switchCloudConfirm": { + "message": "クラウドに切り替え" + }, + "settings.network.section.connectivity": { + "message": "ネットワーク接続" + }, + "settings.network.section.routingDns": { + "message": "ルーティングとDNS" + }, + "settings.network.monitor.label": { + "message": "ネットワーク変更時に再接続" + }, + "settings.network.monitor.help": { + "message": "ネットワークを監視し、Wi-Fiの切り替え、イーサネットの変更、スリープからの復帰などの変化時に自動的に再接続します。" + }, + "settings.network.dns.label": { + "message": "DNSを有効にする" + }, + "settings.network.dns.help": { + "message": "NetBird が管理する DNS 設定をホストのリゾルバに適用します。" + }, + "settings.network.clientRoutes.label": { + "message": "クライアントルートを有効にする" + }, + "settings.network.clientRoutes.help": { + "message": "他のピアからルートを受け入れ、そのネットワークに到達できるようにします。" + }, + "settings.network.serverRoutes.label": { + "message": "サーバールートを有効にする" + }, + "settings.network.serverRoutes.help": { + "message": "このホストのローカルルートを他のピアにアドバタイズします。" + }, + "settings.network.ipv6.label": { + "message": "IPv6を有効にする" + }, + "settings.network.ipv6.help": { + "message": "NetBird オーバーレイネットワークで IPv6 アドレッシングを使用します。" + }, + "settings.security.section.firewall": { + "message": "ファイアウォール" + }, + "settings.security.section.encryption": { + "message": "暗号化" + }, + "settings.security.blockInbound.label": { + "message": "受信トラフィックをブロック" + }, + "settings.security.blockInbound.help": { + "message": "このデバイスおよびこのデバイスがルーティングするネットワークへの、ピアからの要求されていない接続を拒否します。送信トラフィックには影響しません。" + }, + "settings.security.blockLan.label": { + "message": "LANアクセスをブロック" + }, + "settings.security.blockLan.help": { + "message": "このデバイスがピアのトラフィックをルーティングする際に、ピアがローカルネットワークやそのデバイスに到達できないようにします。" + }, + "settings.security.rosenpass.label": { + "message": "量子耐性を有効にする" + }, + "settings.security.rosenpass.help": { + "message": "WireGuard® に加えて Rosenpass によるポスト量子鍵交換を追加します。" + }, + "settings.security.rosenpassPermissive.label": { + "message": "寛容モードを有効にする" + }, + "settings.security.rosenpassPermissive.help": { + "message": "量子耐性に対応していないピアへの接続を許可します。" + }, + "settings.ssh.section.server": { + "message": "サーバー" + }, + "settings.ssh.section.capabilities": { + "message": "機能" + }, + "settings.ssh.section.authentication": { + "message": "認証" + }, + "settings.ssh.server.label": { + "message": "SSHサーバーを有効にする" + }, + "settings.ssh.server.help": { + "message": "このホストで NetBird SSH サーバーを実行し、他のピアが接続できるようにします。" + }, + "settings.ssh.root.label": { + "message": "rootログインを許可" + }, + "settings.ssh.root.help": { + "message": "ピアが root ユーザーとしてサインインできるようにします。無効にすると非特権アカウントが必要になります。" + }, + "settings.ssh.sftp.label": { + "message": "SFTPを許可" + }, + "settings.ssh.sftp.help": { + "message": "ネイティブの SFTP または SCP クライアントを使用してファイルを安全に転送します。" + }, + "settings.ssh.localForward.label": { + "message": "ローカルポート転送" + }, + "settings.ssh.localForward.help": { + "message": "接続するピアが、このホストから到達可能なサービスへローカルポートをトンネリングできるようにします。" + }, + "settings.ssh.remoteForward.label": { + "message": "リモートポート転送" + }, + "settings.ssh.remoteForward.help": { + "message": "接続するピアが、このホスト上のポートを自身のマシンに公開できるようにします。" + }, + "settings.ssh.jwt.label": { + "message": "JWT認証を有効にする" + }, + "settings.ssh.jwt.help": { + "message": "各 SSH セッションを IdP に対して検証し、ユーザー ID と監査を行います。無効にするとネットワークの ACL ポリシーのみに依存します。IdP が利用できない場合に便利です。" + }, + "settings.ssh.jwtTtl.label": { + "message": "JWTキャッシュTTL" + }, + "settings.ssh.jwtTtl.help": { + "message": "発信 SSH 接続で再度認証を求めるまでに、このクライアントが JWT をキャッシュする期間です。0 に設定するとキャッシュを無効にし、接続ごとに認証します。" + }, + "settings.ssh.jwtTtl.suffix": { + "message": "秒" + }, + "settings.advanced.section.interface": { + "message": "インターフェース" + }, + "settings.advanced.section.security": { + "message": "セキュリティ" + }, + "settings.advanced.interfaceName.label": { + "message": "名前" + }, + "settings.advanced.interfaceName.error": { + "message": "1〜15文字の英字、数字、ドット、ハイフン、アンダースコアを使用してください。" + }, + "settings.advanced.interfaceName.errorMac": { + "message": "「utun」に続けて数字で始まる必要があります(例: utun100)。" + }, + "settings.advanced.port.label": { + "message": "ポート" + }, + "settings.advanced.port.error": { + "message": "{min}〜{max}の範囲でポートを入力してください。" + }, + "settings.advanced.port.help": { + "message": "0 に設定すると、ランダムな空きポートが使用されます。" + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "{min}〜{max}の範囲で MTU 値を入力してください。" + }, + "settings.advanced.psk.label": { + "message": "事前共有鍵" + }, + "settings.advanced.psk.help": { + "message": "追加の対称暗号化のためのオプションの WireGuard PSK です。NetBird セットアップキーとは異なります。同じ事前共有鍵を使用するピアとのみ通信できます。" + }, + "settings.troubleshooting.section.title": { + "message": "デバッグバンドル" + }, + "settings.troubleshooting.anonymize.label": { + "message": "機密情報を匿名化" + }, + "settings.troubleshooting.anonymize.help": { + "message": "IP アドレス、ドメイン、その他の機密性の高い値を隠します。" + }, + "settings.troubleshooting.anonymize.info": { + "message": "「デフォルト」では、サポートのために内部 IPv4 アドレスとピア名は読める状態のまま残ります。「厳格」では、さらにプライベート (RFC 1918)、CGNAT、リンクローカルの IP アドレス、ピア名、WireGuard 公開鍵も匿名化されます。繰り返し現れる値は同じプレースホルダーに置き換えられるため、ピアは区別できます。デバッグバンドルを組織外に共有する場合は「厳格」を使用してください。" + }, + "settings.troubleshooting.anonymize.none": { + "message": "なし" + }, + "settings.troubleshooting.anonymize.default": { + "message": "デフォルト" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "厳格" + }, + "settings.troubleshooting.systemInfo.label": { + "message": "システム情報を含める" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "OS、カーネル、ネットワークインターフェース、ルーティングテーブルを含めます。" + }, + "settings.troubleshooting.upload.label": { + "message": "バンドルを NetBird サーバーにアップロード" + }, + "settings.troubleshooting.upload.help": { + "message": "NetBird サポートと共有するためのアップロードキーを返します。" + }, + "settings.troubleshooting.trace.label": { + "message": "トレースログを有効にする" + }, + "settings.troubleshooting.trace.help": { + "message": "ログレベルを TRACE に引き上げ、その後元に戻します。" + }, + "settings.troubleshooting.capture.label": { + "message": "キャプチャセッション" + }, + "settings.troubleshooting.capture.help": { + "message": "再接続して待機し、問題を再現できるようにします。" + }, + "settings.troubleshooting.packets.label": { + "message": "ネットワークパケットをキャプチャ" + }, + "settings.troubleshooting.packets.help": { + "message": "キャプチャ期間中のネットワークトラフィックを .pcap として保存します。" + }, + "settings.troubleshooting.duration.label": { + "message": "キャプチャ時間" + }, + "settings.troubleshooting.duration.help": { + "message": "キャプチャセッションを実行する時間です。" + }, + "settings.troubleshooting.duration.suffix": { + "message": "分" + }, + "settings.troubleshooting.create": { + "message": "バンドルを作成" + }, + "settings.troubleshooting.progress.description": { + "message": "ログ、システムの詳細、接続状態を収集しています。通常はしばらくで完了します。完了するまで NetBird を使い続けても、設定を閉じても構いません。" + }, + "settings.troubleshooting.cancelling": { + "message": "キャンセル中…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "デバッグバンドルのアップロードに成功しました!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "バンドルを保存しました" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "下記のアップロードキーを NetBird サポート と共有してください。ローカルコピーもお使いのデバイスに保存されました。" + }, + "settings.troubleshooting.done.savedDescription": { + "message": "デバッグバンドルはローカルに保存されました。" + }, + "settings.troubleshooting.done.copyKey": { + "message": "キーをコピー" + }, + "settings.troubleshooting.done.openFolder": { + "message": "フォルダを開く" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "ファイルの場所を開く" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "アップロードに失敗しました: {reason} バンドルはローカルに保存されています。" + }, + "settings.troubleshooting.uploadFailed": { + "message": "アップロードに失敗しました。バンドルはローカルに保存されています。" + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "NetBird を再接続しています…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "デバッグログをキャプチャしています" + }, + "settings.troubleshooting.stage.bundling": { + "message": "デバッグバンドルを生成しています…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "NetBird にアップロードしています…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "キャンセル中…" + }, + "settings.about.client": { + "message": "NetBird Client v{version}" + }, + "settings.about.clientName": { + "message": "NetBird Client" + }, + "settings.about.development": { + "message": "[開発版]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. All Rights Reserved." + }, + "settings.about.links.imprint": { + "message": "運営者情報" + }, + "settings.about.links.privacy": { + "message": "プライバシー" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "利用規約" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "フォーラム" + }, + "settings.about.community.documentation": { + "message": "ドキュメント" + }, + "settings.about.community.feedback": { + "message": "フィードバック" + }, + "update.banner.message": { + "message": "NetBird {version} をインストールする準備ができました。" + }, + "update.banner.later": { + "message": "後で" + }, + "update.banner.installNow": { + "message": "今すぐインストール" + }, + "update.card.versionAvailableDownload": { + "message": "バージョン {version} がダウンロード可能です。" + }, + "update.card.versionAvailableInstall": { + "message": "バージョン {version} がインストール可能です。" + }, + "update.card.whatsNew": { + "message": "新機能は?" + }, + "update.card.installNow": { + "message": "今すぐインストール" + }, + "update.card.getInstaller": { + "message": "ダウンロード" + }, + "update.card.autoCheckInterval": { + "message": "NetBird はバックグラウンドで更新を確認します。" + }, + "update.card.changelog": { + "message": "変更履歴" + }, + "update.card.onLatestVersion": { + "message": "最新バージョンを使用しています" + }, + "update.header.tooltip": { + "message": "更新が利用可能" + }, + "update.overlay.updatingVersion": { + "message": "NetBird を v{version} に更新しています" + }, + "update.overlay.updating": { + "message": "NetBird を更新しています" + }, + "update.overlay.description": { + "message": "新しいバージョンが利用可能で、インストール中です。更新が完了すると NetBird は自動的に再起動します。" + }, + "update.overlay.error.timeoutTitle": { + "message": "更新に時間がかかっています" + }, + "update.overlay.error.timeoutDescription": { + "message": "{target} のインストールに時間がかかりすぎ、完了しませんでした。" + }, + "update.overlay.error.canceledTitle": { + "message": "更新が停止されました" + }, + "update.overlay.error.canceledDescription": { + "message": "{target} への更新は完了前にキャンセルされました。" + }, + "update.overlay.error.failTitle": { + "message": "更新をインストールできませんでした" + }, + "update.overlay.error.failDescription": { + "message": "{target} をインストールできませんでした。" + }, + "update.overlay.error.unknownMessage": { + "message": "不明なエラー" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "新しいバージョン" + }, + "update.error.loadStateTitle": { + "message": "更新状態の読み込みに失敗しました" + }, + "update.error.triggerTitle": { + "message": "更新の開始に失敗しました" + }, + "update.page.versionLine": { + "message": "クライアントを次のバージョンに更新しています: {version}。" + }, + "update.page.versionLineGeneric": { + "message": "クライアントを更新しています。" + }, + "update.page.outdated": { + "message": "クライアントのバージョンが、管理サーバーで設定された自動更新バージョンより古くなっています。" + }, + "update.page.status.running": { + "message": "更新中" + }, + "update.page.status.timeout": { + "message": "更新がタイムアウトしました。もう一度お試しください。" + }, + "update.page.status.canceled": { + "message": "更新がキャンセルされました。" + }, + "update.page.status.failed": { + "message": "更新に失敗しました: {message}" + }, + "update.page.status.unknownError": { + "message": "不明な更新エラー" + }, + "update.page.failedTitle": { + "message": "更新に失敗しました" + }, + "update.page.timeoutMessage": { + "message": "更新がタイムアウトしました。" + }, + "update.page.dontClose": { + "message": "このウィンドウを閉じないでください。" + }, + "update.page.updating": { + "message": "更新中…" + }, + "update.page.complete": { + "message": "更新が完了しました" + }, + "update.page.failed": { + "message": "更新に失敗しました" + }, + "window.title.settings": { + "message": "設定" + }, + "window.title.signIn": { + "message": "サインイン" + }, + "window.title.sessionExpiration": { + "message": "セッションの期限切れ" + }, + "window.title.updating": { + "message": "更新中" + }, + "window.title.welcome": { + "message": "NetBird へようこそ" + }, + "window.title.error": { + "message": "エラー" + }, + "welcome.title": { + "message": "トレイの NetBird を確認してください" + }, + "welcome.titleMac": { + "message": "メニューバーの NetBird を確認してください" + }, + "welcome.description": { + "message": "NetBird はトレイに常駐します。アイコンをクリックして、接続、プロファイルの切り替え、設定を開くことができます。" + }, + "welcome.descriptionMac": { + "message": "NetBird はメニューバーに常駐します。アイコンをクリックして、接続、プロファイルの切り替え、設定を開くことができます。" + }, + "welcome.continue": { + "message": "続ける" + }, + "welcome.back": { + "message": "戻る" + }, + "welcome.management.title": { + "message": "NetBird をセットアップ" + }, + "welcome.management.description": { + "message": "「続ける」をクリックして開始するか、独自の NetBird サーバーをお持ちの場合は「セルフホスト」を選択してください。" + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "当社のホスト型サービスを使用します。セットアップは不要です。" + }, + "welcome.management.selfHosted.title": { + "message": "セルフホスト" + }, + "welcome.management.selfHosted.description": { + "message": "独自の管理サーバーに接続します。" + }, + "welcome.management.urlLabel": { + "message": "管理サーバーのURL" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "有効なURLを入力してください(例: https://netbird.selfhosted.com:443)" + }, + "welcome.management.urlUnreachable": { + "message": "このサーバーに到達できませんでした。URLまたはネットワークを確認し、正しいことが確実な場合は続行してください。" + }, + "welcome.management.checking": { + "message": "確認中…" + }, + "browserLogin.title": { + "message": "ブラウザでログインを完了してください" + }, + "browserLogin.notSeeing": { + "message": "サインインを完了できるようブラウザのタブを開きました。表示されませんか?" + }, + "browserLogin.tryAgain": { + "message": "再試行" + }, + "browserLogin.openFailedTitle": { + "message": "ブラウザの起動に失敗しました" + }, + "sessionExpiration.title": { + "message": "まもなくセッションが期限切れになります" + }, + "sessionExpiration.titleLater": { + "message": "セッションが期限切れになります" + }, + "sessionExpiration.description": { + "message": "このデバイスはまもなく切断されます。ブラウザでのサインインで更新してください。" + }, + "sessionExpiration.descriptionLater": { + "message": "ブラウザでサインインすると、このデバイスがネットワークに接続されたままになります。" + }, + "sessionExpiration.stay": { + "message": "セッションを更新" + }, + "sessionExpiration.authenticate": { + "message": "認証" + }, + "sessionExpiration.logout": { + "message": "ログアウト" + }, + "sessionExpiration.expired": { + "message": "セッションが期限切れになりました" + }, + "sessionExpiration.expiredDescription": { + "message": "デバイスが切断されました。再接続するにはブラウザでサインインして認証してください。" + }, + "sessionExpiration.close": { + "message": "閉じる" + }, + "sessionExpiration.extendFailedTitle": { + "message": "セッションの延長に失敗しました" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "ログアウトに失敗しました" + }, + "peers.search.placeholder": { + "message": "名前または IP で検索" + }, + "peers.filter.all": { + "message": "すべて" + }, + "peers.filter.online": { + "message": "オンライン" + }, + "peers.filter.offline": { + "message": "オフライン" + }, + "peers.empty.title": { + "message": "利用可能なピアがありません" + }, + "peers.empty.description": { + "message": "利用可能なピアがないか、いずれのピアにもアクセス権がありません。" + }, + "peers.details.domain": { + "message": "ドメイン" + }, + "peers.details.netbirdIp": { + "message": "NetBird IP" + }, + "peers.details.netbirdIpv6": { + "message": "NetBird IPv6" + }, + "peers.details.publicKey": { + "message": "公開鍵" + }, + "peers.details.connection": { + "message": "接続" + }, + "peers.details.latency": { + "message": "レイテンシ" + }, + "peers.details.lastHandshake": { + "message": "最終ハンドシェイク" + }, + "peers.details.statusSince": { + "message": "最終接続更新" + }, + "peers.details.bytes": { + "message": "バイト" + }, + "peers.details.bytesSent": { + "message": "送信" + }, + "peers.details.bytesReceived": { + "message": "受信" + }, + "peers.details.localIce": { + "message": "ローカル ICE" + }, + "peers.details.remoteIce": { + "message": "リモート ICE" + }, + "peers.details.never": { + "message": "なし" + }, + "peers.details.justNow": { + "message": "たった今" + }, + "peers.details.refresh": { + "message": "更新" + }, + "peers.status.connected": { + "message": "接続済み" + }, + "peers.status.connecting": { + "message": "接続中" + }, + "peers.status.disconnected": { + "message": "未接続" + }, + "peers.details.relayAddress": { + "message": "リレー" + }, + "peers.details.networks": { + "message": "リソース" + }, + "peers.details.relayed": { + "message": "リレー経由" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass 有効" + }, + "networks.search.placeholder": { + "message": "ネットワークまたはドメインで検索" + }, + "networks.filter.all": { + "message": "すべて" + }, + "networks.filter.active": { + "message": "有効" + }, + "networks.filter.overlapping": { + "message": "重複" + }, + "networks.empty.title": { + "message": "利用可能なリソースがありません" + }, + "networks.empty.description": { + "message": "利用可能なネットワークリソースがないか、いずれのリソースにもアクセス権がありません。" + }, + "networks.selected": { + "message": "選択中" + }, + "networks.unselected": { + "message": "未選択" + }, + "networks.ips.heading": { + "message": "解決された IP" + }, + "networks.bulk.selectionCount": { + "message": "{total}件中{selected}件有効" + }, + "networks.bulk.enableAll": { + "message": "すべて有効化" + }, + "networks.bulk.disableAll": { + "message": "すべて無効化" + }, + "exitNodes.search.placeholder": { + "message": "出口ノードを検索" + }, + "exitNodes.none": { + "message": "なし" + }, + "exitNodes.empty.title": { + "message": "利用可能な出口ノードがありません" + }, + "exitNodes.empty.description": { + "message": "このピアと共有されている出口ノードはありません。" + }, + "exitNodes.card.title": { + "message": "出口ノード" + }, + "exitNodes.card.statusActive": { + "message": "有効" + }, + "exitNodes.card.statusInactive": { + "message": "無効" + }, + "exitNodes.dropdown.noneTitle": { + "message": "なし" + }, + "exitNodes.dropdown.noneDescription": { + "message": "出口ノードを使用しない直接接続" + }, + "quickActions.connect": { + "message": "接続" + }, + "quickActions.disconnect": { + "message": "切断" + }, + "daemon.unavailable.title": { + "message": "NetBird サービスが実行されていません" + }, + "daemon.unavailable.description": { + "message": "サービスが実行されると、アプリは自動的に再接続します。" + }, + "daemon.unavailable.docsLink": { + "message": "ドキュメント" + }, + "daemon.outdated.title": { + "message": "NetBird サービスが古くなっています" + }, + "daemon.outdated.description": { + "message": "このアプリを使用するには NetBird サービスを更新してください。" + }, + "daemon.outdated.download": { + "message": "最新版をダウンロード" + }, + "error.jwt_clock_skew": { + "message": "サインインに失敗しました: このデバイスの時計がサーバーと同期していません。システムの時計を同期してからもう一度お試しください。" + }, + "error.jwt_expired": { + "message": "サインイントークンの有効期限が切れました。もう一度サインインしてください。" + }, + "error.jwt_signature_invalid": { + "message": "サインインに失敗しました: トークンの署名が無効です。管理者にお問い合わせください。" + }, + "error.session_expired": { + "message": "セッションの有効期限が切れました。もう一度サインインしてください。" + }, + "error.invalid_setup_key": { + "message": "セットアップキーがないか、無効です。" + }, + "error.permission_denied": { + "message": "サインインがサーバーによって拒否されました。" + }, + "error.daemon_unreachable": { + "message": "NetBird デーモンが応答していません。サービスが実行されているか確認してください。" + }, + "error.unknown": { + "message": "操作に失敗しました。" + }, + "error.elevation_unavailable": { + "message": "NetBird はこのシステムに必要な権限を要求できませんでした。代わりに次のコマンドを実行してください:" + }, + "error.elevation_failed": { + "message": "昇格した権限でも変更を適用できませんでした。代わりに次のコマンドを実行してください:" + }, + "settings.ssh.privilege.actorRoot": { + "message": "root 権限" + }, + "settings.ssh.privilege.actorAdministrator": { + "message": "管理者権限" + }, + "settings.ssh.privilege.hint": { + "message": "{actor}が必要です。代わりに次のコマンドを実行してください:" + }, + "settings.ssh.privilege.oneWay": { + "message": "無効にはできますが、再度有効にするには{actor}が必要です。" + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "有効にはできますが、再度無効にするには{actor}が必要です。" + }, + "settings.ssh.privilege.authorizePending": { + "message": "承認を待っています…" + } +} diff --git a/client/ui/i18n/locales/pt/common.json b/client/ui/i18n/locales/pt/common.json new file mode 100644 index 000000000..123e7a042 --- /dev/null +++ b/client/ui/i18n/locales/pt/common.json @@ -0,0 +1,1378 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Desconectado" + }, + "tray.status.daemonUnavailable": { + "message": "Não está em execução" + }, + "tray.status.error": { + "message": "Erro" + }, + "tray.status.connected": { + "message": "Conectado" + }, + "tray.status.connecting": { + "message": "Conectando" + }, + "tray.status.needsLogin": { + "message": "Login necessário" + }, + "tray.status.loginFailed": { + "message": "Falha no login" + }, + "tray.status.sessionExpired": { + "message": "Sessão expirada" + }, + "tray.session.expiresIn": { + "message": "A sessão expira em {remaining}" + }, + "tray.session.unit.lessThanMinute": { + "message": "menos de um minuto" + }, + "tray.session.unit.minute": { + "message": "1 minuto" + }, + "tray.session.unit.minutes": { + "message": "{count} minutos" + }, + "tray.session.unit.hour": { + "message": "1 hora" + }, + "tray.session.unit.hours": { + "message": "{count} horas" + }, + "tray.session.unit.day": { + "message": "1 dia" + }, + "tray.session.unit.days": { + "message": "{count} dias" + }, + "tray.menu.open": { + "message": "Abrir o NetBird" + }, + "tray.menu.connect": { + "message": "Conectar" + }, + "tray.menu.disconnect": { + "message": "Desconectar" + }, + "tray.menu.exitNode": { + "message": "Nó de saída" + }, + "tray.menu.networks": { + "message": "Recursos" + }, + "tray.menu.profiles": { + "message": "Perfis" + }, + "tray.menu.manageProfiles": { + "message": "Gerenciar perfis" + }, + "tray.menu.settings": { + "message": "Configurações..." + }, + "tray.menu.debugBundle": { + "message": "Criar pacote de depuração" + }, + "tray.menu.about": { + "message": "Ajuda e suporte" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Documentação" + }, + "tray.menu.troubleshoot": { + "message": "Solução de problemas" + }, + "tray.menu.downloadLatest": { + "message": "Baixar a versão mais recente" + }, + "tray.menu.installVersion": { + "message": "Instalar a versão {version}" + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}" + }, + "tray.menu.daemonVersion": { + "message": "Daemon: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "Sair do NetBird" + }, + "notify.daemonOutdated.title": { + "message": "O serviço NetBird está desatualizado" + }, + "notify.daemonOutdated.body": { + "message": "Atualize o serviço NetBird para usar este aplicativo." + }, + "notify.update.title": { + "message": "Atualização do NetBird disponível" + }, + "notify.update.body": { + "message": "O NetBird {version} está disponível." + }, + "notify.update.enforcedSuffix": { + "message": " O seu administrador exige esta atualização." + }, + "notify.error.title": { + "message": "Erro" + }, + "notify.error.connect": { + "message": "Falha ao conectar" + }, + "notify.error.disconnect": { + "message": "Falha ao desconectar" + }, + "notify.error.switchProfile": { + "message": "Falha ao alternar para {profile}" + }, + "notify.error.exitNode": { + "message": "Falha ao atualizar o nó de saída {name}" + }, + "notify.sessionExpired.title": { + "message": "Sessão do NetBird expirada" + }, + "notify.sessionExpired.body": { + "message": "A sua sessão do NetBird expirou. Faça login novamente." + }, + "notify.sessionWarning.title": { + "message": "A sessão expira em breve" + }, + "notify.sessionWarning.body": { + "message": "A sua sessão do NetBird expira em {remaining}. Clique em Renovar agora para renová-la." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "A sua sessão do NetBird está prestes a expirar. Clique em Renovar agora para renová-la." + }, + "notify.sessionWarning.extend": { + "message": "Renovar agora" + }, + "notify.sessionWarning.dismiss": { + "message": "Dispensar" + }, + "notify.sessionWarning.failed": { + "message": "Falha ao renovar a sessão do NetBird" + }, + "notify.sessionWarning.successTitle": { + "message": "Sessão do NetBird renovada" + }, + "notify.sessionWarning.successBody": { + "message": "A sua sessão foi renovada." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Prazo da sessão rejeitado" + }, + "notify.sessionDeadlineRejected.body": { + "message": "O servidor enviou um prazo de sessão inválido. Faça login novamente." + }, + "notify.mdm.policyApplied.title": { + "message": "Definições do NetBird atualizadas" + }, + "notify.mdm.policyApplied.body": { + "message": "A sua configuração do NetBird foi atualizada pela política de TI." + }, + "common.cancel": { + "message": "Cancelar" + }, + "common.save": { + "message": "Salvar" + }, + "common.saveChanges": { + "message": "Salvar alterações" + }, + "common.saving": { + "message": "Salvando…" + }, + "common.close": { + "message": "Fechar" + }, + "common.copy": { + "message": "Copiar" + }, + "common.togglePasswordVisibility": { + "message": "Alternar visibilidade da senha" + }, + "common.increase": { + "message": "Aumentar" + }, + "common.decrease": { + "message": "Diminuir" + }, + "common.delete": { + "message": "Excluir" + }, + "common.create": { + "message": "Criar" + }, + "common.add": { + "message": "Adicionar" + }, + "common.remove": { + "message": "Remover" + }, + "common.refresh": { + "message": "Atualizar" + }, + "common.loading": { + "message": "Carregando…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "Nenhum resultado encontrado" + }, + "common.noResults.description": { + "message": "Não encontramos nenhum resultado. Tente um termo de busca diferente ou altere os filtros." + }, + "notConnected.title": { + "message": "Desconectado" + }, + "notConnected.description": { + "message": "Conecte-se ao NetBird primeiro para ver informações detalhadas sobre seus peers, recursos de rede e nós de saída." + }, + "connect.status.disconnected": { + "message": "Desconectado" + }, + "connect.status.connecting": { + "message": "Conectando..." + }, + "connect.status.connected": { + "message": "Conectado" + }, + "connect.status.disconnecting": { + "message": "Desconectando..." + }, + "connect.status.daemonUnavailable": { + "message": "Daemon indisponível" + }, + "connect.status.loginRequired": { + "message": "Login necessário" + }, + "connect.error.loginTitle": { + "message": "Falha no login" + }, + "connect.error.connectTitle": { + "message": "Falha ao conectar" + }, + "connect.error.disconnectTitle": { + "message": "Falha ao desconectar" + }, + "nav.peers.title": { + "message": "Peers" + }, + "nav.peers.description": { + "message": "{connected} de {total} conectados" + }, + "nav.resources.title": { + "message": "Recursos" + }, + "nav.resources.description": { + "message": "{active} de {total} ativos" + }, + "nav.exitNode.title": { + "message": "Nós de saída" + }, + "nav.exitNode.none": { + "message": "Inativo" + }, + "nav.exitNode.using": { + "message": "Via {name}" + }, + "header.openSettings": { + "message": "Abrir configurações" + }, + "header.togglePanel": { + "message": "Alternar painel lateral" + }, + "profile.selector.loading": { + "message": "Carregando..." + }, + "profile.selector.noProfile": { + "message": "Nenhum perfil" + }, + "profile.selector.searchPlaceholder": { + "message": "Buscar perfil por nome..." + }, + "profile.selector.emptyTitle": { + "message": "Nenhum perfil encontrado" + }, + "profile.selector.emptyDescription": { + "message": "Tente um termo de busca diferente ou crie um novo perfil." + }, + "profile.selector.newProfile": { + "message": "Novo perfil" + }, + "profile.selector.moreOptions": { + "message": "Mais opções" + }, + "profile.selector.deregister": { + "message": "Cancelar registro" + }, + "profile.selector.delete": { + "message": "Excluir" + }, + "profile.selector.switchTo": { + "message": "Alternar para este perfil" + }, + "profile.selector.edit": { + "message": "Editar" + }, + "profile.edit.title": { + "message": "Editar perfil" + }, + "profile.edit.submit": { + "message": "Salvar alterações" + }, + "profile.dialog.title": { + "message": "Insira o nome do perfil" + }, + "profile.dialog.nameLabel": { + "message": "Nome do perfil" + }, + "profile.dialog.description": { + "message": "Defina um nome fácil de identificar para o seu perfil." + }, + "profile.dialog.placeholder": { + "message": "ex.: trabalho" + }, + "profile.dialog.submit": { + "message": "Adicionar perfil" + }, + "profile.dialog.required": { + "message": "Insira um nome de perfil, ex.: trabalho, casa" + }, + "profile.dialog.managementHelp": { + "message": "Use o NetBird Cloud ou seu próprio servidor." + }, + "profile.dialog.urlUnreachable": { + "message": "Não foi possível acessar este servidor. Verifique a URL ou adicione o perfil mesmo assim se tiver certeza de que está correta." + }, + "header.menu.settings": { + "message": "Configurações..." + }, + "header.menu.defaultView": { + "message": "Visualização padrão" + }, + "header.menu.advancedView": { + "message": "Visualização avançada" + }, + "header.menu.updateAvailable": { + "message": "Atualização disponível" + }, + "header.menu.open": { + "message": "Abrir menu" + }, + "header.profile.switch": { + "message": "Trocar perfil" + }, + "connect.toggle.label": { + "message": "Alternar conexão NetBird" + }, + "connect.localIp.label": { + "message": "Endereços IP locais" + }, + "common.search": { + "message": "Pesquisar" + }, + "common.filter": { + "message": "Filtrar" + }, + "exitNodes.dropdown.trigger": { + "message": "Selecionar nó de saída" + }, + "peers.row.label": { + "message": "Abrir detalhes de {name}, {status}" + }, + "peers.dialog.title": { + "message": "Detalhes do par" + }, + "networks.row.toggle": { + "message": "Alternar {name}" + }, + "networks.bulk.label": { + "message": "Alternar todos os recursos visíveis" + }, + "profile.switch.title": { + "message": "Alternar perfil para \"{name}\"?" + }, + "profile.switch.message": { + "message": "Tem certeza de que deseja alternar de perfil?\nO seu perfil atual será desconectado." + }, + "profile.switch.confirm": { + "message": "Confirmar" + }, + "profile.deregister.title": { + "message": "Cancelar registro do perfil \"{name}\"?" + }, + "profile.deregister.message": { + "message": "Tem certeza de que deseja cancelar o registro deste perfil?\nVocê precisará fazer login novamente para usá-lo." + }, + "profile.deregister.confirm": { + "message": "Cancelar registro" + }, + "profile.delete.title": { + "message": "Excluir o perfil \"{name}\"?" + }, + "profile.delete.message": { + "message": "Tem certeza de que deseja excluir este perfil?\nEsta ação não pode ser desfeita." + }, + "profile.delete.disabledActive": { + "message": "Perfis ativos não podem ser excluídos. Alterne para outro antes de excluir este perfil." + }, + "profile.delete.disabledDefault": { + "message": "O perfil padrão não pode ser excluído." + }, + "profile.error.switchTitle": { + "message": "Falha ao alternar de perfil" + }, + "profile.error.deregisterTitle": { + "message": "Falha ao cancelar registro do perfil" + }, + "profile.error.deleteTitle": { + "message": "Falha ao excluir o perfil" + }, + "profile.error.createTitle": { + "message": "Falha ao criar o perfil" + }, + "profile.error.editTitle": { + "message": "Falha ao editar o perfil" + }, + "profile.error.loadTitle": { + "message": "Falha ao carregar os perfis" + }, + "profile.dropdown.activeProfile": { + "message": "Perfil ativo" + }, + "profile.dropdown.switchProfile": { + "message": "Alternar perfil" + }, + "profile.dropdown.noEmail": { + "message": "Outro" + }, + "profile.dropdown.addProfile": { + "message": "Adicionar perfil" + }, + "profile.dropdown.manageProfiles": { + "message": "Gerenciar perfis" + }, + "profile.dropdown.settings": { + "message": "Configurações" + }, + "settings.profiles.section.profiles": { + "message": "Perfis" + }, + "settings.profiles.intro": { + "message": "Mantenha identidades separadas do NetBird lado a lado, por exemplo contas de trabalho e pessoais, ou diferentes servidores de gerenciamento. Adicione, cancele o registro ou exclua perfis abaixo." + }, + "settings.profiles.addProfile": { + "message": "Adicionar perfil" + }, + "settings.profiles.active": { + "message": "Ativo" + }, + "settings.profiles.emptyTitle": { + "message": "Nenhum perfil" + }, + "settings.profiles.emptyDescription": { + "message": "Crie um perfil para conectar a um servidor de gerenciamento do NetBird." + }, + "settings.error.loadTitle": { + "message": "Falha ao carregar as configurações" + }, + "settings.error.saveTitle": { + "message": "Falha ao salvar as configurações" + }, + "settings.error.debugBundleTitle": { + "message": "Falha no pacote de depuração" + }, + "settings.nav.label": { + "message": "Seções das configurações" + }, + "settings.tabs.general": { + "message": "Geral" + }, + "settings.tabs.network": { + "message": "Rede" + }, + "settings.tabs.security": { + "message": "Segurança" + }, + "settings.tabs.profiles": { + "message": "Perfis" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "Avançado" + }, + "settings.tabs.troubleshooting": { + "message": "Solução de problemas" + }, + "settings.tabs.about": { + "message": "Sobre" + }, + "settings.tabs.updateAvailable": { + "message": "Atualização disponível" + }, + "settings.general.section.general": { + "message": "Geral" + }, + "settings.general.section.connection": { + "message": "Conexão" + }, + "settings.general.connectOnStartup.label": { + "message": "Conectar ao iniciar" + }, + "settings.general.connectOnStartup.help": { + "message": "Estabelecer uma conexão automaticamente quando o serviço iniciar." + }, + "settings.general.notifications.label": { + "message": "Notificações na área de trabalho" + }, + "settings.general.notifications.help": { + "message": "Mostrar notificações na área de trabalho para novas atualizações e eventos de conexão." + }, + "settings.general.autostart.label": { + "message": "Iniciar a interface do NetBird ao fazer login" + }, + "settings.general.autostart.help": { + "message": "Iniciar a interface do NetBird automaticamente quando você fizer login. Isto afeta apenas a interface gráfica, não o serviço em segundo plano." + }, + "settings.general.autostart.errorTitle": { + "message": "Falha ao alterar o início automático" + }, + "settings.general.keepConnectedOnQuit.label": { + "message": "Permanecer conectado ao sair", + "description": "Toggle label: keep the VPN connection up after quitting the UI." + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "A conexão continua ativa em segundo plano depois de fechar o NetBird. Ela só para quando você mesmo a desconecta.", + "description": "Helper text for the stay-connected-after-quitting toggle." + }, + "settings.general.language.label": { + "message": "Idioma de exibição" + }, + "settings.general.language.help": { + "message": "Escolha o idioma da interface do NetBird." + }, + "settings.general.language.search": { + "message": "Buscar idioma…" + }, + "settings.general.language.empty": { + "message": "Nenhum idioma corresponde." + }, + "settings.general.management.label": { + "message": "Servidor de gerenciamento" + }, + "settings.general.management.help": { + "message": "Conecte ao NetBird Cloud ou ao seu próprio servidor de gerenciamento auto-hospedado. As alterações reconectarão o cliente." + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "Auto-hospedado" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Insira uma URL válida, ex.: https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "Não foi possível acessar este servidor. Verifique a URL ou salve mesmo assim se tiver certeza de que está correta." + }, + "settings.general.management.switchCloudTitle": { + "message": "Alternar para o NetBird Cloud?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Isto desconecta o seu servidor auto-hospedado.\nVocê pode precisar fazer login novamente." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Alternar para o Cloud" + }, + "settings.network.section.connectivity": { + "message": "Conectividade" + }, + "settings.network.section.routingDns": { + "message": "Roteamento e DNS" + }, + "settings.network.monitor.label": { + "message": "Reconectar ao mudar de rede" + }, + "settings.network.monitor.help": { + "message": "Monitora a rede e reconecta automaticamente diante de mudanças como troca de Wi-Fi, alterações na Ethernet ou retorno do modo de suspensão." + }, + "settings.network.dns.label": { + "message": "Ativar DNS" + }, + "settings.network.dns.help": { + "message": "Aplicar as configurações de DNS gerenciadas pelo NetBird ao resolvedor do host." + }, + "settings.network.clientRoutes.label": { + "message": "Ativar rotas de cliente" + }, + "settings.network.clientRoutes.help": { + "message": "Aceitar rotas de outros peers para alcançar as redes deles." + }, + "settings.network.serverRoutes.label": { + "message": "Ativar rotas de servidor" + }, + "settings.network.serverRoutes.help": { + "message": "Anunciar as rotas locais deste host para outros peers." + }, + "settings.network.ipv6.label": { + "message": "Ativar IPv6" + }, + "settings.network.ipv6.help": { + "message": "Usar endereçamento IPv6 para a rede de sobreposição do NetBird." + }, + "settings.security.section.firewall": { + "message": "Firewall" + }, + "settings.security.section.encryption": { + "message": "Criptografia" + }, + "settings.security.blockInbound.label": { + "message": "Bloquear tráfego de entrada" + }, + "settings.security.blockInbound.help": { + "message": "Rejeitar conexões não solicitadas de peers para este dispositivo e quaisquer redes que ele roteie. O tráfego de saída não é afetado." + }, + "settings.security.blockLan.label": { + "message": "Bloquear acesso à LAN" + }, + "settings.security.blockLan.help": { + "message": "Impedir que peers alcancem a sua rede local ou os dispositivos dela quando este dispositivo roteia o tráfego deles." + }, + "settings.security.rosenpass.label": { + "message": "Ativar resistência quântica" + }, + "settings.security.rosenpass.help": { + "message": "Adicionar uma troca de chaves pós-quântica via Rosenpass sobre o WireGuard®." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Ativar modo permissivo" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Permitir conexões com peers sem suporte a resistência quântica." + }, + "settings.ssh.section.server": { + "message": "Servidor" + }, + "settings.ssh.section.capabilities": { + "message": "Recursos" + }, + "settings.ssh.section.authentication": { + "message": "Autenticação" + }, + "settings.ssh.server.label": { + "message": "Ativar servidor SSH" + }, + "settings.ssh.server.help": { + "message": "Executar o servidor SSH do NetBird neste host para que outros peers possam conectar a ele." + }, + "settings.ssh.root.label": { + "message": "Permitir login como root" + }, + "settings.ssh.root.help": { + "message": "Permitir que peers façam login como usuário root. Desative para exigir uma conta sem privilégios." + }, + "settings.ssh.sftp.label": { + "message": "Permitir SFTP" + }, + "settings.ssh.sftp.help": { + "message": "Transferir arquivos com segurança usando clientes SFTP ou SCP nativos." + }, + "settings.ssh.localForward.label": { + "message": "Encaminhamento de porta local" + }, + "settings.ssh.localForward.help": { + "message": "Permitir que peers conectados encaminhem portas locais para serviços acessíveis a partir deste host." + }, + "settings.ssh.remoteForward.label": { + "message": "Encaminhamento de porta remota" + }, + "settings.ssh.remoteForward.help": { + "message": "Permitir que peers conectados exponham portas deste host de volta para a própria máquina deles." + }, + "settings.ssh.jwt.label": { + "message": "Ativar autenticação JWT" + }, + "settings.ssh.jwt.help": { + "message": "Verificar cada sessão SSH no seu IdP para identidade do usuário e auditoria. Desative para depender apenas das políticas de ACL da rede, útil quando nenhum IdP está disponível." + }, + "settings.ssh.jwtTtl.label": { + "message": "TTL do cache de JWT" + }, + "settings.ssh.jwtTtl.help": { + "message": "Por quanto tempo este cliente mantém um JWT em cache antes de solicitar novamente em conexões SSH de saída. Defina como 0 para desativar o cache e autenticar em cada conexão." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "s" + }, + "settings.advanced.section.interface": { + "message": "Interface" + }, + "settings.advanced.section.security": { + "message": "Segurança" + }, + "settings.advanced.interfaceName.label": { + "message": "Nome" + }, + "settings.advanced.interfaceName.error": { + "message": "Use de 1 a 15 letras, dígitos, pontos, hifens ou sublinhados." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Deve começar com \"utun\" seguido de um número (ex.: utun100)." + }, + "settings.advanced.port.label": { + "message": "Porta" + }, + "settings.advanced.port.error": { + "message": "Insira uma porta entre {min} e {max}." + }, + "settings.advanced.port.help": { + "message": "Se definida como 0, uma porta livre aleatória será usada." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Insira um valor de MTU entre {min} e {max}." + }, + "settings.advanced.psk.label": { + "message": "Chave pré-compartilhada" + }, + "settings.advanced.psk.help": { + "message": "PSK opcional do WireGuard para criptografia simétrica adicional. Não é o mesmo que uma chave de configuração do NetBird. Você só se comunicará com peers que usem a mesma chave pré-compartilhada." + }, + "settings.troubleshooting.section.title": { + "message": "Pacote de depuração" + }, + "settings.troubleshooting.anonymize.label": { + "message": "Anonimizar informações sensíveis" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Oculta endereços IP, domínios e outros valores sensíveis." + }, + "settings.troubleshooting.anonymize.info": { + "message": "O modo padrão mantém os endereços IPv4 internos e os nomes dos peers legíveis para o suporte. O modo estrito anonimiza também os endereços IP privados (RFC 1918), CGNAT e link-local, os nomes dos peers e as chaves públicas do WireGuard. Valores recorrentes recebem o mesmo marcador, então os peers continuam distinguíveis. Use o modo estrito ao compartilhar o pacote de depuração fora da sua organização." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Nenhum" + }, + "settings.troubleshooting.anonymize.default": { + "message": "Padrão" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Estrito" + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Incluir informações do sistema" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Incluir o OS, o kernel, as interfaces de rede e as tabelas de roteamento." + }, + "settings.troubleshooting.upload.label": { + "message": "Enviar pacote aos servidores do NetBird" + }, + "settings.troubleshooting.upload.help": { + "message": "Retorna uma chave de upload para compartilhar com o suporte do NetBird." + }, + "settings.troubleshooting.trace.label": { + "message": "Habilitar logs de trace" + }, + "settings.troubleshooting.trace.help": { + "message": "Eleva o nível de log para TRACE e o restaura em seguida." + }, + "settings.troubleshooting.capture.label": { + "message": "Sessão de captura" + }, + "settings.troubleshooting.capture.help": { + "message": "Reconecta e aguarda para que você possa reproduzir o problema." + }, + "settings.troubleshooting.packets.label": { + "message": "Capturar pacotes de rede" + }, + "settings.troubleshooting.packets.help": { + "message": "Salva um .pcap do tráfego de rede durante a sessão de captura." + }, + "settings.troubleshooting.duration.label": { + "message": "Duração da captura" + }, + "settings.troubleshooting.duration.help": { + "message": "Por quanto tempo a sessão de captura é executada." + }, + "settings.troubleshooting.duration.suffix": { + "message": "min" + }, + "settings.troubleshooting.create": { + "message": "Criar pacote" + }, + "settings.troubleshooting.progress.description": { + "message": "Coletando logs, detalhes do sistema e estado da conexão. Isto costuma levar um instante — mantenha esta janela aberta até concluir." + }, + "settings.troubleshooting.cancelling": { + "message": "Cancelando…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "Pacote de depuração enviado com sucesso!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Pacote salvo" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Compartilhe a chave de upload abaixo com o suporte do NetBird. Uma cópia local também foi salva no seu dispositivo." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "O seu pacote de depuração foi salvo localmente." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Copiar chave" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Abrir pasta" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Abrir local do arquivo" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Falha no upload: {reason} O pacote continua salvo localmente." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Falha no upload. O pacote continua salvo localmente." + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "Reconectando o NetBird…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Capturando logs de depuração" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Gerando o pacote de depuração…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Enviando para o NetBird…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Cancelando…" + }, + "settings.about.client": { + "message": "NetBird Client v{version}" + }, + "settings.about.clientName": { + "message": "NetBird Client" + }, + "settings.about.development": { + "message": "[Development]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Todos os direitos reservados." + }, + "settings.about.links.imprint": { + "message": "Identificação legal" + }, + "settings.about.links.privacy": { + "message": "Privacidade" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Termos de serviço" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Fórum" + }, + "settings.about.community.documentation": { + "message": "Documentação" + }, + "settings.about.community.feedback": { + "message": "Feedback" + }, + "update.banner.message": { + "message": "O NetBird {version} está pronto para instalar." + }, + "update.banner.later": { + "message": "Mais tarde" + }, + "update.banner.installNow": { + "message": "Instalar agora" + }, + "update.card.versionAvailableDownload": { + "message": "A versão {version} está disponível para download." + }, + "update.card.versionAvailableInstall": { + "message": "A versão {version} está disponível para instalação." + }, + "update.card.whatsNew": { + "message": "Novidades?" + }, + "update.card.installNow": { + "message": "Instalar agora" + }, + "update.card.getInstaller": { + "message": "Baixar" + }, + "update.card.autoCheckInterval": { + "message": "O NetBird verifica atualizações em segundo plano." + }, + "update.card.changelog": { + "message": "Registro de alterações" + }, + "update.card.onLatestVersion": { + "message": "Você está na versão mais recente" + }, + "update.header.tooltip": { + "message": "Atualização disponível" + }, + "update.overlay.updatingVersion": { + "message": "Atualizando o NetBird para a v{version}" + }, + "update.overlay.updating": { + "message": "Atualizando o NetBird" + }, + "update.overlay.description": { + "message": "Uma versão mais recente está disponível e sendo instalada. O NetBird reiniciará automaticamente quando a atualização terminar." + }, + "update.overlay.error.timeoutTitle": { + "message": "A atualização está demorando demais" + }, + "update.overlay.error.timeoutDescription": { + "message": "A instalação de {target} demorou demais e não foi concluída." + }, + "update.overlay.error.canceledTitle": { + "message": "A atualização foi interrompida" + }, + "update.overlay.error.canceledDescription": { + "message": "A atualização para {target} foi cancelada antes de terminar." + }, + "update.overlay.error.failTitle": { + "message": "Não foi possível instalar a atualização" + }, + "update.overlay.error.failDescription": { + "message": "Não foi possível instalar {target}." + }, + "update.overlay.error.unknownMessage": { + "message": "erro desconhecido" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "a nova versão" + }, + "update.error.loadStateTitle": { + "message": "Falha ao carregar o estado da atualização" + }, + "update.error.triggerTitle": { + "message": "Falha ao iniciar a atualização" + }, + "update.page.versionLine": { + "message": "Atualizando o cliente para: {version}." + }, + "update.page.versionLineGeneric": { + "message": "Atualizando o cliente." + }, + "update.page.outdated": { + "message": "A versão do seu cliente é anterior à versão de atualização automática definida no Management." + }, + "update.page.status.running": { + "message": "Atualizando" + }, + "update.page.status.timeout": { + "message": "A atualização expirou. Tente novamente." + }, + "update.page.status.canceled": { + "message": "Atualização cancelada." + }, + "update.page.status.failed": { + "message": "Falha na atualização: {message}" + }, + "update.page.status.unknownError": { + "message": "erro de atualização desconhecido" + }, + "update.page.failedTitle": { + "message": "Falha na atualização" + }, + "update.page.timeoutMessage": { + "message": "A atualização expirou." + }, + "update.page.dontClose": { + "message": "Não feche esta janela." + }, + "update.page.updating": { + "message": "Atualizando…" + }, + "update.page.complete": { + "message": "Atualização concluída" + }, + "update.page.failed": { + "message": "Falha na atualização" + }, + "window.title.settings": { + "message": "Configurações" + }, + "window.title.signIn": { + "message": "Login" + }, + "window.title.sessionExpiration": { + "message": "Sessão expirando" + }, + "window.title.updating": { + "message": "Atualizando" + }, + "window.title.welcome": { + "message": "Bem-vindo ao NetBird" + }, + "window.title.error": { + "message": "Erro" + }, + "welcome.title": { + "message": "Procure o NetBird na sua bandeja" + }, + "welcome.titleMac": { + "message": "Procure o NetBird na sua barra de menus" + }, + "welcome.description": { + "message": "O NetBird fica na sua bandeja. Clique no ícone para conectar, alternar perfis ou abrir as configurações." + }, + "welcome.descriptionMac": { + "message": "O NetBird fica na sua barra de menus. Clique no ícone para conectar, alternar perfis ou abrir as configurações." + }, + "welcome.continue": { + "message": "Continuar" + }, + "welcome.back": { + "message": "Voltar" + }, + "welcome.management.title": { + "message": "Configurar o NetBird" + }, + "welcome.management.description": { + "message": "Clique em Continuar para começar ou escolha Auto-hospedado se você tiver o seu próprio servidor NetBird." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Use o nosso serviço hospedado. Sem configuração necessária." + }, + "welcome.management.selfHosted.title": { + "message": "Auto-hospedado" + }, + "welcome.management.selfHosted.description": { + "message": "Conecte ao seu próprio servidor de gerenciamento." + }, + "welcome.management.urlLabel": { + "message": "URL do servidor de gerenciamento" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Insira uma URL válida, ex.: https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "Não foi possível acessar este servidor. Verifique a URL ou a sua rede e continue se tiver certeza de que está correta." + }, + "welcome.management.checking": { + "message": "Verificando…" + }, + "browserLogin.title": { + "message": "Continue no seu navegador para concluir o login" + }, + "browserLogin.notSeeing": { + "message": "Não está vendo a aba do navegador?" + }, + "browserLogin.tryAgain": { + "message": "Tentar novamente" + }, + "browserLogin.openFailedTitle": { + "message": "Falha ao abrir o navegador" + }, + "sessionExpiration.title": { + "message": "A sessão expira em breve" + }, + "sessionExpiration.titleLater": { + "message": "A sua sessão vai expirar" + }, + "sessionExpiration.description": { + "message": "Este dispositivo será desconectado em breve. Renove com um login pelo navegador." + }, + "sessionExpiration.descriptionLater": { + "message": "Um login pelo navegador mantém este dispositivo conectado à sua rede." + }, + "sessionExpiration.stay": { + "message": "Renovar sessão" + }, + "sessionExpiration.authenticate": { + "message": "Autenticar" + }, + "sessionExpiration.logout": { + "message": "Sair" + }, + "sessionExpiration.expired": { + "message": "Sessão expirada" + }, + "sessionExpiration.expiredDescription": { + "message": "Dispositivo desconectado. Autentique com um login pelo navegador para reconectar." + }, + "sessionExpiration.close": { + "message": "Fechar" + }, + "sessionExpiration.extendFailedTitle": { + "message": "Falha ao renovar a sessão" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Falha ao sair" + }, + "peers.search.placeholder": { + "message": "Buscar por nome ou IP" + }, + "peers.filter.all": { + "message": "Todos" + }, + "peers.filter.online": { + "message": "Online" + }, + "peers.filter.offline": { + "message": "Offline" + }, + "peers.empty.title": { + "message": "Nenhum peer disponível" + }, + "peers.empty.description": { + "message": "Você não tem nenhum peer disponível ou não tem acesso a nenhum deles." + }, + "peers.details.domain": { + "message": "Domínio" + }, + "peers.details.netbirdIp": { + "message": "IP do NetBird" + }, + "peers.details.netbirdIpv6": { + "message": "IPv6 do NetBird" + }, + "peers.details.publicKey": { + "message": "Chave pública" + }, + "peers.details.connection": { + "message": "Conexão" + }, + "peers.details.latency": { + "message": "Latência" + }, + "peers.details.lastHandshake": { + "message": "Último handshake" + }, + "peers.details.statusSince": { + "message": "Última atualização da conexão" + }, + "peers.details.bytes": { + "message": "Bytes" + }, + "peers.details.bytesSent": { + "message": "Enviados" + }, + "peers.details.bytesReceived": { + "message": "Recebidos" + }, + "peers.details.localIce": { + "message": "ICE local" + }, + "peers.details.remoteIce": { + "message": "ICE remoto" + }, + "peers.details.never": { + "message": "Nunca" + }, + "peers.details.justNow": { + "message": "Agora mesmo" + }, + "peers.details.refresh": { + "message": "Atualizar" + }, + "peers.status.connected": { + "message": "Conectado" + }, + "peers.status.connecting": { + "message": "Conectando" + }, + "peers.status.disconnected": { + "message": "Desconectado" + }, + "peers.details.relayAddress": { + "message": "Relay" + }, + "peers.details.networks": { + "message": "Recursos" + }, + "peers.details.relayed": { + "message": "Via relay" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass ativado" + }, + "networks.search.placeholder": { + "message": "Buscar por rede ou domínio" + }, + "networks.filter.all": { + "message": "Todos" + }, + "networks.filter.active": { + "message": "Ativos" + }, + "networks.filter.overlapping": { + "message": "Sobrepostos" + }, + "networks.empty.title": { + "message": "Nenhum recurso disponível" + }, + "networks.empty.description": { + "message": "Você não tem nenhum recurso de rede disponível ou não tem acesso a nenhum deles." + }, + "networks.selected": { + "message": "Selecionado" + }, + "networks.unselected": { + "message": "Não selecionado" + }, + "networks.ips.heading": { + "message": "IPs resolvidos" + }, + "networks.bulk.selectionCount": { + "message": "{selected} de {total} ativos" + }, + "networks.bulk.enableAll": { + "message": "Ativar todos" + }, + "networks.bulk.disableAll": { + "message": "Desativar todos" + }, + "exitNodes.search.placeholder": { + "message": "Buscar nós de saída" + }, + "exitNodes.none": { + "message": "Nenhum" + }, + "exitNodes.empty.title": { + "message": "Nenhum nó de saída disponível" + }, + "exitNodes.empty.description": { + "message": "Nenhum nó de saída foi compartilhado com este peer." + }, + "exitNodes.card.title": { + "message": "Nó de saída" + }, + "exitNodes.card.statusActive": { + "message": "Ativo" + }, + "exitNodes.card.statusInactive": { + "message": "Inativo" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Nenhum" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Conexão direta sem um nó de saída" + }, + "quickActions.connect": { + "message": "Conectar" + }, + "quickActions.disconnect": { + "message": "Desconectar" + }, + "daemon.unavailable.title": { + "message": "O serviço do NetBird não está em execução" + }, + "daemon.unavailable.description": { + "message": "O aplicativo reconectará automaticamente assim que o serviço estiver em execução." + }, + "daemon.unavailable.docsLink": { + "message": "Documentação" + }, + "daemon.outdated.title": { + "message": "O NetBird Client está desatualizado" + }, + "daemon.outdated.description": { + "message": "A nova GUI não é compatível com o seu cliente mais antigo. Atualize o seu cliente para usar o novo aplicativo." + }, + "daemon.outdated.download": { + "message": "Baixar a versão mais recente" + }, + "error.jwt_clock_skew": { + "message": "Falha no login: o relógio deste dispositivo está fora de sincronia com o servidor. Sincronize o relógio do sistema e tente novamente." + }, + "error.jwt_expired": { + "message": "O seu token de login expirou. Faça login novamente." + }, + "error.jwt_signature_invalid": { + "message": "Falha no login: a assinatura do token é inválida. Entre em contato com o seu administrador." + }, + "error.session_expired": { + "message": "A sua sessão expirou. Faça login novamente." + }, + "error.invalid_setup_key": { + "message": "A chave de configuração está ausente ou é inválida." + }, + "error.permission_denied": { + "message": "O login foi rejeitado pelo servidor." + }, + "error.daemon_unreachable": { + "message": "O daemon do NetBird não está respondendo. Verifique se o serviço está em execução." + }, + "error.unknown": { + "message": "A operação falhou." + }, + "error.elevation_unavailable": { + "message": "O NetBird não conseguiu solicitar a este sistema os privilégios necessários. Execute isto em vez disso:" + }, + "error.elevation_failed": { + "message": "Não foi possível aplicar a alteração com privilégios elevados. Execute isto em vez disso:" + }, + "settings.ssh.privilege.actorRoot": { + "message": "privilégios de root" + }, + "settings.ssh.privilege.actorAdministrator": { + "message": "privilégios de administrador" + }, + "settings.ssh.privilege.hint": { + "message": "Requer {actor}. Execute isto em vez disso:" + }, + "settings.ssh.privilege.oneWay": { + "message": "Você pode desativar isto, mas ativar novamente requer {actor}." + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Você pode ativar isto, mas desativar novamente requer {actor}." + }, + "settings.ssh.privilege.authorizePending": { + "message": "Aguardando a autorização…" + } +} diff --git a/client/ui/i18n/locales/ru/common.json b/client/ui/i18n/locales/ru/common.json new file mode 100644 index 000000000..3881a3783 --- /dev/null +++ b/client/ui/i18n/locales/ru/common.json @@ -0,0 +1,1378 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Отключено" + }, + "tray.status.daemonUnavailable": { + "message": "Не запущено" + }, + "tray.status.error": { + "message": "Ошибка" + }, + "tray.status.connected": { + "message": "Подключено" + }, + "tray.status.connecting": { + "message": "Подключение" + }, + "tray.status.needsLogin": { + "message": "Требуется вход" + }, + "tray.status.loginFailed": { + "message": "Ошибка входа" + }, + "tray.status.sessionExpired": { + "message": "Сеанс истёк" + }, + "tray.session.expiresIn": { + "message": "Сеанс истекает через {remaining}" + }, + "tray.session.unit.lessThanMinute": { + "message": "менее чем минуту" + }, + "tray.session.unit.minute": { + "message": "1 минуту" + }, + "tray.session.unit.minutes": { + "message": "{count} минут" + }, + "tray.session.unit.hour": { + "message": "1 час" + }, + "tray.session.unit.hours": { + "message": "{count} часов" + }, + "tray.session.unit.day": { + "message": "1 день" + }, + "tray.session.unit.days": { + "message": "{count} дней" + }, + "tray.menu.open": { + "message": "Открыть NetBird" + }, + "tray.menu.connect": { + "message": "Подключиться" + }, + "tray.menu.disconnect": { + "message": "Отключиться" + }, + "tray.menu.exitNode": { + "message": "Выходной узел" + }, + "tray.menu.networks": { + "message": "Ресурсы" + }, + "tray.menu.profiles": { + "message": "Профили" + }, + "tray.menu.manageProfiles": { + "message": "Управление профилями" + }, + "tray.menu.settings": { + "message": "Настройки…" + }, + "tray.menu.debugBundle": { + "message": "Создать отладочный пакет" + }, + "tray.menu.about": { + "message": "Помощь и поддержка" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Документация" + }, + "tray.menu.troubleshoot": { + "message": "Диагностика" + }, + "tray.menu.downloadLatest": { + "message": "Загрузить последнюю версию" + }, + "tray.menu.installVersion": { + "message": "Установить версию {version}" + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}" + }, + "tray.menu.daemonVersion": { + "message": "Демон: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "Выйти из NetBird" + }, + "notify.daemonOutdated.title": { + "message": "Служба NetBird устарела" + }, + "notify.daemonOutdated.body": { + "message": "Обновите службу NetBird, чтобы использовать это приложение." + }, + "notify.update.title": { + "message": "Доступно обновление NetBird" + }, + "notify.update.body": { + "message": "Доступна версия NetBird {version}." + }, + "notify.update.enforcedSuffix": { + "message": " Ваш администратор требует установить это обновление." + }, + "notify.error.title": { + "message": "Ошибка" + }, + "notify.error.connect": { + "message": "Не удалось подключиться" + }, + "notify.error.disconnect": { + "message": "Не удалось отключиться" + }, + "notify.error.switchProfile": { + "message": "Не удалось переключиться на {profile}" + }, + "notify.error.exitNode": { + "message": "Не удалось обновить выходной узел {name}" + }, + "notify.sessionExpired.title": { + "message": "Сеанс NetBird истёк" + }, + "notify.sessionExpired.body": { + "message": "Сеанс NetBird истёк. Пожалуйста, войдите снова." + }, + "notify.sessionWarning.title": { + "message": "Сеанс скоро истечёт" + }, + "notify.sessionWarning.body": { + "message": "Сеанс NetBird истекает через {remaining}. Нажмите «Продлить сейчас», чтобы продлить сеанс." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "Сеанс NetBird скоро истечёт. Нажмите «Продлить сейчас», чтобы продлить сеанс." + }, + "notify.sessionWarning.extend": { + "message": "Продлить сейчас" + }, + "notify.sessionWarning.dismiss": { + "message": "Закрыть" + }, + "notify.sessionWarning.failed": { + "message": "Не удалось продлить сеанс NetBird" + }, + "notify.sessionWarning.successTitle": { + "message": "Сеанс NetBird продлён" + }, + "notify.sessionWarning.successBody": { + "message": "Сеанс обновлён." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Неверный срок действия сеанса" + }, + "notify.sessionDeadlineRejected.body": { + "message": "Сервер передал неверный срок действия сеанса. Пожалуйста, войдите снова." + }, + "notify.mdm.policyApplied.title": { + "message": "Настройки NetBird обновлены" + }, + "notify.mdm.policyApplied.body": { + "message": "Конфигурация NetBird была обновлена в соответствии с вашей ИТ-политикой." + }, + "common.cancel": { + "message": "Отмена" + }, + "common.save": { + "message": "Сохранить" + }, + "common.saveChanges": { + "message": "Сохранить изменения" + }, + "common.saving": { + "message": "Сохранение…" + }, + "common.close": { + "message": "Закрыть" + }, + "common.copy": { + "message": "Копировать" + }, + "common.togglePasswordVisibility": { + "message": "Показать или скрыть пароль" + }, + "common.increase": { + "message": "Увеличить" + }, + "common.decrease": { + "message": "Уменьшить" + }, + "common.delete": { + "message": "Удалить" + }, + "common.create": { + "message": "Создать" + }, + "common.add": { + "message": "Добавить" + }, + "common.remove": { + "message": "Убрать" + }, + "common.refresh": { + "message": "Обновить" + }, + "common.loading": { + "message": "Загрузка…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "Ничего не найдено" + }, + "common.noResults.description": { + "message": "Ничего не найдено. Попробуйте изменить поисковый запрос или фильтры." + }, + "notConnected.title": { + "message": "Отключено" + }, + "notConnected.description": { + "message": "Сначала подключитесь к NetBird, чтобы увидеть подробную информацию о пирах, сетевых ресурсах и выходных узлах." + }, + "connect.status.disconnected": { + "message": "Отключено" + }, + "connect.status.connecting": { + "message": "Подключение…" + }, + "connect.status.connected": { + "message": "Подключено" + }, + "connect.status.disconnecting": { + "message": "Отключение…" + }, + "connect.status.daemonUnavailable": { + "message": "Демон недоступен" + }, + "connect.status.loginRequired": { + "message": "Требуется вход" + }, + "connect.error.loginTitle": { + "message": "Не удалось войти" + }, + "connect.error.connectTitle": { + "message": "Не удалось подключиться" + }, + "connect.error.disconnectTitle": { + "message": "Не удалось отключиться" + }, + "nav.peers.title": { + "message": "Пиры" + }, + "nav.peers.description": { + "message": "{connected} из {total} подключено" + }, + "nav.resources.title": { + "message": "Ресурсы" + }, + "nav.resources.description": { + "message": "{active} из {total} активно" + }, + "nav.exitNode.title": { + "message": "Выходные узлы" + }, + "nav.exitNode.none": { + "message": "Не активен" + }, + "nav.exitNode.using": { + "message": "Через {name}" + }, + "header.openSettings": { + "message": "Открыть настройки" + }, + "header.togglePanel": { + "message": "Показать или скрыть боковую панель" + }, + "profile.selector.loading": { + "message": "Загрузка…" + }, + "profile.selector.noProfile": { + "message": "Нет профиля" + }, + "profile.selector.searchPlaceholder": { + "message": "Поиск профиля по имени…" + }, + "profile.selector.emptyTitle": { + "message": "Профили не найдены" + }, + "profile.selector.emptyDescription": { + "message": "Измените поисковый запрос или создайте новый профиль." + }, + "profile.selector.newProfile": { + "message": "Новый профиль" + }, + "profile.selector.moreOptions": { + "message": "Дополнительные параметры" + }, + "profile.selector.deregister": { + "message": "Отменить регистрацию" + }, + "profile.selector.delete": { + "message": "Удалить" + }, + "profile.selector.switchTo": { + "message": "Переключиться на этот профиль" + }, + "profile.selector.edit": { + "message": "Изменить" + }, + "profile.edit.title": { + "message": "Изменить профиль" + }, + "profile.edit.submit": { + "message": "Сохранить изменения" + }, + "profile.dialog.title": { + "message": "Введите имя профиля" + }, + "profile.dialog.nameLabel": { + "message": "Имя профиля" + }, + "profile.dialog.description": { + "message": "Задайте легко узнаваемое имя для профиля." + }, + "profile.dialog.placeholder": { + "message": "например, работа" + }, + "profile.dialog.submit": { + "message": "Добавить профиль" + }, + "profile.dialog.required": { + "message": "Введите имя профиля, например работа или дом" + }, + "profile.dialog.managementHelp": { + "message": "Используйте NetBird Cloud или собственный сервер." + }, + "profile.dialog.urlUnreachable": { + "message": "Не удалось связаться с сервером. Проверьте URL или всё равно добавьте профиль, если уверены, что он правильный." + }, + "header.menu.settings": { + "message": "Настройки…" + }, + "header.menu.defaultView": { + "message": "Обычный вид" + }, + "header.menu.advancedView": { + "message": "Расширенный вид" + }, + "header.menu.updateAvailable": { + "message": "Доступно обновление" + }, + "header.menu.open": { + "message": "Открыть меню" + }, + "header.profile.switch": { + "message": "Сменить профиль" + }, + "connect.toggle.label": { + "message": "Переключить подключение NetBird" + }, + "connect.localIp.label": { + "message": "Локальные IP-адреса" + }, + "common.search": { + "message": "Поиск" + }, + "common.filter": { + "message": "Фильтр" + }, + "exitNodes.dropdown.trigger": { + "message": "Выбрать выходной узел" + }, + "peers.row.label": { + "message": "Открыть подробности для {name}, {status}" + }, + "peers.dialog.title": { + "message": "Сведения об узле" + }, + "networks.row.toggle": { + "message": "Переключить {name}" + }, + "networks.bulk.label": { + "message": "Переключить все видимые ресурсы" + }, + "profile.switch.title": { + "message": "Переключиться на профиль «{name}»?" + }, + "profile.switch.message": { + "message": "Вы действительно хотите переключить профиль?\nТекущий профиль будет отключён." + }, + "profile.switch.confirm": { + "message": "Подтвердить" + }, + "profile.deregister.title": { + "message": "Отменить регистрацию профиля «{name}»?" + }, + "profile.deregister.message": { + "message": "Вы действительно хотите отменить регистрацию этого профиля?\nДля его использования потребуется войти снова." + }, + "profile.deregister.confirm": { + "message": "Отменить регистрацию" + }, + "profile.delete.title": { + "message": "Удалить профиль «{name}»?" + }, + "profile.delete.message": { + "message": "Вы действительно хотите удалить этот профиль?\nЭто действие нельзя отменить." + }, + "profile.delete.disabledActive": { + "message": "Активные профили нельзя удалить. Переключитесь на другой профиль, прежде чем удалять этот." + }, + "profile.delete.disabledDefault": { + "message": "Профиль по умолчанию нельзя удалить." + }, + "profile.error.switchTitle": { + "message": "Не удалось переключить профиль" + }, + "profile.error.deregisterTitle": { + "message": "Не удалось отменить регистрацию профиля" + }, + "profile.error.deleteTitle": { + "message": "Не удалось удалить профиль" + }, + "profile.error.createTitle": { + "message": "Не удалось создать профиль" + }, + "profile.error.editTitle": { + "message": "Не удалось изменить профиль" + }, + "profile.error.loadTitle": { + "message": "Не удалось загрузить профили" + }, + "profile.dropdown.activeProfile": { + "message": "Активный профиль" + }, + "profile.dropdown.switchProfile": { + "message": "Переключить профиль" + }, + "profile.dropdown.noEmail": { + "message": "Другое" + }, + "profile.dropdown.addProfile": { + "message": "Добавить профиль" + }, + "profile.dropdown.manageProfiles": { + "message": "Управление профилями" + }, + "profile.dropdown.settings": { + "message": "Настройки" + }, + "settings.profiles.section.profiles": { + "message": "Профили" + }, + "settings.profiles.intro": { + "message": "Управляйте несколькими профилями NetBird параллельно — например, рабочими и личными учётными записями или разными серверами управления. Ниже можно добавлять профили, отменять их регистрацию и удалять их." + }, + "settings.profiles.addProfile": { + "message": "Добавить профиль" + }, + "settings.profiles.active": { + "message": "Активен" + }, + "settings.profiles.emptyTitle": { + "message": "Нет профилей" + }, + "settings.profiles.emptyDescription": { + "message": "Создайте профиль, чтобы подключиться к серверу управления NetBird." + }, + "settings.error.loadTitle": { + "message": "Не удалось загрузить настройки" + }, + "settings.error.saveTitle": { + "message": "Не удалось сохранить настройки" + }, + "settings.error.debugBundleTitle": { + "message": "Не удалось создать отладочный пакет" + }, + "settings.nav.label": { + "message": "Разделы настроек" + }, + "settings.tabs.general": { + "message": "Общие" + }, + "settings.tabs.network": { + "message": "Сеть" + }, + "settings.tabs.security": { + "message": "Безопасность" + }, + "settings.tabs.profiles": { + "message": "Профили" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "Дополнительно" + }, + "settings.tabs.troubleshooting": { + "message": "Диагностика" + }, + "settings.tabs.about": { + "message": "О программе" + }, + "settings.tabs.updateAvailable": { + "message": "Доступно обновление" + }, + "settings.general.section.general": { + "message": "Общие" + }, + "settings.general.section.connection": { + "message": "Подключение" + }, + "settings.general.connectOnStartup.label": { + "message": "Подключаться при запуске" + }, + "settings.general.connectOnStartup.help": { + "message": "Автоматически устанавливать подключение при запуске службы." + }, + "settings.general.notifications.label": { + "message": "Уведомления на рабочем столе" + }, + "settings.general.notifications.help": { + "message": "Показывать уведомления о новых обновлениях и событиях подключения." + }, + "settings.general.autostart.label": { + "message": "Запускать интерфейс NetBird при входе" + }, + "settings.general.autostart.help": { + "message": "Автоматически запускать интерфейс NetBird при входе в систему. Это влияет только на графический интерфейс, но не на фоновую службу." + }, + "settings.general.autostart.errorTitle": { + "message": "Не удалось изменить автозапуск" + }, + "settings.general.keepConnectedOnQuit.label": { + "message": "Оставаться подключённым после выхода", + "description": "Toggle label: keep the VPN connection up after quitting the UI." + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "Соединение остаётся активным в фоне после закрытия NetBird. Оно прервётся, только когда вы отключите его сами.", + "description": "Helper text for the stay-connected-after-quitting toggle." + }, + "settings.general.language.label": { + "message": "Язык интерфейса" + }, + "settings.general.language.help": { + "message": "Выберите язык интерфейса NetBird." + }, + "settings.general.language.search": { + "message": "Поиск языка…" + }, + "settings.general.language.empty": { + "message": "Языки не найдены." + }, + "settings.general.management.label": { + "message": "Сервер управления" + }, + "settings.general.management.help": { + "message": "Подключайтесь к NetBird Cloud или к собственному серверу управления. Изменения вызовут переподключение клиента." + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "Собственный сервер" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Введите корректный URL, например https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "Не удалось связаться с сервером. Проверьте URL или всё равно сохраните, если уверены, что он правильный." + }, + "settings.general.management.switchCloudTitle": { + "message": "Переключиться на NetBird Cloud?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Это отключит ваш собственный сервер.\nВозможно, потребуется войти снова." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Переключиться на Cloud" + }, + "settings.network.section.connectivity": { + "message": "Подключение" + }, + "settings.network.section.routingDns": { + "message": "Маршрутизация и DNS" + }, + "settings.network.monitor.label": { + "message": "Переподключаться при смене сети" + }, + "settings.network.monitor.help": { + "message": "Отслеживать сеть и автоматически переподключаться при изменениях, таких как смена Wi-Fi, изменения Ethernet или выход из спящего режима." + }, + "settings.network.dns.label": { + "message": "Включить DNS" + }, + "settings.network.dns.help": { + "message": "Применять управляемые NetBird настройки DNS к системному резолверу." + }, + "settings.network.clientRoutes.label": { + "message": "Включить клиентские маршруты" + }, + "settings.network.clientRoutes.help": { + "message": "Принимать маршруты от других пиров для доступа к их сетям." + }, + "settings.network.serverRoutes.label": { + "message": "Включить серверные маршруты" + }, + "settings.network.serverRoutes.help": { + "message": "Анонсировать локальные маршруты этого хоста другим пирам." + }, + "settings.network.ipv6.label": { + "message": "Включить IPv6" + }, + "settings.network.ipv6.help": { + "message": "Использовать IPv6-адресацию для оверлейной сети NetBird." + }, + "settings.security.section.firewall": { + "message": "Брандмауэр" + }, + "settings.security.section.encryption": { + "message": "Шифрование" + }, + "settings.security.blockInbound.label": { + "message": "Блокировать входящий трафик" + }, + "settings.security.blockInbound.help": { + "message": "Отклонять незапрошенные подключения от пиров к этому устройству и сетям, которые оно маршрутизирует. Исходящий трафик не затрагивается." + }, + "settings.security.blockLan.label": { + "message": "Блокировать доступ к LAN" + }, + "settings.security.blockLan.help": { + "message": "Запрещать пирам доступ к вашей локальной сети и её устройствам, когда это устройство маршрутизирует их трафик." + }, + "settings.security.rosenpass.label": { + "message": "Включить квантовую устойчивость" + }, + "settings.security.rosenpass.help": { + "message": "Добавить постквантовый обмен ключами через Rosenpass поверх WireGuard®." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Включить разрешающий режим" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Разрешать подключения к пирам без поддержки квантовой устойчивости." + }, + "settings.ssh.section.server": { + "message": "Сервер" + }, + "settings.ssh.section.capabilities": { + "message": "Возможности" + }, + "settings.ssh.section.authentication": { + "message": "Аутентификация" + }, + "settings.ssh.server.label": { + "message": "Включить SSH-сервер" + }, + "settings.ssh.server.help": { + "message": "Запускать SSH-сервер NetBird на этом хосте, чтобы другие пиры могли к нему подключаться." + }, + "settings.ssh.root.label": { + "message": "Разрешить вход под root" + }, + "settings.ssh.root.help": { + "message": "Разрешить пирам входить под пользователем root. Отключите, чтобы требовать непривилегированную учётную запись." + }, + "settings.ssh.sftp.label": { + "message": "Разрешить SFTP" + }, + "settings.ssh.sftp.help": { + "message": "Безопасно передавать файлы через стандартные клиенты SFTP или SCP." + }, + "settings.ssh.localForward.label": { + "message": "Локальная переадресация портов" + }, + "settings.ssh.localForward.help": { + "message": "Разрешить подключающимся пирам туннелировать локальные порты к службам, доступным с этого хоста." + }, + "settings.ssh.remoteForward.label": { + "message": "Удалённая переадресация портов" + }, + "settings.ssh.remoteForward.help": { + "message": "Разрешить подключающимся пирам пробрасывать порты этого хоста на свою машину." + }, + "settings.ssh.jwt.label": { + "message": "Включить аутентификацию JWT" + }, + "settings.ssh.jwt.help": { + "message": "Проверять каждую сессию SSH через ваш IdP для идентификации пользователя и аудита. Отключите, чтобы полагаться только на сетевые политики ACL — полезно, когда IdP недоступен." + }, + "settings.ssh.jwtTtl.label": { + "message": "TTL кэша JWT" + }, + "settings.ssh.jwtTtl.help": { + "message": "Как долго этот клиент кэширует JWT, прежде чем снова запрашивать его при исходящих SSH-подключениях. Установите 0, чтобы отключить кэширование и аутентифицироваться при каждом подключении." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "сек." + }, + "settings.advanced.section.interface": { + "message": "Интерфейс" + }, + "settings.advanced.section.security": { + "message": "Безопасность" + }, + "settings.advanced.interfaceName.label": { + "message": "Имя" + }, + "settings.advanced.interfaceName.error": { + "message": "Используйте от 1 до 15 букв, цифр, точек, дефисов или подчёркиваний." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Должно начинаться с «utun», за которым следует число (например, utun100)." + }, + "settings.advanced.port.label": { + "message": "Порт" + }, + "settings.advanced.port.error": { + "message": "Введите порт от {min} до {max}." + }, + "settings.advanced.port.help": { + "message": "Если задано 0, будет использован случайный свободный порт." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Введите значение MTU от {min} до {max}." + }, + "settings.advanced.psk.label": { + "message": "Общий ключ" + }, + "settings.advanced.psk.help": { + "message": "Необязательный PSK WireGuard для дополнительного симметричного шифрования. Это не то же самое, что ключ установки NetBird. Вы будете обмениваться данными только с пирами, использующими тот же общий ключ." + }, + "settings.troubleshooting.section.title": { + "message": "Отладочный пакет" + }, + "settings.troubleshooting.anonymize.label": { + "message": "Анонимизировать конфиденциальную информацию" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Скрывает IP-адреса, домены и другие конфиденциальные значения." + }, + "settings.troubleshooting.anonymize.info": { + "message": "Режим «По умолчанию» оставляет внутренние IPv4-адреса и имена пиров читаемыми для поддержки. Режим «Строгий» дополнительно анонимизирует частные (RFC 1918), CGNAT и link-local IP-адреса, имена пиров и публичные ключи WireGuard. Повторяющиеся значения заменяются одним и тем же заполнителем, поэтому пиры остаются различимыми. Используйте режим «Строгий», когда передаёте отладочный пакет за пределы вашей организации." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Нет" + }, + "settings.troubleshooting.anonymize.default": { + "message": "По умолчанию" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Строгий" + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Включить сведения о системе" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Включить ОС, ядро, сетевые интерфейсы и таблицы маршрутизации." + }, + "settings.troubleshooting.upload.label": { + "message": "Загрузить пакет на серверы NetBird" + }, + "settings.troubleshooting.upload.help": { + "message": "Возвращает ключ загрузки, который можно передать поддержке NetBird." + }, + "settings.troubleshooting.trace.label": { + "message": "Включить журналы TRACE" + }, + "settings.troubleshooting.trace.help": { + "message": "Повышает уровень журналирования до TRACE и затем восстанавливает прежний." + }, + "settings.troubleshooting.capture.label": { + "message": "Сеанс записи" + }, + "settings.troubleshooting.capture.help": { + "message": "Переподключается и ожидает, чтобы вы могли воспроизвести проблему." + }, + "settings.troubleshooting.packets.label": { + "message": "Записывать сетевые пакеты" + }, + "settings.troubleshooting.packets.help": { + "message": "Сохраняет .pcap сетевого трафика во время сеанса записи." + }, + "settings.troubleshooting.duration.label": { + "message": "Длительность записи" + }, + "settings.troubleshooting.duration.help": { + "message": "Как долго длится сеанс записи." + }, + "settings.troubleshooting.duration.suffix": { + "message": "мин." + }, + "settings.troubleshooting.create": { + "message": "Создать отладочный пакет" + }, + "settings.troubleshooting.progress.description": { + "message": "Сбор журналов, сведений о системе и состояния подключения. Обычно это занимает несколько секунд — не закрывайте это окно до завершения." + }, + "settings.troubleshooting.cancelling": { + "message": "Отмена…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "Отладочный пакет успешно загружен!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Пакет сохранён" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Поделитесь ключом загрузки ниже с поддержкой NetBird. Локальная копия также сохранена на вашем устройстве." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "Отладочный пакет сохранён локально." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Копировать ключ" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Открыть папку" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Открыть расположение файла" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Не удалось загрузить: {reason} Пакет всё равно сохранён локально." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Не удалось загрузить. Пакет всё равно сохранён локально." + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "Переподключение NetBird…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Сбор отладочных журналов" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Создание отладочного пакета…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Загрузка в NetBird…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Отмена…" + }, + "settings.about.client": { + "message": "Клиент NetBird v{version}" + }, + "settings.about.clientName": { + "message": "Клиент NetBird" + }, + "settings.about.development": { + "message": "[Разработка]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Все права защищены." + }, + "settings.about.links.imprint": { + "message": "Правовая информация" + }, + "settings.about.links.privacy": { + "message": "Конфиденциальность" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Условия использования" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Форум" + }, + "settings.about.community.documentation": { + "message": "Документация" + }, + "settings.about.community.feedback": { + "message": "Обратная связь" + }, + "update.banner.message": { + "message": "NetBird {version} готов к установке." + }, + "update.banner.later": { + "message": "Позже" + }, + "update.banner.installNow": { + "message": "Установить сейчас" + }, + "update.card.versionAvailableDownload": { + "message": "Версия {version} доступна для загрузки." + }, + "update.card.versionAvailableInstall": { + "message": "Версия {version} доступна для установки." + }, + "update.card.whatsNew": { + "message": "Что нового?" + }, + "update.card.installNow": { + "message": "Установить сейчас" + }, + "update.card.getInstaller": { + "message": "Загрузить" + }, + "update.card.autoCheckInterval": { + "message": "NetBird проверяет обновления в фоновом режиме." + }, + "update.card.changelog": { + "message": "Список изменений" + }, + "update.card.onLatestVersion": { + "message": "У вас установлена последняя версия" + }, + "update.header.tooltip": { + "message": "Доступно обновление" + }, + "update.overlay.updatingVersion": { + "message": "Обновление NetBird до v{version}" + }, + "update.overlay.updating": { + "message": "Обновление NetBird" + }, + "update.overlay.description": { + "message": "Доступна более новая версия, идёт её установка. NetBird автоматически перезапустится после завершения обновления." + }, + "update.overlay.error.timeoutTitle": { + "message": "Обновление занимает слишком много времени" + }, + "update.overlay.error.timeoutDescription": { + "message": "Установка {target} заняла слишком много времени и не завершилась." + }, + "update.overlay.error.canceledTitle": { + "message": "Обновление остановлено" + }, + "update.overlay.error.canceledDescription": { + "message": "Обновление до {target} было отменено до завершения." + }, + "update.overlay.error.failTitle": { + "message": "Не удалось установить обновление" + }, + "update.overlay.error.failDescription": { + "message": "Не удалось установить обновление до {target}." + }, + "update.overlay.error.unknownMessage": { + "message": "неизвестная ошибка" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "новой версии" + }, + "update.error.loadStateTitle": { + "message": "Не удалось загрузить состояние обновления" + }, + "update.error.triggerTitle": { + "message": "Не удалось запустить обновление" + }, + "update.page.versionLine": { + "message": "Обновление клиента до версии {version}." + }, + "update.page.versionLineGeneric": { + "message": "Обновление клиента." + }, + "update.page.outdated": { + "message": "Версия вашего клиента старше версии автообновления, заданной на сервере управления." + }, + "update.page.status.running": { + "message": "Обновление" + }, + "update.page.status.timeout": { + "message": "Время ожидания обновления истекло. Повторите попытку." + }, + "update.page.status.canceled": { + "message": "Обновление отменено." + }, + "update.page.status.failed": { + "message": "Не удалось обновить: {message}" + }, + "update.page.status.unknownError": { + "message": "неизвестная ошибка обновления" + }, + "update.page.failedTitle": { + "message": "Не удалось обновить" + }, + "update.page.timeoutMessage": { + "message": "Время ожидания обновления истекло." + }, + "update.page.dontClose": { + "message": "Пожалуйста, не закрывайте это окно." + }, + "update.page.updating": { + "message": "Обновление…" + }, + "update.page.complete": { + "message": "Обновление завершено" + }, + "update.page.failed": { + "message": "Обновление не удалось" + }, + "window.title.settings": { + "message": "Настройки" + }, + "window.title.signIn": { + "message": "Вход" + }, + "window.title.sessionExpiration": { + "message": "Истечение сеанса" + }, + "window.title.updating": { + "message": "Обновление" + }, + "window.title.welcome": { + "message": "Добро пожаловать в NetBird" + }, + "window.title.error": { + "message": "Ошибка" + }, + "welcome.title": { + "message": "Найдите NetBird в системном трее" + }, + "welcome.titleMac": { + "message": "Найдите NetBird в строке меню" + }, + "welcome.description": { + "message": "NetBird находится в системном трее. Нажмите на значок, чтобы подключиться, переключить профиль или открыть настройки." + }, + "welcome.descriptionMac": { + "message": "NetBird находится в строке меню. Нажмите на значок, чтобы подключиться, переключить профиль или открыть настройки." + }, + "welcome.continue": { + "message": "Продолжить" + }, + "welcome.back": { + "message": "Назад" + }, + "welcome.management.title": { + "message": "Настройка NetBird" + }, + "welcome.management.description": { + "message": "Нажмите «Продолжить», чтобы начать, или выберите «Собственный сервер», если у вас есть свой сервер NetBird." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Используйте наш облачный сервис. Настройка не требуется." + }, + "welcome.management.selfHosted.title": { + "message": "Собственный сервер" + }, + "welcome.management.selfHosted.description": { + "message": "Подключитесь к собственному серверу управления." + }, + "welcome.management.urlLabel": { + "message": "URL сервера управления" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Введите корректный URL, например https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "Не удалось связаться с сервером. Проверьте URL или сеть, затем продолжите, если уверены, что он правильный." + }, + "welcome.management.checking": { + "message": "Проверка…" + }, + "browserLogin.title": { + "message": "Завершите вход в браузере" + }, + "browserLogin.notSeeing": { + "message": "Мы открыли вкладку браузера, чтобы вы могли войти в аккаунт. Не видите вкладку?" + }, + "browserLogin.tryAgain": { + "message": "Повторить" + }, + "browserLogin.openFailedTitle": { + "message": "Не удалось открыть браузер" + }, + "sessionExpiration.title": { + "message": "Сеанс скоро истечёт" + }, + "sessionExpiration.titleLater": { + "message": "Ваш сеанс истечёт" + }, + "sessionExpiration.description": { + "message": "Это устройство скоро будет отключено. Продлите сеанс через вход в браузере." + }, + "sessionExpiration.descriptionLater": { + "message": "Вход в браузере сохранит подключение этого устройства к вашей сети." + }, + "sessionExpiration.stay": { + "message": "Продлить сеанс" + }, + "sessionExpiration.authenticate": { + "message": "Войти" + }, + "sessionExpiration.logout": { + "message": "Выйти" + }, + "sessionExpiration.expired": { + "message": "Сеанс истёк" + }, + "sessionExpiration.expiredDescription": { + "message": "Устройство отключено. Войдите через браузер, чтобы переподключиться." + }, + "sessionExpiration.close": { + "message": "Закрыть" + }, + "sessionExpiration.extendFailedTitle": { + "message": "Не удалось продлить сеанс" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Не удалось выйти" + }, + "peers.search.placeholder": { + "message": "Поиск по имени или IP" + }, + "peers.filter.all": { + "message": "Все" + }, + "peers.filter.online": { + "message": "В сети" + }, + "peers.filter.offline": { + "message": "Не в сети" + }, + "peers.empty.title": { + "message": "Нет доступных пиров" + }, + "peers.empty.description": { + "message": "У вас нет доступных пиров или нет доступа ни к одному из них." + }, + "peers.details.domain": { + "message": "Домен" + }, + "peers.details.netbirdIp": { + "message": "NetBird IP" + }, + "peers.details.netbirdIpv6": { + "message": "NetBird IPv6" + }, + "peers.details.publicKey": { + "message": "Открытый ключ" + }, + "peers.details.connection": { + "message": "Подключение" + }, + "peers.details.latency": { + "message": "Задержка" + }, + "peers.details.lastHandshake": { + "message": "Последнее рукопожатие" + }, + "peers.details.statusSince": { + "message": "Последнее обновление подключения" + }, + "peers.details.bytes": { + "message": "Байты" + }, + "peers.details.bytesSent": { + "message": "Отправлено" + }, + "peers.details.bytesReceived": { + "message": "Получено" + }, + "peers.details.localIce": { + "message": "Локальный ICE" + }, + "peers.details.remoteIce": { + "message": "Удалённый ICE" + }, + "peers.details.never": { + "message": "Никогда" + }, + "peers.details.justNow": { + "message": "Только что" + }, + "peers.details.refresh": { + "message": "Обновить" + }, + "peers.status.connected": { + "message": "Подключено" + }, + "peers.status.connecting": { + "message": "Подключение" + }, + "peers.status.disconnected": { + "message": "Отключено" + }, + "peers.details.relayAddress": { + "message": "Ретранслятор" + }, + "peers.details.networks": { + "message": "Ресурсы" + }, + "peers.details.relayed": { + "message": "Через ретранслятор" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass включён" + }, + "networks.search.placeholder": { + "message": "Поиск по сети или домену" + }, + "networks.filter.all": { + "message": "Все" + }, + "networks.filter.active": { + "message": "Активные" + }, + "networks.filter.overlapping": { + "message": "Пересекающиеся" + }, + "networks.empty.title": { + "message": "Нет доступных ресурсов" + }, + "networks.empty.description": { + "message": "У вас нет доступных сетевых ресурсов или нет доступа ни к одному из них." + }, + "networks.selected": { + "message": "Выбрано" + }, + "networks.unselected": { + "message": "Не выбрано" + }, + "networks.ips.heading": { + "message": "Распознанные IP-адреса" + }, + "networks.bulk.selectionCount": { + "message": "{selected} из {total} активно" + }, + "networks.bulk.enableAll": { + "message": "Включить все" + }, + "networks.bulk.disableAll": { + "message": "Отключить все" + }, + "exitNodes.search.placeholder": { + "message": "Поиск выходных узлов" + }, + "exitNodes.none": { + "message": "Нет" + }, + "exitNodes.empty.title": { + "message": "Нет доступных выходных узлов" + }, + "exitNodes.empty.description": { + "message": "Этому пиру не предоставлены выходные узлы." + }, + "exitNodes.card.title": { + "message": "Выходной узел" + }, + "exitNodes.card.statusActive": { + "message": "Активен" + }, + "exitNodes.card.statusInactive": { + "message": "Неактивен" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Нет" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Прямое подключение без выходного узла" + }, + "quickActions.connect": { + "message": "Подключиться" + }, + "quickActions.disconnect": { + "message": "Отключиться" + }, + "daemon.unavailable.title": { + "message": "Служба NetBird не запущена" + }, + "daemon.unavailable.description": { + "message": "Приложение автоматически переподключится, как только служба будет запущена." + }, + "daemon.unavailable.docsLink": { + "message": "Документация" + }, + "daemon.outdated.title": { + "message": "Клиент NetBird устарел" + }, + "daemon.outdated.description": { + "message": "Новый GUI несовместим с вашим более старым клиентом. Обновите клиент, чтобы использовать новое приложение." + }, + "daemon.outdated.download": { + "message": "Скачать последнюю версию" + }, + "error.jwt_clock_skew": { + "message": "Не удалось войти: часы этого устройства рассинхронизированы с сервером. Синхронизируйте системные часы и повторите попытку." + }, + "error.jwt_expired": { + "message": "Срок действия токена входа истёк. Войдите снова." + }, + "error.jwt_signature_invalid": { + "message": "Не удалось войти: недействительная подпись токена. Обратитесь к администратору." + }, + "error.session_expired": { + "message": "Ваш сеанс истёк. Войдите снова." + }, + "error.invalid_setup_key": { + "message": "Ключ установки отсутствует или недействителен." + }, + "error.permission_denied": { + "message": "Сервер отклонил вход." + }, + "error.daemon_unreachable": { + "message": "Демон NetBird не отвечает. Проверьте, запущена ли служба." + }, + "error.unknown": { + "message": "Не удалось выполнить операцию." + }, + "error.elevation_unavailable": { + "message": "NetBird не смог запросить у этой системы нужные права. Выполните вместо этого:" + }, + "error.elevation_failed": { + "message": "Не удалось применить изменение с повышенными правами. Выполните вместо этого:" + }, + "settings.ssh.privilege.actorRoot": { + "message": "права root" + }, + "settings.ssh.privilege.actorAdministrator": { + "message": "права администратора" + }, + "settings.ssh.privilege.hint": { + "message": "Требуются {actor}. Выполните вместо этого:" + }, + "settings.ssh.privilege.oneWay": { + "message": "Отключить можно, но чтобы включить снова, нужны {actor}." + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Включить можно, но чтобы отключить снова, нужны {actor}." + }, + "settings.ssh.privilege.authorizePending": { + "message": "Ожидание авторизации…" + } +} diff --git a/client/ui/i18n/locales/uk/common.json b/client/ui/i18n/locales/uk/common.json new file mode 100644 index 000000000..4e3f24102 --- /dev/null +++ b/client/ui/i18n/locales/uk/common.json @@ -0,0 +1,1376 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Відключено" + }, + "tray.status.daemonUnavailable": { + "message": "Не запущено" + }, + "tray.status.error": { + "message": "Помилка" + }, + "tray.status.connected": { + "message": "Підключено" + }, + "tray.status.connecting": { + "message": "Підключення" + }, + "tray.status.needsLogin": { + "message": "Потрібно ввійти" + }, + "tray.status.loginFailed": { + "message": "Помилка входу" + }, + "tray.status.sessionExpired": { + "message": "Сеанс закінчився" + }, + "tray.session.expiresIn": { + "message": "До завершення сеансу: {remaining}" + }, + "tray.session.unit.lessThanMinute": { + "message": "менше хвилини" + }, + "tray.session.unit.minute": { + "message": "1 хв." + }, + "tray.session.unit.minutes": { + "message": "{count} хв." + }, + "tray.session.unit.hour": { + "message": "1 год." + }, + "tray.session.unit.hours": { + "message": "{count} год." + }, + "tray.session.unit.day": { + "message": "1 дн." + }, + "tray.session.unit.days": { + "message": "{count} дн." + }, + "tray.menu.open": { + "message": "Відкрити NetBird" + }, + "tray.menu.connect": { + "message": "Підключитися" + }, + "tray.menu.disconnect": { + "message": "Відключитися" + }, + "tray.menu.exitNode": { + "message": "Вихідний вузол" + }, + "tray.menu.networks": { + "message": "Ресурси" + }, + "tray.menu.profiles": { + "message": "Профілі" + }, + "tray.menu.manageProfiles": { + "message": "Керування профілями" + }, + "tray.menu.settings": { + "message": "Налаштування…" + }, + "tray.menu.debugBundle": { + "message": "Створити архів діагностики" + }, + "tray.menu.about": { + "message": "Допомога та підтримка" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Документація" + }, + "tray.menu.troubleshoot": { + "message": "Діагностика" + }, + "tray.menu.downloadLatest": { + "message": "Завантажити останню версію" + }, + "tray.menu.installVersion": { + "message": "Встановити версію {version}" + }, + "tray.menu.guiVersion": { + "message": "Графічний інтерфейс: {version}" + }, + "tray.menu.daemonVersion": { + "message": "Служба: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "Вийти з NetBird" + }, + "notify.daemonOutdated.title": { + "message": "Служба NetBird застаріла" + }, + "notify.daemonOutdated.body": { + "message": "Оновіть службу NetBird, щоб користуватися застосунком." + }, + "notify.update.title": { + "message": "Доступне оновлення NetBird" + }, + "notify.update.body": { + "message": "Доступна версія NetBird {version}." + }, + "notify.update.enforcedSuffix": { + "message": " Ваш адміністратор вимагає встановити це оновлення." + }, + "notify.error.title": { + "message": "Помилка" + }, + "notify.error.connect": { + "message": "Не вдалося підключитися" + }, + "notify.error.disconnect": { + "message": "Не вдалося відключитися" + }, + "notify.error.switchProfile": { + "message": "Не вдалося перемкнутися на {profile}" + }, + "notify.error.exitNode": { + "message": "Не вдалося оновити вихідний вузол {name}" + }, + "notify.sessionExpired.title": { + "message": "Сеанс NetBird закінчився" + }, + "notify.sessionExpired.body": { + "message": "Ваш сеанс NetBird закінчився. Будь ласка, увійдіть знову." + }, + "notify.sessionWarning.title": { + "message": "Сеанс невдовзі закінчиться" + }, + "notify.sessionWarning.body": { + "message": "Ваш сеанс NetBird закінчиться через {remaining}. Натисніть «Продовжити зараз», щоб оновити його." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "Ваш сеанс NetBird невдовзі закінчиться. Натисніть «Продовжити зараз», щоб оновити його." + }, + "notify.sessionWarning.extend": { + "message": "Продовжити зараз" + }, + "notify.sessionWarning.dismiss": { + "message": "Закрити" + }, + "notify.sessionWarning.failed": { + "message": "Не вдалося продовжити сеанс NetBird" + }, + "notify.sessionWarning.successTitle": { + "message": "Сеанс NetBird продовжено" + }, + "notify.sessionWarning.successBody": { + "message": "Ваш сеанс успішно продовжено." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Недійсний термін дії сеансу" + }, + "notify.sessionDeadlineRejected.body": { + "message": "Сервер надіслав недійсний термін дії сеансу. Будь ласка, увійдіть знову." + }, + "notify.mdm.policyApplied.title": { + "message": "Налаштування NetBird оновлено" + }, + "notify.mdm.policyApplied.body": { + "message": "Конфігурацію NetBird оновлено відповідно до політики вашої організації." + }, + "common.cancel": { + "message": "Скасувати" + }, + "common.save": { + "message": "Зберегти" + }, + "common.saveChanges": { + "message": "Зберегти зміни" + }, + "common.saving": { + "message": "Збереження…" + }, + "common.close": { + "message": "Закрити" + }, + "common.copy": { + "message": "Копіювати" + }, + "common.togglePasswordVisibility": { + "message": "Показати/сховати пароль" + }, + "common.increase": { + "message": "Збільшити" + }, + "common.decrease": { + "message": "Зменшити" + }, + "common.delete": { + "message": "Видалити" + }, + "common.create": { + "message": "Створити" + }, + "common.add": { + "message": "Додати" + }, + "common.remove": { + "message": "Вилучити" + }, + "common.refresh": { + "message": "Оновити" + }, + "common.loading": { + "message": "Завантаження…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "Результатів не знайдено" + }, + "common.noResults.description": { + "message": "Ми не змогли нічого знайти. Спробуйте змінити пошуковий запит або налаштування фільтрів." + }, + "notConnected.title": { + "message": "Відключено" + }, + "notConnected.description": { + "message": "Спочатку підключіться до NetBird, щоб переглянути детальну інформацію про піри, мережеві ресурси та вихідні вузли." + }, + "connect.status.disconnected": { + "message": "Відключено" + }, + "connect.status.connecting": { + "message": "Підключення…" + }, + "connect.status.connected": { + "message": "Підключено" + }, + "connect.status.disconnecting": { + "message": "Відключення…" + }, + "connect.status.daemonUnavailable": { + "message": "Служба недоступна" + }, + "connect.status.loginRequired": { + "message": "Потрібно ввійти" + }, + "connect.error.loginTitle": { + "message": "Помилка входу" + }, + "connect.error.connectTitle": { + "message": "Помилка підключення" + }, + "connect.error.disconnectTitle": { + "message": "Помилка відключення" + }, + "nav.peers.title": { + "message": "Піри" + }, + "nav.peers.description": { + "message": "Підключено {connected} з {total}" + }, + "nav.resources.title": { + "message": "Ресурси" + }, + "nav.resources.description": { + "message": "Активно {active} з {total}" + }, + "nav.exitNode.title": { + "message": "Вихідні вузли" + }, + "nav.exitNode.none": { + "message": "Неактивний" + }, + "nav.exitNode.using": { + "message": "Через {name}" + }, + "header.openSettings": { + "message": "Відкрити налаштування" + }, + "header.togglePanel": { + "message": "Показати/сховати бічну панель" + }, + "profile.selector.loading": { + "message": "Завантаження…" + }, + "profile.selector.noProfile": { + "message": "Немає профілю" + }, + "profile.selector.searchPlaceholder": { + "message": "Пошук профілю за назвою…" + }, + "profile.selector.emptyTitle": { + "message": "Профілів не знайдено" + }, + "profile.selector.emptyDescription": { + "message": "Спробуйте змінити пошуковий запит або створіть новий профіль." + }, + "profile.selector.newProfile": { + "message": "Новий профіль" + }, + "profile.selector.moreOptions": { + "message": "Додаткові параметри" + }, + "profile.selector.deregister": { + "message": "Вийти з профілю" + }, + "profile.selector.delete": { + "message": "Видалити" + }, + "profile.selector.switchTo": { + "message": "Перемкнутися на цей профіль" + }, + "profile.selector.edit": { + "message": "Редагувати" + }, + "profile.edit.title": { + "message": "Редагувати профіль" + }, + "profile.edit.submit": { + "message": "Зберегти зміни" + }, + "profile.dialog.title": { + "message": "Введіть назву профілю" + }, + "profile.dialog.nameLabel": { + "message": "Назва профілю" + }, + "profile.dialog.description": { + "message": "Вкажіть зрозумілу назву для вашого профілю." + }, + "profile.dialog.placeholder": { + "message": "наприклад, Робота" + }, + "profile.dialog.submit": { + "message": "Додати профіль" + }, + "profile.dialog.required": { + "message": "Будь ласка, введіть назву профілю, наприклад, «Робота» або «Дім»." + }, + "profile.dialog.managementHelp": { + "message": "Використовуйте NetBird Cloud або власний сервер." + }, + "profile.dialog.urlUnreachable": { + "message": "Не вдалося підключитися до цього сервера. Перевірте URL-адресу або додайте профіль, якщо ви впевнені, що вона правильна." + }, + "header.menu.settings": { + "message": "Налаштування…" + }, + "header.menu.defaultView": { + "message": "Стандартний вигляд" + }, + "header.menu.advancedView": { + "message": "Розширений вигляд" + }, + "header.menu.updateAvailable": { + "message": "Доступне оновлення" + }, + "header.menu.open": { + "message": "Відкрити меню" + }, + "header.profile.switch": { + "message": "Змінити профіль" + }, + "connect.toggle.label": { + "message": "Перемкнути підключення NetBird" + }, + "connect.localIp.label": { + "message": "Локальні IP-адреси" + }, + "common.search": { + "message": "Пошук" + }, + "common.filter": { + "message": "Фільтр" + }, + "exitNodes.dropdown.trigger": { + "message": "Вибрати вихідний вузол" + }, + "peers.row.label": { + "message": "Відкрити деталі для {name}, {status}" + }, + "peers.dialog.title": { + "message": "Деталі піра" + }, + "networks.row.toggle": { + "message": "Перемкнути {name}" + }, + "networks.bulk.label": { + "message": "Перемкнути всі видимі ресурси" + }, + "profile.switch.title": { + "message": "Перемкнутися на профіль «{name}»?" + }, + "profile.switch.message": { + "message": "Ви впевнені, що хочете змінити профіль?\nВаш поточний профіль буде відключено." + }, + "profile.switch.confirm": { + "message": "Підтвердити" + }, + "profile.deregister.title": { + "message": "Вийти з профілю «{name}»?" + }, + "profile.deregister.message": { + "message": "Ви впевнені, що хочете вийти з цього профілю?\nВам доведеться увійти знову, щоб використовувати його." + }, + "profile.deregister.confirm": { + "message": "Вийти" + }, + "profile.delete.title": { + "message": "Видалити профіль «{name}»?" + }, + "profile.delete.message": { + "message": "Ви впевнені, що хочете видалити цей профіль?\nЦю дію неможливо скасувати." + }, + "profile.delete.disabledActive": { + "message": "Активні профілі не можна видаляти. Перемкніться на інший профіль перед видаленням цього." + }, + "profile.delete.disabledDefault": { + "message": "Профіль за замовчуванням не можна видалити." + }, + "profile.error.switchTitle": { + "message": "Помилка зміни профілю" + }, + "profile.error.deregisterTitle": { + "message": "Помилка виходу з профілю" + }, + "profile.error.deleteTitle": { + "message": "Помилка видалення профілю" + }, + "profile.error.createTitle": { + "message": "Помилка створення профілю" + }, + "profile.error.editTitle": { + "message": "Помилка редагування профілю" + }, + "profile.error.loadTitle": { + "message": "Помилка завантаження профілів" + }, + "profile.dropdown.activeProfile": { + "message": "Активний профіль" + }, + "profile.dropdown.switchProfile": { + "message": "Змінити профіль" + }, + "profile.dropdown.noEmail": { + "message": "Інше" + }, + "profile.dropdown.addProfile": { + "message": "Додати профіль" + }, + "profile.dropdown.manageProfiles": { + "message": "Керування профілями" + }, + "profile.dropdown.settings": { + "message": "Налаштування" + }, + "settings.profiles.section.profiles": { + "message": "Профілі" + }, + "settings.profiles.intro": { + "message": "Використовуйте кілька профілів NetBird одночасно, наприклад, робочий та особистий облікові записи або різні сервери керування. Додавайте профілі, виходьте з них або видаляйте їх нижче." + }, + "settings.profiles.addProfile": { + "message": "Додати профіль" + }, + "settings.profiles.active": { + "message": "Активний" + }, + "settings.profiles.emptyTitle": { + "message": "Немає профілів" + }, + "settings.profiles.emptyDescription": { + "message": "Створіть профіль, щоб підключитися до сервера керування NetBird." + }, + "settings.error.loadTitle": { + "message": "Помилка завантаження налаштувань" + }, + "settings.error.saveTitle": { + "message": "Помилка збереження налаштувань" + }, + "settings.error.debugBundleTitle": { + "message": "Помилка створення архіву діагностики" + }, + "settings.nav.label": { + "message": "Розділи налаштувань" + }, + "settings.tabs.general": { + "message": "Загальні" + }, + "settings.tabs.network": { + "message": "Мережа" + }, + "settings.tabs.security": { + "message": "Безпека" + }, + "settings.tabs.profiles": { + "message": "Профілі" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "Розширені" + }, + "settings.tabs.troubleshooting": { + "message": "Діагностика" + }, + "settings.tabs.about": { + "message": "Про програму" + }, + "settings.tabs.updateAvailable": { + "message": "Доступне оновлення" + }, + "settings.general.section.general": { + "message": "Загальні" + }, + "settings.general.section.connection": { + "message": "Підключення" + }, + "settings.general.connectOnStartup.label": { + "message": "Підключитися під час запуску" + }, + "settings.general.connectOnStartup.help": { + "message": "Автоматично встановлювати підключення під час запуску служби." + }, + "settings.general.notifications.label": { + "message": "Сповіщення на робочому столі" + }, + "settings.general.notifications.help": { + "message": "Показувати сповіщення на робочому столі про нові оновлення та події підключення." + }, + "settings.general.autostart.label": { + "message": "Запускати інтерфейс NetBird під час входу" + }, + "settings.general.autostart.help": { + "message": "Автоматично запускати інтерфейс NetBird під час входу в систему. Це стосується лише графічного інтерфейсу, а не фонової служби." + }, + "settings.general.autostart.errorTitle": { + "message": "Помилка зміни автозапуску" + }, + "settings.general.keepConnectedOnQuit.label": { + "message": "Залишатися підключеним після виходу" + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "Підключення залишатиметься активним у фоновому режимі після закриття NetBird. Воно буде розірвано лише тоді, коли ви відключите його самостійно." + }, + "settings.general.language.label": { + "message": "Мова інтерфейсу" + }, + "settings.general.language.help": { + "message": "Виберіть мову для інтерфейсу NetBird." + }, + "settings.general.language.search": { + "message": "Пошук мови…" + }, + "settings.general.language.empty": { + "message": "Не знайдено жодної мови." + }, + "settings.general.management.label": { + "message": "Сервер керування" + }, + "settings.general.management.help": { + "message": "Підключайтеся до NetBird Cloud або власного сервера керування. Зміни призведуть до перепідключення клієнта." + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "Власний сервер" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Будь ласка, введіть дійсну URL-адресу, наприклад: https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "Не вдалося підключитися до цього сервера. Перевірте URL-адресу або все одно збережіть зміни, якщо ви впевнені, що вона правильна." + }, + "settings.general.management.switchCloudTitle": { + "message": "Перемкнутися на NetBird Cloud?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Це відключить вас від власного сервера.\nВам може знадобитися увійти знову." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Перемкнутися на Cloud" + }, + "settings.network.section.connectivity": { + "message": "Підключення" + }, + "settings.network.section.routingDns": { + "message": "Маршрутизація та DNS" + }, + "settings.network.monitor.label": { + "message": "Перепідключатися при зміні мережі" + }, + "settings.network.monitor.help": { + "message": "Відстежувати мережу й автоматично перепідключатися у разі таких змін, як перемикання Wi-Fi, зміна Ethernet-підключення або вихід із режиму сну." + }, + "settings.network.dns.label": { + "message": "Увімкнути DNS" + }, + "settings.network.dns.help": { + "message": "Застосовувати налаштування DNS, якими керує NetBird, до локального DNS-розв’язувача хоста." + }, + "settings.network.clientRoutes.label": { + "message": "Увімкнути клієнтські маршрути" + }, + "settings.network.clientRoutes.help": { + "message": "Приймати маршрути від інших пірів для доступу до їхніх мереж." + }, + "settings.network.serverRoutes.label": { + "message": "Увімкнути серверні маршрути" + }, + "settings.network.serverRoutes.help": { + "message": "Анонсувати локальні маршрути цього хоста іншим пірам." + }, + "settings.network.ipv6.label": { + "message": "Увімкнути IPv6" + }, + "settings.network.ipv6.help": { + "message": "Використовувати адресацію IPv6 для оверлейної мережі NetBird." + }, + "settings.security.section.firewall": { + "message": "Брандмауер" + }, + "settings.security.section.encryption": { + "message": "Шифрування" + }, + "settings.security.blockInbound.label": { + "message": "Блокувати вхідний трафік" + }, + "settings.security.blockInbound.help": { + "message": "Відхиляти небажані підключення від пірів до цього пристрою та будь-яких мереж, які він маршрутизує. Вихідний трафік не обмежується." + }, + "settings.security.blockLan.label": { + "message": "Блокувати доступ до LAN" + }, + "settings.security.blockLan.help": { + "message": "Заборонити пірам отримувати доступ до вашої локальної мережі або її пристроїв, коли цей пристрій маршрутизує їхній трафік." + }, + "settings.security.rosenpass.label": { + "message": "Увімкнути постквантову стійкість" + }, + "settings.security.rosenpass.help": { + "message": "Додати постквантовий обмін ключами через Rosenpass поверх WireGuard®." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Увімкнути дозвільний режим" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Дозволити підключення до пірів без підтримки постквантової стійкості." + }, + "settings.ssh.section.server": { + "message": "Сервер" + }, + "settings.ssh.section.capabilities": { + "message": "Можливості" + }, + "settings.ssh.section.authentication": { + "message": "Автентифікація" + }, + "settings.ssh.server.label": { + "message": "Увімкнути SSH-сервер" + }, + "settings.ssh.server.help": { + "message": "Запустити SSH-сервер NetBird на цьому хості, щоб інші піри могли підключатися до нього." + }, + "settings.ssh.root.label": { + "message": "Дозволити вхід як root" + }, + "settings.ssh.root.help": { + "message": "Дозволити пірам входити як користувач root. Вимкніть, щоб вимагати непривілейований обліковий запис." + }, + "settings.ssh.sftp.label": { + "message": "Дозволити SFTP" + }, + "settings.ssh.sftp.help": { + "message": "Безпечно передавати файли за допомогою нативних клієнтів SFTP або SCP." + }, + "settings.ssh.localForward.label": { + "message": "Локальне переспрямування портів" + }, + "settings.ssh.localForward.help": { + "message": "Дозволити пірам, що підключаються, переспрямовувати локальні порти до сервісів, доступних із цього хоста." + }, + "settings.ssh.remoteForward.label": { + "message": "Віддалене переспрямування портів" + }, + "settings.ssh.remoteForward.help": { + "message": "Дозволити підключеним пірам відкривати порти на цьому хості з переспрямуванням на свої машини." + }, + "settings.ssh.jwt.label": { + "message": "Увімкнути JWT-автентифікацію" + }, + "settings.ssh.jwt.help": { + "message": "Перевіряти кожен сеанс SSH через ваш IdP для ідентифікації користувачів та аудиту. Вимкніть, щоб покладатися лише на політики мережевих ACL, що корисно, коли IdP недоступний." + }, + "settings.ssh.jwtTtl.label": { + "message": "Час кешування JWT (TTL)" + }, + "settings.ssh.jwtTtl.help": { + "message": "Як довго цей клієнт кешує JWT перед повторним запитом для вихідних SSH-з’єднань. Встановіть 0, щоб вимкнути кешування та проходити автентифікацію при кожному підключенні." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "сек." + }, + "settings.advanced.section.interface": { + "message": "Інтерфейс" + }, + "settings.advanced.section.security": { + "message": "Безпека" + }, + "settings.advanced.interfaceName.label": { + "message": "Назва" + }, + "settings.advanced.interfaceName.error": { + "message": "Використовуйте 1-15 літер, цифр, крапок, дефісів або підкреслень." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Повинно починатися з «utun», після якого має йти число (наприклад, utun100)." + }, + "settings.advanced.port.label": { + "message": "Порт" + }, + "settings.advanced.port.error": { + "message": "Введіть порт між {min} та {max}." + }, + "settings.advanced.port.help": { + "message": "Якщо встановлено 0, буде використано випадковий вільний порт." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Введіть значення MTU між {min} та {max}." + }, + "settings.advanced.psk.label": { + "message": "Попередньо узгоджений ключ" + }, + "settings.advanced.psk.help": { + "message": "Додатковий PSK WireGuard для симетричного шифрування. Це не те саме, що NetBird Setup Key. Ви зможете обмінюватися даними лише з тими пірами, які використовують такий самий попередньо узгоджений ключ." + }, + "settings.troubleshooting.section.title": { + "message": "Архів діагностики" + }, + "settings.troubleshooting.anonymize.label": { + "message": "Анонімізувати чутливу інформацію" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Приховує IP-адреси, домени та інші конфіденційні дані." + }, + "settings.troubleshooting.anonymize.info": { + "message": "«Стандартний» залишає внутрішні адреси IPv4 та імена пірів читабельними для служби підтримки. «Суворий» додатково анонімізує приватні (RFC 1918), CGNAT- та link-local-адреси, імена пірів і публічні ключі WireGuard. Однакові значення замінюються тим самим псевдонімом, тож піри залишаються розрізнюваними. Використовуйте «Суворий», якщо ділитеся архівом за межами організації." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Вимкнено" + }, + "settings.troubleshooting.anonymize.default": { + "message": "Стандартний" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Суворий" + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Додати інформацію про систему" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Додати дані про ОС, ядро, мережеві інтерфейси та таблиці маршрутизації." + }, + "settings.troubleshooting.upload.label": { + "message": "Завантажити архів на сервери NetBird" + }, + "settings.troubleshooting.upload.help": { + "message": "Створює ключ завантаження, який можна передати службі підтримки NetBird." + }, + "settings.troubleshooting.trace.label": { + "message": "Увімкнути журнали рівня TRACE" + }, + "settings.troubleshooting.trace.help": { + "message": "Підвищує рівень журналювання до TRACE на час створення архіву та відновлює його після завершення." + }, + "settings.troubleshooting.capture.label": { + "message": "Запис сеансу" + }, + "settings.troubleshooting.capture.help": { + "message": "Перепідключає NetBird і чекає, щоб ви могли відтворити проблему." + }, + "settings.troubleshooting.packets.label": { + "message": "Захоплювати мережеві пакети" + }, + "settings.troubleshooting.packets.help": { + "message": "Зберігає файл .pcap із мережевим трафіком протягом сеансу захоплення." + }, + "settings.troubleshooting.duration.label": { + "message": "Тривалість захоплення" + }, + "settings.troubleshooting.duration.help": { + "message": "Скільки часу триває сеанс захоплення." + }, + "settings.troubleshooting.duration.suffix": { + "message": "хв." + }, + "settings.troubleshooting.create": { + "message": "Створити архів" + }, + "settings.troubleshooting.progress.description": { + "message": "Збір журналів, даних про систему та інформації про стан підключення. Зазвичай це займає хвилину. Ви можете продовжувати використовувати NetBird або закрити вікно налаштувань, поки процес триває." + }, + "settings.troubleshooting.cancelling": { + "message": "Скасування…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "Архів діагностики успішно завантажено!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Архів збережено" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Поділіться ключем завантаження нижче зі службою підтримки NetBird. Локальну копію також збережено на вашому пристрої." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "Ваш архів діагностики збережено локально." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Копіювати ключ" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Відкрити папку" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Відкрити розташування файлу" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Помилка завантаження: {reason} Архів все одно збережено локально" + }, + "settings.troubleshooting.uploadFailed": { + "message": "Помилка завантаження. Архів все одно збережено локально." + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "Перепідключення NetBird…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Запис журналів діагностики" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Створення архіву діагностики…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Завантаження на сервери NetBird…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Скасування…" + }, + "settings.about.client": { + "message": "NetBird Client v{version}" + }, + "settings.about.clientName": { + "message": "NetBird Client" + }, + "settings.about.development": { + "message": "[Розробка]" + }, + "settings.about.gui": { + "message": "Графічний інтерфейс v{version}" + }, + "settings.about.guiName": { + "message": "Графічний інтерфейс" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Усі права захищено." + }, + "settings.about.links.imprint": { + "message": "Реквізити" + }, + "settings.about.links.privacy": { + "message": "Конфіденційність" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Умови використання" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Форум" + }, + "settings.about.community.documentation": { + "message": "Документація" + }, + "settings.about.community.feedback": { + "message": "Зворотний зв’язок" + }, + "update.banner.message": { + "message": "NetBird {version} готовий до встановлення." + }, + "update.banner.later": { + "message": "Пізніше" + }, + "update.banner.installNow": { + "message": "Встановити зараз" + }, + "update.card.versionAvailableDownload": { + "message": "Версія {version} доступна для завантаження." + }, + "update.card.versionAvailableInstall": { + "message": "Версія {version} доступна для встановлення." + }, + "update.card.whatsNew": { + "message": "Що нового?" + }, + "update.card.installNow": { + "message": "Встановити зараз" + }, + "update.card.getInstaller": { + "message": "Завантажити" + }, + "update.card.autoCheckInterval": { + "message": "NetBird перевіряє наявність оновлень у фоновому режимі." + }, + "update.card.changelog": { + "message": "Список змін" + }, + "update.card.onLatestVersion": { + "message": "Ви використовуєте останню версію" + }, + "update.header.tooltip": { + "message": "Доступне оновлення" + }, + "update.overlay.updatingVersion": { + "message": "Оновлення NetBird до v{version}" + }, + "update.overlay.updating": { + "message": "Оновлення NetBird" + }, + "update.overlay.description": { + "message": "Доступна новіша версія, яка зараз встановлюється. NetBird автоматично перезапуститься після завершення оновлення." + }, + "update.overlay.error.timeoutTitle": { + "message": "Оновлення триває занадто довго" + }, + "update.overlay.error.timeoutDescription": { + "message": "Встановлення {target} тривало занадто довго і не завершилося." + }, + "update.overlay.error.canceledTitle": { + "message": "Оновлення зупинено" + }, + "update.overlay.error.canceledDescription": { + "message": "Оновлення до {target} було скасовано до його завершення." + }, + "update.overlay.error.failTitle": { + "message": "Не вдалося встановити оновлення" + }, + "update.overlay.error.failDescription": { + "message": "Не вдалося встановити оновлення до {target}." + }, + "update.overlay.error.unknownMessage": { + "message": "Невідома помилка" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "нової версії" + }, + "update.error.loadStateTitle": { + "message": "Помилка завантаження стану оновлення" + }, + "update.error.triggerTitle": { + "message": "Помилка запуску оновлення" + }, + "update.page.versionLine": { + "message": "Оновлення клієнта до версії {version}." + }, + "update.page.versionLineGeneric": { + "message": "Оновлення клієнта." + }, + "update.page.outdated": { + "message": "Ваша версія клієнта старіша за версію для автооновлення, задану в Management." + }, + "update.page.status.running": { + "message": "Оновлення" + }, + "update.page.status.timeout": { + "message": "Час очікування оновлення минув. Будь ласка, спробуйте ще раз." + }, + "update.page.status.canceled": { + "message": "Оновлення скасовано." + }, + "update.page.status.failed": { + "message": "Помилка оновлення: {message}" + }, + "update.page.status.unknownError": { + "message": "невідома помилка оновлення" + }, + "update.page.failedTitle": { + "message": "Помилка оновлення" + }, + "update.page.timeoutMessage": { + "message": "Час очікування оновлення минув." + }, + "update.page.dontClose": { + "message": "Будь ласка, не закривайте це вікно." + }, + "update.page.updating": { + "message": "Оновлення…" + }, + "update.page.complete": { + "message": "Оновлення завершено" + }, + "update.page.failed": { + "message": "Помилка оновлення" + }, + "window.title.settings": { + "message": "Налаштування" + }, + "window.title.signIn": { + "message": "Вхід" + }, + "window.title.sessionExpiration": { + "message": "Термін дії сеансу закінчується" + }, + "window.title.updating": { + "message": "Оновлення" + }, + "window.title.welcome": { + "message": "Ласкаво просимо до NetBird" + }, + "window.title.error": { + "message": "Помилка" + }, + "welcome.title": { + "message": "Знайдіть NetBird в області сповіщень" + }, + "welcome.titleMac": { + "message": "Знайдіть NetBird у рядку меню" + }, + "welcome.description": { + "message": "NetBird працює в області сповіщень. Натисніть на іконку, щоб підключитися, змінити профіль або відкрити налаштування." + }, + "welcome.descriptionMac": { + "message": "NetBird працює в рядку меню. Натисніть на іконку, щоб підключитися, змінити профіль або відкрити налаштування." + }, + "welcome.continue": { + "message": "Продовжити" + }, + "welcome.back": { + "message": "Назад" + }, + "welcome.management.title": { + "message": "Налаштування NetBird" + }, + "welcome.management.description": { + "message": "Натисніть «Продовжити», щоб розпочати, або виберіть Власний сервер, якщо у вас є власний сервер NetBird." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Використовуйте наш хмарний сервіс. Налаштування не потрібне." + }, + "welcome.management.selfHosted.title": { + "message": "Власний сервер" + }, + "welcome.management.selfHosted.description": { + "message": "Підключіться до власного сервера керування." + }, + "welcome.management.urlLabel": { + "message": "URL-адреса сервера керування" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Будь ласка, введіть дійсну URL-адресу, наприклад: https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "Не вдалося підключитися до цього сервера. Перевірте URL-адресу або вашу мережу, а потім продовжуйте, якщо ви впевнені, що вона правильна." + }, + "welcome.management.checking": { + "message": "Перевірка…" + }, + "browserLogin.title": { + "message": "Завершіть вхід у браузері" + }, + "browserLogin.notSeeing": { + "message": "Ми відкрили вкладку браузера, щоб ви могли завершити вхід. Не бачите її?" + }, + "browserLogin.tryAgain": { + "message": "Спробувати ще раз" + }, + "browserLogin.openFailedTitle": { + "message": "Помилка відкриття браузера" + }, + "sessionExpiration.title": { + "message": "Термін дії сеансу невдовзі закінчиться" + }, + "sessionExpiration.titleLater": { + "message": "Термін дії вашого сеансу закінчиться" + }, + "sessionExpiration.description": { + "message": "Цей пристрій невдовзі буде відключено. Поновіть сеанс, увійшовши через браузер." + }, + "sessionExpiration.descriptionLater": { + "message": "Вхід через браузер підтримує підключення цього пристрою до вашої мережі." + }, + "sessionExpiration.stay": { + "message": "Продовжити сеанс" + }, + "sessionExpiration.authenticate": { + "message": "Увійти" + }, + "sessionExpiration.logout": { + "message": "Вийти" + }, + "sessionExpiration.expired": { + "message": "Термін дії сеансу закінчився" + }, + "sessionExpiration.expiredDescription": { + "message": "Пристрій відключено. Пройдіть автентифікацію у браузері, щоб перепідключитися." + }, + "sessionExpiration.close": { + "message": "Закрити" + }, + "sessionExpiration.extendFailedTitle": { + "message": "Помилка продовження сеансу" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Помилка виходу" + }, + "peers.search.placeholder": { + "message": "Пошук за ім’ям або IP" + }, + "peers.filter.all": { + "message": "Усі" + }, + "peers.filter.online": { + "message": "Онлайн" + }, + "peers.filter.offline": { + "message": "Офлайн" + }, + "peers.empty.title": { + "message": "Немає доступних пірів" + }, + "peers.empty.description": { + "message": "У вас немає доступних пірів або доступу до жодного з них." + }, + "peers.details.domain": { + "message": "Домен" + }, + "peers.details.netbirdIp": { + "message": "NetBird IP" + }, + "peers.details.netbirdIpv6": { + "message": "NetBird IPv6" + }, + "peers.details.publicKey": { + "message": "Публічний ключ" + }, + "peers.details.connection": { + "message": "Підключення" + }, + "peers.details.latency": { + "message": "Затримка" + }, + "peers.details.lastHandshake": { + "message": "Останнє рукостискання" + }, + "peers.details.statusSince": { + "message": "Останнє оновлення підключення" + }, + "peers.details.bytes": { + "message": "Байти" + }, + "peers.details.bytesSent": { + "message": "Надіслано" + }, + "peers.details.bytesReceived": { + "message": "Отримано" + }, + "peers.details.localIce": { + "message": "Локальний ICE" + }, + "peers.details.remoteIce": { + "message": "Віддалений ICE" + }, + "peers.details.never": { + "message": "Ніколи" + }, + "peers.details.justNow": { + "message": "Щойно" + }, + "peers.details.refresh": { + "message": "Оновити" + }, + "peers.status.connected": { + "message": "Підключено" + }, + "peers.status.connecting": { + "message": "Підключення" + }, + "peers.status.disconnected": { + "message": "Відключено" + }, + "peers.details.relayAddress": { + "message": "Ретранслятор" + }, + "peers.details.networks": { + "message": "Ресурси" + }, + "peers.details.relayed": { + "message": "Через ретранслятор" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass увімкнено" + }, + "networks.search.placeholder": { + "message": "Пошук за мережею або доменом" + }, + "networks.filter.all": { + "message": "Усі" + }, + "networks.filter.active": { + "message": "Активні" + }, + "networks.filter.overlapping": { + "message": "Перетинаються" + }, + "networks.empty.title": { + "message": "Немає доступних ресурсів" + }, + "networks.empty.description": { + "message": "У вас немає доступних мережевих ресурсів або доступу до жодного з них." + }, + "networks.selected": { + "message": "Вибрано" + }, + "networks.unselected": { + "message": "Не вибрано" + }, + "networks.ips.heading": { + "message": "Визначені IP-адреси" + }, + "networks.bulk.selectionCount": { + "message": "Активні: {selected} з {total}" + }, + "networks.bulk.enableAll": { + "message": "Увімкнути всі" + }, + "networks.bulk.disableAll": { + "message": "Вимкнути всі" + }, + "exitNodes.search.placeholder": { + "message": "Пошук вихідних вузлів" + }, + "exitNodes.none": { + "message": "Немає" + }, + "exitNodes.empty.title": { + "message": "Немає доступних вихідних вузлів" + }, + "exitNodes.empty.description": { + "message": "Цьому піру не надано жодного вихідного вузла." + }, + "exitNodes.card.title": { + "message": "Вихідний вузол" + }, + "exitNodes.card.statusActive": { + "message": "Активний" + }, + "exitNodes.card.statusInactive": { + "message": "Неактивний" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Немає" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Пряме підключення без вихідного вузла" + }, + "quickActions.connect": { + "message": "Підключитися" + }, + "quickActions.disconnect": { + "message": "Відключитися" + }, + "daemon.unavailable.title": { + "message": "Служба NetBird не запущена" + }, + "daemon.unavailable.description": { + "message": "Програма перепідключиться автоматично, щойно служба запрацює." + }, + "daemon.unavailable.docsLink": { + "message": "Документація" + }, + "daemon.outdated.title": { + "message": "Клієнт NetBird застарів" + }, + "daemon.outdated.description": { + "message": "Новий графічний інтерфейс несумісний зі старою версією клієнта NetBird. Оновіть клієнт, щоб використовувати нову програму." + }, + "daemon.outdated.download": { + "message": "Завантажити останню версію" + }, + "error.jwt_clock_skew": { + "message": "Помилка входу: годинник цього пристрою не синхронізовано із сервером. Будь ласка, синхронізуйте системний годинник і спробуйте знову." + }, + "error.jwt_expired": { + "message": "Термін дії вашого токена входу закінчився. Будь ласка, увійдіть знову." + }, + "error.jwt_signature_invalid": { + "message": "Помилка входу: недійсний підпис токена. Будь ласка, зверніться до адміністратора." + }, + "error.session_expired": { + "message": "Термін дії вашого сеансу закінчився. Будь ласка, увійдіть знову." + }, + "error.invalid_setup_key": { + "message": "Setup Key відсутній або недійсний." + }, + "error.permission_denied": { + "message": "Вхід відхилено сервером." + }, + "error.daemon_unreachable": { + "message": "Служба NetBird не відповідає. Будь ласка, перевірте, чи запущена служба." + }, + "error.unknown": { + "message": "Помилка операції." + }, + "error.elevation_unavailable": { + "message": "NetBird не зміг запросити в системи привілеї, необхідні для внесення змін. Замість цього виконайте:" + }, + "error.elevation_failed": { + "message": "Не вдалося застосувати зміни з підвищеними привілеями. Замість цього виконайте:" + }, + "settings.ssh.privilege.actorRoot": { + "message": "прав root" + }, + "settings.ssh.privilege.actorAdministrator": { + "message": "прав адміністратора" + }, + "settings.ssh.privilege.hint": { + "message": "Потребує {actor}. Замість цього виконайте:" + }, + "settings.ssh.privilege.oneWay": { + "message": "Ви можете вимкнути це, але щоб увімкнути знову, знадобиться {actor}:" + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Ви можете увімкнути це, але щоб вимкнути знову, знадобиться {actor}:" + }, + "settings.ssh.privilege.authorizePending": { + "message": "Очікування авторизації…" + } +} diff --git a/client/ui/i18n/locales/zh-CN/common.json b/client/ui/i18n/locales/zh-CN/common.json new file mode 100644 index 000000000..b1ff3370d --- /dev/null +++ b/client/ui/i18n/locales/zh-CN/common.json @@ -0,0 +1,1378 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "已断开连接" + }, + "tray.status.daemonUnavailable": { + "message": "未运行" + }, + "tray.status.error": { + "message": "错误" + }, + "tray.status.connected": { + "message": "已连接" + }, + "tray.status.connecting": { + "message": "正在连接" + }, + "tray.status.needsLogin": { + "message": "需要登录" + }, + "tray.status.loginFailed": { + "message": "登录失败" + }, + "tray.status.sessionExpired": { + "message": "会话已过期" + }, + "tray.session.expiresIn": { + "message": "会话将在 {remaining} 后过期" + }, + "tray.session.unit.lessThanMinute": { + "message": "不到一分钟" + }, + "tray.session.unit.minute": { + "message": "1 分钟" + }, + "tray.session.unit.minutes": { + "message": "{count} 分钟" + }, + "tray.session.unit.hour": { + "message": "1 小时" + }, + "tray.session.unit.hours": { + "message": "{count} 小时" + }, + "tray.session.unit.day": { + "message": "1 天" + }, + "tray.session.unit.days": { + "message": "{count} 天" + }, + "tray.menu.open": { + "message": "打开 NetBird" + }, + "tray.menu.connect": { + "message": "连接" + }, + "tray.menu.disconnect": { + "message": "断开连接" + }, + "tray.menu.exitNode": { + "message": "出口节点" + }, + "tray.menu.networks": { + "message": "资源" + }, + "tray.menu.profiles": { + "message": "配置文件" + }, + "tray.menu.manageProfiles": { + "message": "管理配置文件" + }, + "tray.menu.settings": { + "message": "设置…" + }, + "tray.menu.debugBundle": { + "message": "创建调试包" + }, + "tray.menu.about": { + "message": "帮助与支持" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "文档" + }, + "tray.menu.troubleshoot": { + "message": "故障排除" + }, + "tray.menu.downloadLatest": { + "message": "下载最新版本" + }, + "tray.menu.installVersion": { + "message": "安装 {version} 版本" + }, + "tray.menu.guiVersion": { + "message": "GUI:{version}" + }, + "tray.menu.daemonVersion": { + "message": "守护进程:{version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "退出 NetBird" + }, + "notify.daemonOutdated.title": { + "message": "NetBird 服务版本过旧" + }, + "notify.daemonOutdated.body": { + "message": "请更新 NetBird 服务以使用此应用。" + }, + "notify.update.title": { + "message": "NetBird 有可用更新" + }, + "notify.update.body": { + "message": "NetBird {version} 已可用。" + }, + "notify.update.enforcedSuffix": { + "message": " 您的管理员要求进行此次更新。" + }, + "notify.error.title": { + "message": "错误" + }, + "notify.error.connect": { + "message": "连接失败" + }, + "notify.error.disconnect": { + "message": "断开连接失败" + }, + "notify.error.switchProfile": { + "message": "切换到 {profile} 失败" + }, + "notify.error.exitNode": { + "message": "更新出口节点 {name} 失败" + }, + "notify.sessionExpired.title": { + "message": "NetBird 会话已过期" + }, + "notify.sessionExpired.body": { + "message": "您的 NetBird 会话已过期。请重新登录。" + }, + "notify.sessionWarning.title": { + "message": "会话即将过期" + }, + "notify.sessionWarning.body": { + "message": "您的 NetBird 会话将在 {remaining} 后过期。点击“立即延长”以续期。" + }, + "notify.sessionWarning.bodyGeneric": { + "message": "您的 NetBird 会话即将过期。点击“立即延长”以续期。" + }, + "notify.sessionWarning.extend": { + "message": "立即延长" + }, + "notify.sessionWarning.dismiss": { + "message": "忽略" + }, + "notify.sessionWarning.failed": { + "message": "延长 NetBird 会话失败" + }, + "notify.sessionWarning.successTitle": { + "message": "NetBird 会话已延长" + }, + "notify.sessionWarning.successBody": { + "message": "您的会话已刷新。" + }, + "notify.sessionDeadlineRejected.title": { + "message": "会话截止时间被拒绝" + }, + "notify.sessionDeadlineRejected.body": { + "message": "服务器发送了无效的会话截止时间。请重新登录。" + }, + "notify.mdm.policyApplied.title": { + "message": "NetBird 设置已更新" + }, + "notify.mdm.policyApplied.body": { + "message": "您的 NetBird 配置已根据 IT 策略更新。" + }, + "common.cancel": { + "message": "取消" + }, + "common.save": { + "message": "保存" + }, + "common.saveChanges": { + "message": "保存更改" + }, + "common.saving": { + "message": "正在保存…" + }, + "common.close": { + "message": "关闭" + }, + "common.copy": { + "message": "复制" + }, + "common.togglePasswordVisibility": { + "message": "切换密码可见性" + }, + "common.increase": { + "message": "增加" + }, + "common.decrease": { + "message": "减少" + }, + "common.delete": { + "message": "删除" + }, + "common.create": { + "message": "创建" + }, + "common.add": { + "message": "添加" + }, + "common.remove": { + "message": "移除" + }, + "common.refresh": { + "message": "刷新" + }, + "common.loading": { + "message": "正在加载…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "未找到任何结果" + }, + "common.noResults.description": { + "message": "我们未能找到任何结果。请尝试其他搜索词或更改筛选条件。" + }, + "notConnected.title": { + "message": "已断开连接" + }, + "notConnected.description": { + "message": "请先连接到 NetBird,以查看有关对等节点、网络资源和出口节点的详细信息。" + }, + "connect.status.disconnected": { + "message": "已断开连接" + }, + "connect.status.connecting": { + "message": "正在连接…" + }, + "connect.status.connected": { + "message": "已连接" + }, + "connect.status.disconnecting": { + "message": "正在断开连接…" + }, + "connect.status.daemonUnavailable": { + "message": "守护进程不可用" + }, + "connect.status.loginRequired": { + "message": "需要登录" + }, + "connect.error.loginTitle": { + "message": "登录失败" + }, + "connect.error.connectTitle": { + "message": "连接失败" + }, + "connect.error.disconnectTitle": { + "message": "断开连接失败" + }, + "nav.peers.title": { + "message": "对等节点" + }, + "nav.peers.description": { + "message": "{total} 个中已连接 {connected} 个" + }, + "nav.resources.title": { + "message": "资源" + }, + "nav.resources.description": { + "message": "{total} 个中已激活 {active} 个" + }, + "nav.exitNode.title": { + "message": "出口节点" + }, + "nav.exitNode.none": { + "message": "未激活" + }, + "nav.exitNode.using": { + "message": "经由 {name}" + }, + "header.openSettings": { + "message": "打开设置" + }, + "header.togglePanel": { + "message": "切换侧边栏" + }, + "profile.selector.loading": { + "message": "正在加载…" + }, + "profile.selector.noProfile": { + "message": "无配置文件" + }, + "profile.selector.searchPlaceholder": { + "message": "按名称搜索配置文件…" + }, + "profile.selector.emptyTitle": { + "message": "未找到配置文件" + }, + "profile.selector.emptyDescription": { + "message": "请尝试其他搜索词或创建新的配置文件。" + }, + "profile.selector.newProfile": { + "message": "新建配置文件" + }, + "profile.selector.moreOptions": { + "message": "更多选项" + }, + "profile.selector.deregister": { + "message": "注销" + }, + "profile.selector.delete": { + "message": "删除" + }, + "profile.selector.switchTo": { + "message": "切换到此配置文件" + }, + "profile.selector.edit": { + "message": "编辑" + }, + "profile.edit.title": { + "message": "编辑配置文件" + }, + "profile.edit.submit": { + "message": "保存更改" + }, + "profile.dialog.title": { + "message": "输入配置文件名称" + }, + "profile.dialog.nameLabel": { + "message": "配置文件名称" + }, + "profile.dialog.description": { + "message": "为您的配置文件设置一个易于识别的名称。" + }, + "profile.dialog.placeholder": { + "message": "例如:工作" + }, + "profile.dialog.submit": { + "message": "添加配置文件" + }, + "profile.dialog.required": { + "message": "请输入配置文件名称,例如:工作、家庭" + }, + "profile.dialog.managementHelp": { + "message": "使用 NetBird Cloud 或您自己的服务器。" + }, + "profile.dialog.urlUnreachable": { + "message": "无法连接到此服务器。请检查 URL,如果确认无误,也可以照常添加该配置文件。" + }, + "header.menu.settings": { + "message": "设置…" + }, + "header.menu.defaultView": { + "message": "默认视图" + }, + "header.menu.advancedView": { + "message": "高级视图" + }, + "header.menu.updateAvailable": { + "message": "有可用更新" + }, + "header.menu.open": { + "message": "打开菜单" + }, + "header.profile.switch": { + "message": "切换配置文件" + }, + "connect.toggle.label": { + "message": "切换 NetBird 连接" + }, + "connect.localIp.label": { + "message": "本地 IP 地址" + }, + "common.search": { + "message": "搜索" + }, + "common.filter": { + "message": "筛选" + }, + "exitNodes.dropdown.trigger": { + "message": "选择出口节点" + }, + "peers.row.label": { + "message": "打开 {name} 的详情,{status}" + }, + "peers.dialog.title": { + "message": "对等节点详情" + }, + "networks.row.toggle": { + "message": "切换 {name}" + }, + "networks.bulk.label": { + "message": "切换所有可见资源" + }, + "profile.switch.title": { + "message": "切换到配置文件“{name}”?" + }, + "profile.switch.message": { + "message": "您确定要切换配置文件吗?\n您当前的配置文件将被断开连接。" + }, + "profile.switch.confirm": { + "message": "确认" + }, + "profile.deregister.title": { + "message": "注销配置文件“{name}”?" + }, + "profile.deregister.message": { + "message": "您确定要注销此配置文件吗?\n您将需要重新登录才能使用它。" + }, + "profile.deregister.confirm": { + "message": "注销" + }, + "profile.delete.title": { + "message": "删除配置文件“{name}”?" + }, + "profile.delete.message": { + "message": "您确定要删除此配置文件吗?\n此操作无法撤销。" + }, + "profile.delete.disabledActive": { + "message": "无法删除处于活动状态的配置文件。请先切换到其他配置文件,再删除此配置文件。" + }, + "profile.delete.disabledDefault": { + "message": "无法删除默认配置文件。" + }, + "profile.error.switchTitle": { + "message": "切换配置文件失败" + }, + "profile.error.deregisterTitle": { + "message": "注销配置文件失败" + }, + "profile.error.deleteTitle": { + "message": "删除配置文件失败" + }, + "profile.error.createTitle": { + "message": "创建配置文件失败" + }, + "profile.error.editTitle": { + "message": "编辑配置文件失败" + }, + "profile.error.loadTitle": { + "message": "加载配置文件失败" + }, + "profile.dropdown.activeProfile": { + "message": "当前配置文件" + }, + "profile.dropdown.switchProfile": { + "message": "切换配置文件" + }, + "profile.dropdown.noEmail": { + "message": "其他" + }, + "profile.dropdown.addProfile": { + "message": "添加配置文件" + }, + "profile.dropdown.manageProfiles": { + "message": "管理配置文件" + }, + "profile.dropdown.settings": { + "message": "设置" + }, + "settings.profiles.section.profiles": { + "message": "配置文件" + }, + "settings.profiles.intro": { + "message": "并行保留多个独立的 NetBird 身份,例如工作和个人账户,或不同的管理服务器。可在下方添加、注销或删除配置文件。" + }, + "settings.profiles.addProfile": { + "message": "添加配置文件" + }, + "settings.profiles.active": { + "message": "活动" + }, + "settings.profiles.emptyTitle": { + "message": "无配置文件" + }, + "settings.profiles.emptyDescription": { + "message": "创建一个配置文件以连接到 NetBird 管理服务器。" + }, + "settings.error.loadTitle": { + "message": "加载设置失败" + }, + "settings.error.saveTitle": { + "message": "保存设置失败" + }, + "settings.error.debugBundleTitle": { + "message": "创建调试包失败" + }, + "settings.nav.label": { + "message": "设置部分" + }, + "settings.tabs.general": { + "message": "常规" + }, + "settings.tabs.network": { + "message": "网络" + }, + "settings.tabs.security": { + "message": "安全" + }, + "settings.tabs.profiles": { + "message": "配置文件" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "高级" + }, + "settings.tabs.troubleshooting": { + "message": "故障排除" + }, + "settings.tabs.about": { + "message": "关于" + }, + "settings.tabs.updateAvailable": { + "message": "有可用更新" + }, + "settings.general.section.general": { + "message": "常规" + }, + "settings.general.section.connection": { + "message": "连接" + }, + "settings.general.connectOnStartup.label": { + "message": "启动时连接" + }, + "settings.general.connectOnStartup.help": { + "message": "在服务启动时自动建立连接。" + }, + "settings.general.notifications.label": { + "message": "桌面通知" + }, + "settings.general.notifications.help": { + "message": "显示有关新更新和连接事件的桌面通知。" + }, + "settings.general.autostart.label": { + "message": "登录时启动 NetBird 界面" + }, + "settings.general.autostart.help": { + "message": "在您登录时自动启动 NetBird 界面。此设置仅影响图形界面,不影响后台服务。" + }, + "settings.general.autostart.errorTitle": { + "message": "更改自启动设置失败" + }, + "settings.general.keepConnectedOnQuit.label": { + "message": "退出后保持连接", + "description": "Toggle label: keep the VPN connection up after quitting the UI." + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "关闭 NetBird 后,连接会在后台保持。只有你自己断开时才会停止。", + "description": "Helper text for the stay-connected-after-quitting toggle." + }, + "settings.general.language.label": { + "message": "显示语言" + }, + "settings.general.language.help": { + "message": "选择 NetBird 界面的语言。" + }, + "settings.general.language.search": { + "message": "搜索语言…" + }, + "settings.general.language.empty": { + "message": "没有匹配的语言。" + }, + "settings.general.management.label": { + "message": "管理服务器" + }, + "settings.general.management.help": { + "message": "连接到 NetBird Cloud 或您自己的自托管管理服务器。更改将使客户端重新连接。" + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "自托管" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "请输入有效的 URL,例如:https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "无法连接到此服务器。请检查 URL,如果确认无误,也可以照常保存。" + }, + "settings.general.management.switchCloudTitle": { + "message": "切换到 NetBird Cloud?" + }, + "settings.general.management.switchCloudMessage": { + "message": "这将断开您的自托管服务器。\n您可能需要重新登录。" + }, + "settings.general.management.switchCloudConfirm": { + "message": "切换到 Cloud" + }, + "settings.network.section.connectivity": { + "message": "连接性" + }, + "settings.network.section.routingDns": { + "message": "路由与 DNS" + }, + "settings.network.monitor.label": { + "message": "网络变化时重新连接" + }, + "settings.network.monitor.help": { + "message": "监测网络,并在发生变化时自动重新连接,例如切换 Wi-Fi、以太网变化或从睡眠中恢复。" + }, + "settings.network.dns.label": { + "message": "启用 DNS" + }, + "settings.network.dns.help": { + "message": "将 NetBird 管理的 DNS 设置应用到主机解析器。" + }, + "settings.network.clientRoutes.label": { + "message": "启用客户端路由" + }, + "settings.network.clientRoutes.help": { + "message": "接受来自其他对等节点的路由,以访问它们的网络。" + }, + "settings.network.serverRoutes.label": { + "message": "启用服务器路由" + }, + "settings.network.serverRoutes.help": { + "message": "向其他对等节点通告此主机的本地路由。" + }, + "settings.network.ipv6.label": { + "message": "启用 IPv6" + }, + "settings.network.ipv6.help": { + "message": "为 NetBird 叠加网络使用 IPv6 寻址。" + }, + "settings.security.section.firewall": { + "message": "防火墙" + }, + "settings.security.section.encryption": { + "message": "加密" + }, + "settings.security.blockInbound.label": { + "message": "阻止入站流量" + }, + "settings.security.blockInbound.help": { + "message": "拒绝对等节点向本设备及其路由的任何网络发起的未经请求的连接。出站流量不受影响。" + }, + "settings.security.blockLan.label": { + "message": "阻止 LAN 访问" + }, + "settings.security.blockLan.help": { + "message": "当本设备为对等节点路由流量时,阻止它们访问您的本地网络或其设备。" + }, + "settings.security.rosenpass.label": { + "message": "启用抗量子加密" + }, + "settings.security.rosenpass.help": { + "message": "在 WireGuard® 之上通过 Rosenpass 添加后量子密钥交换。" + }, + "settings.security.rosenpassPermissive.label": { + "message": "启用宽松模式" + }, + "settings.security.rosenpassPermissive.help": { + "message": "允许连接到不支持抗量子加密的对等节点。" + }, + "settings.ssh.section.server": { + "message": "服务器" + }, + "settings.ssh.section.capabilities": { + "message": "功能" + }, + "settings.ssh.section.authentication": { + "message": "身份验证" + }, + "settings.ssh.server.label": { + "message": "启用 SSH 服务器" + }, + "settings.ssh.server.help": { + "message": "在此主机上运行 NetBird SSH 服务器,以便其他对等节点可以连接到它。" + }, + "settings.ssh.root.label": { + "message": "允许 root 登录" + }, + "settings.ssh.root.help": { + "message": "允许对等节点以 root 用户身份登录。禁用后将要求使用非特权账户。" + }, + "settings.ssh.sftp.label": { + "message": "允许 SFTP" + }, + "settings.ssh.sftp.help": { + "message": "使用原生 SFTP 或 SCP 客户端安全地传输文件。" + }, + "settings.ssh.localForward.label": { + "message": "本地端口转发" + }, + "settings.ssh.localForward.help": { + "message": "允许连接的对等节点将本地端口隧道转发到此主机可访问的服务。" + }, + "settings.ssh.remoteForward.label": { + "message": "远程端口转发" + }, + "settings.ssh.remoteForward.help": { + "message": "允许连接的对等节点将此主机上的端口反向暴露到它们自己的机器。" + }, + "settings.ssh.jwt.label": { + "message": "启用 JWT 身份验证" + }, + "settings.ssh.jwt.help": { + "message": "针对您的 IdP 验证每个 SSH 会话,以确认用户身份并进行审计。禁用后将仅依赖网络 ACL 策略,在没有可用 IdP 时很有用。" + }, + "settings.ssh.jwtTtl.label": { + "message": "JWT 缓存 TTL" + }, + "settings.ssh.jwtTtl.help": { + "message": "在出站 SSH 连接再次提示前,此客户端缓存 JWT 的时长。设为 0 可禁用缓存,每次连接都进行身份验证。" + }, + "settings.ssh.jwtTtl.suffix": { + "message": "秒" + }, + "settings.advanced.section.interface": { + "message": "接口" + }, + "settings.advanced.section.security": { + "message": "安全" + }, + "settings.advanced.interfaceName.label": { + "message": "名称" + }, + "settings.advanced.interfaceName.error": { + "message": "请使用 1-15 个字母、数字、点、连字符或下划线。" + }, + "settings.advanced.interfaceName.errorMac": { + "message": "必须以“utun”开头,后跟一个数字(例如 utun100)。" + }, + "settings.advanced.port.label": { + "message": "端口" + }, + "settings.advanced.port.error": { + "message": "请输入介于 {min} 和 {max} 之间的端口。" + }, + "settings.advanced.port.help": { + "message": "如果设为 0,将使用一个随机的空闲端口。" + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "请输入介于 {min} 和 {max} 之间的 MTU 值。" + }, + "settings.advanced.psk.label": { + "message": "预共享密钥" + }, + "settings.advanced.psk.help": { + "message": "可选的 WireGuard PSK,用于额外的对称加密。它与 NetBird 设置密钥不同。您将只能与使用相同预共享密钥的对等节点通信。" + }, + "settings.troubleshooting.section.title": { + "message": "调试包" + }, + "settings.troubleshooting.anonymize.label": { + "message": "匿名化敏感信息" + }, + "settings.troubleshooting.anonymize.help": { + "message": "隐藏 IP 地址、域名和其他敏感值。" + }, + "settings.troubleshooting.anonymize.info": { + "message": "默认级别保留内部 IPv4 地址和对等节点名称,便于支持人员阅读。严格级别还会匿名化私有 (RFC 1918)、CGNAT 和链路本地 IP 地址、对等节点名称以及 WireGuard 公钥。相同的值会映射到相同的占位符,因此对等节点仍可区分。向组织外部分享调试包时请使用严格级别。" + }, + "settings.troubleshooting.anonymize.none": { + "message": "无" + }, + "settings.troubleshooting.anonymize.default": { + "message": "默认" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "严格" + }, + "settings.troubleshooting.systemInfo.label": { + "message": "包含系统信息" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "包含操作系统、内核、网络接口和路由表。" + }, + "settings.troubleshooting.upload.label": { + "message": "将调试包上传到 NetBird 服务器" + }, + "settings.troubleshooting.upload.help": { + "message": "返回一个上传密钥,供您分享给 NetBird 支持团队。" + }, + "settings.troubleshooting.trace.label": { + "message": "启用跟踪日志" + }, + "settings.troubleshooting.trace.help": { + "message": "将日志级别提升到 TRACE,之后再恢复原级别。" + }, + "settings.troubleshooting.capture.label": { + "message": "捕获会话" + }, + "settings.troubleshooting.capture.help": { + "message": "重新连接并等待,以便您复现问题。" + }, + "settings.troubleshooting.packets.label": { + "message": "捕获网络数据包" + }, + "settings.troubleshooting.packets.help": { + "message": "在捕获期间将网络流量保存为 .pcap 文件。" + }, + "settings.troubleshooting.duration.label": { + "message": "捕获时长" + }, + "settings.troubleshooting.duration.help": { + "message": "捕获会话运行的时长。" + }, + "settings.troubleshooting.duration.suffix": { + "message": "分钟" + }, + "settings.troubleshooting.create": { + "message": "创建调试包" + }, + "settings.troubleshooting.progress.description": { + "message": "正在收集日志、系统详情和连接状态。这通常只需片刻——请保持此窗口打开,直到完成。" + }, + "settings.troubleshooting.cancelling": { + "message": "正在取消…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "调试包已成功上传!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "调试包已保存" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "请将下方的上传密钥分享给 NetBird 支持团队。本地也已保存了一份副本。" + }, + "settings.troubleshooting.done.savedDescription": { + "message": "您的调试包已保存在本地。" + }, + "settings.troubleshooting.done.copyKey": { + "message": "复制密钥" + }, + "settings.troubleshooting.done.openFolder": { + "message": "打开文件夹" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "打开文件位置" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "上传失败:{reason} 调试包仍已保存在本地。" + }, + "settings.troubleshooting.uploadFailed": { + "message": "上传失败。调试包仍已保存在本地。" + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "正在重新连接 NetBird…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "正在捕获调试日志" + }, + "settings.troubleshooting.stage.bundling": { + "message": "正在生成调试包…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "正在上传到 NetBird…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "正在取消…" + }, + "settings.about.client": { + "message": "NetBird 客户端 v{version}" + }, + "settings.about.clientName": { + "message": "NetBird 客户端" + }, + "settings.about.development": { + "message": "[开发版]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird。保留所有权利。" + }, + "settings.about.links.imprint": { + "message": "法律声明" + }, + "settings.about.links.privacy": { + "message": "隐私" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "服务条款" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "论坛" + }, + "settings.about.community.documentation": { + "message": "文档" + }, + "settings.about.community.feedback": { + "message": "反馈" + }, + "update.banner.message": { + "message": "NetBird {version} 已准备好安装。" + }, + "update.banner.later": { + "message": "稍后" + }, + "update.banner.installNow": { + "message": "立即安装" + }, + "update.card.versionAvailableDownload": { + "message": "{version} 版本可供下载。" + }, + "update.card.versionAvailableInstall": { + "message": "{version} 版本可供安装。" + }, + "update.card.whatsNew": { + "message": "更新内容?" + }, + "update.card.installNow": { + "message": "立即安装" + }, + "update.card.getInstaller": { + "message": "下载" + }, + "update.card.autoCheckInterval": { + "message": "NetBird 会在后台检查更新。" + }, + "update.card.changelog": { + "message": "更新日志" + }, + "update.card.onLatestVersion": { + "message": "您已是最新版本" + }, + "update.header.tooltip": { + "message": "有可用更新" + }, + "update.overlay.updatingVersion": { + "message": "正在将 NetBird 更新到 v{version}" + }, + "update.overlay.updating": { + "message": "正在更新 NetBird" + }, + "update.overlay.description": { + "message": "有更新的版本可用,正在安装。更新完成后,NetBird 将自动重启。" + }, + "update.overlay.error.timeoutTitle": { + "message": "更新耗时过长" + }, + "update.overlay.error.timeoutDescription": { + "message": "安装 {target} 耗时过长,未能完成。" + }, + "update.overlay.error.canceledTitle": { + "message": "更新已停止" + }, + "update.overlay.error.canceledDescription": { + "message": "对 {target} 的更新在完成前已被取消。" + }, + "update.overlay.error.failTitle": { + "message": "无法安装更新" + }, + "update.overlay.error.failDescription": { + "message": "无法安装 {target}。" + }, + "update.overlay.error.unknownMessage": { + "message": "未知错误" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "新版本" + }, + "update.error.loadStateTitle": { + "message": "加载更新状态失败" + }, + "update.error.triggerTitle": { + "message": "启动更新失败" + }, + "update.page.versionLine": { + "message": "正在将客户端更新到:{version}。" + }, + "update.page.versionLineGeneric": { + "message": "正在更新客户端。" + }, + "update.page.outdated": { + "message": "您的客户端版本早于管理服务器中设置的自动更新版本。" + }, + "update.page.status.running": { + "message": "正在更新" + }, + "update.page.status.timeout": { + "message": "更新超时。请重试。" + }, + "update.page.status.canceled": { + "message": "更新已取消。" + }, + "update.page.status.failed": { + "message": "更新失败:{message}" + }, + "update.page.status.unknownError": { + "message": "未知的更新错误" + }, + "update.page.failedTitle": { + "message": "更新失败" + }, + "update.page.timeoutMessage": { + "message": "更新超时。" + }, + "update.page.dontClose": { + "message": "请勿关闭此窗口。" + }, + "update.page.updating": { + "message": "正在更新…" + }, + "update.page.complete": { + "message": "更新完成" + }, + "update.page.failed": { + "message": "更新失败" + }, + "window.title.settings": { + "message": "设置" + }, + "window.title.signIn": { + "message": "登录" + }, + "window.title.sessionExpiration": { + "message": "会话即将过期" + }, + "window.title.updating": { + "message": "正在更新" + }, + "window.title.welcome": { + "message": "欢迎使用 NetBird" + }, + "window.title.error": { + "message": "错误" + }, + "welcome.title": { + "message": "在托盘中查找 NetBird" + }, + "welcome.titleMac": { + "message": "在菜单栏中查找 NetBird" + }, + "welcome.description": { + "message": "NetBird 驻留在您的托盘中。点击图标即可连接、切换配置文件或打开设置。" + }, + "welcome.descriptionMac": { + "message": "NetBird 驻留在您的菜单栏中。点击图标即可连接、切换配置文件或打开设置。" + }, + "welcome.continue": { + "message": "继续" + }, + "welcome.back": { + "message": "返回" + }, + "welcome.management.title": { + "message": "设置 NetBird" + }, + "welcome.management.description": { + "message": "点击“继续”即可开始;如果您有自己的 NetBird 服务器,请选择“自托管”。" + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "使用我们的托管服务。无需任何设置。" + }, + "welcome.management.selfHosted.title": { + "message": "自托管" + }, + "welcome.management.selfHosted.description": { + "message": "连接到您自己的管理服务器。" + }, + "welcome.management.urlLabel": { + "message": "管理服务器 URL" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "请输入有效的 URL,例如:https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "无法连接到此服务器。请检查 URL 或您的网络,如果确认无误,再继续。" + }, + "welcome.management.checking": { + "message": "正在检查…" + }, + "browserLogin.title": { + "message": "请在浏览器中继续以完成登录" + }, + "browserLogin.notSeeing": { + "message": "没看到浏览器标签页?" + }, + "browserLogin.tryAgain": { + "message": "重试" + }, + "browserLogin.openFailedTitle": { + "message": "打开浏览器失败" + }, + "sessionExpiration.title": { + "message": "会话即将过期" + }, + "sessionExpiration.titleLater": { + "message": "您的会话即将过期" + }, + "sessionExpiration.description": { + "message": "此设备即将断开连接。通过浏览器登录进行续期。" + }, + "sessionExpiration.descriptionLater": { + "message": "通过浏览器登录可让此设备保持连接到您的网络。" + }, + "sessionExpiration.stay": { + "message": "续期会话" + }, + "sessionExpiration.authenticate": { + "message": "进行身份验证" + }, + "sessionExpiration.logout": { + "message": "退出登录" + }, + "sessionExpiration.expired": { + "message": "会话已过期" + }, + "sessionExpiration.expiredDescription": { + "message": "设备已断开连接。通过浏览器登录进行身份验证以重新连接。" + }, + "sessionExpiration.close": { + "message": "关闭" + }, + "sessionExpiration.extendFailedTitle": { + "message": "延长会话失败" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "退出登录失败" + }, + "peers.search.placeholder": { + "message": "按名称或 IP 搜索" + }, + "peers.filter.all": { + "message": "全部" + }, + "peers.filter.online": { + "message": "在线" + }, + "peers.filter.offline": { + "message": "离线" + }, + "peers.empty.title": { + "message": "无可用的对等节点" + }, + "peers.empty.description": { + "message": "您可能没有任何可用的对等节点,或者无权访问其中任何一个。" + }, + "peers.details.domain": { + "message": "域名" + }, + "peers.details.netbirdIp": { + "message": "NetBird IP" + }, + "peers.details.netbirdIpv6": { + "message": "NetBird IPv6" + }, + "peers.details.publicKey": { + "message": "公钥" + }, + "peers.details.connection": { + "message": "连接" + }, + "peers.details.latency": { + "message": "延迟" + }, + "peers.details.lastHandshake": { + "message": "上次握手" + }, + "peers.details.statusSince": { + "message": "上次连接更新" + }, + "peers.details.bytes": { + "message": "字节" + }, + "peers.details.bytesSent": { + "message": "已发送" + }, + "peers.details.bytesReceived": { + "message": "已接收" + }, + "peers.details.localIce": { + "message": "本地 ICE" + }, + "peers.details.remoteIce": { + "message": "远程 ICE" + }, + "peers.details.never": { + "message": "从不" + }, + "peers.details.justNow": { + "message": "刚刚" + }, + "peers.details.refresh": { + "message": "刷新" + }, + "peers.status.connected": { + "message": "已连接" + }, + "peers.status.connecting": { + "message": "正在连接" + }, + "peers.status.disconnected": { + "message": "已断开连接" + }, + "peers.details.relayAddress": { + "message": "中继" + }, + "peers.details.networks": { + "message": "资源" + }, + "peers.details.relayed": { + "message": "经中继" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "已启用 Rosenpass" + }, + "networks.search.placeholder": { + "message": "按网络或域名搜索" + }, + "networks.filter.all": { + "message": "全部" + }, + "networks.filter.active": { + "message": "活动" + }, + "networks.filter.overlapping": { + "message": "重叠" + }, + "networks.empty.title": { + "message": "无可用资源" + }, + "networks.empty.description": { + "message": "您可能没有任何可用的网络资源,或者无权访问其中任何一个。" + }, + "networks.selected": { + "message": "已选择" + }, + "networks.unselected": { + "message": "未选择" + }, + "networks.ips.heading": { + "message": "已解析的 IP" + }, + "networks.bulk.selectionCount": { + "message": "{total} 个中已激活 {selected} 个" + }, + "networks.bulk.enableAll": { + "message": "全部启用" + }, + "networks.bulk.disableAll": { + "message": "全部禁用" + }, + "exitNodes.search.placeholder": { + "message": "搜索出口节点" + }, + "exitNodes.none": { + "message": "无" + }, + "exitNodes.empty.title": { + "message": "无可用的出口节点" + }, + "exitNodes.empty.description": { + "message": "尚未向此对等节点共享任何出口节点。" + }, + "exitNodes.card.title": { + "message": "出口节点" + }, + "exitNodes.card.statusActive": { + "message": "活动" + }, + "exitNodes.card.statusInactive": { + "message": "非活动" + }, + "exitNodes.dropdown.noneTitle": { + "message": "无" + }, + "exitNodes.dropdown.noneDescription": { + "message": "不使用出口节点的直接连接" + }, + "quickActions.connect": { + "message": "连接" + }, + "quickActions.disconnect": { + "message": "断开连接" + }, + "daemon.unavailable.title": { + "message": "NetBird 服务未运行" + }, + "daemon.unavailable.description": { + "message": "服务运行后,应用将自动重新连接。" + }, + "daemon.unavailable.docsLink": { + "message": "文档" + }, + "daemon.outdated.title": { + "message": "NetBird 客户端版本过旧" + }, + "daemon.outdated.description": { + "message": "新版 GUI 与您较旧的客户端不兼容。请更新客户端以使用新应用。" + }, + "daemon.outdated.download": { + "message": "下载最新版本" + }, + "error.jwt_clock_skew": { + "message": "登录失败:此设备的时钟与服务器不同步。请同步您的系统时钟后重试。" + }, + "error.jwt_expired": { + "message": "您的登录令牌已过期。请重新登录。" + }, + "error.jwt_signature_invalid": { + "message": "登录失败:令牌签名无效。请联系您的管理员。" + }, + "error.session_expired": { + "message": "您的会话已过期。请重新登录。" + }, + "error.invalid_setup_key": { + "message": "设置密钥缺失或无效。" + }, + "error.permission_denied": { + "message": "登录被服务器拒绝。" + }, + "error.daemon_unreachable": { + "message": "NetBird 守护进程无响应。请检查服务是否正在运行。" + }, + "error.unknown": { + "message": "操作失败。" + }, + "error.elevation_unavailable": { + "message": "NetBird 无法向此系统请求所需的权限。请改为运行:" + }, + "error.elevation_failed": { + "message": "即使使用提升的权限也无法应用此更改。请改为运行:" + }, + "settings.ssh.privilege.actorRoot": { + "message": "root 权限" + }, + "settings.ssh.privilege.actorAdministrator": { + "message": "管理员权限" + }, + "settings.ssh.privilege.hint": { + "message": "需要{actor}。请改为运行:" + }, + "settings.ssh.privilege.oneWay": { + "message": "您可以关闭此项,但重新开启需要{actor}。" + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "您可以开启此项,但再次关闭需要{actor}。" + }, + "settings.ssh.privilege.authorizePending": { + "message": "正在等待授权…" + } +} diff --git a/client/ui/icons.go b/client/ui/icons.go index 874f24fdd..5ab19ca05 100644 --- a/client/ui/icons.go +++ b/client/ui/icons.go @@ -1,16 +1,10 @@ -//go:build !(linux && 386) && !windows +//go:build !android && !ios && !freebsd && !js package main -import ( - _ "embed" -) +import _ "embed" -//go:embed assets/netbird.png -var iconAbout []byte - -//go:embed assets/netbird-disconnected.png -var iconAboutDisconnected []byte +// Windows reuses these PNGs: multi-frame .ico never redrew under Wails3's NIM_MODIFY, single-frame PNG does. //go:embed assets/netbird-systemtray-connected.png var iconConnected []byte @@ -21,18 +15,6 @@ var iconConnectedDark []byte //go:embed assets/netbird-systemtray-disconnected.png var iconDisconnected []byte -//go:embed assets/netbird-systemtray-update-disconnected.png -var iconUpdateDisconnected []byte - -//go:embed assets/netbird-systemtray-update-disconnected-dark.png -var iconUpdateDisconnectedDark []byte - -//go:embed assets/netbird-systemtray-update-connected.png -var iconUpdateConnected []byte - -//go:embed assets/netbird-systemtray-update-connected-dark.png -var iconUpdateConnectedDark []byte - //go:embed assets/netbird-systemtray-connecting.png var iconConnecting []byte @@ -44,3 +26,91 @@ var iconError []byte //go:embed assets/netbird-systemtray-error-dark.png var iconErrorDark []byte + +//go:embed assets/netbird-systemtray-needs-login.png +var iconNeedsLogin []byte + +//go:embed assets/netbird-systemtray-update-connected.png +var iconUpdateConnected []byte + +//go:embed assets/netbird-systemtray-update-connected-dark.png +var iconUpdateConnectedDark []byte + +//go:embed assets/netbird-systemtray-update-disconnected.png +var iconUpdateDisconnected []byte + +//go:embed assets/netbird-systemtray-update-disconnected-dark.png +var iconUpdateDisconnectedDark []byte + +//go:embed assets/netbird-systemtray-connected-macos.png +var iconConnectedMacOS []byte + +//go:embed assets/netbird-systemtray-disconnected-macos.png +var iconDisconnectedMacOS []byte + +//go:embed assets/netbird-systemtray-connecting-macos.png +var iconConnectingMacOS []byte + +//go:embed assets/netbird-systemtray-error-macos.png +var iconErrorMacOS []byte + +//go:embed assets/netbird-systemtray-needs-login-macos.png +var iconNeedsLoginMacOS []byte + +//go:embed assets/netbird-systemtray-update-connected-macos.png +var iconUpdateConnectedMacOS []byte + +//go:embed assets/netbird-systemtray-update-disconnected-macos.png +var iconUpdateDisconnectedMacOS []byte + +// SNI has no template recoloring, so ship an explicit pair: black (*-mono.png) +// for light panels, white (*-mono-dark.png) for dark panels. + +//go:embed assets/netbird-systemtray-connected-mono.png +var iconConnectedMono []byte + +//go:embed assets/netbird-systemtray-connected-mono-dark.png +var iconConnectedMonoDark []byte + +//go:embed assets/netbird-systemtray-connecting-mono.png +var iconConnectingMono []byte + +//go:embed assets/netbird-systemtray-connecting-mono-dark.png +var iconConnectingMonoDark []byte + +//go:embed assets/netbird-systemtray-disconnected-mono.png +var iconDisconnectedMono []byte + +//go:embed assets/netbird-systemtray-disconnected-mono-dark.png +var iconDisconnectedMonoDark []byte + +//go:embed assets/netbird-systemtray-error-mono.png +var iconErrorMono []byte + +//go:embed assets/netbird-systemtray-error-mono-dark.png +var iconErrorMonoDark []byte + +//go:embed assets/netbird-systemtray-needs-login-mono.png +var iconNeedsLoginMono []byte + +//go:embed assets/netbird-systemtray-needs-login-mono-dark.png +var iconNeedsLoginMonoDark []byte + +//go:embed assets/netbird-systemtray-update-connected-mono.png +var iconUpdateConnectedMono []byte + +//go:embed assets/netbird-systemtray-update-connected-mono-dark.png +var iconUpdateConnectedMonoDark []byte + +//go:embed assets/netbird-systemtray-update-disconnected-mono.png +var iconUpdateDisconnectedMono []byte + +//go:embed assets/netbird-systemtray-update-disconnected-mono-dark.png +var iconUpdateDisconnectedMonoDark []byte + +//go:embed assets/netbird.png +var iconWindow []byte + +// Per-platform menu-row icons live in icons_menu_{windows,other}.go. Windows +// uses 16x16: they go into the Win32 check-mark slot (SM_CXMENUCHECK, ~16x16 at +// 100% DPI) which crops anything bigger; macOS/Linux use 24x24. diff --git a/client/ui/icons_menu_darwin.go b/client/ui/icons_menu_darwin.go new file mode 100644 index 000000000..c077a6d2d --- /dev/null +++ b/client/ui/icons_menu_darwin.go @@ -0,0 +1,23 @@ +//go:build darwin + +package main + +import _ "embed" + +// 22px matches the NSMenuItem row text weight (HIG's 18-22 range); +// Windows uses 16px and Linux 24px — see the sibling icons_menu_*.go. + +//go:embed assets/netbird-menu-dot-connected-22.png +var iconMenuDotConnected []byte + +//go:embed assets/netbird-menu-dot-connecting-22.png +var iconMenuDotConnecting []byte + +//go:embed assets/netbird-menu-dot-error-22.png +var iconMenuDotError []byte + +//go:embed assets/netbird-menu-dot-idle-22.png +var iconMenuDotIdle []byte + +//go:embed assets/netbird-menu-dot-offline-22.png +var iconMenuDotOffline []byte diff --git a/client/ui/icons_menu_linux.go b/client/ui/icons_menu_linux.go new file mode 100644 index 000000000..54e0c743a --- /dev/null +++ b/client/ui/icons_menu_linux.go @@ -0,0 +1,22 @@ +//go:build linux + +package main + +import _ "embed" + +// 24x24: GTK4 menu rows render 22–48 px with no downscaling. + +//go:embed assets/netbird-menu-dot-connected.png +var iconMenuDotConnected []byte + +//go:embed assets/netbird-menu-dot-connecting.png +var iconMenuDotConnecting []byte + +//go:embed assets/netbird-menu-dot-error.png +var iconMenuDotError []byte + +//go:embed assets/netbird-menu-dot-idle.png +var iconMenuDotIdle []byte + +//go:embed assets/netbird-menu-dot-offline.png +var iconMenuDotOffline []byte diff --git a/client/ui/icons_menu_windows.go b/client/ui/icons_menu_windows.go new file mode 100644 index 000000000..c08359902 --- /dev/null +++ b/client/ui/icons_menu_windows.go @@ -0,0 +1,24 @@ +//go:build windows + +package main + +import _ "embed" + +// SetMenuItemBitmaps sizes the HBITMAP to SM_CXMENUCHECK/SM_CYMENUCHECK (16x16 +// at 100% DPI); larger bitmaps overflow the row, hence this Windows-only set +// downscaled from the 24x24 originals. + +//go:embed assets/netbird-menu-dot-connected-16.png +var iconMenuDotConnected []byte + +//go:embed assets/netbird-menu-dot-connecting-16.png +var iconMenuDotConnecting []byte + +//go:embed assets/netbird-menu-dot-error-16.png +var iconMenuDotError []byte + +//go:embed assets/netbird-menu-dot-idle-16.png +var iconMenuDotIdle []byte + +//go:embed assets/netbird-menu-dot-offline-16.png +var iconMenuDotOffline []byte diff --git a/client/ui/icons_windows.go b/client/ui/icons_windows.go deleted file mode 100644 index bd57b2690..000000000 --- a/client/ui/icons_windows.go +++ /dev/null @@ -1,44 +0,0 @@ -package main - -import ( - _ "embed" -) - -//go:embed assets/netbird.ico -var iconAbout []byte - -//go:embed assets/netbird-disconnected.ico -var iconAboutDisconnected []byte - -//go:embed assets/netbird-systemtray-connected.ico -var iconConnected []byte - -//go:embed assets/netbird-systemtray-connected-dark.ico -var iconConnectedDark []byte - -//go:embed assets/netbird-systemtray-disconnected.ico -var iconDisconnected []byte - -//go:embed assets/netbird-systemtray-update-disconnected.ico -var iconUpdateDisconnected []byte - -//go:embed assets/netbird-systemtray-update-disconnected-dark.ico -var iconUpdateDisconnectedDark []byte - -//go:embed assets/netbird-systemtray-update-connected.ico -var iconUpdateConnected []byte - -//go:embed assets/netbird-systemtray-update-connected-dark.ico -var iconUpdateConnectedDark []byte - -//go:embed assets/netbird-systemtray-connecting.ico -var iconConnecting []byte - -//go:embed assets/netbird-systemtray-connecting-dark.ico -var iconConnectingDark []byte - -//go:embed assets/netbird-systemtray-error.ico -var iconError []byte - -//go:embed assets/netbird-systemtray-error-dark.ico -var iconErrorDark []byte diff --git a/client/ui/localizer.go b/client/ui/localizer.go new file mode 100644 index 000000000..33c1cf205 --- /dev/null +++ b/client/ui/localizer.go @@ -0,0 +1,130 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "strings" + "sync" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/ui/i18n" + "github.com/netbirdio/netbird/client/ui/preferences" + "github.com/netbirdio/netbird/client/ui/services" +) + +// Localizer caches the active language so key lookups skip the preferences store. +// +// Kept in the main package (not i18n/) because StatusLabel maps daemon +// status enum strings to translations; moving it would invert the +// dependency direction. +type Localizer struct { + bundle *i18n.Bundle + store *preferences.Store + + mu sync.RWMutex + lang i18n.LanguageCode + + unsubscribe func() +} + +// NewLocalizer seeds the active language from the on-disk preference. Either +// argument may be nil (tests): T then returns the raw key and Watch is a no-op. +func NewLocalizer(bundle *i18n.Bundle, store *preferences.Store) *Localizer { + l := &Localizer{ + bundle: bundle, + store: store, + lang: i18n.DefaultLanguage, + } + if store != nil { + if p := store.Get(); p.Language != "" { + l.lang = p.Language + } + } + return l +} + +// Language returns the active language code. +func (l *Localizer) Language() i18n.LanguageCode { + l.mu.RLock() + defer l.mu.RUnlock() + return l.lang +} + +// T resolves key in the current language; args are {placeholder}/value pairs. +// With no bundle wired it returns key unchanged. +func (l *Localizer) T(key string, args ...string) string { + if l == nil || l.bundle == nil { + return key + } + l.mu.RLock() + lang := l.lang + l.mu.RUnlock() + return l.bundle.Translate(lang, key, args...) +} + +// Watch invokes cb on each language change, after the cached language is +// updated so cb may call l.T with the new locale. Replaces any prior subscription. +func (l *Localizer) Watch(cb func(lang i18n.LanguageCode)) { + if l.store == nil { + return + } + ch, unsubscribe := l.store.Subscribe() + l.mu.Lock() + if l.unsubscribe != nil { + l.unsubscribe() + } + l.unsubscribe = unsubscribe + l.mu.Unlock() + + go func() { + for p := range ch { + if p.Language == "" { + continue + } + l.mu.Lock() + if l.lang == p.Language { + l.mu.Unlock() + continue + } + l.lang = p.Language + l.mu.Unlock() + log.Infof("localizer: language switched to %s", p.Language) + if cb != nil { + cb(p.Language) + } + } + }() +} + +// Close cancels the preference subscription. +func (l *Localizer) Close() { + l.mu.Lock() + defer l.mu.Unlock() + if l.unsubscribe != nil { + l.unsubscribe() + l.unsubscribe = nil + } +} + +// StatusLabel maps a daemon status string to its tray label; unrecognised +// statuses pass through verbatim. +func (l *Localizer) StatusLabel(status string) string { + switch { + case status == "", strings.EqualFold(status, services.StatusIdle): + return l.T("tray.status.disconnected") + case strings.EqualFold(status, services.StatusDaemonUnavailable): + return l.T("tray.status.daemonUnavailable") + case strings.EqualFold(status, services.StatusConnected): + return l.T("tray.status.connected") + case strings.EqualFold(status, services.StatusConnecting): + return l.T("tray.status.connecting") + case strings.EqualFold(status, services.StatusNeedsLogin): + return l.T("tray.status.needsLogin") + case strings.EqualFold(status, services.StatusLoginFailed): + return l.T("tray.status.loginFailed") + case strings.EqualFold(status, services.StatusSessionExpired): + return l.T("tray.status.sessionExpired") + } + return status +} diff --git a/client/ui/main.go b/client/ui/main.go new file mode 100644 index 000000000..5652efcf2 --- /dev/null +++ b/client/ui/main.go @@ -0,0 +1,399 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + "embed" + "flag" + "io/fs" + "log" + "os" + "runtime" + "strings" + + "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" + + "github.com/netbirdio/netbird/client/ui/authsession" + "github.com/netbirdio/netbird/client/ui/i18n" + "github.com/netbirdio/netbird/client/ui/preferences" + "github.com/netbirdio/netbird/client/ui/services" + "github.com/netbirdio/netbird/client/ui/updater" + "github.com/netbirdio/netbird/util" +) + +//go:embed all:frontend/dist +var assets embed.FS + +// localesRoot embeds the i18n bundles shared by the tray (Go) and the React +// UI (Vite imports the same files). The `all:` prefix is required so +// _index.json is included — //go:embed drops files starting with "_" or "." +// otherwise. +// +//go:embed all:i18n/locales +var localesRoot embed.FS + +// stringList collects repeated string flags. The first user-supplied value +// drops the seeded default; subsequent passes append. +type stringList struct { + values []string + userSet bool +} + +func (s *stringList) String() string { + return strings.Join(s.values, ",") +} + +func (s *stringList) Set(v string) error { + if !s.userSet { + s.values = nil + s.userSet = true + } + s.values = append(s.values, v) + return nil +} + +type registeredServices struct { + connection *services.Connection + authSession *authsession.Session + settings *services.Settings + networks *services.Networks + profiles *services.Profiles + update *services.Update + daemonFeed *services.DaemonFeed + notifier *Notifier + compat *services.Compat + profileSwitcher *services.ProfileSwitcher + bundle *i18n.Bundle + prefStore *preferences.Store +} + +func init() { + application.RegisterEvent[services.Status](services.EventStatusSnapshot) + application.RegisterEvent[services.SystemEvent](services.EventDaemonNotification) + application.RegisterEvent[services.ProfileRef](services.EventProfileChanged) + application.RegisterEvent[authsession.Warning](services.EventSessionWarning) + application.RegisterEvent[updater.State](updater.EventStateChanged) + application.RegisterEvent[preferences.UIPreferences](preferences.EventPreferencesChanged) +} + +func main() { + // The one-shot that applies the settings the daemon restricts to + // root/administrator, which this binary runs itself as under the platform's + // elevation prompt. Handled before anything GUI so no window, tray or + // single-instance lock is involved. + if services.IsPrivilegedSettingsRun(os.Args[1:]) { + os.Exit(runPrivilegedSettings(os.Args[1:])) + } + + daemonAddr, userSetLogFile := parseFlagsAndInitLog() + conn := NewConn(daemonAddr) + + // Without --log-file, the GUI manages a gui-client.log that follows the + // daemon's debug level and is collected in the debug bundle. It rides + // DaemonFeed's SubscribeEvents stream (see guilog.DebugLog). + debugLog := newDebugLog(userSetLogFile) + + // Declared before app.New so the SingleInstance callback closes over it. + var tray *Tray + app := newApplication(func() { + if tray != nil { + tray.ShowWindow() + } + }) + + profiles := services.NewProfiles(conn) + // updater.Holder owns the typed update State; DaemonFeed feeds it and the + // Update service is a thin Wails-bound facade over it plus the install RPCs. + updaterHolder := updater.NewHolder(app.Event) + update := services.NewUpdate(conn, updaterHolder) + daemonFeed := services.NewDaemonFeed(conn, app.Event, updaterHolder, debugLog) + notifier := newNotifier() + compat := services.NewCompat(conn) + // macOS shows no toast until permission is requested. Run it after + // ApplicationStarted so the notifier's Startup has initialised the + // notification-center delegate. No-op on Linux/Windows (stubs report + // authorized). + app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { + go requestNotificationAuthorization(notifier) + initDockObserver() + }) + + bundle, prefStore, localizer := buildI18n(app) + + // After bundle + prefStore: both are used to localise daemon errors. + settings := services.NewSettings(conn, bundle, prefStore, daemonAddr) + connection := services.NewConnection(conn, bundle, prefStore) + profileSwitcher := services.NewProfileSwitcher(profiles, connection, daemonFeed) + // authsession.Session owns the full extend + dismiss surface the tray + // drives directly; the Wails-bound services.Session wraps only the subset + // the React frontend calls, keeping the generated TS surface minimal. + authSession := authsession.NewSession(conn) + networks := services.NewNetworks(conn) + + registerServices(app, conn, registeredServices{ + connection: connection, + authSession: authSession, + settings: settings, + networks: networks, + profiles: profiles, + update: update, + daemonFeed: daemonFeed, + notifier: notifier, + compat: compat, + profileSwitcher: profileSwitcher, + bundle: bundle, + prefStore: prefStore, + }) + + windowManager := services.NewWindowManager(app, nil, bundle, prefStore, iconWindow) + windowManager.SetMainFactory(func(startURL string) *application.WebviewWindow { + return newMainWindow(app, prefStore, windowManager, startURL) + }) + registerDockReopenHook(app, windowManager) + // Minimal WMs (XEmbed-tray path) neither center small windows nor restore + // position across hide -> show, dropping them top-left. Gate Go-side + // re-centering on that environment; nil leaves placement to the WM on full + // desktops, macOS, and Windows. + windowManager.SetRecenterOnShow(recenterOnShowPredicate()) + app.RegisterService(application.NewService(windowManager)) + + // Welcome window, first launch only — Continue flips OnboardingCompleted + // so later launches skip it. ApplicationStarted hook so the Wails window + // machinery is fully up before the window is created. + if !prefStore.Get().OnboardingCompleted { + app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { + windowManager.OpenWelcome() + }) + } + + // In-process StatusNotifierWatcher so the tray works on minimal WMs that + // don't ship one (Fluxbox, i3, GNOME without AppIndicator). No-op off + // Linux. Must run before NewTray so the systray's + // RegisterStatusNotifierItem hits a watcher we control. + startStatusNotifierWatcher() + + tray = NewTray(app, nil, TrayServices{ + Connection: connection, + Settings: settings, + Profiles: profiles, + Networks: networks, + DaemonFeed: daemonFeed, + Notifier: notifier, + Update: update, + ProfileSwitcher: profileSwitcher, + WindowManager: windowManager, + Session: authSession, + Localizer: localizer, + Preferences: prefStore, + }) + listenForShowSignal(context.Background(), tray) + + // Start the feed only after every service's ServiceStartup has run. The + // first SubscribeEvents message replays cached state synchronously and can + // fire an OS notification; if Watch ran before app.Run it could beat the + // notifier's ServiceStartup, where the Linux notifier connects the session + // bus — its *dbus.Conn would still be nil and SendNotification would + // nil-deref (fatal panic on the dispatch goroutine, observed on Linux + // Mint). ApplicationStarted fires after the startup loop, so the bus is up. + app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { + daemonFeed.Watch(context.Background()) + // Probe daemon compatibility once the notifier bus is up; an outdated + // daemon may keep the main window from showing, so the OS toast is the + // only reliable signal the user gets. + go notifyIfDaemonOutdated(compat, notifier, localizer) + // One-time launch-on-login default for fresh installs; gated by the + // NetBird footprint check, MDM policy, and the persisted marker. + go applyAutostartDefault(context.Background(), services.NewAutostart(app.Autostart), prefStore, prefStore.ExistedAtLoad()) + }) + + if err := app.Run(); err != nil { + log.Fatal(err) + } +} + +// requestNotificationAuthorization prompts for macOS notification permission. +// The request blocks until the user responds (up to 3 minutes), so callers run +// it in a goroutine. No-op on Linux/Windows. +func requestNotificationAuthorization(notifier *Notifier) { + authorized, err := notifier.CheckNotificationAuthorization() + if err != nil { + logrus.Debugf("check notification authorization: %v", err) + return + } + if authorized { + return + } + if _, err := notifier.RequestNotificationAuthorization(); err != nil { + logrus.Debugf("request notification authorization: %v", err) + } +} + +// parseFlagsAndInitLog returns the daemon gRPC address and userSetLogFile +// (true when --log-file was passed). userSetLogFile is the manual-override +// signal: true leaves logging alone, false lets the GUI manage a +// daemon-driven gui-client.log. The flag default is empty (not "console") so +// "no flag" and an explicit "--log-file console" stay distinguishable; empty +// falls back to console for InitLog. +func parseFlagsAndInitLog() (string, bool) { + daemonAddr := flag.String("daemon-addr", DaemonAddr(), "Daemon gRPC address: unix:///path or tcp://host:port") + logFiles := &stringList{} + flag.Var(logFiles, "log-file", "Log destination. Repeat to log to multiple targets at once, e.g. `--log-file console --log-file Y:/netbird-ui.log`. Each value is one of: console, syslog, or a file path. File destinations are rotated by lumberjack (same as the daemon). Defaults to console. Passing any value disables the daemon-debug-driven gui-client.log.") + logLevel := flag.String("log-level", "info", "Log level: trace|debug|info|warn|error.") + flag.Parse() + + userSetLogFile := len(logFiles.values) > 0 + targets := logFiles.values + if !userSetLogFile { + targets = []string{"console"} + } + + if err := util.InitLog(*logLevel, targets...); err != nil { + log.Fatalf("init log: %v", err) + } + return *daemonAddr, userSetLogFile +} + +// newApplication constructs the Wails application. onSecondInstance fires when +// a second process launches under the same SingleInstance UniqueID. +func newApplication(onSecondInstance func()) *application.App { + // On macOS, Options.Icon feeds NSApplication's setApplicationIconImage, + // overriding the bundle icon (Assets.car / icons.icns) the OS already + // picked. Suppress it on darwin to keep the bundle's squircle. + appIcon := iconWindow + if runtime.GOOS == "darwin" { + appIcon = nil + } + + return application.New(application.Options{ + // On Windows, Name is the AppUserModelID for toast notifications and + // the HKCU\Software\Classes\AppUserModelId\ registry path. It must + // match the System.AppUserModel.ID the MSI sets on the Start Menu + // shortcut (client/netbird.wxs) and the AppUserModelId key the + // installer pre-populates with the toast activator CLSID; otherwise + // toasts show under a different identity and the MSI's CustomActivator + // value is orphaned. + Name: "NetBird", + Description: "NetBird desktop client", + Icon: appIcon, + Assets: application.AssetOptions{ + Handler: application.AssetFileServerFS(assets), + }, + Mac: application.MacOptions{ + ApplicationShouldTerminateAfterLastWindowClosed: false, + ActivationPolicy: application.ActivationPolicyAccessory, + }, + Linux: application.LinuxOptions{ + ProgramName: "netbird", + DisableQuitOnLastWindowClosed: true, + }, + Windows: application.WindowsOptions{ + WndProcInterceptor: endSessionInterceptor(), + DisableQuitOnLastWindowClosed: true, + }, + SingleInstance: &application.SingleInstanceOptions{ + UniqueID: "io.netbird.ui", + OnSecondInstanceLaunch: func(_ application.SecondInstanceData) { + onSecondInstance() + }, + }, + }) +} + +// buildI18n constructs the i18n bundle, preferences store, and tray localizer. +// The Bundle satisfies preferences.LanguageValidator so SetLanguage rejects +// codes that have no shipped translation. +func buildI18n(app *application.App) (*i18n.Bundle, *preferences.Store, *Localizer) { + // Reroot the embedded tree at the locales dir so the bundle sees + // _index.json and /common.json at top level (//go:embed roots at + // the package, not the leaf dir). + localesFS, err := fs.Sub(localesRoot, "i18n/locales") + if err != nil { + log.Fatalf("locate locales fs: %v", err) + } + bundle, err := i18n.NewBundle(localesFS) + if err != nil { + log.Fatalf("init i18n bundle: %v", err) + } + prefStore, err := preferences.NewStore(bundle, app.Event) + if err != nil { + log.Fatalf("init preferences store: %v", err) + } + return bundle, prefStore, NewLocalizer(bundle, prefStore) +} + +// registerServices binds every Wails-facing service onto the application. +// Services with no other caller are constructed inline; the rest arrive +// already built so the tray and feed loops share the same instances. +func registerServices(app *application.App, conn *Conn, s registeredServices) { + app.RegisterService(application.NewService(s.connection)) + app.RegisterService(application.NewService(services.NewSession(s.authSession, s.bundle, s.prefStore))) + app.RegisterService(application.NewService(s.settings)) + app.RegisterService(application.NewService(s.networks)) + app.RegisterService(application.NewService(services.NewForwarding(conn))) + app.RegisterService(application.NewService(s.profiles)) + app.RegisterService(application.NewService(services.NewDebug(conn))) + app.RegisterService(application.NewService(s.update)) + app.RegisterService(application.NewService(s.daemonFeed)) + app.RegisterService(application.NewService(s.notifier)) + app.RegisterService(application.NewService(s.profileSwitcher)) + app.RegisterService(application.NewService(services.NewI18n(s.bundle))) + app.RegisterService(application.NewService(services.NewPreferences(s.prefStore))) + app.RegisterService(application.NewService(services.NewAutostart(app.Autostart))) + app.RegisterService(application.NewService(services.NewVersion())) + app.RegisterService(application.NewService(services.NewUILog())) + app.RegisterService(application.NewService(s.compat)) +} + +func newMainWindow(app *application.App, prefStore *preferences.Store, wm *services.WindowManager, startURL string) *application.WebviewWindow { + // Width matches the last view mode so Advanced-mode users don't see the + // window pop from 380px to 900px on launch. Height is mode-agnostic. + initialWidth := 380 + if prefStore.Get().ViewMode == preferences.ViewModeAdvanced { + initialWidth = 900 + } + window := app.Window.NewWithOptions(application.WebviewWindowOptions{ + Name: "main", + Title: "NetBird", + Width: initialWidth, + Height: services.WindowHeight, + // Center on first show; minimal WMs (fluxbox, the XEmbed tray path) + // drop new windows top-left unless asked. + InitialPosition: application.WindowCentered, + Hidden: true, + BackgroundColour: services.WindowBackgroundColour, + URL: startURL, + DisableResize: true, + MinimiseButtonState: application.ButtonHidden, + MaximiseButtonState: application.ButtonHidden, + Mac: services.AppleMacOSAppearanceOptions(), + Windows: services.MicrosoftWindowsAppearanceOptions(), + Linux: application.LinuxWindow{ + Icon: iconWindow, + }, + }) + + window.RegisterHook(events.Common.WindowClosing, func(_ *application.WindowEvent) { + if services.ShuttingDown() { + return + } + wm.ForgetMain() + }) + + return window +} + +func registerDockReopenHook(app *application.App, wm *services.WindowManager) { + if runtime.GOOS != "darwin" { + return + } + app.Event.RegisterApplicationEventHook(events.Mac.ApplicationShouldHandleReopen, func(e *application.ApplicationEvent) { + if e.Context().HasVisibleWindows() { + return + } + e.Cancel() + wm.ShowMain() + }) +} diff --git a/client/ui/manifest.xml b/client/ui/manifest.xml deleted file mode 100644 index c71a407e5..000000000 --- a/client/ui/manifest.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - Netbird UI application - - - - - - - - \ No newline at end of file diff --git a/client/ui/netbird-ui.rb.tmpl b/client/ui/netbird-ui.rb.tmpl index 06971909d..1c77e6717 100644 --- a/client/ui/netbird-ui.rb.tmpl +++ b/client/ui/netbird-ui.rb.tmpl @@ -29,8 +29,13 @@ cask "{{ $projectName }}" do end uninstall_preflight do - system_command "#{appdir}/Netbird UI.app/uninstaller.sh", - sudo: false + system_command "/bin/sh", + args: ["-c", <<~CMD], + launchctl bootout system/netbird 2>/dev/null || \ + launchctl unload /Library/LaunchDaemons/netbird.plist 2>/dev/null || true + rm -f /Library/LaunchDaemons/netbird.plist + CMD + sudo: true end name "Netbird UI" diff --git a/client/ui/network.go b/client/ui/network.go deleted file mode 100644 index 571e871bb..000000000 --- a/client/ui/network.go +++ /dev/null @@ -1,695 +0,0 @@ -//go:build !(linux && 386) - -package main - -import ( - "context" - "fmt" - "runtime" - "sort" - "strings" - "time" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/dialog" - "fyne.io/fyne/v2/layout" - "fyne.io/fyne/v2/widget" - "fyne.io/systray" - log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/proto" -) - -const ( - allNetworksText = "All networks" - overlappingNetworksText = "Overlapping networks" - exitNodeNetworksText = "Exit-node networks" - allNetworks filter = "all" - overlappingNetworks filter = "overlapping" - exitNodeNetworks filter = "exit-node" - getClientFMT = "get client: %v" -) - -type filter string - -func (s *serviceClient) showNetworksUI() { - s.wNetworks = s.app.NewWindow("Networks") - s.wNetworks.SetOnClosed(s.cancel) - - allGrid := container.New(layout.NewGridLayout(3)) - go s.updateNetworks(allGrid, allNetworks) - overlappingGrid := container.New(layout.NewGridLayout(3)) - exitNodeGrid := container.New(layout.NewGridLayout(3)) - routeCheckContainer := container.NewVBox() - tabs := container.NewAppTabs( - container.NewTabItem(allNetworksText, allGrid), - container.NewTabItem(overlappingNetworksText, overlappingGrid), - container.NewTabItem(exitNodeNetworksText, exitNodeGrid), - ) - tabs.OnSelected = func(item *container.TabItem) { - s.updateNetworksBasedOnDisplayTab(tabs, allGrid, overlappingGrid, exitNodeGrid) - } - tabs.OnUnselected = func(item *container.TabItem) { - grid, _ := getGridAndFilterFromTab(tabs, allGrid, overlappingGrid, exitNodeGrid) - grid.Objects = nil - } - - routeCheckContainer.Add(tabs) - scrollContainer := container.NewVScroll(routeCheckContainer) - scrollContainer.SetMinSize(fyne.NewSize(200, 300)) - - buttonBox := container.NewHBox( - layout.NewSpacer(), - widget.NewButton("Refresh", func() { - s.updateNetworksBasedOnDisplayTab(tabs, allGrid, overlappingGrid, exitNodeGrid) - }), - widget.NewButton("Select all", func() { - _, f := getGridAndFilterFromTab(tabs, allGrid, overlappingGrid, exitNodeGrid) - s.selectAllFilteredNetworks(f) - s.updateNetworksBasedOnDisplayTab(tabs, allGrid, overlappingGrid, exitNodeGrid) - }), - widget.NewButton("Deselect All", func() { - _, f := getGridAndFilterFromTab(tabs, allGrid, overlappingGrid, exitNodeGrid) - s.deselectAllFilteredNetworks(f) - s.updateNetworksBasedOnDisplayTab(tabs, allGrid, overlappingGrid, exitNodeGrid) - }), - layout.NewSpacer(), - ) - - content := container.NewBorder(nil, buttonBox, nil, nil, scrollContainer) - - s.wNetworks.SetContent(content) - s.wNetworks.Show() - - s.startAutoRefresh(10*time.Second, tabs, allGrid, overlappingGrid, exitNodeGrid) -} - -func (s *serviceClient) updateNetworks(grid *fyne.Container, f filter) { - grid.Objects = nil - grid.Refresh() - idHeader := widget.NewLabelWithStyle(" ID", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) - networkHeader := widget.NewLabelWithStyle("Range/Domains", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) - resolvedIPsHeader := widget.NewLabelWithStyle("Resolved IPs", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) - - grid.Add(idHeader) - grid.Add(networkHeader) - grid.Add(resolvedIPsHeader) - - filteredRoutes, err := s.getFilteredNetworks(f) - if err != nil { - return - } - - sortNetworksByIDs(filteredRoutes) - - for _, route := range filteredRoutes { - r := route - - checkBox := widget.NewCheck(r.GetID(), func(checked bool) { - s.selectNetwork(r.ID, checked) - }) - checkBox.Checked = route.Selected - checkBox.Resize(fyne.NewSize(20, 20)) - checkBox.Refresh() - - grid.Add(checkBox) - network := r.GetRange() - domains := r.GetDomains() - - if len(domains) == 0 { - grid.Add(widget.NewLabel(network)) - grid.Add(widget.NewLabel("")) - continue - } - - // our selectors are only for display - noopFunc := func(_ string) { - // do nothing - } - - domainsSelector := widget.NewSelect(domains, noopFunc) - domainsSelector.Selected = domains[0] - grid.Add(domainsSelector) - - var resolvedIPsList []string - for domain, ipList := range r.GetResolvedIPs() { - resolvedIPsList = append(resolvedIPsList, fmt.Sprintf("%s: %s", domain, strings.Join(ipList.GetIps(), ", "))) - } - - if len(resolvedIPsList) == 0 { - grid.Add(widget.NewLabel("")) - continue - } - - // TODO: limit width within the selector display - resolvedIPsSelector := widget.NewSelect(resolvedIPsList, noopFunc) - resolvedIPsSelector.Selected = resolvedIPsList[0] - resolvedIPsSelector.Resize(fyne.NewSize(100, 100)) - grid.Add(resolvedIPsSelector) - } - - s.wNetworks.Content().Refresh() - grid.Refresh() -} - -func (s *serviceClient) getFilteredNetworks(f filter) ([]*proto.Network, error) { - routes, err := s.fetchNetworks() - if err != nil { - log.Errorf(getClientFMT, err) - s.showError(fmt.Errorf(getClientFMT, err)) - return nil, err - } - switch f { - case overlappingNetworks: - return getOverlappingNetworks(routes), nil - case exitNodeNetworks: - return getExitNodeNetworks(routes), nil - default: - } - return routes, nil -} - -func getOverlappingNetworks(routes []*proto.Network) []*proto.Network { - var filteredRoutes []*proto.Network - existingRange := make(map[string][]*proto.Network) - for _, route := range routes { - if len(route.Domains) > 0 { - continue - } - if r, exists := existingRange[route.GetRange()]; exists { - r = append(r, route) - existingRange[route.GetRange()] = r - } else { - existingRange[route.GetRange()] = []*proto.Network{route} - } - } - for _, r := range existingRange { - if len(r) > 1 { - filteredRoutes = append(filteredRoutes, r...) - } - } - return filteredRoutes -} - -func getExitNodeNetworks(routes []*proto.Network) []*proto.Network { - var filteredRoutes []*proto.Network - for _, route := range routes { - if route.Range == "0.0.0.0/0" { - filteredRoutes = append(filteredRoutes, route) - } - } - return filteredRoutes -} - -func sortNetworksByIDs(routes []*proto.Network) { - sort.Slice(routes, func(i, j int) bool { - return strings.ToLower(routes[i].GetID()) < strings.ToLower(routes[j].GetID()) - }) -} - -func (s *serviceClient) fetchNetworks() ([]*proto.Network, error) { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return nil, fmt.Errorf(getClientFMT, err) - } - - resp, err := conn.ListNetworks(s.ctx, &proto.ListNetworksRequest{}) - if err != nil { - return nil, fmt.Errorf("failed to list routes: %v", err) - } - - return resp.Routes, nil -} - -func (s *serviceClient) selectNetwork(id string, checked bool) { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - log.Errorf(getClientFMT, err) - s.showError(fmt.Errorf(getClientFMT, err)) - return - } - - req := &proto.SelectNetworksRequest{ - NetworkIDs: []string{id}, - Append: checked, - } - - if checked { - if _, err := conn.SelectNetworks(s.ctx, req); err != nil { - log.Errorf("failed to select network: %v", err) - s.showError(fmt.Errorf("failed to select network: %v", err)) - return - } - log.Infof("Network '%s' selected", id) - } else { - if _, err := conn.DeselectNetworks(s.ctx, req); err != nil { - log.Errorf("failed to deselect network: %v", err) - s.showError(fmt.Errorf("failed to deselect network: %v", err)) - return - } - log.Infof("Network '%s' deselected", id) - } -} - -func (s *serviceClient) selectAllFilteredNetworks(f filter) { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - log.Errorf(getClientFMT, err) - return - } - - req := s.getNetworksRequest(f, true) - if _, err := conn.SelectNetworks(s.ctx, req); err != nil { - log.Errorf("failed to select all networks: %v", err) - s.showError(fmt.Errorf("failed to select all networks: %v", err)) - return - } - - log.Debug("All networks selected") -} - -func (s *serviceClient) deselectAllFilteredNetworks(f filter) { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - log.Errorf(getClientFMT, err) - return - } - - req := s.getNetworksRequest(f, false) - if _, err := conn.DeselectNetworks(s.ctx, req); err != nil { - log.Errorf("failed to deselect all networks: %v", err) - s.showError(fmt.Errorf("failed to deselect all networks: %v", err)) - return - } - - log.Debug("All networks deselected") -} - -func (s *serviceClient) getNetworksRequest(f filter, appendRoute bool) *proto.SelectNetworksRequest { - req := &proto.SelectNetworksRequest{} - if f == allNetworks { - req.All = true - } else { - routes, err := s.getFilteredNetworks(f) - if err != nil { - return nil - } - for _, route := range routes { - req.NetworkIDs = append(req.NetworkIDs, route.GetID()) - } - req.Append = appendRoute - } - return req -} - -func (s *serviceClient) showError(err error) { - wrappedMessage := wrapText(err.Error(), 50) - - dialog.ShowError(fmt.Errorf("%s", wrappedMessage), s.wNetworks) -} - -func (s *serviceClient) startAutoRefresh(interval time.Duration, tabs *container.AppTabs, allGrid, overlappingGrid, exitNodesGrid *fyne.Container) { - ticker := time.NewTicker(interval) - go func() { - for range ticker.C { - s.updateNetworksBasedOnDisplayTab(tabs, allGrid, overlappingGrid, exitNodesGrid) - } - }() - - s.wNetworks.SetOnClosed(func() { - ticker.Stop() - s.cancel() - }) -} - -func (s *serviceClient) updateNetworksBasedOnDisplayTab(tabs *container.AppTabs, allGrid, overlappingGrid, exitNodesGrid *fyne.Container) { - grid, f := getGridAndFilterFromTab(tabs, allGrid, overlappingGrid, exitNodesGrid) - s.wNetworks.Content().Refresh() - s.updateNetworks(grid, f) -} - -// 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 false - } - exitNodes, err := s.getExitNodes(conn) - if err != nil { - log.Errorf("get exit nodes: %v", err) - return false - } - - s.exitNodeMu.Lock() - defer s.exitNodeMu.Unlock() - - s.recreateExitNodeMenu(exitNodes) - - if len(s.mExitNodeItems) > 0 { - s.mExitNode.Enable() - return true - } - - s.mExitNode.Disable() - return false -} - -func (s *serviceClient) recreateExitNodeMenu(exitNodes []*proto.Network) { - for _, node := range s.mExitNodeItems { - node.cancel() - node.Hide() - node.Remove() - } - s.mExitNodeItems = nil - if s.mExitNodeSeparator != nil { - s.mExitNodeSeparator.Remove() - s.mExitNodeSeparator = nil - } - if s.mExitNodeDeselectAll != nil { - s.mExitNodeDeselectAll.Remove() - s.mExitNodeDeselectAll = nil - } - - if runtime.GOOS == "linux" || runtime.GOOS == "freebsd" { - s.mExitNode.Remove() - s.mExitNode = systray.AddMenuItem("Exit Node", disabledMenuDescr) - } - - var showDeselectAll bool - - for _, node := range exitNodes { - if node.Selected { - showDeselectAll = true - } - - menuItem := s.mExitNode.AddSubMenuItemCheckbox( - node.ID, - fmt.Sprintf("Use exit node %s", node.ID), - node.Selected, - ) - - ctx, cancel := context.WithCancel(s.ctx) - s.mExitNodeItems = append(s.mExitNodeItems, menuHandler{ - MenuItem: menuItem, - cancel: cancel, - }) - go s.handleChecked(ctx, node.ID, menuItem) - } - - if showDeselectAll { - s.addExitNodeDeselectAll() - } - -} - -func (s *serviceClient) addExitNodeDeselectAll() { - sep := s.mExitNode.AddSubMenuItem("───────────────", "") - sep.Disable() - s.mExitNodeSeparator = sep - - deselectAllItem := s.mExitNode.AddSubMenuItem("Deselect All", "Deselect All") - s.mExitNodeDeselectAll = deselectAllItem - - go func() { - for { - _, ok := <-deselectAllItem.ClickedCh - if !ok { - return - } - exitNodes, err := s.handleExitNodeMenuDeselectAll() - if err != nil { - log.Warnf("failed to handle deselect all exit nodes: %v", err) - } else { - s.exitNodeMu.Lock() - s.recreateExitNodeMenu(exitNodes) - s.exitNodeMu.Unlock() - } - } - }() -} - -func (s *serviceClient) getExitNodes(conn proto.DaemonServiceClient) ([]*proto.Network, error) { - ctx, cancel := context.WithTimeout(s.ctx, defaultFailTimeout) - defer cancel() - - resp, err := conn.ListNetworks(ctx, &proto.ListNetworksRequest{}) - if err != nil { - return nil, fmt.Errorf("list networks: %v", err) - } - - var exitNodes []*proto.Network - for _, network := range resp.Routes { - if network.Range == "0.0.0.0/0" { - exitNodes = append(exitNodes, network) - } - } - return exitNodes, nil -} - -func (s *serviceClient) handleChecked(ctx context.Context, id string, item *systray.MenuItem) { - for { - select { - case <-ctx.Done(): - return - case _, ok := <-item.ClickedCh: - if !ok { - return - } - if err := s.toggleExitNode(id, item); err != nil { - log.Errorf("failed to toggle exit node: %v", err) - continue - } - } - } -} - -func (s *serviceClient) handleExitNodeMenuDeselectAll() ([]*proto.Network, error) { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return nil, fmt.Errorf("get client: %v", err) - } - - exitNodes, err := s.getExitNodes(conn) - if err != nil { - return nil, fmt.Errorf("get exit nodes: %v", err) - } - - var ids []string - for _, e := range exitNodes { - if e.Selected { - ids = append(ids, e.ID) - } - } - - // deselect selected exit nodes - if err := s.deselectOtherExitNodes(conn, ids); err != nil { - return nil, err - } - - updatedExitNodes, err := s.getExitNodes(conn) - if err != nil { - return nil, fmt.Errorf("re-fetch exit nodes: %v", err) - } - - return updatedExitNodes, nil -} - -// Add function to toggle exit node selection -func (s *serviceClient) toggleExitNode(nodeID string, item *systray.MenuItem) error { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return fmt.Errorf("get client: %v", err) - } - - log.Infof("Toggling exit node '%s'", nodeID) - - s.exitNodeMu.Lock() - defer s.exitNodeMu.Unlock() - - exitNodes, err := s.getExitNodes(conn) - if err != nil { - return fmt.Errorf("get exit nodes: %v", err) - } - - var exitNode *proto.Network - // find other selected nodes and ours - ids := make([]string, 0, len(exitNodes)) - for _, node := range exitNodes { - if node.ID == nodeID { - // preserve original state - cp := *node //nolint:govet - exitNode = &cp - - // set desired state for recreation - node.Selected = true - continue - } - if node.Selected { - ids = append(ids, node.ID) - - // set desired state for recreation - node.Selected = false - } - } - - // exit node is the only selected node, deselect it - deselectAll := item.Checked() && len(ids) == 0 - if deselectAll { - ids = append(ids, nodeID) - for _, node := range exitNodes { - if node.ID == nodeID { - // set desired state for recreation - node.Selected = false - } - } - } - - // deselect all other selected exit nodes - if err := s.deselectOtherExitNodes(conn, ids); err != nil { - return err - } - - if !deselectAll { - if err := s.selectNewExitNode(conn, exitNode, nodeID, item); err != nil { - return err - } - } - - // linux/bsd doesn't handle Check/Uncheck well, so we recreate the menu - if runtime.GOOS == "linux" || runtime.GOOS == "freebsd" { - s.recreateExitNodeMenu(exitNodes) - } - - return nil -} - -func (s *serviceClient) deselectOtherExitNodes(conn proto.DaemonServiceClient, ids []string) error { - // deselect all other selected exit nodes - if len(ids) > 0 { - deselectReq := &proto.SelectNetworksRequest{ - NetworkIDs: ids, - } - if _, err := conn.DeselectNetworks(s.ctx, deselectReq); err != nil { - return fmt.Errorf("deselect networks: %v", err) - } - - log.Infof("Deselected exit nodes: %v", ids) - } - - // uncheck all other exit node menu items - for _, i := range s.mExitNodeItems { - i.Uncheck() - log.Infof("Unchecked exit node %v", i) - } - - return nil -} - -func (s *serviceClient) selectNewExitNode(conn proto.DaemonServiceClient, exitNode *proto.Network, nodeID string, item *systray.MenuItem) error { - if exitNode != nil && !exitNode.Selected { - selectReq := &proto.SelectNetworksRequest{ - NetworkIDs: []string{exitNode.ID}, - Append: true, - } - if _, err := conn.SelectNetworks(s.ctx, selectReq); err != nil { - return fmt.Errorf("select network: %v", err) - } - - log.Infof("Selected exit node '%s'", nodeID) - } - - item.Check() - log.Infof("Checked exit node '%s'", nodeID) - - return nil -} - -func getGridAndFilterFromTab(tabs *container.AppTabs, allGrid, overlappingGrid, exitNodesGrid *fyne.Container) (*fyne.Container, filter) { - switch tabs.Selected().Text { - case overlappingNetworksText: - return overlappingGrid, overlappingNetworks - case exitNodeNetworksText: - return exitNodesGrid, exitNodeNetworks - default: - return allGrid, allNetworks - } -} - -// wrapText inserts newlines into the text to ensure that each line is -// no longer than 'lineLength' runes. -func wrapText(text string, lineLength int) string { - var sb strings.Builder - var currentLineLength int - - for _, runeValue := range text { - sb.WriteRune(runeValue) - currentLineLength++ - - if currentLineLength >= lineLength || runeValue == '\n' { - sb.WriteRune('\n') - currentLineLength = 0 - } - } - - return sb.String() -} diff --git a/client/ui/notifier.go b/client/ui/notifier.go new file mode 100644 index 000000000..71ae3b0df --- /dev/null +++ b/client/ui/notifier.go @@ -0,0 +1,101 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + "errors" + "sync/atomic" + + log "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/services/notifications" +) + +var errNotificationsUnavailable = errors.New("notifications unavailable") + +// Notifier wraps the Wails notification service so an unavailable backend +// disables notifications instead of aborting the app. Startup fails for +// environment reasons (a bare unbundled binary on macOS has no bundle +// identifier, a headless Linux session has no D-Bus session bus), and Wails +// treats a service startup error as fatal. After a failed startup every call +// is a no-op: on macOS, touching UNUserNotificationCenter without a bundle +// identifier raises an Objective-C exception that recover() cannot catch. +type Notifier struct { + inner *notifications.NotificationService + available atomic.Bool +} + +func newNotifier() *Notifier { + return &Notifier{inner: notifications.New()} +} + +// ServiceName implements the Wails service-name hook for startup logs. +func (n *Notifier) ServiceName() string { + return n.inner.ServiceName() +} + +// ServiceStartup starts the platform notifier, downgrading failure to a +// warning so the app keeps running without notifications. +func (n *Notifier) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { + if err := n.inner.ServiceStartup(ctx, options); err != nil { + log.Warnf("notifications disabled: %v", err) + return nil + } + n.available.Store(true) + return nil +} + +func (n *Notifier) ServiceShutdown() error { + if !n.available.Load() { + return nil + } + return n.inner.ServiceShutdown() +} + +func (n *Notifier) CheckNotificationAuthorization() (bool, error) { + if !n.available.Load() { + return false, errNotificationsUnavailable + } + return n.inner.CheckNotificationAuthorization() +} + +func (n *Notifier) RequestNotificationAuthorization() (bool, error) { + if !n.available.Load() { + return false, errNotificationsUnavailable + } + return n.inner.RequestNotificationAuthorization() +} + +// SendNotification delivers a notification, silently dropping it when the +// backend never started (notifications are best-effort everywhere). +func (n *Notifier) SendNotification(options notifications.NotificationOptions) error { + if !n.available.Load() { + log.Debugf("notifications disabled, dropping %q", options.ID) + return nil + } + return n.inner.SendNotification(options) +} + +func (n *Notifier) SendNotificationWithActions(options notifications.NotificationOptions) error { + if !n.available.Load() { + log.Debugf("notifications disabled, dropping %q", options.ID) + return nil + } + return n.inner.SendNotificationWithActions(options) +} + +func (n *Notifier) RegisterNotificationCategory(category notifications.NotificationCategory) error { + if !n.available.Load() { + return nil + } + return n.inner.RegisterNotificationCategory(category) +} + +// OnNotificationResponse registers the response callback. Pure Go state, so +// it is safe (and simply inert) when the backend never started. +// +//wails:ignore +func (n *Notifier) OnNotificationResponse(callback func(result notifications.NotificationResult)) { + n.inner.OnNotificationResponse(callback) +} diff --git a/client/ui/preferences/store.go b/client/ui/preferences/store.go new file mode 100644 index 000000000..3b677016f --- /dev/null +++ b/client/ui/preferences/store.go @@ -0,0 +1,334 @@ +//go:build !android && !ios && !freebsd && !js + +// Package preferences holds user-scope UI state, independent of the daemon +// profile and shared across all profiles. The Store persists to JSON under +// os.UserConfigDir() and broadcasts changes to in-process subscribers plus an +// optional emitter. +package preferences + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/ui/i18n" + "github.com/netbirdio/netbird/util" +) + +// Lives under os.UserConfigDir()/netbird (OS-user writable, not the daemon's +// root-owned state). +const preferencesFileName = "ui-preferences.json" + +// EventPreferencesChanged fires on every persisted update, payload UIPreferences. +const EventPreferencesChanged = "netbird:preferences:changed" + +// ViewMode is the preferred Main-window layout: "default" (compact, 380-wide) +// or "advanced" (900-wide). +type ViewMode string + +const ( + ViewModeDefault ViewMode = "default" + ViewModeAdvanced ViewMode = "advanced" +) + +// DefaultViewMode applies when no file exists or its view-mode is empty. +const DefaultViewMode = ViewModeDefault + +var ErrUnsupportedViewMode = errors.New("unsupported view mode") + +func (v ViewMode) IsValid() bool { + switch v { + case ViewModeDefault, ViewModeAdvanced: + return true + } + return false +} + +// UIPreferences is rewritten in full on every change; there are no partial updates. +type UIPreferences struct { + Language i18n.LanguageCode `json:"language"` + ViewMode ViewMode `json:"viewMode"` + OnboardingCompleted bool `json:"onboardingCompleted"` + // AutostartInitialized records that the one-time autostart default + // decision has run for this OS user. It only ever transitions to true + // and is never reset, so the default-on flow runs at most once, ever. + AutostartInitialized bool `json:"autostartInitialized"` + // KeepConnectedOnQuit leaves the daemon connected when the GUI quits. + // Its false zero value preserves the historical disconnect-on-quit + // behaviour for preference files written before the field existed. + KeepConnectedOnQuit bool `json:"keepConnectedOnQuit"` +} + +// LanguageValidator rejects SetLanguage inputs with no shipped bundle. +// *i18n.Bundle satisfies it. +type LanguageValidator interface { + HasLanguage(code i18n.LanguageCode) bool +} + +// Emitter broadcasts changes to the frontend. Wails' +// *application.EventProcessor satisfies it; tests pass nil or a fake. +type Emitter interface { + Emit(name string, data ...any) bool +} + +// Store is the user-scope UI preferences store. +type Store struct { + path string + + mu sync.RWMutex + current UIPreferences + existedAtLoad bool + + subsMu sync.Mutex + subs []chan UIPreferences + + validator LanguageValidator + emitter Emitter +} + +// NewStore loads preferences from disk, falling back to defaults. A nil +// validator skips SetLanguage validation; a nil emitter skips broadcasting. +func NewStore(validator LanguageValidator, emitter Emitter) (*Store, error) { + path, err := preferencesPath() + if err != nil { + return nil, fmt.Errorf("resolve preferences path: %w", err) + } + + // Language starts empty: the frontend treats absence as the signal to + // detect the browser locale on first launch and call SetLanguage. + s := &Store{ + path: path, + validator: validator, + emitter: emitter, + current: UIPreferences{ViewMode: DefaultViewMode}, + } + + if err := s.load(); err != nil { + log.Warnf("load ui preferences from %s: %v (using defaults)", path, err) + } + + return s, nil +} + +// Get returns a copy of the current preferences. +func (s *Store) Get() UIPreferences { + s.mu.RLock() + defer s.mu.RUnlock() + return s.current +} + +// SetViewMode validates, persists, and broadcasts. No-op if unchanged. +func (s *Store) SetViewMode(mode ViewMode) error { + if !mode.IsValid() { + return fmt.Errorf("%w: %q", ErrUnsupportedViewMode, mode) + } + + s.mu.Lock() + if s.current.ViewMode == mode { + s.mu.Unlock() + return nil + } + next := s.current + next.ViewMode = mode + if err := s.persistLocked(next); err != nil { + s.mu.Unlock() + return fmt.Errorf("persist preferences: %w", err) + } + s.current = next + s.mu.Unlock() + + s.broadcast(next) + return nil +} + +// SetOnboardingCompleted persists the welcome-window dismissal. No-op if unchanged. +func (s *Store) SetOnboardingCompleted(done bool) error { + s.mu.Lock() + if s.current.OnboardingCompleted == done { + s.mu.Unlock() + return nil + } + next := s.current + next.OnboardingCompleted = done + if err := s.persistLocked(next); err != nil { + s.mu.Unlock() + return fmt.Errorf("persist preferences: %w", err) + } + s.current = next + s.mu.Unlock() + + s.broadcast(next) + return nil +} + +// SetAutostartInitialized persists the one-time autostart decision marker. +// No-op if unchanged. +func (s *Store) SetAutostartInitialized(done bool) error { + s.mu.Lock() + if s.current.AutostartInitialized == done { + s.mu.Unlock() + return nil + } + next := s.current + next.AutostartInitialized = done + if err := s.persistLocked(next); err != nil { + s.mu.Unlock() + return fmt.Errorf("persist preferences: %w", err) + } + s.current = next + s.mu.Unlock() + + s.broadcast(next) + return nil +} + +// SetKeepConnectedOnQuit persists the disconnect-on-quit opt-out. No-op if unchanged. +func (s *Store) SetKeepConnectedOnQuit(keep bool) error { + s.mu.Lock() + if s.current.KeepConnectedOnQuit == keep { + s.mu.Unlock() + return nil + } + next := s.current + next.KeepConnectedOnQuit = keep + if err := s.persistLocked(next); err != nil { + s.mu.Unlock() + return fmt.Errorf("persist preferences: %w", err) + } + s.current = next + s.mu.Unlock() + + s.broadcast(next) + return nil +} + +// SetLanguage validates, persists, and broadcasts. No-op if unchanged. +func (s *Store) SetLanguage(lang i18n.LanguageCode) error { + if lang == "" { + return fmt.Errorf("%w: empty code", i18n.ErrUnsupportedLanguage) + } + if s.validator != nil && !s.validator.HasLanguage(lang) { + return fmt.Errorf("%w: %q", i18n.ErrUnsupportedLanguage, lang) + } + + s.mu.Lock() + if s.current.Language == lang { + s.mu.Unlock() + return nil + } + next := s.current + next.Language = lang + if err := s.persistLocked(next); err != nil { + s.mu.Unlock() + return fmt.Errorf("persist preferences: %w", err) + } + s.current = next + s.mu.Unlock() + + s.broadcast(next) + return nil +} + +// Subscribe returns a channel of persisted changes and an unsubscribe func. +// The unsubscribe func closes the channel; callers must not close it themselves. +func (s *Store) Subscribe() (<-chan UIPreferences, func()) { + ch := make(chan UIPreferences, 4) + s.subsMu.Lock() + s.subs = append(s.subs, ch) + s.subsMu.Unlock() + + unsubscribe := func() { + s.subsMu.Lock() + defer s.subsMu.Unlock() + for i, c := range s.subs { + if c == ch { + s.subs = append(s.subs[:i], s.subs[i+1:]...) + close(ch) + return + } + } + } + return ch, unsubscribe +} + +// ExistedAtLoad reports whether the backing preferences file was present on +// disk when the store loaded. It distinguishes a user who ran a prior GUI +// version from a brand-new OS user with no preferences yet. +func (s *Store) ExistedAtLoad() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.existedAtLoad +} + +// load reads the file into current. A missing file is not an error (the +// in-memory default stands); malformed contents return an error. +func (s *Store) load() error { + if _, err := os.Stat(s.path); err != nil { + if errors.Is(err, os.ErrNotExist) { + log.Infof("no ui preferences file at %s; using defaults", s.path) + return nil + } + return fmt.Errorf("stat preferences: %w", err) + } + + s.mu.Lock() + s.existedAtLoad = true + s.mu.Unlock() + + var loaded UIPreferences + if _, err := util.ReadJson(s.path, &loaded); err != nil { + return err + } + + if !loaded.ViewMode.IsValid() { + loaded.ViewMode = DefaultViewMode + } + + s.mu.Lock() + s.current = loaded + s.mu.Unlock() + return nil +} + +// persistLocked writes v to disk. Caller must hold s.mu and update in-memory +// state only after this returns nil. +func (s *Store) persistLocked(v UIPreferences) error { + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", filepath.Dir(s.path), err) + } + return util.WriteJson(context.Background(), s.path, v) +} + +// broadcast fans v out to subscribers and the emitter. Full-buffer subscribers +// are skipped: consumers only need the latest value, so dropping is safe. +func (s *Store) broadcast(v UIPreferences) { + s.subsMu.Lock() + subs := make([]chan UIPreferences, len(s.subs)) + copy(subs, s.subs) + s.subsMu.Unlock() + + for _, ch := range subs { + select { + case ch <- v: + default: + log.Debugf("preferences subscriber channel full; dropping update") + } + } + + if s.emitter != nil { + s.emitter.Emit(EventPreferencesChanged, v) + } +} + +func preferencesPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "netbird", preferencesFileName), nil +} diff --git a/client/ui/preferences/store_test.go b/client/ui/preferences/store_test.go new file mode 100644 index 000000000..3e1cb3107 --- /dev/null +++ b/client/ui/preferences/store_test.go @@ -0,0 +1,301 @@ +//go:build !android && !ios && !freebsd && !js + +package preferences + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "runtime" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/ui/i18n" +) + +// fakeValidator implements LanguageValidator for tests so we don't need a +// fully-loaded i18n.Bundle. +type fakeValidator struct{ ok map[i18n.LanguageCode]bool } + +func (f fakeValidator) HasLanguage(code i18n.LanguageCode) bool { return f.ok[code] } + +// recordingEmitter captures Emit calls so tests can assert the broadcast +// fired. +type recordingEmitter struct { + mu sync.Mutex + calls []emitCall +} + +type emitCall struct { + name string + data []any +} + +func (r *recordingEmitter) Emit(name string, data ...any) bool { + r.mu.Lock() + defer r.mu.Unlock() + r.calls = append(r.calls, emitCall{name: name, data: data}) + return true +} + +func (r *recordingEmitter) calledWith(name string) []emitCall { + r.mu.Lock() + defer r.mu.Unlock() + var out []emitCall + for _, c := range r.calls { + if c.name == name { + out = append(out, c) + } + } + return out +} + +// withTempConfigDir reroots os.UserConfigDir() at a temporary directory by +// pointing the OS-specific env vars there. Restored automatically by +// t.Setenv. +func withTempConfigDir(t *testing.T) string { + t.Helper() + tmp := t.TempDir() + switch runtime.GOOS { + case "darwin": + t.Setenv("HOME", tmp) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, "Library", "Application Support"), 0o755)) + case "windows": + t.Setenv("AppData", tmp) + default: + t.Setenv("XDG_CONFIG_HOME", tmp) + } + return tmp +} + +func TestStore_DefaultsWhenFileMissing(t *testing.T) { + withTempConfigDir(t) + s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true}}, nil) + require.NoError(t, err) + + got := s.Get() + assert.Equal(t, i18n.LanguageCode(""), got.Language, "language must be empty when no file is on disk so the frontend can detect the browser locale") + assert.Equal(t, DefaultViewMode, got.ViewMode, "view-mode default should still apply") +} + +func TestStore_SetLanguagePersistsAndBroadcasts(t *testing.T) { + withTempConfigDir(t) + emitter := &recordingEmitter{} + s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true, "hu": true}}, emitter) + require.NoError(t, err) + + ch, unsubscribe := s.Subscribe() + defer unsubscribe() + + require.NoError(t, s.SetLanguage("hu")) + + got := s.Get() + assert.Equal(t, i18n.LanguageCode("hu"), got.Language, "Get should reflect the SetLanguage value") + + select { + case v := <-ch: + assert.Equal(t, i18n.LanguageCode("hu"), v.Language, "subscriber should receive the new value") + case <-time.After(time.Second): + t.Fatal("subscriber timed out waiting for update") + } + + emits := emitter.calledWith(EventPreferencesChanged) + require.Len(t, emits, 1, "Emit should fire exactly once per SetLanguage") + payload, ok := emits[0].data[0].(UIPreferences) + require.True(t, ok, "emitter payload should be UIPreferences") + assert.Equal(t, i18n.LanguageCode("hu"), payload.Language) +} + +func TestStore_LoadFromDisk(t *testing.T) { + withTempConfigDir(t) + path, err := preferencesPath() + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(`{"language":"hu"}`), 0o644)) + + s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"hu": true}}, nil) + require.NoError(t, err) + + got := s.Get() + assert.Equal(t, i18n.LanguageCode("hu"), got.Language, "Get should load language from existing file") +} + +func TestStore_UnsupportedLanguageRejected(t *testing.T) { + withTempConfigDir(t) + s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true}}, nil) + require.NoError(t, err) + + err = s.SetLanguage("xx") + require.Error(t, err, "unknown language must be rejected") + assert.ErrorIs(t, err, i18n.ErrUnsupportedLanguage) + + err = s.SetLanguage("") + assert.ErrorIs(t, err, i18n.ErrUnsupportedLanguage, "empty language code must be rejected") +} + +func TestStore_NoValidatorAcceptsAnything(t *testing.T) { + withTempConfigDir(t) + s, err := NewStore(nil, nil) + require.NoError(t, err) + + require.NoError(t, s.SetLanguage("fr")) + got := s.Get() + assert.Equal(t, i18n.LanguageCode("fr"), got.Language) +} + +func TestStore_SetLanguageIdempotent(t *testing.T) { + withTempConfigDir(t) + emitter := &recordingEmitter{} + s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true}}, emitter) + require.NoError(t, err) + + // First call goes from "" (unset) to "en" — real change, one broadcast. + require.NoError(t, s.SetLanguage("en")) + require.Len(t, emitter.calledWith(EventPreferencesChanged), 1, + "first SetLanguage from unset should broadcast") + + // Second call is a no-op — no disk write, no broadcast. Without this + // guard the tray would re-render the menu on every cosmetic re-save of + // the preferences file. + require.NoError(t, s.SetLanguage("en")) + assert.Len(t, emitter.calledWith(EventPreferencesChanged), 1, + "re-setting the current language should not broadcast again") +} + +func TestStore_CorruptFileFallsBackToDefault(t *testing.T) { + withTempConfigDir(t) + path, err := preferencesPath() + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte("{not json"), 0o644)) + + s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true}}, nil) + require.NoError(t, err, "corrupt file should not fail construction") + + got := s.Get() + assert.Equal(t, i18n.LanguageCode(""), got.Language, "corrupt JSON should leave the empty (unset) default in place so the frontend can re-detect") +} + +func TestStore_UnsubscribeStopsUpdates(t *testing.T) { + withTempConfigDir(t) + s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true, "hu": true}}, nil) + require.NoError(t, err) + + ch, unsubscribe := s.Subscribe() + unsubscribe() + + require.NoError(t, s.SetLanguage("hu")) + + select { + case _, ok := <-ch: + assert.False(t, ok, "channel should be closed after unsubscribe") + case <-time.After(time.Second): + t.Fatal("expected closed channel, got nothing") + } +} + +func TestStore_FileShapeIsJSON(t *testing.T) { + withTempConfigDir(t) + s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"hu": true}}, nil) + require.NoError(t, err) + require.NoError(t, s.SetLanguage("hu")) + + path, err := preferencesPath() + require.NoError(t, err) + data, err := os.ReadFile(path) + require.NoError(t, err) + + var parsed UIPreferences + require.NoError(t, json.Unmarshal(data, &parsed), "on-disk file must be valid JSON") + assert.Equal(t, i18n.LanguageCode("hu"), parsed.Language) +} + +func TestStore_SetAutostartInitializedPersistsAcrossReload(t *testing.T) { + withTempConfigDir(t) + emitter := &recordingEmitter{} + s, err := NewStore(nil, emitter) + require.NoError(t, err) + + assert.False(t, s.Get().AutostartInitialized, "marker must default to false when no file is on disk") + + require.NoError(t, s.SetAutostartInitialized(true)) + assert.True(t, s.Get().AutostartInitialized, "Get should reflect the persisted marker") + require.Len(t, emitter.calledWith(EventPreferencesChanged), 1, "first marker write should broadcast") + + // Re-setting the same value must be a no-op: no disk write, no broadcast. + require.NoError(t, s.SetAutostartInitialized(true)) + assert.Len(t, emitter.calledWith(EventPreferencesChanged), 1, "idempotent marker write should not broadcast again") + + // A fresh Store (new GUI launch) must see the marker so the autostart + // default decision never runs twice. + reloaded, err := NewStore(nil, nil) + require.NoError(t, err) + assert.True(t, reloaded.Get().AutostartInitialized, "marker must survive a reload from disk") +} + +func TestStore_SetKeepConnectedOnQuitPersistsAcrossReload(t *testing.T) { + withTempConfigDir(t) + emitter := &recordingEmitter{} + s, err := NewStore(nil, emitter) + require.NoError(t, err) + + assert.False(t, s.Get().KeepConnectedOnQuit, "quitting must disconnect by default") + + require.NoError(t, s.SetKeepConnectedOnQuit(true)) + assert.True(t, s.Get().KeepConnectedOnQuit, "Get should reflect the persisted opt-out") + require.Len(t, emitter.calledWith(EventPreferencesChanged), 1, "first write should broadcast") + + require.NoError(t, s.SetKeepConnectedOnQuit(true)) + assert.Len(t, emitter.calledWith(EventPreferencesChanged), 1, "idempotent write should not broadcast again") + + reloaded, err := NewStore(nil, nil) + require.NoError(t, err) + assert.True(t, reloaded.Get().KeepConnectedOnQuit, "opt-out must survive a reload from disk") +} + +func TestStore_KeepConnectedOnQuitDefaultsFalseForPreExistingFile(t *testing.T) { + withTempConfigDir(t) + + // A preferences file written before the field existed must keep the + // historical disconnect-on-quit behaviour rather than silently opting out. + path, err := preferencesPath() + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(`{"language":"en","viewMode":"default"}`), 0o600)) + + s, err := NewStore(nil, nil) + require.NoError(t, err) + assert.False(t, s.Get().KeepConnectedOnQuit, "a file predating the field must not opt out of disconnect-on-quit") + assert.True(t, s.ExistedAtLoad(), "the pre-existing file must be seen on disk") +} + +func TestStore_ExistedAtLoad(t *testing.T) { + withTempConfigDir(t) + + // Brand-new OS user: no preferences file on disk yet. + fresh, err := NewStore(nil, nil) + require.NoError(t, err) + assert.False(t, fresh.ExistedAtLoad(), "ExistedAtLoad must be false when no file is on disk") + + // Persisting a value writes the file to disk. + require.NoError(t, fresh.SetLanguage("en")) + + // A subsequent GUI launch reopens the now-present file. + reopened, err := NewStore(nil, nil) + require.NoError(t, err) + assert.True(t, reopened.ExistedAtLoad(), "ExistedAtLoad must be true after the store has persisted and is reopened") +} + +func TestStore_ErrUnsupportedSentinel(t *testing.T) { + // Verifies callers can match on the sentinel error rather than parsing + // strings — protects against accidental %v -> %w changes that would + // silently break errors.Is. + err := errors.New("inner") + wrapped := errors.Join(i18n.ErrUnsupportedLanguage, err) + assert.ErrorIs(t, wrapped, i18n.ErrUnsupportedLanguage) +} diff --git a/client/ui/privileged_settings.go b/client/ui/privileged_settings.go new file mode 100644 index 000000000..1e8b4bbf6 --- /dev/null +++ b/client/ui/privileged_settings.go @@ -0,0 +1,27 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/client/ui/services" +) + +// The one-shot mode this binary runs itself in, elevated, to apply the settings the +// daemon restricts to root/administrator. It is handled before anything GUI, so no +// window, tray or single-instance lock is involved. +// +// Only the wiring is here: what the mode accepts and does lives beside the code +// that asks for it, in services.RunPrivilegedSettings, so the settings it will +// apply are declared once. There is nothing privileged about the mode itself; it +// sends the same request the frontend would have sent, and the daemon authorizes it +// from the identity the kernel reports on the control channel exactly as it does +// for `sudo netbird up`. +func runPrivilegedSettings(args []string) int { + return services.RunPrivilegedSettings(args, func(addr string) (proto.DaemonServiceClient, error) { + if addr == "" { + addr = DaemonAddr() + } + return NewConn(addr).Client() + }) +} diff --git a/client/ui/process/process.go b/client/ui/process/process.go deleted file mode 100644 index 28276f416..000000000 --- a/client/ui/process/process.go +++ /dev/null @@ -1,38 +0,0 @@ -package process - -import ( - "os" - "path/filepath" - "strings" - - "github.com/shirou/gopsutil/v3/process" -) - -func IsAnotherProcessRunning() (int32, bool, error) { - processes, err := process.Processes() - if err != nil { - return 0, false, err - } - - pid := os.Getpid() - processName := strings.ToLower(filepath.Base(os.Args[0])) - - for _, p := range processes { - if int(p.Pid) == pid { - continue - } - - runningProcessPath, err := p.Exe() - // most errors are related to short-lived processes - if err != nil { - continue - } - - runningProcessName := strings.ToLower(filepath.Base(runningProcessPath)) - if runningProcessName == processName && isProcessOwnedByCurrentUser(p) { - return p.Pid, true, nil - } - } - - return 0, false, nil -} diff --git a/client/ui/process/process_nonwindows.go b/client/ui/process/process_nonwindows.go deleted file mode 100644 index cf9f6443d..000000000 --- a/client/ui/process/process_nonwindows.go +++ /dev/null @@ -1,26 +0,0 @@ -//go:build !windows - -package process - -import ( - "os" - - "github.com/shirou/gopsutil/v3/process" - log "github.com/sirupsen/logrus" -) - -func isProcessOwnedByCurrentUser(p *process.Process) bool { - currentUserID := os.Getuid() - uids, err := p.Uids() - if err != nil { - log.Errorf("get process uids: %v", err) - return false - } - for _, id := range uids { - log.Debugf("checking process uid: %d", id) - if int(id) == currentUserID { - return true - } - } - return false -} diff --git a/client/ui/process/process_windows.go b/client/ui/process/process_windows.go deleted file mode 100644 index 2d211d1a4..000000000 --- a/client/ui/process/process_windows.go +++ /dev/null @@ -1,24 +0,0 @@ -package process - -import ( - "os/user" - - "github.com/shirou/gopsutil/v3/process" - log "github.com/sirupsen/logrus" -) - -func isProcessOwnedByCurrentUser(p *process.Process) bool { - processUsername, err := p.Username() - if err != nil { - log.Errorf("get process username error: %v", err) - return false - } - - currUser, err := user.Current() - if err != nil { - log.Errorf("get current user error: %v", err) - return false - } - - return processUsername == currUser.Username -} diff --git a/client/ui/profile.go b/client/ui/profile.go deleted file mode 100644 index 74189c9a0..000000000 --- a/client/ui/profile.go +++ /dev/null @@ -1,719 +0,0 @@ -//go:build !(linux && 386) - -package main - -import ( - "context" - "errors" - "fmt" - "os/user" - "slices" - "sort" - "sync" - "time" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/dialog" - "fyne.io/fyne/v2/layout" - "fyne.io/fyne/v2/widget" - "fyne.io/systray" - log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/internal" - "github.com/netbirdio/netbird/client/internal/profilemanager" - "github.com/netbirdio/netbird/client/proto" -) - -// showProfilesUI creates and displays the Profiles window with a list of existing profiles, -// a button to add new profiles, allows removal, and lets the user switch the active profile. -func (s *serviceClient) showProfilesUI() { - - profiles, err := s.getProfiles() - if err != nil { - log.Errorf("get profiles: %v", err) - return - } - - var refresh func() - // List widget for profiles - list := widget.NewList( - func() int { return len(profiles) }, - func() fyne.CanvasObject { - // Each item: Selected indicator, Name, spacer, Select, Logout & Remove buttons - return container.NewHBox( - widget.NewLabel(""), // indicator - widget.NewLabel(""), // profile name - layout.NewSpacer(), - widget.NewButton("Select", nil), - widget.NewButton("Deregister", nil), - widget.NewButton("Remove", nil), - ) - }, - func(i widget.ListItemID, item fyne.CanvasObject) { - // Populate each row - row := item.(*fyne.Container) - indicator := row.Objects[0].(*widget.Label) - nameLabel := row.Objects[1].(*widget.Label) - selectBtn := row.Objects[3].(*widget.Button) - logoutBtn := row.Objects[4].(*widget.Button) - removeBtn := row.Objects[5].(*widget.Button) - - profile := profiles[i] - // Show a checkmark if selected - if profile.IsActive { - indicator.SetText("✓") - } else { - indicator.SetText("") - } - nameLabel.SetText(profile.Name) - - // Configure Select/Active button - selectBtn.SetText(func() string { - if profile.IsActive { - return "Active" - } - return "Select" - }()) - selectBtn.OnTapped = func() { - if profile.IsActive { - return // already active - } - // confirm switch - dialog.ShowConfirm( - "Switch Profile", - fmt.Sprintf("Are you sure you want to switch to '%s'?", profile.Name), - func(confirm bool) { - if !confirm { - return - } - // switch - err = s.switchProfile(profile.Name) - if err != nil { - log.Errorf("failed to switch profile: %v", err) - dialog.ShowError(errors.New("failed to select profile"), s.wProfiles) - return - } - - dialog.ShowInformation( - "Profile Switched", - fmt.Sprintf("Profile '%s' switched successfully", profile.Name), - s.wProfiles, - ) - - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - log.Errorf("failed to get daemon client: %v", err) - return - } - - status, err := conn.Status(s.ctx, &proto.StatusRequest{}) - if err != nil { - log.Errorf("failed to get status after switching profile: %v", err) - return - } - - if status.Status == string(internal.StatusConnected) { - if err := s.menuDownClick(); err != nil { - log.Errorf("failed to handle down click after switching profile: %v", err) - dialog.ShowError(fmt.Errorf("failed to handle down click"), s.wProfiles) - return - } - } - // update slice flags - refresh() - }, - s.wProfiles, - ) - } - - logoutBtn.Show() - logoutBtn.SetText("Deregister") - logoutBtn.OnTapped = func() { - s.handleProfileLogout(profile.Name, refresh) - } - - // Remove profile - removeBtn.SetText("Remove") - removeBtn.OnTapped = func() { - dialog.ShowConfirm( - "Delete Profile", - fmt.Sprintf("Are you sure you want to delete '%s'?", profile.Name), - func(confirm bool) { - if !confirm { - return - } - - err = s.removeProfile(profile.Name) - if err != nil { - log.Errorf("failed to remove profile: %v", err) - dialog.ShowError(fmt.Errorf("failed to remove profile"), s.wProfiles) - return - } - dialog.ShowInformation( - "Profile Removed", - fmt.Sprintf("Profile '%s' removed successfully", profile.Name), - s.wProfiles, - ) - // update slice - refresh() - }, - s.wProfiles, - ) - } - }, - ) - - refresh = func() { - newProfiles, err := s.getProfiles() - if err != nil { - dialog.ShowError(err, s.wProfiles) - return - } - profiles = newProfiles // update the slice - list.Refresh() // tell Fyne to re-call length/update on every visible row - } - - // Button to add a new profile - newBtn := widget.NewButton("New Profile", func() { - nameEntry := widget.NewEntry() - nameEntry.SetPlaceHolder("Enter Profile Name") - - formItems := []*widget.FormItem{{Text: "Name:", Widget: nameEntry}} - dlg := dialog.NewForm( - "New Profile", - "Create", - "Cancel", - formItems, - func(confirm bool) { - if !confirm { - return - } - name := nameEntry.Text - if name == "" { - dialog.ShowError(errors.New("profile name cannot be empty"), s.wProfiles) - return - } - - // add profile - err = s.addProfile(name) - if err != nil { - log.Errorf("failed to create profile: %v", err) - dialog.ShowError(fmt.Errorf("failed to create profile"), s.wProfiles) - return - } - dialog.ShowInformation( - "Profile Created", - fmt.Sprintf("Profile '%s' created successfully", name), - s.wProfiles, - ) - // update slice - refresh() - }, - s.wProfiles, - ) - // make dialog wider - dlg.Resize(fyne.NewSize(350, 150)) - dlg.Show() - }) - - // Assemble window content - content := container.NewBorder(nil, newBtn, nil, nil, list) - s.wProfiles = s.app.NewWindow("NetBird Profiles") - s.wProfiles.SetContent(content) - s.wProfiles.Resize(fyne.NewSize(400, 300)) - s.wProfiles.SetOnClosed(s.cancel) - - s.wProfiles.Show() -} - -func (s *serviceClient) addProfile(profileName string) error { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return fmt.Errorf(getClientFMT, err) - } - - currUser, err := user.Current() - if err != nil { - return fmt.Errorf("get current user: %w", err) - } - - _, err = conn.AddProfile(s.ctx, &proto.AddProfileRequest{ - ProfileName: profileName, - Username: currUser.Username, - }) - - if err != nil { - return fmt.Errorf("add profile: %w", err) - } - - return nil -} - -func (s *serviceClient) switchProfile(profileName string) error { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return fmt.Errorf(getClientFMT, err) - } - - currUser, err := user.Current() - if err != nil { - return fmt.Errorf("get current user: %w", err) - } - - if _, err := conn.SwitchProfile(s.ctx, &proto.SwitchProfileRequest{ - ProfileName: &profileName, - Username: &currUser.Username, - }); err != nil { - return fmt.Errorf("switch profile failed: %w", err) - } - - err = s.profileManager.SwitchProfile(profileName) - if err != nil { - return fmt.Errorf("switch profile: %w", err) - } - - return nil -} - -func (s *serviceClient) removeProfile(profileName string) error { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return fmt.Errorf(getClientFMT, err) - } - - currUser, err := user.Current() - if err != nil { - return fmt.Errorf("get current user: %w", err) - } - - _, err = conn.RemoveProfile(s.ctx, &proto.RemoveProfileRequest{ - ProfileName: profileName, - Username: currUser.Username, - }) - if err != nil { - return fmt.Errorf("remove profile: %w", err) - } - - return nil -} - -type Profile struct { - Name string - IsActive bool -} - -func (s *serviceClient) getProfiles() ([]Profile, error) { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return nil, fmt.Errorf(getClientFMT, err) - } - - currUser, err := user.Current() - if err != nil { - return nil, fmt.Errorf("get current user: %w", err) - } - profilesResp, err := conn.ListProfiles(s.ctx, &proto.ListProfilesRequest{ - Username: currUser.Username, - }) - if err != nil { - return nil, fmt.Errorf("list profiles: %w", err) - } - - var profiles []Profile - - for _, profile := range profilesResp.Profiles { - profiles = append(profiles, Profile{ - Name: profile.Name, - IsActive: profile.IsActive, - }) - } - - return profiles, nil -} - -func (s *serviceClient) handleProfileLogout(profileName string, refreshCallback func()) { - dialog.ShowConfirm( - "Deregister", - fmt.Sprintf("Are you sure you want to deregister from '%s'?", profileName), - func(confirm bool) { - if !confirm { - return - } - - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - log.Errorf("failed to get service client: %v", err) - dialog.ShowError(fmt.Errorf("failed to connect to service"), s.wProfiles) - return - } - - currUser, err := user.Current() - if err != nil { - log.Errorf("failed to get current user: %v", err) - dialog.ShowError(fmt.Errorf("failed to get current user"), s.wProfiles) - return - } - - username := currUser.Username - _, err = conn.Logout(s.ctx, &proto.LogoutRequest{ - ProfileName: &profileName, - Username: &username, - }) - if err != nil { - log.Errorf("logout failed: %v", err) - dialog.ShowError(fmt.Errorf("deregister failed"), s.wProfiles) - return - } - - dialog.ShowInformation( - "Deregistered", - fmt.Sprintf("Successfully deregistered from '%s'", profileName), - s.wProfiles, - ) - - refreshCallback() - }, - s.wProfiles, - ) -} - -type subItem struct { - *systray.MenuItem - ctx context.Context - cancel context.CancelFunc -} - -type profileMenu struct { - mu sync.Mutex - ctx context.Context - serviceClient *serviceClient - profileManager *profilemanager.ProfileManager - eventHandler *eventHandler - profileMenuItem *systray.MenuItem - emailMenuItem *systray.MenuItem - profileSubItems []*subItem - manageProfilesSubItem *subItem - logoutSubItem *subItem - profilesState []Profile - downClickCallback func() error - upClickCallback func(context.Context) error - getSrvClientCallback func(timeout time.Duration) (proto.DaemonServiceClient, error) - loadSettingsCallback func() - app fyne.App -} - -type newProfileMenuArgs struct { - ctx context.Context - serviceClient *serviceClient - profileManager *profilemanager.ProfileManager - eventHandler *eventHandler - profileMenuItem *systray.MenuItem - emailMenuItem *systray.MenuItem - downClickCallback func() error - upClickCallback func(context.Context) error - getSrvClientCallback func(timeout time.Duration) (proto.DaemonServiceClient, error) - loadSettingsCallback func() - app fyne.App -} - -func newProfileMenu(args newProfileMenuArgs) *profileMenu { - p := profileMenu{ - ctx: args.ctx, - serviceClient: args.serviceClient, - profileManager: args.profileManager, - eventHandler: args.eventHandler, - profileMenuItem: args.profileMenuItem, - emailMenuItem: args.emailMenuItem, - downClickCallback: args.downClickCallback, - upClickCallback: args.upClickCallback, - getSrvClientCallback: args.getSrvClientCallback, - loadSettingsCallback: args.loadSettingsCallback, - app: args.app, - } - - p.emailMenuItem.Disable() - p.emailMenuItem.Hide() - p.refresh() - go p.updateMenu() - - return &p -} - -func (p *profileMenu) getProfiles() ([]Profile, error) { - conn, err := p.getSrvClientCallback(defaultFailTimeout) - if err != nil { - return nil, fmt.Errorf(getClientFMT, err) - } - currUser, err := user.Current() - if err != nil { - return nil, fmt.Errorf("get current user: %w", err) - } - - profilesResp, err := conn.ListProfiles(p.ctx, &proto.ListProfilesRequest{ - Username: currUser.Username, - }) - if err != nil { - return nil, fmt.Errorf("list profiles: %w", err) - } - - var profiles []Profile - - for _, profile := range profilesResp.Profiles { - profiles = append(profiles, Profile{ - Name: profile.Name, - IsActive: profile.IsActive, - }) - } - - return profiles, nil -} - -func (p *profileMenu) refresh() { - p.mu.Lock() - defer p.mu.Unlock() - - profiles, err := p.getProfiles() - if err != nil { - log.Errorf("failed to list profiles: %v", err) - return - } - - // Clear existing profile items - p.clear(profiles) - - currUser, err := user.Current() - if err != nil { - log.Errorf("failed to get current user: %v", err) - return - } - - conn, err := p.getSrvClientCallback(defaultFailTimeout) - if err != nil { - log.Errorf("failed to get daemon client: %v", err) - return - } - - activeProf, err := conn.GetActiveProfile(p.ctx, &proto.GetActiveProfileRequest{}) - if err != nil { - log.Errorf("failed to get active profile: %v", err) - return - } - - if activeProf.ProfileName == "default" || activeProf.Username == currUser.Username { - activeProfState, err := p.profileManager.GetProfileState(activeProf.ProfileName) - if err != nil { - log.Warnf("failed to get active profile state: %v", err) - p.emailMenuItem.Hide() - } else if activeProfState.Email != "" { - p.emailMenuItem.SetTitle(fmt.Sprintf("(%s)", activeProfState.Email)) - p.emailMenuItem.Show() - } - } - - for _, profile := range profiles { - item := p.profileMenuItem.AddSubMenuItem(profile.Name, "") - if profile.IsActive { - item.Check() - } - - ctx, cancel := context.WithCancel(context.Background()) - p.profileSubItems = append(p.profileSubItems, &subItem{item, ctx, cancel}) - - go func() { - for { - select { - case <-ctx.Done(): - return // context cancelled - case _, ok := <-item.ClickedCh: - if !ok { - return // channel closed - } - - // Handle profile selection - if profile.IsActive { - log.Infof("Profile '%s' is already active", profile.Name) - return - } - conn, err := p.getSrvClientCallback(defaultFailTimeout) - if err != nil { - log.Errorf("failed to get daemon client: %v", err) - return - } - - _, err = conn.SwitchProfile(ctx, &proto.SwitchProfileRequest{ - ProfileName: &profile.Name, - Username: &currUser.Username, - }) - if err != nil { - log.Errorf("failed to switch profile: %v", err) - // show notification dialog - p.app.SendNotification(fyne.NewNotification("Error", "Failed to switch profile")) - return - } - - err = p.profileManager.SwitchProfile(profile.Name) - if err != nil { - log.Errorf("failed to switch profile '%s': %v", profile.Name, err) - return - } - - log.Infof("Switched to profile '%s'", profile.Name) - - status, err := conn.Status(ctx, &proto.StatusRequest{}) - if err != nil { - log.Errorf("failed to get status after switching profile: %v", err) - return - } - - if status.Status == string(internal.StatusConnected) { - if err := p.downClickCallback(); err != nil { - log.Errorf("failed to handle down click after switching profile: %v", err) - } - } - - if p.serviceClient.connectCancel != nil { - p.serviceClient.connectCancel() - } - - connectCtx, connectCancel := context.WithCancel(p.ctx) - p.serviceClient.connectCancel = connectCancel - - if err := p.upClickCallback(connectCtx); err != nil { - log.Errorf("failed to handle up click after switching profile: %v", err) - } - - connectCancel() - - p.refresh() - p.loadSettingsCallback() - } - } - }() - - } - ctx, cancel := context.WithCancel(context.Background()) - manageItem := p.profileMenuItem.AddSubMenuItem("Manage Profiles", "") - p.manageProfilesSubItem = &subItem{manageItem, ctx, cancel} - - go func() { - for { - select { - case <-ctx.Done(): - return - case _, ok := <-manageItem.ClickedCh: - if !ok { - return - } - p.eventHandler.runSelfCommand(p.ctx, "profiles", "true") - p.refresh() - p.loadSettingsCallback() - } - } - }() - - // Add Logout menu item - ctx2, cancel2 := context.WithCancel(context.Background()) - logoutItem := p.profileMenuItem.AddSubMenuItem("Deregister", "") - p.logoutSubItem = &subItem{logoutItem, ctx2, cancel2} - - go func() { - for { - select { - case <-ctx2.Done(): - return - case _, ok := <-logoutItem.ClickedCh: - if !ok { - return - } - if err := p.eventHandler.logout(p.ctx); err != nil { - log.Errorf("logout failed: %v", err) - p.app.SendNotification(fyne.NewNotification("Error", "Failed to deregister")) - } else { - p.app.SendNotification(fyne.NewNotification("Success", "Deregistered successfully")) - } - } - } - }() - - if activeProf.ProfileName == "default" || activeProf.Username == currUser.Username { - p.profileMenuItem.SetTitle(activeProf.ProfileName) - } else { - p.profileMenuItem.SetTitle(fmt.Sprintf("Profile: %s (User: %s)", activeProf.ProfileName, activeProf.Username)) - p.emailMenuItem.Hide() - } - -} - -func (p *profileMenu) clear(profiles []Profile) { - for _, item := range p.profileSubItems { - item.Remove() - item.cancel() - } - p.profileSubItems = make([]*subItem, 0, len(profiles)) - p.profilesState = profiles - - if p.manageProfilesSubItem != nil { - p.manageProfilesSubItem.Remove() - p.manageProfilesSubItem.cancel() - p.manageProfilesSubItem = nil - } - - if p.logoutSubItem != nil { - p.logoutSubItem.Remove() - p.logoutSubItem.cancel() - p.logoutSubItem = nil - } -} - -// setEnabled enables or disables the profile menu based on the provided state -func (p *profileMenu) setEnabled(enabled bool) { - if p.profileMenuItem != nil { - if enabled { - p.profileMenuItem.Enable() - p.profileMenuItem.SetTooltip("") - } else { - p.profileMenuItem.Hide() - p.profileMenuItem.SetTooltip("Profiles are disabled by daemon") - } - } -} - -func (p *profileMenu) updateMenu() { - // check every second - ticker := time.NewTicker(time.Second) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - // get profilesList - profiles, err := p.getProfiles() - if err != nil { - log.Errorf("failed to list profiles: %v", err) - continue - } - - sort.Slice(profiles, func(i, j int) bool { - return profiles[i].Name < profiles[j].Name - }) - - p.mu.Lock() - state := p.profilesState - p.mu.Unlock() - - sort.Slice(state, func(i, j int) bool { - return state[i].Name < state[j].Name - }) - - if slices.Equal(profiles, state) { - continue - } - - p.refresh() - case <-p.ctx.Done(): - return // context cancelled - - } - } -} diff --git a/client/ui/quickactions.go b/client/ui/quickactions.go deleted file mode 100644 index bf47ac434..000000000 --- a/client/ui/quickactions.go +++ /dev/null @@ -1,349 +0,0 @@ -//go:build !(linux && 386) - -//go:generate fyne bundle -o quickactions_assets.go assets/connected.png -//go:generate fyne bundle -o quickactions_assets.go -append assets/disconnected.png -package main - -import ( - "context" - _ "embed" - "fmt" - "runtime" - "sync/atomic" - "time" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/canvas" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/layout" - "fyne.io/fyne/v2/widget" - log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/internal" - "github.com/netbirdio/netbird/client/proto" -) - -type quickActionsUiState struct { - connectionStatus string - isToggleButtonEnabled bool - isConnectionChanged bool - toggleAction func() -} - -func newQuickActionsUiState() quickActionsUiState { - return quickActionsUiState{ - connectionStatus: string(internal.StatusIdle), - isToggleButtonEnabled: false, - isConnectionChanged: false, - } -} - -type clientConnectionStatusProvider interface { - connectionStatus(ctx context.Context) (string, error) -} - -type daemonClientConnectionStatusProvider struct { - client proto.DaemonServiceClient -} - -func (d daemonClientConnectionStatusProvider) connectionStatus(ctx context.Context) (string, error) { - childCtx, cancel := context.WithTimeout(ctx, 400*time.Millisecond) - defer cancel() - status, err := d.client.Status(childCtx, &proto.StatusRequest{}) - if err != nil { - return "", err - } - - return status.Status, nil -} - -type clientCommand interface { - execute() error -} - -type connectCommand struct { - connectClient func() error -} - -func (c connectCommand) execute() error { - return c.connectClient() -} - -type disconnectCommand struct { - disconnectClient func() error -} - -func (c disconnectCommand) execute() error { - return c.disconnectClient() -} - -type quickActionsViewModel struct { - provider clientConnectionStatusProvider - connect clientCommand - disconnect clientCommand - uiChan chan quickActionsUiState - isWatchingConnectionStatus atomic.Bool -} - -func newQuickActionsViewModel(ctx context.Context, provider clientConnectionStatusProvider, connect, disconnect clientCommand, uiChan chan quickActionsUiState) { - viewModel := quickActionsViewModel{ - provider: provider, - connect: connect, - disconnect: disconnect, - uiChan: uiChan, - } - - viewModel.isWatchingConnectionStatus.Store(true) - - // base UI status - uiChan <- newQuickActionsUiState() - - // this retrieves the current connection status - // and pushes the UI state that reflects it via uiChan - go viewModel.watchConnectionStatus(ctx) -} - -func (q *quickActionsViewModel) updateUiState(ctx context.Context) { - uiState := newQuickActionsUiState() - connectionStatus, err := q.provider.connectionStatus(ctx) - - if err != nil { - log.Errorf("Status: Error - %v", err) - q.uiChan <- uiState - return - } - - if connectionStatus == string(internal.StatusConnected) { - uiState.toggleAction = func() { - q.executeCommand(q.disconnect) - } - } else { - uiState.toggleAction = func() { - q.executeCommand(q.connect) - } - } - - uiState.isToggleButtonEnabled = true - uiState.connectionStatus = connectionStatus - q.uiChan <- uiState -} - -func (q *quickActionsViewModel) watchConnectionStatus(ctx context.Context) { - ticker := time.NewTicker(1000 * time.Millisecond) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - if q.isWatchingConnectionStatus.Load() { - q.updateUiState(ctx) - } - } - } -} - -func (q *quickActionsViewModel) executeCommand(command clientCommand) { - uiState := newQuickActionsUiState() - // newQuickActionsUiState starts with Idle connection status, - // and all that's necessary here is to just disable the toggle button. - uiState.connectionStatus = "" - - q.uiChan <- uiState - - q.isWatchingConnectionStatus.Store(false) - - err := command.execute() - - if err != nil { - log.Errorf("Status: Error - %v", err) - q.isWatchingConnectionStatus.Store(true) - } else { - uiState = newQuickActionsUiState() - uiState.isConnectionChanged = true - q.uiChan <- uiState - } -} - -func getSystemTrayName() string { - os := runtime.GOOS - switch os { - case "darwin": - return "menu bar" - default: - return "system tray" - } -} - -func (s *serviceClient) getNetBirdImage(name string, content []byte) *canvas.Image { - imageSize := fyne.NewSize(64, 64) - - resource := fyne.NewStaticResource(name, content) - image := canvas.NewImageFromResource(resource) - image.FillMode = canvas.ImageFillContain - image.SetMinSize(imageSize) - image.Resize(imageSize) - - return image -} - -type quickActionsUiComponents struct { - content *fyne.Container - toggleConnectionButton *widget.Button - connectedLabelText, disconnectedLabelText string - connectedImage, disconnectedImage *canvas.Image - connectedCircleRes, disconnectedCircleRes fyne.Resource -} - -// applyQuickActionsUiState applies a single UI state to the quick actions window. -// It closes the window and returns true if the connection status has changed, -// in which case the caller should stop processing further states. -func (s *serviceClient) applyQuickActionsUiState( - uiState quickActionsUiState, - components quickActionsUiComponents, -) bool { - if uiState.isConnectionChanged { - fyne.DoAndWait(func() { - s.wQuickActions.Close() - }) - return true - } - - var logo *canvas.Image - var buttonText string - var buttonIcon fyne.Resource - - if uiState.connectionStatus == string(internal.StatusConnected) { - buttonText = components.connectedLabelText - buttonIcon = components.connectedCircleRes - logo = components.connectedImage - } else if uiState.connectionStatus == string(internal.StatusIdle) { - buttonText = components.disconnectedLabelText - buttonIcon = components.disconnectedCircleRes - logo = components.disconnectedImage - } - - fyne.DoAndWait(func() { - if buttonText != "" { - components.toggleConnectionButton.SetText(buttonText) - } - - if buttonIcon != nil { - components.toggleConnectionButton.SetIcon(buttonIcon) - } - - if uiState.isToggleButtonEnabled { - components.toggleConnectionButton.Enable() - } else { - components.toggleConnectionButton.Disable() - } - - components.toggleConnectionButton.OnTapped = func() { - if uiState.toggleAction != nil { - go uiState.toggleAction() - } - } - - components.toggleConnectionButton.Refresh() - - // the second position in the content's object array is the NetBird logo. - if logo != nil { - components.content.Objects[1] = logo - components.content.Refresh() - } - }) - - return false -} - -// showQuickActionsUI displays a simple window with the NetBird logo and a connection toggle button. -func (s *serviceClient) showQuickActionsUI() { - s.wQuickActions = s.app.NewWindow("NetBird") - vmCtx, vmCancel := context.WithCancel(s.ctx) - s.wQuickActions.SetOnClosed(vmCancel) - - client, err := s.getSrvClient(defaultFailTimeout) - - connCmd := connectCommand{ - connectClient: func() error { - return s.menuUpClick(s.ctx) - }, - } - - disConnCmd := disconnectCommand{ - disconnectClient: func() error { - return s.menuDownClick() - }, - } - - if err != nil { - log.Errorf("get service client: %v", err) - return - } - - uiChan := make(chan quickActionsUiState, 1) - newQuickActionsViewModel(vmCtx, daemonClientConnectionStatusProvider{client: client}, connCmd, disConnCmd, uiChan) - - connectedImage := s.getNetBirdImage("netbird.png", iconAbout) - disconnectedImage := s.getNetBirdImage("netbird-disconnected.png", iconAboutDisconnected) - - connectedCircle := canvas.NewImageFromResource(resourceConnectedPng) - disconnectedCircle := canvas.NewImageFromResource(resourceDisconnectedPng) - - connectedLabelText := "Disconnect" - disconnectedLabelText := "Connect" - - toggleConnectionButton := widget.NewButtonWithIcon(disconnectedLabelText, disconnectedCircle.Resource, func() { - // This button's tap function will be set when an ui state arrives via the uiChan channel. - }) - - // Button starts disabled until the first ui state arrives. - toggleConnectionButton.Disable() - - hintLabelText := fmt.Sprintf("You can always access NetBird from your %s.", getSystemTrayName()) - hintLabel := widget.NewLabel(hintLabelText) - - content := container.NewVBox( - layout.NewSpacer(), - disconnectedImage, - layout.NewSpacer(), - container.NewCenter(toggleConnectionButton), - layout.NewSpacer(), - container.NewCenter(hintLabel), - ) - - // this watches for ui state updates. - go func() { - - for { - select { - case <-vmCtx.Done(): - return - case uiState, ok := <-uiChan: - if !ok { - return - } - - closed := s.applyQuickActionsUiState( - uiState, - quickActionsUiComponents{ - content, - toggleConnectionButton, - connectedLabelText, disconnectedLabelText, - connectedImage, disconnectedImage, - connectedCircle.Resource, disconnectedCircle.Resource, - }, - ) - if closed { - return - } - } - } - }() - - s.wQuickActions.SetContent(content) - s.wQuickActions.Resize(fyne.NewSize(400, 200)) - s.wQuickActions.SetFixedSize(true) - s.wQuickActions.Show() -} diff --git a/client/ui/quickactions_assets.go b/client/ui/quickactions_assets.go deleted file mode 100644 index 9ff5e85a2..000000000 --- a/client/ui/quickactions_assets.go +++ /dev/null @@ -1,23 +0,0 @@ -// auto-generated -// Code generated by '$ fyne bundle'. DO NOT EDIT. - -package main - -import ( - _ "embed" - "fyne.io/fyne/v2" -) - -//go:embed assets/connected.png -var resourceConnectedPngData []byte -var resourceConnectedPng = &fyne.StaticResource{ - StaticName: "assets/connected.png", - StaticContent: resourceConnectedPngData, -} - -//go:embed assets/disconnected.png -var resourceDisconnectedPngData []byte -var resourceDisconnectedPng = &fyne.StaticResource{ - StaticName: "assets/disconnected.png", - StaticContent: resourceDisconnectedPngData, -} diff --git a/client/ui/recenter_linux.go b/client/ui/recenter_linux.go new file mode 100644 index 000000000..25c468d7f --- /dev/null +++ b/client/ui/recenter_linux.go @@ -0,0 +1,13 @@ +//go:build linux && !(linux && 386) + +package main + +// recenterOnShowPredicate returns a per-show predicate; re-centering is only +// needed under the minimal-WM / in-process-XEmbed-tray environment, which neither +// centers small windows nor restores position across hide -> show. Evaluated per +// show, not at startup, because the XEmbed tray can appear after the UI starts +// (panel and autostarted app race at login); xembedTrayAvailable is a cheap, +// side-effect-free probe safe to call repeatedly. +func recenterOnShowPredicate() func() bool { + return xembedTrayAvailable +} diff --git a/client/ui/recenter_other.go b/client/ui/recenter_other.go new file mode 100644 index 000000000..18ffe8730 --- /dev/null +++ b/client/ui/recenter_other.go @@ -0,0 +1,10 @@ +//go:build (!linux || (linux && 386)) && !freebsd && !android && !ios && !js + +package main + +// recenterOnShowPredicate returns nil off Linux: macOS and Windows WMs restore +// window position across hide -> show themselves, so Go-side re-centering would +// only fight a window the user moved. +func recenterOnShowPredicate() func() bool { + return nil +} diff --git a/client/ui/services/autostart.go b/client/ui/services/autostart.go new file mode 100644 index 000000000..98e893f04 --- /dev/null +++ b/client/ui/services/autostart.go @@ -0,0 +1,52 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "errors" + "fmt" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +// Autostart facade over Wails' AutostartManager. The OS autostart entry registration +// is the single source of truth; nothing is mirrored to preferences. +type Autostart struct { + mgr *application.AutostartManager +} + +func NewAutostart(mgr *application.AutostartManager) *Autostart { + return &Autostart{mgr: mgr} +} + +func (a *Autostart) Supported(_ context.Context) bool { + _, err := a.mgr.Status() + return !errors.Is(err, application.ErrAutostartNotSupported) +} + +// IsEnabled returns false without error on unsupported platforms. +func (a *Autostart) IsEnabled(_ context.Context) (bool, error) { + enabled, err := a.mgr.IsEnabled() + if err != nil { + if errors.Is(err, application.ErrAutostartNotSupported) { + return false, nil + } + return false, fmt.Errorf("read autostart state: %w", err) + } + return enabled, nil +} + +// SetEnabled takes effect on the next login, not immediately. +func (a *Autostart) SetEnabled(_ context.Context, enabled bool) error { + if enabled { + if err := a.mgr.Enable(); err != nil { + return fmt.Errorf("enable autostart: %w", err) + } + return nil + } + if err := a.mgr.Disable(); err != nil { + return fmt.Errorf("disable autostart: %w", err) + } + return nil +} diff --git a/client/ui/services/compat.go b/client/ui/services/compat.go new file mode 100644 index 000000000..0cc6dd99f --- /dev/null +++ b/client/ui/services/compat.go @@ -0,0 +1,40 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/proto" +) + +// Compat answers whether the running daemon is new enough to drive this UI. +type Compat struct { + conn DaemonConn +} + +func NewCompat(conn DaemonConn) *Compat { + return &Compat{conn: conn} +} + +// DaemonReady probes the WailsUIReady RPC once. A true result means the daemon +// implements it and is compatible. An Unimplemented response means the daemon +// predates this UI and is too old; the caller should surface an upgrade prompt. +// Any other error (daemon not running, transport failure) is returned so the +// frontend can tell "outdated" apart from "not reachable". +func (c *Compat) DaemonReady(ctx context.Context) (bool, error) { + client, err := c.conn.Client() + if err != nil { + return false, err + } + if _, err := client.WailsUIReady(ctx, &proto.WailsUIReadyRequest{}); err != nil { + if st, ok := status.FromError(err); ok && st.Code() == codes.Unimplemented { + return false, nil + } + return false, err + } + return true, nil +} diff --git a/client/ui/services/conn.go b/client/ui/services/conn.go new file mode 100644 index 000000000..531abe7d9 --- /dev/null +++ b/client/ui/services/conn.go @@ -0,0 +1,13 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import "github.com/netbirdio/netbird/client/proto" + +// DaemonConn returns a lazy gRPC client to the NetBird daemon. +// All services receive a DaemonConn so they share a single connection. +type DaemonConn interface { + Client() (proto.DaemonServiceClient, error) +} + +func ptrStr(s string) *string { return &s } diff --git a/client/ui/services/connection.go b/client/ui/services/connection.go new file mode 100644 index 000000000..f78ce4c0f --- /dev/null +++ b/client/ui/services/connection.go @@ -0,0 +1,290 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "fmt" + "os" + "os/exec" + "os/user" + "runtime" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/proto" +) + +// LoginParams are the inputs to Login. +type LoginParams struct { + ProfileName string `json:"profileName"` + Username string `json:"username"` + ManagementURL string `json:"managementUrl"` + SetupKey string `json:"setupKey"` + PreSharedKey string `json:"preSharedKey"` + Hostname string `json:"hostname"` + Hint string `json:"hint"` +} + +// LoginResult is the daemon's reply to Login. +type LoginResult struct { + NeedsSSOLogin bool `json:"needsSsoLogin"` + UserCode string `json:"userCode"` + VerificationURI string `json:"verificationUri"` + VerificationURIComplete string `json:"verificationUriComplete"` + // ProfileID is the ID of the profile this login ran against, or "" when the + // caller named the profile itself and no ID was resolved. Pass it back in + // WaitSSOParams so the account email lands on this profile even if the + // active one changes during SSO. + ProfileID string `json:"profileId"` +} + +// WaitSSOParams are the inputs to waitSSOLogin. +type WaitSSOParams struct { + UserCode string `json:"userCode"` + Hostname string `json:"hostname"` + // ProfileID is the profile the login was started for, used to file the + // account email against it rather than against whichever profile is active + // when the flow returns. Optional: empty falls back to the active profile. + ProfileID string `json:"profileId"` +} + +// UpParams selects the profile to bring up. +type UpParams struct { + ProfileName string `json:"profileName"` + Username string `json:"username"` +} + +// LogoutParams selects the profile to log out. +type LogoutParams struct { + ProfileName string `json:"profileName"` + Username string `json:"username"` +} + +// Connection groups the daemon RPCs that drive login / connect / disconnect. +type Connection struct { + conn DaemonConn + classifier errorClassifier +} + +// NewConnection wires up a Connection. translator or prefs may be nil, in which +// case classifyDaemonError falls back to the bare error key. +func NewConnection(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference) *Connection { + return &Connection{conn: conn, classifier: errorClassifier{translator: translator, prefs: prefs}} +} + +func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, error) { + cli, err := s.conn.Client() + if err != nil { + return LoginResult{}, err + } + + // No pre-Login Down: Login dislodges a pending WaitSSOLogin itself, and a + // defensive Down would only flash an Idle blink in the tray during handoff. + + // Fall back to the daemon's active profile and the current OS user. + profileName := p.ProfileName + username := p.Username + // Only set when the daemon told us the ID. A caller-supplied ProfileName is + // a handle — a display name or an ID prefix resolve too — and the state file + // is named after the ID, so passing a handle on would name the wrong file. + profileID := "" + if profileName == "" { + if active, aerr := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}); aerr == nil { + // Address the active profile by ID (the daemon resolves it as a + // handle); names can collide, the ID cannot. + profileName = active.GetId() + profileID = profileName + if username == "" { + username = active.GetUsername() + } + } + } + if username == "" { + if u, uerr := user.Current(); uerr == nil { + username = u.Username + } + } + + req := &proto.LoginRequest{ + ManagementUrl: p.ManagementURL, + SetupKey: p.SetupKey, + Hostname: p.Hostname, + // a login driven by the UI always has a graphical session available + IsUnixDesktopClient: true, + } + if profileName != "" { + req.ProfileName = ptrStr(profileName) + } + if username != "" { + req.Username = ptrStr(username) + } + if p.PreSharedKey != "" { + req.OptionalPreSharedKey = ptrStr(p.PreSharedKey) + } + hint := p.Hint + if hint == "" && profileID != "" { + if state, serr := profilemanager.NewProfileManager().GetProfileState(profilemanager.ID(profileID)); serr == nil { + hint = state.Email + } else { + log.Debugf("failed to get profile state for login hint: %v", serr) + } + } + if hint != "" { + req.Hint = ptrStr(hint) + } + + resp, err := cli.Login(ctx, req) + if err != nil { + return LoginResult{}, s.classifyDaemonError(err) + } + log.Infof("daemon login response received, needs SSO login: %v", resp.GetNeedsSSOLogin()) + return LoginResult{ + NeedsSSOLogin: resp.GetNeedsSSOLogin(), + UserCode: resp.GetUserCode(), + VerificationURI: resp.GetVerificationURI(), + VerificationURIComplete: resp.GetVerificationURIComplete(), + ProfileID: profileID, + }, nil +} + +func (s *Connection) Up(ctx context.Context, p UpParams) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + log.Infof("sending up request to daemon") + // Always async: status updates flow via SubscribeStatus. + req := &proto.UpRequest{Async: true} + if p.ProfileName != "" { + req.ProfileName = ptrStr(p.ProfileName) + } + if p.Username != "" { + req.Username = ptrStr(p.Username) + } + if _, err = cli.Up(ctx, req); err != nil { + return s.classifyDaemonError(err) + } + return nil +} + +// WaitSSOLoginAndUp blocks until the SSO login completes and then brings the +// connection up, both from the Go side. Keeping the post-login Up here rather +// than as a frontend continuation is deliberate: during SSO the tray window is +// hidden and the webview is suspended (macOS App Nap / hidden-window timer +// throttling), so a frontend-driven Up would not run until the user woke the +// window (e.g. by hovering the tray icon). Doing it in Go connects the moment +// the daemon reports SSO success. Returns the authenticated user's email. +func (s *Connection) WaitSSOLoginAndUp(ctx context.Context, wait WaitSSOParams, up UpParams) (string, error) { + email, err := s.waitSSOLogin(ctx, wait) + if err != nil { + return "", err + } + if err := ctx.Err(); err != nil { + return "", err + } + if err := s.Up(ctx, up); err != nil { + return "", err + } + return email, nil +} + +func (s *Connection) Down(ctx context.Context) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + if _, err = cli.Down(ctx, &proto.DownRequest{}); err != nil { + return s.classifyDaemonError(err) + } + return nil +} + +// OpenURL opens url in an external browser; the embedded webview blocks +// window.open, so the SSO verification page can't pop inline. Honors $BROWSER +// before the platform default. +func (s *Connection) OpenURL(url string) error { + if browser := os.Getenv("BROWSER"); browser != "" { + return exec.Command(browser, url).Start() + } + switch runtime.GOOS { + case "windows": + return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + case "darwin": + return exec.Command("open", url).Start() + case "linux": + return exec.Command("xdg-open", url).Start() + default: + return fmt.Errorf("unsupported platform") + } +} + +func (s *Connection) Logout(ctx context.Context, p LogoutParams) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + req := &proto.LogoutRequest{} + if p.ProfileName != "" { + req.ProfileName = ptrStr(p.ProfileName) + } + if p.Username != "" { + req.Username = ptrStr(p.Username) + } + if _, err = cli.Logout(ctx, req); err != nil { + return s.classifyDaemonError(err) + } + + return nil +} + +// waitSSOLogin blocks until the daemon reports the SSO login result and returns +// the authenticated user's email. It is unexported because the frontend drives +// SSO through the exported WaitSSOLoginAndUp. +func (s *Connection) waitSSOLogin(ctx context.Context, p WaitSSOParams) (string, error) { + cli, err := s.conn.Client() + if err != nil { + return "", err + } + log.Infof("waiting for SSO login to complete") + resp, err := cli.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{ + UserCode: p.UserCode, + Hostname: p.Hostname, + }) + if err != nil { + return "", s.classifyDaemonError(err) + } + log.Infof("SSO login completed, daemon reported success") + + // Persist the account email the same way the CLI does after its own + // WaitSSOLogin: the daemon returns it but cannot store it, since it runs as + // root and the per-profile state file is user-owned (see Profiles.List). + // Without this the profile has no email, so Profiles.List shows no account + // and later logins and session extends go out without a login_hint — + // leaving the IdP to guess which account was meant. + if email := resp.GetEmail(); email != "" { + state := &profilemanager.ProfileState{Email: email} + pm := profilemanager.NewProfileManager() + + // Against the profile the login was started for: SSO spans seconds of + // user interaction, and a profile switch in that window would otherwise + // file the email under the wrong profile. + if p.ProfileID != "" { + err = pm.SetProfileState(profilemanager.ID(p.ProfileID), state) + } else { + err = pm.SetActiveProfileState(state) + } + if err != nil { + // Non-fatal: the login itself succeeded. + log.Warnf("failed to store account email: %v", err) + } + } + + return resp.GetEmail(), nil +} + +// classifyDaemonError maps a gRPC error to a localised ClientError. +func (s *Connection) classifyDaemonError(err error) *ClientError { + return s.classifier.classify(err) +} diff --git a/client/ui/services/cursor_darwin.go b/client/ui/services/cursor_darwin.go new file mode 100644 index 000000000..a927ff719 --- /dev/null +++ b/client/ui/services/cursor_darwin.go @@ -0,0 +1,42 @@ +//go:build darwin + +package services + +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework Foundation -framework Cocoa -framework AppKit +#import +#import + +typedef struct CursorPoint { + int x; + int y; + int ok; +} CursorPoint; + +// NSEvent.mouseLocation is Y-up from primary's bottom-left; flip against +// the primary's frame height so the point matches Wails' Y-down Screen.Bounds. +CursorPoint nbGetCursorPos(void) { + CursorPoint p = {0, 0, 0}; + NSArray *screens = [NSScreen screens]; + if (screens == nil || screens.count == 0) return p; + NSScreen *primary = [screens firstObject]; + if (primary == nil) return p; + NSPoint loc = [NSEvent mouseLocation]; + p.x = (int)loc.x; + p.y = (int)(primary.frame.size.height - loc.y); + p.ok = 1; + return p; +} +*/ +import "C" + +import "github.com/wailsapp/wails/v3/pkg/application" + +func getCursorPosition(_ *application.App) (application.Point, bool) { + res := C.nbGetCursorPos() + if res.ok == 0 { + return application.Point{}, false + } + return application.Point{X: int(res.x), Y: int(res.y)}, true +} diff --git a/client/ui/services/cursor_linux.go b/client/ui/services/cursor_linux.go new file mode 100644 index 000000000..3294f3c95 --- /dev/null +++ b/client/ui/services/cursor_linux.go @@ -0,0 +1,59 @@ +//go:build linux + +package services + +/* +#cgo pkg-config: x11 +#cgo LDFLAGS: -lX11 +#include +#include + +typedef struct CursorPoint { + int x; + int y; + int ok; +} CursorPoint; + +// XQueryPointer works on X11 and, via XWayland, on Wayland sessions. +// ok=0 when no X server is reachable. +CursorPoint nbGetCursorPos(void) { + CursorPoint p = {0, 0, 0}; + Display *dpy = XOpenDisplay(NULL); + if (!dpy) return p; + Window root = DefaultRootWindow(dpy); + if (root == 0) { XCloseDisplay(dpy); return p; } + Window root_return = 0, child_return = 0; + int root_x = 0, root_y = 0, win_x = 0, win_y = 0; + unsigned int mask_return = 0; + if (XQueryPointer(dpy, root, &root_return, &child_return, + &root_x, &root_y, &win_x, &win_y, &mask_return)) { + p.x = root_x; + p.y = root_y; + p.ok = 1; + } + XCloseDisplay(dpy); + return p; +} +*/ +import "C" + +import "github.com/wailsapp/wails/v3/pkg/application" + +func getCursorPosition(app *application.App) (application.Point, bool) { + res := C.nbGetCursorPos() + if res.ok == 0 { + return application.Point{}, false + } + p := application.Point{X: int(res.x), Y: int(res.y)} + // X11 root coords are physical pixels; Screen.Bounds is in DIPs. + if app == nil || app.Screen == nil { + return p, true + } + // The wails GTK3 backend caches screens from the active window; a tray app + // has none at startup, so the cache is empty and PhysicalToDipPoint would + // dereference a nil nearest screen. Raw pixels are correct there anyway. + if app.Screen.ScreenNearestPhysicalPoint(p) == nil { + return p, true + } + return app.Screen.PhysicalToDipPoint(p), true +} diff --git a/client/ui/services/cursor_other.go b/client/ui/services/cursor_other.go new file mode 100644 index 000000000..7f35ff438 --- /dev/null +++ b/client/ui/services/cursor_other.go @@ -0,0 +1,9 @@ +//go:build !darwin && !windows && !linux && !freebsd && !android && !ios && !js + +package services + +import "github.com/wailsapp/wails/v3/pkg/application" + +func getCursorPosition(_ *application.App) (application.Point, bool) { + return application.Point{}, false +} diff --git a/client/ui/services/cursor_windows.go b/client/ui/services/cursor_windows.go new file mode 100644 index 000000000..42ed32d74 --- /dev/null +++ b/client/ui/services/cursor_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package services + +import ( + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/w32" +) + +func getCursorPosition(app *application.App) (application.Point, bool) { + x, y, ok := w32.GetCursorPos() + if !ok || app == nil || app.Screen == nil { + return application.Point{}, false + } + // GetCursorPos is in physical pixels; Screen.Bounds is in DIPs. + return app.Screen.PhysicalToDipPoint(application.Point{X: x, Y: y}), true +} diff --git a/client/ui/services/daemon_feed.go b/client/ui/services/daemon_feed.go new file mode 100644 index 000000000..632581fe9 --- /dev/null +++ b/client/ui/services/daemon_feed.go @@ -0,0 +1,590 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/cenkalti/backoff/v4" + log "github.com/sirupsen/logrus" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/client/ui/authsession" + "github.com/netbirdio/netbird/client/ui/updater" +) + +const ( + EventStatusSnapshot = "netbird:status" + // EventDaemonNotification carries each SubscribeEvents message. Auto-update + // SystemEvents are also forwarded to updater.Holder.OnSystemEvent so the typed + // update state needs no second daemon subscription. + EventDaemonNotification = "netbird:event" + // EventProfileChanged fires after a daemon-side switch (payload: the new + // ProfileRef). The daemon emits no profile event, so this is the only signal + // that lets a flip driven from one surface paint in the others. + EventProfileChanged = "netbird:profile:changed" + // EventSessionWarning is a typed sibling of EventDaemonNotification so + // subscribers needn't filter the notification firehose. Consumers branch on + // SessionWarning.Final to tell the T-10 event from the T-2 fallback. + EventSessionWarning = "netbird:session:warning" + + // StatusDaemonUnavailable is the synthetic Status emitted when the daemon's + // gRPC socket is unreachable. No internal.Status* collides with this label. + StatusDaemonUnavailable = "DaemonUnavailable" + + // Daemon connection status strings — mirror internal.Status* in + // client/internal/state.go. + StatusConnected = "Connected" + StatusConnecting = "Connecting" + StatusIdle = "Idle" + StatusNeedsLogin = "NeedsLogin" + StatusLoginFailed = "LoginFailed" + StatusSessionExpired = "SessionExpired" + + // SeverityCritical is the lower-cased proto SystemEvent_CRITICAL severity, as + // emitted by systemEventFromProto. Critical events bypass the notifications gate. + SeverityCritical = "critical" +) + +// Emitter sends a named payload to the frontend. Satisfied by Wails app.Event. +type Emitter interface { + Emit(name string, data ...any) bool +} + +// SystemEvent is the frontend-facing shape of a daemon SystemEvent. +type SystemEvent struct { + ID string `json:"id"` + Severity string `json:"severity"` + Category string `json:"category"` + Message string `json:"message"` + UserMessage string `json:"userMessage"` + Timestamp int64 `json:"timestamp"` + Metadata map[string]string `json:"metadata"` +} + +// PeerStatus is the frontend-facing shape of a daemon PeerState. +type PeerStatus struct { + IP string `json:"ip"` + IPv6 string `json:"ipv6"` + PubKey string `json:"pubKey"` + ConnStatus string `json:"connStatus"` + ConnStatusUpdateUnix int64 `json:"connStatusUpdateUnix"` + Relayed bool `json:"relayed"` + LocalIceCandidateType string `json:"localIceCandidateType"` + RemoteIceCandidateType string `json:"remoteIceCandidateType"` + LocalIceCandidateEndpoint string `json:"localIceCandidateEndpoint"` + RemoteIceCandidateEndpoint string `json:"remoteIceCandidateEndpoint"` + Fqdn string `json:"fqdn"` + BytesRx int64 `json:"bytesRx"` + BytesTx int64 `json:"bytesTx"` + LatencyMs int64 `json:"latencyMs"` + RelayAddress string `json:"relayAddress"` + LastHandshakeUnix int64 `json:"lastHandshakeUnix"` + RosenpassEnabled bool `json:"rosenpassEnabled"` + Networks []string `json:"networks"` +} + +// PeerLink is this peer's connection to its mgmt or signal server. +type PeerLink struct { + URL string `json:"url"` + Connected bool `json:"connected"` + Error string `json:"error,omitempty"` +} + +// LocalPeer mirrors LocalPeerState. +type LocalPeer struct { + IP string `json:"ip"` + IPv6 string `json:"ipv6"` + PubKey string `json:"pubKey"` + Fqdn string `json:"fqdn"` + Networks []string `json:"networks"` +} + +// Status is the snapshot the frontend renders on the dashboard. +type Status struct { + Status string `json:"status"` + DaemonVersion string `json:"daemonVersion"` + Management PeerLink `json:"management"` + Signal PeerLink `json:"signal"` + Local LocalPeer `json:"local"` + Peers []PeerStatus `json:"peers"` + Events []SystemEvent `json:"events"` + // NetworksRevision bumps whenever the daemon's routed-networks set or their + // selected state changes, so consumers know when to re-fetch ListNetworks + // instead of polling every snapshot. + NetworksRevision uint64 `json:"networksRevision"` + // SessionExpiresAt is the absolute UTC instant the SSO session expires; nil + // when the peer is not SSO-tracked or login expiration is disabled. + SessionExpiresAt *time.Time `json:"sessionExpiresAt,omitempty"` +} + +// DaemonFeed fans the daemon's two long-running gRPC streams (SubscribeStatus, +// SubscribeEvents) out to the frontend and tray, and exposes a one-shot Status +// RPC for callers wanting the current snapshot without subscribing. +// +// Profile-switch suppression: BeginProfileSwitch makes statusStreamLoop swallow +// the transient stale Connected and Idle pushes the daemon emits during Down, so +// consumers see Connecting → new-profile-state instead of the full blink. +// +// Two flags govern the switch lifecycle, evaluated independently by +// consumeForSwitch on every push because their lifetimes differ: +// +// switchInProgress (suppression): clears on the first real push from the new +// Up. Daemon-side StatusConnecting comes BEFORE any NeedsLogin, so +// suppression must release here before the terminal arrives. +// switchLoginWatch (trigger): outlives suppression. Watches for NeedsLogin +// / LoginFailed / SessionExpired along the Up's retry loop and emits +// EventTriggerLogin so the React orchestrator opens browser-login. +// +// ┌────────────────────────────────────────────┬──────────────────────────────────┐ +// │ Incoming daemon status │ Action │ +// ├────────────────────────────────────────────┼──────────────────────────────────┤ +// │ Connected, Idle (while switchInProgress) │ Suppress (the blink we hide) │ +// │ Connecting │ Emit, clear switchInProgress │ +// │ NeedsLogin, LoginFailed, SessionExpired │ Emit, clear both flags, also │ +// │ │ emit EventTriggerLogin │ +// │ Connected, Idle (while only login-watch) │ Emit, clear switchLoginWatch │ +// │ DaemonUnavailable │ Emit, clear both flags │ +// │ (timeout elapsed) │ Clear flags, emit normally │ +// └────────────────────────────────────────────┴──────────────────────────────────┘ +type DaemonFeed struct { + conn DaemonConn + emitter Emitter + updater *updater.Holder + // logCtl attaches/detaches the GUI file log in response to the daemon's log + // level (a marked SystemEvent on the SubscribeEvents stream). nil when the GUI + // doesn't manage its log (server build / not wired), in which case the marker + // is ignored. + logCtl LogController + + mu sync.Mutex + cancel context.CancelFunc + streamWg sync.WaitGroup + + switchMu sync.Mutex + switchInProgress bool + switchInProgressUntil time.Time + switchLoginWatch bool + switchLoginWatchUntil time.Time +} + +// LogController is the subset of guilog.DebugLog that DaemonFeed drives: Apply +// turns the GUI file log on/off for a daemon level; Path is the gui-client.log +// path to register with the daemon (empty when the GUI doesn't own its log). +type LogController interface { + Apply(level string) + Path() string +} + +// NewDaemonFeed builds the feed. logCtl may be nil (server build / GUI log not +// managed), in which case log-level markers on the event stream are ignored. +func NewDaemonFeed(conn DaemonConn, emitter Emitter, updaterHolder *updater.Holder, logCtl LogController) *DaemonFeed { + return &DaemonFeed{conn: conn, emitter: emitter, updater: updaterHolder, logCtl: logCtl} +} + +// BeginProfileSwitch arms suppression for a switch from Connected/Connecting, +// where the daemon emits stale Connected updates during Down's teardown then an +// Idle before the new Up; statusStreamLoop drops those, and a synthetic +// Connecting snapshot is emitted so consumers paint optimistically. A 30s safety +// timeout clears the flag if no follow-up status arrives. +func (s *DaemonFeed) BeginProfileSwitch() { + now := time.Now() + s.switchMu.Lock() + s.switchInProgress = true + s.switchInProgressUntil = now.Add(30 * time.Second) + s.switchLoginWatch = true + s.switchLoginWatchUntil = now.Add(30 * time.Second) + s.switchMu.Unlock() + s.emitter.Emit(EventStatusSnapshot, Status{Status: StatusConnecting}) +} + +// CancelProfileSwitch aborts a switch midway (tray Disconnect while Connecting): +// clears suppression so the next daemon Idle paints through, and disarms the +// login-watch so the abort doesn't pop a browser-login after the user cancelled. +func (s *DaemonFeed) CancelProfileSwitch() { + s.switchMu.Lock() + s.switchInProgress = false + s.switchLoginWatch = false + s.switchMu.Unlock() +} + +// Watch starts the two background stream loops. Idempotent (a second call while +// running is a no-op); both loops self-restart via exponential backoff. +func (s *DaemonFeed) Watch(ctx context.Context) { + s.mu.Lock() + if s.cancel != nil { + s.mu.Unlock() + return + } + ctx, cancel := context.WithCancel(ctx) + s.cancel = cancel + s.mu.Unlock() + + s.streamWg.Add(2) + go s.statusStreamLoop(ctx) + go s.toastStreamLoop(ctx) +} + +// ServiceShutdown is the Wails service hook fired on app exit. +func (s *DaemonFeed) ServiceShutdown() error { + s.mu.Lock() + cancel := s.cancel + s.cancel = nil + s.mu.Unlock() + if cancel != nil { + cancel() + } + s.streamWg.Wait() + return nil +} + +// Get returns the current daemon status snapshot. An unreachable daemon socket +// yields Status{Status: StatusDaemonUnavailable} rather than an error, so the +// frontend keys off a single status enum without a parallel "error" path. +func (s *DaemonFeed) Get(ctx context.Context) (Status, error) { + cli, err := s.conn.Client() + if err != nil { + if isDaemonUnreachable(err) { + return Status{Status: StatusDaemonUnavailable}, nil + } + return Status{}, err + } + resp, err := cli.Status(ctx, &proto.StatusRequest{GetFullPeerStatus: true}) + if err != nil { + if isDaemonUnreachable(err) { + return Status{Status: StatusDaemonUnavailable}, nil + } + return Status{}, err + } + return statusFromProto(resp), nil +} + +// consumeForSwitch decides, for an incoming push during a profile switch, +// whether to suppress it (suppress) and whether the switch landed in a state +// needing the SSO flow (triggerLogin: NeedsLogin / SessionExpired / LoginFailed). +// +// The two flags have different lifetimes: suppression clears on Connecting, but +// the trigger watcher must survive past it to catch the eventual NeedsLogin — +// daemon-side StatusConnecting fires before loginToManagement, which is what may +// then set StatusNeedsLogin. +func (s *DaemonFeed) consumeForSwitch(st Status) (suppress, triggerLogin bool) { + s.switchMu.Lock() + defer s.switchMu.Unlock() + + now := time.Now() + if s.switchInProgress && now.After(s.switchInProgressUntil) { + s.switchInProgress = false + } + if s.switchLoginWatch && now.After(s.switchLoginWatchUntil) { + s.switchLoginWatch = false + } + + if s.switchInProgress { + switch { + case strings.EqualFold(st.Status, StatusConnecting), + strings.EqualFold(st.Status, StatusNeedsLogin), + strings.EqualFold(st.Status, StatusLoginFailed), + strings.EqualFold(st.Status, StatusSessionExpired), + strings.EqualFold(st.Status, StatusDaemonUnavailable): + // New flow has begun (Up started, or daemon refused it). + s.switchInProgress = false + default: + // Stale Connected from teardown or transient Idle: suppress so the + // optimistic Connecting stays painted. Login-watch stays armed. + return true, false + } + } + + if s.switchLoginWatch { + switch { + case strings.EqualFold(st.Status, StatusNeedsLogin), + strings.EqualFold(st.Status, StatusLoginFailed), + strings.EqualFold(st.Status, StatusSessionExpired): + // SSO-needed terminal: trigger browser-login without a second click. + s.switchLoginWatch = false + return false, true + case strings.EqualFold(st.Status, StatusConnected), + strings.EqualFold(st.Status, StatusIdle), + strings.EqualFold(st.Status, StatusDaemonUnavailable): + // Terminal but not SSO — disarm without triggering. + s.switchLoginWatch = false + } + } + + return false, false +} + +// statusStreamLoop subscribes to SubscribeStatus and re-emits each snapshot on +// the Wails event bus. The first message is the current snapshot; later ones +// fire on connection-state changes only — no polling. +func (s *DaemonFeed) statusStreamLoop(ctx context.Context) { + defer s.streamWg.Done() + + bo := backoff.WithContext(&backoff.ExponentialBackOff{ + InitialInterval: time.Second, + RandomizationFactor: backoff.DefaultRandomizationFactor, + Multiplier: backoff.DefaultMultiplier, + MaxInterval: 10 * time.Second, + MaxElapsedTime: 0, + Stop: backoff.Stop, + Clock: backoff.SystemClock, + }, ctx) + + // unavailable fires the synthetic event once per outage, not on every retry. + unavailable := false + emitUnavailable := func() { + if unavailable { + return + } + unavailable = true + s.emitter.Emit(EventStatusSnapshot, Status{Status: StatusDaemonUnavailable}) + } + + op := func() error { + return s.subscribeAndStreamStatus(ctx, &unavailable, emitUnavailable) + } + + if err := backoff.Retry(op, bo); err != nil && ctx.Err() == nil { + log.Errorf("status stream ended: %v", err) + } +} + +// subscribeAndStreamStatus is one attempt of the status backoff loop: open +// SubscribeStatus and re-emit every snapshot until it errors. A daemon- +// unreachable failure also flips the synthetic-unavailable signal. +func (s *DaemonFeed) subscribeAndStreamStatus(ctx context.Context, unavailable *bool, emitUnavailable func()) error { + cli, err := s.conn.Client() + if err != nil { + emitUnavailable() + return fmt.Errorf("get client: %w", err) + } + stream, err := cli.SubscribeStatus(ctx, &proto.StatusRequest{GetFullPeerStatus: true}) + if err != nil { + if isDaemonUnreachable(err) { + emitUnavailable() + } + return fmt.Errorf("subscribe status: %w", err) + } + for { + resp, err := stream.Recv() + if err != nil { + return s.handleStatusRecvErr(ctx, err, emitUnavailable) + } + *unavailable = false + s.emitStatus(statusFromProto(resp)) + } +} + +// handleStatusRecvErr maps a SubscribeStatus Recv error into the backoff loop's +// return: ctx cancellation stops the loop, an unreachable socket flips the +// synthetic-unavailable signal, everything else is retryable. +func (s *DaemonFeed) handleStatusRecvErr(ctx context.Context, err error, emitUnavailable func()) error { + if ctx.Err() != nil { + return ctx.Err() + } + if isDaemonUnreachable(err) { + emitUnavailable() + } + return fmt.Errorf("status stream recv: %w", err) +} + +// emitStatus pushes a snapshot to the frontend, dropping the transient +// stale-Connected / Idle pushes that occur mid profile switch. +func (s *DaemonFeed) emitStatus(st Status) { + log.Infof("backend event: status status=%q peers=%d", st.Status, len(st.Peers)) + suppress, triggerLogin := s.consumeForSwitch(st) + if suppress { + log.Debugf("suppressing status=%q during profile switch", st.Status) + return + } + s.emitter.Emit(EventStatusSnapshot, st) + if triggerLogin { + s.emitter.Emit(EventTriggerLogin) + } +} + +// toastStreamLoop subscribes to SubscribeEvents and re-emits every SystemEvent +// on the Wails event bus. Local name differs from the RPC so the file's two +// streams aren't both called streamLoop. +func (s *DaemonFeed) toastStreamLoop(ctx context.Context) { + defer s.streamWg.Done() + + bo := backoff.WithContext(&backoff.ExponentialBackOff{ + InitialInterval: time.Second, + RandomizationFactor: backoff.DefaultRandomizationFactor, + Multiplier: backoff.DefaultMultiplier, + MaxInterval: 10 * time.Second, + MaxElapsedTime: 0, + Stop: backoff.Stop, + Clock: backoff.SystemClock, + }, ctx) + + op := func() error { + return s.subscribeAndStreamEvents(ctx) + } + + if err := backoff.Retry(op, bo); err != nil && ctx.Err() == nil { + log.Errorf("event stream ended: %v", err) + } +} + +// subscribeAndStreamEvents is one attempt of the event backoff loop: open +// SubscribeEvents and fan out every SystemEvent until it errors. +func (s *DaemonFeed) subscribeAndStreamEvents(ctx context.Context) error { + cli, err := s.conn.Client() + if err != nil { + return fmt.Errorf("get client: %w", err) + } + stream, err := cli.SubscribeEvents(ctx, &proto.SubscribeRequest{}) + if err != nil { + return fmt.Errorf("subscribe: %w", err) + } + + // Re-register the GUI log path on every (re)connect so a daemon restart + // re-learns it. Best-effort — a failure must not abort the stream. Done even + // when file logging is off, so the path is known ahead of any debug toggle. + if s.logCtl != nil && s.logCtl.Path() != "" { + if _, err := cli.RegisterUILog(ctx, &proto.RegisterUILogRequest{Path: s.logCtl.Path()}); err != nil { + log.Warnf("register UI log path: %v", err) + } + } + for { + ev, err := stream.Recv() + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return fmt.Errorf("stream recv: %w", err) + } + s.dispatchSystemEvent(ev) + } +} + +// dispatchSystemEvent fans one daemon SystemEvent out to the frontend +// notification stream, the typed session-warning event (when the metadata +// carries one), and the updater holder (when present). +func (s *DaemonFeed) dispatchSystemEvent(ev *proto.SystemEvent) { + se := systemEventFromProto(ev) + log.Infof("backend event: system severity=%s category=%s msg=%q", se.Severity, se.Category, se.UserMessage) + // Internal refresh signal (CLI-driven profile add/remove), not a notification: + // translate and stop so it never reaches Recent Events or fires an OS toast. + if se.Metadata[proto.MetadataKindKey] == proto.MetadataKindProfileListChanged { + s.emitter.Emit(EventProfileChanged, ProfileRef{}) + return + } + // Internal control signal driving the GUI file log on/off — handle and stop + // so it never reaches Recent Events or toasts. + if se.Metadata[proto.MetadataKindKey] == proto.MetadataKindLogLevelChanged { + if s.logCtl != nil { + s.logCtl.Apply(se.Metadata[proto.MetadataLevelKey]) + } + return + } + s.emitter.Emit(EventDaemonNotification, se) + if warn, ok := authsession.WarningFromMetadata(se.Metadata); ok { + s.emitter.Emit(EventSessionWarning, warn) + } + if s.updater != nil { + s.updater.OnSystemEvent(ev) + } +} + +func statusFromProto(resp *proto.StatusResponse) Status { + full := resp.GetFullStatus() + mgmt := full.GetManagementState() + sig := full.GetSignalState() + local := full.GetLocalPeerState() + + st := Status{ + Status: resp.GetStatus(), + DaemonVersion: resp.GetDaemonVersion(), + NetworksRevision: full.GetNetworksRevision(), + Management: PeerLink{ + URL: mgmt.GetURL(), + Connected: mgmt.GetConnected(), + Error: mgmt.GetError(), + }, + Signal: PeerLink{ + URL: sig.GetURL(), + Connected: sig.GetConnected(), + Error: sig.GetError(), + }, + Local: LocalPeer{ + IP: local.GetIP(), + IPv6: local.GetIpv6(), + PubKey: local.GetPubKey(), + Fqdn: local.GetFqdn(), + Networks: append([]string{}, local.GetNetworks()...), + }, + } + + for _, p := range full.GetPeers() { + st.Peers = append(st.Peers, PeerStatus{ + IP: p.GetIP(), + IPv6: p.GetIpv6(), + PubKey: p.GetPubKey(), + ConnStatus: p.GetConnStatus(), + ConnStatusUpdateUnix: p.GetConnStatusUpdate().GetSeconds(), + Relayed: p.GetRelayed(), + LocalIceCandidateType: p.GetLocalIceCandidateType(), + RemoteIceCandidateType: p.GetRemoteIceCandidateType(), + LocalIceCandidateEndpoint: p.GetLocalIceCandidateEndpoint(), + RemoteIceCandidateEndpoint: p.GetRemoteIceCandidateEndpoint(), + Fqdn: p.GetFqdn(), + BytesRx: p.GetBytesRx(), + BytesTx: p.GetBytesTx(), + LatencyMs: p.GetLatency().AsDuration().Milliseconds(), + RelayAddress: p.GetRelayAddress(), + LastHandshakeUnix: p.GetLastWireguardHandshake().GetSeconds(), + RosenpassEnabled: p.GetRosenpassEnabled(), + Networks: append([]string{}, p.GetNetworks()...), + }) + } + for _, e := range full.GetEvents() { + st.Events = append(st.Events, systemEventFromProto(e)) + } + if ts := resp.GetSessionExpiresAt(); ts.IsValid() && !ts.AsTime().IsZero() { + t := ts.AsTime().UTC() + st.SessionExpiresAt = &t + } + return st +} + +func systemEventFromProto(e *proto.SystemEvent) SystemEvent { + out := SystemEvent{ + ID: e.GetId(), + Severity: strings.ToLower(strings.TrimPrefix(e.GetSeverity().String(), "SystemEvent_")), + Category: strings.ToLower(strings.TrimPrefix(e.GetCategory().String(), "SystemEvent_")), + Message: e.GetMessage(), + UserMessage: e.GetUserMessage(), + Metadata: map[string]string{}, + } + if ts := e.GetTimestamp(); ts != nil { + out.Timestamp = ts.GetSeconds() + } + for k, v := range e.GetMetadata() { + out.Metadata[k] = v + } + return out +} + +// isDaemonUnreachable reports whether a gRPC error means the daemon socket isn't +// answering, versus the daemon responding with an application-level code. Only +// the former should flip the tray to "Not running" — a daemon returning e.g. +// FailedPrecondition is alive and must not be reported as down. +func isDaemonUnreachable(err error) bool { + if err == nil { + return false + } + st, ok := status.FromError(err) + if !ok { + return true + } + return st.Code() == codes.Unavailable +} diff --git a/client/ui/services/debug.go b/client/ui/services/debug.go new file mode 100644 index 000000000..d1f6555a8 --- /dev/null +++ b/client/ui/services/debug.go @@ -0,0 +1,140 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "fmt" + "strings" + "time" + + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/version" +) + +type DebugBundleParams struct { + Anonymize bool `json:"anonymize"` + // AnonymizeLevel is "default" or "strict"; strict also anonymizes + // private IP ranges, peer names, and WireGuard public keys. + AnonymizeLevel string `json:"anonymizeLevel"` + SystemInfo bool `json:"systemInfo"` + UploadURL string `json:"uploadUrl"` + LogFileCount uint32 `json:"logFileCount"` +} + +// DebugBundleResult: Path is set for local-only bundles, UploadedKey on upload +// success, UploadFailureReason on upload failure. +type DebugBundleResult struct { + Path string `json:"path"` + UploadedKey string `json:"uploadedKey"` + UploadFailureReason string `json:"uploadFailureReason"` +} + +// LogLevel carries a logrus level name: "error", "warn", "info", "debug", "trace". +type LogLevel struct { + Level string `json:"level"` +} + +type Debug struct { + conn DaemonConn +} + +func NewDebug(conn DaemonConn) *Debug { + return &Debug{conn: conn} +} + +func (s *Debug) Bundle(ctx context.Context, p DebugBundleParams) (DebugBundleResult, error) { + cli, err := s.conn.Client() + if err != nil { + return DebugBundleResult{}, err + } + resp, err := cli.DebugBundle(ctx, &proto.DebugBundleRequest{ + Anonymize: p.Anonymize, + AnonymizeLevel: p.AnonymizeLevel, + SystemInfo: p.SystemInfo, + UploadURL: p.UploadURL, + LogFileCount: p.LogFileCount, + CliVersion: version.NetbirdVersion(), + }) + if err != nil { + return DebugBundleResult{}, err + } + return DebugBundleResult{ + Path: resp.GetPath(), + UploadedKey: resp.GetUploadedKey(), + UploadFailureReason: resp.GetUploadFailureReason(), + }, nil +} + +func (s *Debug) GetLogLevel(ctx context.Context) (LogLevel, error) { + cli, err := s.conn.Client() + if err != nil { + return LogLevel{}, err + } + resp, err := cli.GetLogLevel(ctx, &proto.GetLogLevelRequest{}) + if err != nil { + return LogLevel{}, err + } + return LogLevel{Level: resp.GetLevel().String()}, nil +} + +// RevealFile opens the OS file manager focused on path. Needed because Wails' +// Browser.OpenURL refuses non-http(s) schemes like file://. +func (s *Debug) RevealFile(_ context.Context, path string) error { + if path == "" { + return fmt.Errorf("empty path") + } + return revealFile(path) +} + +// RegisterUILog reports the GUI log path to the daemon for bundle collection; +// the daemon runs as root and can't resolve the user's config dir. Called on +// each daemon (re)connect. +func (s *Debug) RegisterUILog(ctx context.Context, path string) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + _, err = cli.RegisterUILog(ctx, &proto.RegisterUILogRequest{Path: path}) + return err +} + +func (s *Debug) StartBundleCapture(ctx context.Context, timeoutSeconds int32) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + req := &proto.StartBundleCaptureRequest{} + if timeoutSeconds > 0 { + req.Timeout = durationpb.New(time.Duration(timeoutSeconds) * time.Second) + } + _, err = cli.StartBundleCapture(ctx, req) + return err +} + +func (s *Debug) StopBundleCapture(ctx context.Context) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + _, err = cli.StopBundleCapture(ctx, &proto.StopBundleCaptureRequest{}) + return err +} + +func (s *Debug) SetLogLevel(ctx context.Context, lvl LogLevel) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + // proto.LogLevel_value keys are upper-case enum names; callers pass + // lowercase logrus names. Upper-case before lookup or a valid level + // silently falls through to INFO. + level, ok := proto.LogLevel_value[strings.ToUpper(lvl.Level)] + if !ok { + level = int32(proto.LogLevel_INFO) + } + _, err = cli.SetLogLevel(ctx, &proto.SetLogLevelRequest{Level: proto.LogLevel(level)}) + return err +} diff --git a/client/ui/services/debug_reveal_other.go b/client/ui/services/debug_reveal_other.go new file mode 100644 index 000000000..bef0a58bb --- /dev/null +++ b/client/ui/services/debug_reveal_other.go @@ -0,0 +1,20 @@ +//go:build !android && !ios && !freebsd && !js && !windows + +package services + +import ( + "os/exec" + "path/filepath" + "runtime" +) + +// revealFile opens the OS file manager focused on path. +func revealFile(path string) error { + var cmd *exec.Cmd + if runtime.GOOS == "darwin" { + cmd = exec.Command("open", "-R", path) + } else { + cmd = exec.Command("xdg-open", filepath.Dir(path)) + } + return cmd.Start() +} diff --git a/client/ui/services/debug_reveal_windows.go b/client/ui/services/debug_reveal_windows.go new file mode 100644 index 000000000..790d23fab --- /dev/null +++ b/client/ui/services/debug_reveal_windows.go @@ -0,0 +1,54 @@ +package services + +import ( + "fmt" + "os/exec" + "path/filepath" + "unsafe" + + "golang.org/x/sys/windows" +) + +// SW_SHOWNORMAL for ShellExecuteW's nShowCmd. +const swShowNormal = 1 + +var ( + shell32 = windows.NewLazySystemDLL("shell32.dll") + procShellExecute = shell32.NewProc("ShellExecuteW") +) + +// revealFile opens Explorer focused on path. The debug bundle is written by the +// daemon (running as SYSTEM) into C:\Windows\SystemTemp, whose ACL denies the +// logged-in user. A plain "explorer /select" can't traverse it, so we elevate +// via the ShellExecuteW "runas" verb (UAC prompt) — the elevated Explorer can +// read the folder and highlight the file. +func revealFile(path string) error { + verb, err := windows.UTF16PtrFromString("runas") + if err != nil { + return fmt.Errorf("encode verb: %w", err) + } + file, err := windows.UTF16PtrFromString("explorer.exe") + if err != nil { + return fmt.Errorf("encode file: %w", err) + } + params, err := windows.UTF16PtrFromString("/select," + path) + if err != nil { + return fmt.Errorf("encode params: %w", err) + } + + // ShellExecuteW returns an HINSTANCE; a value <=32 is an error code. + ret, _, _ := procShellExecute.Call( + 0, + uintptr(unsafe.Pointer(verb)), + uintptr(unsafe.Pointer(file)), + uintptr(unsafe.Pointer(params)), + 0, + swShowNormal, + ) + if ret <= 32 { + // Elevation declined or failed: fall back to an unelevated reveal of the + // parent directory so the user at least lands near the bundle. + return exec.Command("explorer", filepath.Dir(path)).Start() //nolint:gosec + } + return nil +} diff --git a/client/ui/services/errors.go b/client/ui/services/errors.go new file mode 100644 index 000000000..0c6f2f20f --- /dev/null +++ b/client/ui/services/errors.go @@ -0,0 +1,172 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "encoding/json" + "strings" + + "google.golang.org/genproto/googleapis/rpc/errdetails" + gcodes "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/ipcauth" + "github.com/netbirdio/netbird/client/ui/i18n" + "github.com/netbirdio/netbird/client/ui/preferences" +) + +// privilegeErrorInfo returns the daemon's privilege-refusal detail, if the error +// carries one. +func privilegeErrorInfo(err error) (*errdetails.ErrorInfo, bool) { + for _, detail := range gstatus.Convert(err).Details() { + info, ok := detail.(*errdetails.ErrorInfo) + if !ok { + continue + } + if info.GetReason() == ipcauth.ErrorReasonPrivilegeRequired && info.GetDomain() == ipcauth.ErrorDomain { + return info, true + } + } + return nil, false +} + +// ErrorTranslator localises daemon errors; runtime impl is *i18n.Bundle. +type ErrorTranslator interface { + Translate(lang i18n.LanguageCode, key string, args ...string) string +} + +// LanguagePreference reports the current UI language; runtime impl is *preferences.Store. +type LanguagePreference interface { + Get() preferences.UIPreferences +} + +// ClientError is a structured error returned to the frontend. The frontend +// translates Code via i18n; Short is an English fallback; Long carries the +// unwrapped daemon message. +type ClientError struct { + Code string `json:"code"` + Short string `json:"short"` + Long string `json:"long"` + // Command is a command the user can run to complete the operation + // themselves, set when the daemon refused it for want of privileges. The + // frontend offers it for copying. + Command string `json:"command,omitempty"` +} + +// Error returns the short message for plain Go callers. +func (e *ClientError) Error() string { + if e == nil { + return "" + } + return e.Short +} + +// MarshalJSON emits the struct so the Wails binding sends an object, not the +// default "error: ..." string. +func (e *ClientError) MarshalJSON() ([]byte, error) { + if e == nil { + return []byte("null"), nil + } + type alias ClientError + return json.Marshal((*alias)(e)) +} + +// errorClassifier maps gRPC errors to a localised ClientError. Shared by the +// daemon-facing services so the frontend gets a clean short message instead of +// the wrapped gRPC chain. +type errorClassifier struct { + translator ErrorTranslator + prefs LanguagePreference +} + +// classify maps a gRPC error to a ClientError by matching known substrings to a +// stable code. A missing locale entry surfaces as a visible "error." +// string — a deliberate fail-loud signal to update the bundle. +func (c errorClassifier) classify(err error) *ClientError { + if err == nil { + return nil + } + + msg := err.Error() + grpcCode := gcodes.Unknown + if st, ok := gstatus.FromError(err); ok { + msg = st.Message() + grpcCode = st.Code() + } + + // A refusal for want of privileges carries its own summary and the command + // that performs the operation, both written for the user. Surface them + // verbatim: no substring guessing, and no localisation of a message the + // daemon composed. + if info, ok := privilegeErrorInfo(err); ok { + summary := info.GetMetadata()[ipcauth.ErrorMetaSummary] + if summary == "" { + summary = msg + } + return &ClientError{ + Code: "privilege_required", + Short: summary, + Long: summary, + Command: info.GetMetadata()[ipcauth.ErrorMetaCommand], + } + } + + lower := strings.ToLower(msg) + + code := "unknown" + switch { + case strings.Contains(lower, "token used before issued"), + strings.Contains(lower, "token is not valid yet"): + code = "jwt_clock_skew" + case strings.Contains(lower, "token is expired"), + strings.Contains(lower, "token has expired"): + code = "jwt_expired" + case strings.Contains(lower, "token signature is invalid"): + code = "jwt_signature_invalid" + case strings.Contains(lower, "peer login has expired"): + code = "session_expired" + case strings.Contains(lower, "invalid setup-key"), + strings.Contains(lower, "invalid setup key"): + code = "invalid_setup_key" + case strings.Contains(lower, "permission denied"): + code = "permission_denied" + case strings.Contains(lower, "no connection could be made"), + strings.Contains(lower, "connection refused"), + strings.Contains(lower, "context deadline exceeded"): + code = "daemon_unreachable" + } + + // Fall back to the gRPC status code when the message didn't match a known + // substring — the daemon now forwards the innermost code with a clean desc + // that no longer contains the English marker text. + if code == "unknown" { + switch grpcCode { + case gcodes.PermissionDenied: + code = "permission_denied" + case gcodes.Unavailable, gcodes.DeadlineExceeded: + code = "daemon_unreachable" + } + } + + return &ClientError{ + Code: code, + Short: c.translateShort(code), + Long: msg, + } +} + +// translateShort resolves the localised short message for code, returning the +// bare "error." key when no translation is available so the gap stays visible. +func (c errorClassifier) translateShort(code string) string { + key := "error." + code + if c.translator == nil { + return key + } + lang := i18n.DefaultLanguage + if c.prefs != nil { + if pref := c.prefs.Get().Language; pref != "" { + lang = pref + } + } + return c.translator.Translate(lang, key) +} diff --git a/client/ui/services/errors_test.go b/client/ui/services/errors_test.go new file mode 100644 index 000000000..2f8f3d039 --- /dev/null +++ b/client/ui/services/errors_test.go @@ -0,0 +1,50 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" + gcodes "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" +) + +func TestErrorClassifier_Classify(t *testing.T) { + c := errorClassifier{} // nil translator → Short is the bare "error." key + + t.Run("permission denied by gRPC code with a clean desc", func(t *testing.T) { + // The daemon now forwards the innermost status: code + clean desc that + // no longer carries the English "permission denied" marker. + err := gstatus.Error(gcodes.PermissionDenied, "peer is already registered by a different User or a Setup Key") + + ce := c.classify(err) + require.NotNil(t, ce) + require.Equal(t, "permission_denied", ce.Code) + require.Equal(t, "error.permission_denied", ce.Short) + require.Equal(t, "peer is already registered by a different User or a Setup Key", ce.Long) + }) + + t.Run("substring match still wins for unclassified codes", func(t *testing.T) { + err := gstatus.Error(gcodes.Unknown, "peer login has expired") + + ce := c.classify(err) + require.NotNil(t, ce) + require.Equal(t, "session_expired", ce.Code) + }) + + t.Run("unavailable code maps to daemon_unreachable", func(t *testing.T) { + ce := c.classify(gstatus.Error(gcodes.Unavailable, "transport closing")) + require.Equal(t, "daemon_unreachable", ce.Code) + }) + + t.Run("unmatched stays unknown", func(t *testing.T) { + ce := c.classify(errors.New("something odd")) + require.Equal(t, "unknown", ce.Code) + }) + + t.Run("nil error", func(t *testing.T) { + require.Nil(t, c.classify(nil)) + }) +} diff --git a/client/ui/services/foreground_other.go b/client/ui/services/foreground_other.go new file mode 100644 index 000000000..e6e16a21a --- /dev/null +++ b/client/ui/services/foreground_other.go @@ -0,0 +1,11 @@ +//go:build !windows && !android && !ios && !freebsd && !js + +package services + +import "github.com/wailsapp/wails/v3/pkg/application" + +func raiseToForeground(w *application.WebviewWindow) { + if w != nil { + w.Focus() + } +} diff --git a/client/ui/services/foreground_windows.go b/client/ui/services/foreground_windows.go new file mode 100644 index 000000000..215f30093 --- /dev/null +++ b/client/ui/services/foreground_windows.go @@ -0,0 +1,43 @@ +//go:build windows + +package services + +import ( + "syscall" + + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/w32" +) + +var procAttachThreadInput = syscall.NewLazyDLL("user32.dll").NewProc("AttachThreadInput") + +func attachThreadInput(attach, attachTo w32.HANDLE, on bool) { + var flag uintptr + if on { + flag = 1 + } + _, _, _ = procAttachThreadInput.Call(uintptr(attach), uintptr(attachTo), flag) +} + +func raiseToForeground(w *application.WebviewWindow) { + if w == nil { + return + } + application.InvokeSync(func() { + ptr := w.NativeWindow() + if ptr == nil { + return + } + hwnd := w32.HWND(uintptr(ptr)) + + fgThread, _ := w32.GetWindowThreadProcessId(w32.GetForegroundWindow()) + appThread := w32.GetCurrentThreadId() + if fgThread != appThread { + attachThreadInput(fgThread, appThread, true) + defer attachThreadInput(fgThread, appThread, false) + } + w32.ShowWindow(hwnd, w32.SW_SHOW) + w32.BringWindowToTop(hwnd) + w32.SetForegroundWindow(hwnd) + }) +} diff --git a/client/ui/services/forwarding.go b/client/ui/services/forwarding.go new file mode 100644 index 000000000..4ba979ad0 --- /dev/null +++ b/client/ui/services/forwarding.go @@ -0,0 +1,83 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + + "github.com/netbirdio/netbird/client/proto" +) + +// PortRange is a port range; both ends are inclusive. +type PortRange struct { + Start uint32 `json:"start"` + End uint32 `json:"end"` +} + +// PortInfo holds exactly one of Port or Range (the daemon's oneof). +type PortInfo struct { + Port *uint32 `json:"port,omitempty"` + Range *PortRange `json:"range,omitempty"` +} + +// ForwardingRule is one entry from the daemon's reverse-proxy table. +type ForwardingRule struct { + Protocol string `json:"protocol"` + DestinationPort PortInfo `json:"destinationPort"` + TranslatedAddress string `json:"translatedAddress"` + TranslatedHostname string `json:"translatedHostname"` + TranslatedPort PortInfo `json:"translatedPort"` +} + +// Forwarding groups the daemon RPCs that surface exposed/forwarded services. +type Forwarding struct { + conn DaemonConn +} + +func NewForwarding(conn DaemonConn) *Forwarding { + return &Forwarding{conn: conn} +} + +func (s *Forwarding) List(ctx context.Context) ([]ForwardingRule, error) { + cli, err := s.conn.Client() + if err != nil { + return nil, err + } + resp, err := cli.ForwardingRules(ctx, &proto.EmptyRequest{}) + if err != nil { + return nil, err + } + out := make([]ForwardingRule, 0, len(resp.GetRules())) + for _, r := range resp.GetRules() { + out = append(out, forwardingRuleFromProto(r)) + } + return out, nil +} + +func forwardingRuleFromProto(r *proto.ForwardingRule) ForwardingRule { + return ForwardingRule{ + Protocol: r.GetProtocol(), + DestinationPort: portInfoFromProto(r.GetDestinationPort()), + TranslatedAddress: r.GetTranslatedAddress(), + TranslatedHostname: r.GetTranslatedHostname(), + TranslatedPort: portInfoFromProto(r.GetTranslatedPort()), + } +} + +func portInfoFromProto(p *proto.PortInfo) PortInfo { + if p == nil { + return PortInfo{} + } + switch sel := p.GetPortSelection().(type) { + case *proto.PortInfo_Port: + port := sel.Port + return PortInfo{Port: &port} + case *proto.PortInfo_Range_: + r := sel.Range + if r == nil { + return PortInfo{} + } + return PortInfo{Range: &PortRange{Start: r.GetStart(), End: r.GetEnd()}} + } + return PortInfo{} +} diff --git a/client/ui/services/guarded.go b/client/ui/services/guarded.go new file mode 100644 index 000000000..f425428b5 --- /dev/null +++ b/client/ui/services/guarded.go @@ -0,0 +1,231 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/elevate" + "github.com/netbirdio/netbird/client/internal/ipcauth" +) + +// The command line of the one-shot mode this binary runs itself in, elevated, to +// apply a setting the daemon restricts to root/administrator. The setting flags +// spell the same words as `netbird up`, so the command a user is shown and what +// runs behind the prompt read alike. Parsed in oneshot.go. +const ( + FlagApplyPrivilegedSettings = "apply-privileged-settings" + FlagDaemonAddr = "daemon-addr" + FlagProfile = "profile" + FlagUser = "user" + FlagLogLevel = "log-level" + FlagManagementURL = "management-url" + FlagAllowServerSSH = "allow-server-ssh" + FlagEnableSSHRoot = "enable-ssh-root" + FlagDisableSSHAuth = "disable-ssh-auth" +) + +// Error codes for the ways asking for privileges can fail. +const ( + CodeElevationUnavailable = "elevation_unavailable" + CodeElevationFailed = "elevation_failed" +) + +// elevationTimeout bounds the wait for a prompt and the change behind it, so a +// dialog nobody answers does not leave its control disabled for the session. Long +// enough to find a password manager, and no shorter than the platforms' own prompt +// timeouts: Windows gives up on its consent dialog after two minutes by itself. +// +// It always ends our waiting, and not always the prompt: Security.framework offers +// no way to withdraw a request, so on macOS the system's own timeout is what closes +// the dialog. +const elevationTimeout = 5 * time.Minute + +// elevator raises the platform's privilege prompt and runs the change behind it. +// An interface so tests can answer without a prompt. +type elevator interface { + // Run runs this binary again, elevated, with the given arguments. + Run(ctx context.Context, args ...string) error + // Available reports whether there is a prompt to raise on this host at all. + Available() bool +} + +// osElevator is the real thing: see the elevate package. +type osElevator struct{} + +func (osElevator) Run(ctx context.Context, args ...string) error { + return elevate.Run(ctx, args...) +} + +func (osElevator) Available() bool { + return elevate.Available() +} + +// SaveOutcome reports what became of a change that needed authorization. +// +// A declined prompt is a result, not an error: the user was asked and said no, so +// nothing was applied and nothing went wrong. Reporting it as an error would have +// every cancelled prompt logged as one. +type SaveOutcome struct { + // Declined is set when the user dismissed the authorization prompt, or was + // refused by policy. Nothing was changed. + Declined bool `json:"declined"` +} + +// GuardedSettings is the subset of the config the daemon restricts to +// root/administrator. Only the fields that are set are changed: a nil pointer, or +// an empty management URL, leaves that setting alone. +// +// The management URL is in here because pointing a host with the SSH server +// running at another management identity hands the decision of who may open a +// shell on it to whoever runs that server, which is the same power as enabling +// the SSH server in the first place. +type GuardedSettings struct { + ProfileName string `json:"profileName"` + Username string `json:"username"` + ManagementURL string `json:"managementUrl,omitempty"` + ServerSSHAllowed *bool `json:"serverSshAllowed,omitempty"` + EnableSSHRoot *bool `json:"enableSshRoot,omitempty"` + DisableSSHAuth *bool `json:"disableSshAuth,omitempty"` +} + +// guardedSetting is one setting to change, in the two spellings this needs: the +// one-shot's own flag, and the `netbird up` flag that does the same thing from a +// terminal, for when there is no prompt to raise. +type guardedSetting struct { + arg string + flag string +} + +// SetGuardedSettings applies settings the daemon refuses from an unprivileged +// caller, by having the operating system run this binary again, elevated, to send +// the same request the frontend would have sent itself. +// +// The user authorizes it at the platform's own prompt: the UAC consent dialog, +// the macOS authentication dialog, or the polkit agent's. Any credentials are the +// operating system's business; NetBird neither sees nor asks for them. Nothing +// about the daemon's rules changes, and the elevated process is authorized like +// any other privileged caller, from the identity the kernel reports for it. +// +// A declined prompt comes back as SaveOutcome.Declined with no error. When there is +// no prompt to raise, or the elevated run failed, the error carries the command +// that does the same thing from a terminal. +func (s *Settings) SetGuardedSettings(ctx context.Context, p GuardedSettings) (SaveOutcome, error) { + settings := guardedSettings(p) + if len(settings) == 0 { + return SaveOutcome{}, &ClientError{ + Code: CodeElevationFailed, + Short: "no setting to apply", + Long: "no setting to apply", + } + } + + // The elevated run has no window and, on Linux, an environment pkexec has + // cleared, so what it writes to stderr is all there is to go on. It follows + // this process's level so that starting the app with --log-level debug says + // something about the run behind the prompt too. + args := append([]string{ + "--" + FlagApplyPrivilegedSettings, + "--" + FlagDaemonAddr, s.daemonAddr, + "--" + FlagProfile, p.ProfileName, + "--" + FlagUser, p.Username, + "--" + FlagLogLevel, log.GetLevel().String(), + }, oneShotArgs(settings)...) + + ctx, cancel := context.WithTimeout(ctx, elevationTimeout) + defer cancel() + + // These changes hand out shells on this host, so both ends are logged: when the + // prompt went up, and what came of it. It is also the only account of a prompt + // that was slow to appear or never answered. + log.Infof("asking for privileges to apply %s", guardedSummary(p)) + + if err := s.elevator.Run(ctx, args...); err != nil { + return s.elevationOutcome(err, p) + } + + log.Infof("applied %s with the privileges the user authorized", guardedSummary(p)) + return SaveOutcome{}, nil +} + +// elevationOutcome sorts what came back into the one normal ending and the two +// that need reporting, with the command that does the same thing by hand. +func (s *Settings) elevationOutcome(err error, p GuardedSettings) (SaveOutcome, error) { + switch { + case errors.Is(err, elevate.ErrDeclined): + // With the reason: an account that may not elevate at all lands here too, + // and the log is the only place that says which it was. + log.Infof("the elevation prompt for %s was declined: %v", guardedSummary(p), err) + return SaveOutcome{Declined: true}, nil + case errors.Is(err, elevate.ErrUnavailable): + log.Warnf("cannot ask for privileges to apply %s: %v", guardedSummary(p), err) + return SaveOutcome{}, &ClientError{ + Code: CodeElevationUnavailable, + Short: s.classifier.translateShort(CodeElevationUnavailable), + Long: err.Error(), + Command: guardedCommand(p), + } + default: + log.Errorf("applying %s with elevated privileges failed: %v", guardedSummary(p), err) + return SaveOutcome{}, &ClientError{ + Code: CodeElevationFailed, + Short: s.classifier.translateShort(CodeElevationFailed), + Long: err.Error(), + Command: guardedCommand(p), + } + } +} + +// guardedSettings renders the settings that are actually being changed, from the +// same table the one-shot parses them with: see oneshot.go. +func guardedSettings(p GuardedSettings) []guardedSetting { + var settings []guardedSetting + for _, field := range guardedFields { + value, ok := field.read(p) + if !ok { + continue + } + settings = append(settings, guardedSetting{ + arg: "--" + field.flag + "=" + value, + flag: field.up(value), + }) + } + return settings +} + +func oneShotArgs(settings []guardedSetting) []string { + args := make([]string, 0, len(settings)) + for _, setting := range settings { + args = append(args, setting.arg) + } + return args +} + +func upFlags(settings []guardedSetting) []string { + flags := make([]string, 0, len(settings)) + for _, setting := range settings { + flags = append(flags, setting.flag) + } + return flags +} + +// guardedCommand is the elevated command line equivalent to the requested +// change, the same shape the daemon names in its own refusals. +func guardedCommand(p GuardedSettings) string { + settings := guardedSettings(p) + if len(settings) == 0 { + return "" + } + return ipcauth.UpCommand(strings.Join(upFlags(settings), " ")) +} + +// guardedSummary names the change for the log. +func guardedSummary(p GuardedSettings) string { + return fmt.Sprintf("%v for profile %q", oneShotArgs(guardedSettings(p)), p.ProfileName) +} diff --git a/client/ui/services/guarded_test.go b/client/ui/services/guarded_test.go new file mode 100644 index 000000000..42c00ce4f --- /dev/null +++ b/client/ui/services/guarded_test.go @@ -0,0 +1,355 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "errors" + "testing" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/elevate" + "github.com/netbirdio/netbird/client/internal/ipcauth" + "github.com/netbirdio/netbird/client/proto" +) + +// A Unix socket, so the daemon address is one that carries a caller's identity and +// elevation is worth offering at all: see Settings.canElevate. +const testDaemonAddr = "unix:///var/run/netbird.sock" + +// storedManagementURL is what the stub daemon already holds, so that a request +// naming a different one is a change: see Settings.guardedChanges. +const storedManagementURL = "https://stored.example.com" + +// stubElevator stands in for the platform's prompt: it records what would have run +// and answers with a fixed outcome. +type stubElevator struct { + outcome error + available bool + calls [][]string +} + +func (e *stubElevator) Run(_ context.Context, args ...string) error { + e.calls = append(e.calls, args) + return e.outcome +} + +func (e *stubElevator) Available() bool { return e.available } + +// stubDaemon implements only the RPCs under test. The embedded interface is nil, so +// any other call panics rather than passing quietly. +type stubDaemon struct { + proto.DaemonServiceClient + setConfig func(*proto.SetConfigRequest) error + // stored is what GetConfig reports, which is what a refused request's guarded + // settings are compared against. + stored *proto.GetConfigResponse + requests []*proto.SetConfigRequest +} + +func (d *stubDaemon) SetConfig(_ context.Context, in *proto.SetConfigRequest, _ ...grpc.CallOption) (*proto.SetConfigResponse, error) { + d.requests = append(d.requests, in) + if err := d.setConfig(in); err != nil { + return nil, err + } + return &proto.SetConfigResponse{}, nil +} + +func (d *stubDaemon) GetConfig(_ context.Context, _ *proto.GetConfigRequest, _ ...grpc.CallOption) (*proto.GetConfigResponse, error) { + return d.stored, nil +} + +type stubConn struct{ client proto.DaemonServiceClient } + +func (c stubConn) Client() (proto.DaemonServiceClient, error) { return c.client, nil } + +// privilegeRefusal is the error the daemon raises for a change it restricts to +// root, detail and all: see server.privilegeError. +func privilegeRefusal(t *testing.T) error { + t.Helper() + + st, err := gstatus.New(codes.PermissionDenied, "Changing the management URL requires root."). + WithDetails(&errdetails.ErrorInfo{ + Reason: ipcauth.ErrorReasonPrivilegeRequired, + Domain: ipcauth.ErrorDomain, + Metadata: map[string]string{ + ipcauth.ErrorMetaSummary: "Changing the management URL requires root.", + ipcauth.ErrorMetaCommand: "sudo netbird down; sudo netbird up -m https://mgmt.example.com", + }, + }) + require.NoError(t, err, "build the refusal detail") + return st.Err() +} + +func settingsWithElevation(t *testing.T, outcome error) (*Settings, *stubElevator) { + t.Helper() + + elev := &stubElevator{outcome: outcome, available: true} + return &Settings{daemonAddr: testDaemonAddr, elevator: elev}, elev +} + +// settingsRefusingOnce returns a Settings whose daemon refuses the first SetConfig +// for want of privileges and accepts anything after it. Its stored config holds +// another management server and no SSH grants, so a request naming either is a +// change rather than a restatement. +func settingsRefusingOnce(t *testing.T, elev *stubElevator) (*Settings, *stubDaemon) { + t.Helper() + + refusal := privilegeRefusal(t) + daemon := &stubDaemon{stored: &proto.GetConfigResponse{ManagementUrl: storedManagementURL}} + daemon.setConfig = func(*proto.SetConfigRequest) error { + if len(daemon.requests) == 1 { + return refusal + } + return nil + } + return &Settings{conn: stubConn{client: daemon}, daemonAddr: testDaemonAddr, elevator: elev}, daemon +} + +func TestSetGuardedSettingsPassesOnlyTheChangedSettings(t *testing.T) { + s, elev := settingsWithElevation(t, nil) + + root := true + outcome, err := s.SetGuardedSettings(context.Background(), GuardedSettings{ + ProfileName: "work", + Username: "vma", + EnableSSHRoot: &root, + }) + require.NoError(t, err) + assert.False(t, outcome.Declined, "the prompt was answered") + + want := []string{ + "--" + FlagApplyPrivilegedSettings, + "--" + FlagDaemonAddr, testDaemonAddr, + "--" + FlagProfile, "work", + "--" + FlagUser, "vma", + "--" + FlagLogLevel, log.GetLevel().String(), + "--" + FlagEnableSSHRoot + "=true", + } + require.Len(t, elev.calls, 1, "one prompt for one change") + assert.Equal(t, want, elev.calls[0], "elevated arguments") + + // argv[1] is what the polkit action is pinned to, so the marker has to stay + // first however the rest of the line grows. + assert.Equal(t, "--"+FlagApplyPrivilegedSettings, elev.calls[0][0], "the flag polkit matches on") +} + +// Turning a setting off has to be as explicit as turning it on: a bare flag would +// read as "on" to the one-shot's parser. +func TestSetGuardedSettingsSpellsOutFalse(t *testing.T) { + s, elev := settingsWithElevation(t, nil) + + off := false + _, err := s.SetGuardedSettings(context.Background(), GuardedSettings{ + ProfileName: "default", + ServerSSHAllowed: &off, + DisableSSHAuth: &off, + }) + require.NoError(t, err) + + args := elev.calls[0] + assert.Contains(t, args, "--"+FlagAllowServerSSH+"=false", "the setting being switched off") + assert.Contains(t, args, "--"+FlagDisableSSHAuth+"=false", "the setting being switched off") + assert.NotContains(t, args, "--"+FlagEnableSSHRoot+"=false", "no flag for a setting nobody touched") +} + +func TestSetGuardedSettingsPassesTheManagementURL(t *testing.T) { + s, elev := settingsWithElevation(t, nil) + + _, err := s.SetGuardedSettings(context.Background(), GuardedSettings{ + ProfileName: "default", + ManagementURL: "https://mgmt.example.com:33073", + }) + require.NoError(t, err) + + assert.Contains(t, elev.calls[0], "--"+FlagManagementURL+"=https://mgmt.example.com:33073", + "the management URL to point the profile at") +} + +func TestSetGuardedSettingsWithoutASettingDoesNotElevate(t *testing.T) { + s, elev := settingsWithElevation(t, nil) + + _, err := s.SetGuardedSettings(context.Background(), GuardedSettings{ProfileName: "default"}) + + require.Error(t, err, "nothing to apply is not something to prompt for") + assert.Empty(t, elev.calls, "no prompt at all") +} + +// A declined prompt is the one ending that is not an error: reporting it as one +// would have every cancelled prompt logged as a failure. +func TestSetGuardedSettingsReportsADeclinedPromptAsAnOutcome(t *testing.T) { + s, _ := settingsWithElevation(t, elevate.ErrDeclined) + + root := true + outcome, err := s.SetGuardedSettings(context.Background(), GuardedSettings{ + ProfileName: "default", + EnableSSHRoot: &root, + }) + + require.NoError(t, err, "the user was asked and answered; nothing went wrong") + assert.True(t, outcome.Declined, "nothing was applied") +} + +func TestSetGuardedSettingsMapsFailures(t *testing.T) { + tests := []struct { + name string + outcome error + wantCode string + }{ + { + // Nothing to raise a prompt with: the user needs the command. + name: "no mechanism falls back to the command", + outcome: elevate.ErrUnavailable, + wantCode: CodeElevationUnavailable, + }, + { + name: "a failed run falls back to the command", + outcome: errors.New("elevated netbird exited with 1"), + wantCode: CodeElevationFailed, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s, _ := settingsWithElevation(t, tt.outcome) + + root := true + _, err := s.SetGuardedSettings(context.Background(), GuardedSettings{ + ProfileName: "default", + EnableSSHRoot: &root, + }) + + var clientErr *ClientError + require.ErrorAs(t, err, &clientErr, "the frontend needs a code to act on") + assert.Equal(t, tt.wantCode, clientErr.Code, "error code") + assert.Contains(t, clientErr.Command, "--"+FlagEnableSSHRoot+"=true", + "the setting in the fallback command") + assert.Contains(t, clientErr.Command, "netbird up", "the fallback command") + }) + } +} + +// Changing the management URL is only privileged while the host runs the SSH +// server, which no control can know up front, so the refusal is what triggers the +// prompt. The original request goes again afterwards, so the fields the one-shot +// does not understand are applied too. +func TestSetConfigElevatesAfterARefusalAndRetries(t *testing.T) { + elev := &stubElevator{available: true} + s, daemon := settingsRefusingOnce(t, elev) + + mtu := int64(1280) + outcome, err := s.SetConfig(context.Background(), SetConfigParams{ + ProfileName: "default", + ManagementURL: "https://mgmt.example.com", + MTU: &mtu, + }) + require.NoError(t, err) + assert.False(t, outcome.Declined, "the prompt was answered") + + require.Len(t, elev.calls, 1, "one prompt") + assert.Contains(t, elev.calls[0], "--"+FlagManagementURL+"=https://mgmt.example.com", + "the guarded part of the request") + require.Len(t, daemon.requests, 2, "the refused request and the retry") + assert.Equal(t, mtu, daemon.requests[1].GetMtu(), + "the retry carries the rest of the request, which the one-shot does not understand") +} + +func TestSetConfigDoesNotRetryWhenTheUserDeclines(t *testing.T) { + elev := &stubElevator{outcome: elevate.ErrDeclined, available: true} + s, daemon := settingsRefusingOnce(t, elev) + + outcome, err := s.SetConfig(context.Background(), SetConfigParams{ + ProfileName: "default", + ManagementURL: "https://mgmt.example.com", + }) + + require.NoError(t, err, "a declined prompt is not an error") + assert.True(t, outcome.Declined, "nothing was applied") + assert.Len(t, daemon.requests, 1, "only the refused request") +} + +// With no prompt to raise, the refusal is reported as the daemon wrote it, which is +// the guidance that was there before elevation existed. +func TestSetConfigReportsTheRefusalWhenItCannotElevate(t *testing.T) { + elev := &stubElevator{available: false} + s, _ := settingsRefusingOnce(t, elev) + + _, err := s.SetConfig(context.Background(), SetConfigParams{ + ProfileName: "default", + ManagementURL: "https://mgmt.example.com", + }) + + var clientErr *ClientError + require.ErrorAs(t, err, &clientErr) + assert.Equal(t, "privilege_required", clientErr.Code, "error code") + assert.Contains(t, clientErr.Command, "netbird up -m https://mgmt.example.com", + "the daemon's own command") + assert.Empty(t, elev.calls, "no prompt where there is none to raise") +} + +// One authorization must buy only the change the user made. A settings form +// submits every field it holds, so most of a refused request restates what the +// daemon already has, and elevating those too would apply a guarded setting the +// user never touched — a value gone stale since the form loaded above all. +func TestSetConfigElevatesOnlyTheGuardedSettingsThatChange(t *testing.T) { + elev := &stubElevator{available: true} + s, _ := settingsRefusingOnce(t, elev) + + on, off := true, false + _, err := s.SetConfig(context.Background(), SetConfigParams{ + ProfileName: "default", + ManagementURL: storedManagementURL, + ServerSSHAllowed: &off, + EnableSSHRoot: &off, + DisableSSHAuth: &on, + }) + require.NoError(t, err) + + require.Len(t, elev.calls, 1, "one prompt") + args := elev.calls[0] + assert.Contains(t, args, "--"+FlagDisableSSHAuth+"=true", "the setting that changes") + assert.NotContains(t, args, "--"+FlagManagementURL+"="+storedManagementURL, + "a management URL the daemon already holds") + assert.NotContains(t, args, "--"+FlagAllowServerSSH+"=false", "a setting already off") + assert.NotContains(t, args, "--"+FlagEnableSSHRoot+"=false", "a setting already off") +} + +// A request that changes no guarded setting has nothing an elevated run could +// apply, so the refusal must have come from somewhere a prompt cannot reach. +func TestSetConfigDoesNotElevateWhenNoGuardedSettingChanges(t *testing.T) { + elev := &stubElevator{available: true} + s, _ := settingsRefusingOnce(t, elev) + + off := false + _, err := s.SetConfig(context.Background(), SetConfigParams{ + ProfileName: "default", + ManagementURL: storedManagementURL, + ServerSSHAllowed: &off, + }) + + var clientErr *ClientError + require.ErrorAs(t, err, &clientErr) + assert.Equal(t, "privilege_required", clientErr.Code, "error code") + assert.Empty(t, elev.calls, "no prompt for a change nobody made") +} + +// A refusal with nothing in the request the one-shot could apply: the daemon +// cannot see who is calling, and being root would not help either. +func TestSetConfigReportsARefusalWithNothingToElevate(t *testing.T) { + elev := &stubElevator{available: true} + s, _ := settingsRefusingOnce(t, elev) + + _, err := s.SetConfig(context.Background(), SetConfigParams{ProfileName: "default"}) + + var clientErr *ClientError + require.ErrorAs(t, err, &clientErr) + assert.Equal(t, "privilege_required", clientErr.Code, "error code") + assert.Empty(t, elev.calls, "no prompt") +} diff --git a/client/ui/services/i18n.go b/client/ui/services/i18n.go new file mode 100644 index 000000000..755bbe02d --- /dev/null +++ b/client/ui/services/i18n.go @@ -0,0 +1,30 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + + "github.com/netbirdio/netbird/client/ui/i18n" +) + +// I18n is the Wails-bound facade over i18n.Bundle; the translation logic lives +// in client/ui/i18n. +type I18n struct { + bundle *i18n.Bundle +} + +func NewI18n(bundle *i18n.Bundle) *I18n { + return &I18n{bundle: bundle} +} + +// Languages returns the shipped locales. +func (s *I18n) Languages(_ context.Context) ([]i18n.Language, error) { + return s.bundle.Languages(), nil +} + +// Bundle returns the full key->text map so the React side can drive its own +// translation library off the same source bundles. +func (s *I18n) Bundle(_ context.Context, code i18n.LanguageCode) (map[string]string, error) { + return s.bundle.BundleFor(code) +} diff --git a/client/ui/services/network.go b/client/ui/services/network.go new file mode 100644 index 000000000..c2d127494 --- /dev/null +++ b/client/ui/services/network.go @@ -0,0 +1,88 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + + "github.com/netbirdio/netbird/client/proto" +) + +type Network struct { + ID string `json:"id"` + Range string `json:"range"` + Selected bool `json:"selected"` + Domains []string `json:"domains"` + ResolvedIPs map[string][]string `json:"resolvedIps"` +} + +// SelectNetworksParams: All targets every available network; Append merges IDs into the existing selection. +type SelectNetworksParams struct { + NetworkIDs []string `json:"networkIds"` + Append bool `json:"append"` + All bool `json:"all"` +} + +type Networks struct { + conn DaemonConn +} + +func NewNetworks(conn DaemonConn) *Networks { + return &Networks{conn: conn} +} + +func (s *Networks) List(ctx context.Context) ([]Network, error) { + cli, err := s.conn.Client() + if err != nil { + return nil, err + } + resp, err := cli.ListNetworks(ctx, &proto.ListNetworksRequest{}) + if err != nil { + return nil, err + } + out := make([]Network, 0, len(resp.GetRoutes())) + for _, n := range resp.GetRoutes() { + out = append(out, networkFromProto(n)) + } + return out, nil +} + +func (s *Networks) Select(ctx context.Context, p SelectNetworksParams) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + _, err = cli.SelectNetworks(ctx, &proto.SelectNetworksRequest{ + NetworkIDs: p.NetworkIDs, + Append: p.Append, + All: p.All, + }) + return err +} + +func (s *Networks) Deselect(ctx context.Context, p SelectNetworksParams) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + _, err = cli.DeselectNetworks(ctx, &proto.SelectNetworksRequest{ + NetworkIDs: p.NetworkIDs, + Append: p.Append, + All: p.All, + }) + return err +} + +func networkFromProto(n *proto.Network) Network { + resolved := make(map[string][]string, len(n.GetResolvedIPs())) + for k, v := range n.GetResolvedIPs() { + resolved[k] = append([]string{}, v.GetIps()...) + } + return Network{ + ID: n.GetID(), + Range: n.GetRange(), + Selected: n.GetSelected(), + Domains: append([]string{}, n.GetDomains()...), + ResolvedIPs: resolved, + } +} diff --git a/client/ui/services/oneshot.go b/client/ui/services/oneshot.go new file mode 100644 index 000000000..d20b390cd --- /dev/null +++ b/client/ui/services/oneshot.go @@ -0,0 +1,239 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "strconv" + "time" + + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/elevate" + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/util" +) + +// The other end of SetGuardedSettings: the mode this binary runs itself in, +// elevated, to apply the settings the daemon restricts to root/administrator. +// +// Both ends are here on purpose. What may be changed this way is an allowlist, and +// an allowlist declared twice is one that will eventually disagree with itself, so +// the arguments are rendered and parsed from a single table: guardedFields. Adding +// a setting is one row; nothing generic passes through, and no field outside the +// table can be reached with an elevated request no matter what lands on the command +// line. + +// oneShotTimeout bounds the whole one-shot: connect, one RPC, exit. Generous +// because the user has just waited for an authentication dialog, and a failure here +// costs them the entire round trip. +const oneShotTimeout = 30 * time.Second + +// Exit codes the parent reads where the platform gives it one. +const ( + exitOK = 0 + exitFailure = 1 + exitUsage = 2 +) + +// guardedField is one setting the one-shot understands, in the two spellings it +// needs and with the two halves of its plumbing. +type guardedField struct { + // flag names it on the one-shot's command line. + flag string + usage string + // read returns the value to send and whether the caller asked for this setting + // at all. + read func(GuardedSettings) (string, bool) + // write parses a value from the command line onto the request. It is the only + // thing that validates the value, so it fails on anything it does not + // recognise rather than guessing. + write func(*proto.SetConfigRequest, string) error + // up renders the equivalent `netbird up` flag, for the fallback command shown + // when there is no prompt to raise. + up func(value string) string +} + +var guardedFields = []guardedField{ + { + flag: FlagManagementURL, + usage: "Management server the profile registers with.", + read: func(p GuardedSettings) (string, bool) { return p.ManagementURL, p.ManagementURL != "" }, + write: func(req *proto.SetConfigRequest, value string) error { + // Parsed with the config layer's own parser, so what the elevated run + // accepts cannot drift from what the daemon would store. + if _, err := profilemanager.ParseServiceURL("Management URL", value); err != nil { + return err + } + req.ManagementUrl = value + return nil + }, + // The daemon names this one as `-m ` in its own refusals. + up: func(value string) string { return "-m " + value }, + }, + boolField(FlagAllowServerSSH, "Run the NetBird SSH server.", + func(p GuardedSettings) *bool { return p.ServerSSHAllowed }, + func(req *proto.SetConfigRequest, v *bool) { req.ServerSSHAllowed = v }), + boolField(FlagEnableSSHRoot, "Allow SSH sessions to privileged accounts.", + func(p GuardedSettings) *bool { return p.EnableSSHRoot }, + func(req *proto.SetConfigRequest, v *bool) { req.EnableSSHRoot = v }), + boolField(FlagDisableSSHAuth, "Accept SSH sessions without authentication.", + func(p GuardedSettings) *bool { return p.DisableSSHAuth }, + func(req *proto.SetConfigRequest, v *bool) { req.DisableSSHAuth = v }), +} + +// fieldValue is a flag that remembers whether it was given, and requires a value: +// the renderer always writes one, so a bare flag is a caller that got it wrong. +type fieldValue struct { + set bool + value string +} + +func (v *fieldValue) String() string { + if v == nil { + return "" + } + return v.value +} + +func (v *fieldValue) Set(value string) error { + v.set, v.value = true, value + return nil +} + +// boolField describes a setting that is on or off. The value is always spelled out, +// so that turning a setting off is as unambiguous as turning it on and a flag with +// no value is a mistake rather than an "on". +func boolField( + name, usage string, + read func(GuardedSettings) *bool, + write func(*proto.SetConfigRequest, *bool), +) guardedField { + return guardedField{ + flag: name, + usage: usage, + read: func(p GuardedSettings) (string, bool) { + value := read(p) + if value == nil { + return "", false + } + return strconv.FormatBool(*value), true + }, + write: func(req *proto.SetConfigRequest, value string) error { + parsed, err := strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("parse %q as a boolean: %w", value, err) + } + write(req, &parsed) + return nil + }, + up: func(value string) string { return "--" + name + "=" + value }, + } +} + +// IsPrivilegedSettingsRun reports whether this process was started as the one-shot. +// The flag is a marker rather than a value, so only the bare forms count: reading a +// value would mean "--flag=false" started it too. +func IsPrivilegedSettingsRun(args []string) bool { + for _, arg := range args { + if arg == "--"+FlagApplyPrivilegedSettings || arg == "-"+FlagApplyPrivilegedSettings { + return true + } + } + return false +} + +// RunPrivilegedSettings applies the requested settings and returns the process exit +// code. connect dials the daemon, which is the caller's business because only it +// knows how this build talks to it. +// +// Everything it reports goes to stderr, which is what the parent captures where the +// platform lets it. On success it says so on standard output, because macOS gives +// the parent no exit status to read: see elevate.AppliedMarker. +func RunPrivilegedSettings(args []string, connect func(addr string) (proto.DaemonServiceClient, error)) int { + fs := flag.NewFlagSet("netbird-ui --"+FlagApplyPrivilegedSettings, flag.ContinueOnError) + fs.Bool(FlagApplyPrivilegedSettings, false, "Apply the settings the daemon restricts to root/administrator and exit.") + daemonAddr := fs.String(FlagDaemonAddr, "", "Daemon gRPC address: unix:///path, npipe://name or tcp://host:port") + logLevel := fs.String(FlagLogLevel, "info", "Log level: trace|debug|info|warn|error.") + profile := fs.String(FlagProfile, "", "Profile to change.") + username := fs.String(FlagUser, "", "Owner of the profile.") + + values := make([]fieldValue, len(guardedFields)) + for i, field := range guardedFields { + fs.Var(&values[i], field.flag, field.usage) + } + + if err := fs.Parse(args); err != nil { + return exitUsage + } + + if err := util.InitLog(*logLevel, "console"); err != nil { + fmt.Fprintf(os.Stderr, "init log: %v\n", err) + return exitFailure + } + + req, err := privilegedRequest(*profile, *username, values) + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + return exitUsage + } + + ctx, cancel := context.WithTimeout(context.Background(), oneShotTimeout) + defer cancel() + + if err := applyPrivilegedSettings(ctx, *daemonAddr, req, connect); err != nil { + fmt.Fprintf(os.Stderr, "apply settings: %v\n", err) + return exitFailure + } + + fmt.Fprintln(os.Stdout, elevate.AppliedMarker) + return exitOK +} + +// privilegedRequest builds the request from the flags that were given, and refuses +// one that asks for nothing. +func privilegedRequest(profile, username string, values []fieldValue) (*proto.SetConfigRequest, error) { + req := &proto.SetConfigRequest{ProfileName: profile, Username: username} + + given := 0 + for i, field := range guardedFields { + if !values[i].set { + continue + } + if err := field.write(req, values[i].value); err != nil { + return nil, fmt.Errorf("--%s: %w", field.flag, err) + } + given++ + } + if given == 0 { + return nil, errors.New("no setting to apply") + } + return req, nil +} + +func applyPrivilegedSettings( + ctx context.Context, + daemonAddr string, + req *proto.SetConfigRequest, + connect func(addr string) (proto.DaemonServiceClient, error), +) error { + client, err := connect(daemonAddr) + if err != nil { + return err + } + if _, err := client.SetConfig(ctx, req); err != nil { + // Unwrapped: the daemon's message is written for a person, and a refusal + // elevation cannot fix has to say so where the parent can read it off + // stderr. + return errors.New(gstatus.Convert(err).Message()) + } + return nil +} + +// interface guard: the one-shot's flags are flag.Value. +var _ flag.Value = (*fieldValue)(nil) diff --git a/client/ui/services/oneshot_test.go b/client/ui/services/oneshot_test.go new file mode 100644 index 000000000..f8eb43066 --- /dev/null +++ b/client/ui/services/oneshot_test.go @@ -0,0 +1,151 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "flag" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/proto" +) + +func TestIsPrivilegedSettingsRun(t *testing.T) { + tests := []struct { + name string + args []string + want bool + }{ + {name: "no arguments"}, + {name: "double dash", args: []string{"--" + FlagApplyPrivilegedSettings}, want: true}, + {name: "single dash", args: []string{"-" + FlagApplyPrivilegedSettings}, want: true}, + { + name: "among other flags", + args: []string{"--daemon-addr", "unix:///tmp/x.sock", "--" + FlagApplyPrivilegedSettings}, + want: true, + }, + // A marker, not a value: the caller never passes one, and reading a value + // would mean "--flag=false" started the one-shot too. + {name: "with a value", args: []string{"--" + FlagApplyPrivilegedSettings + "=true"}}, + {name: "unrelated flags", args: []string{"--log-level", "debug"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, IsPrivilegedSettingsRun(tt.args), "args %v", tt.args) + }) + } +} + +// What SetGuardedSettings renders has to be what the one-shot reads back, for every +// setting in the table. This is the property that keeps the two ends of an allowlist +// from drifting, so it is checked field by field rather than by example. +func TestGuardedFieldsRoundTrip(t *testing.T) { + on, off := true, false + tests := []struct { + name string + settings GuardedSettings + want func(*testing.T, *proto.SetConfigRequest) + }{ + { + name: "management url", + settings: GuardedSettings{ManagementURL: "https://mgmt.example.com:33073"}, + want: func(t *testing.T, req *proto.SetConfigRequest) { + assert.Equal(t, "https://mgmt.example.com:33073", req.GetManagementUrl()) + }, + }, + { + name: "ssh server on", + settings: GuardedSettings{ServerSSHAllowed: &on}, + want: func(t *testing.T, req *proto.SetConfigRequest) { + require.NotNil(t, req.ServerSSHAllowed) + assert.True(t, *req.ServerSSHAllowed) + }, + }, + { + name: "ssh root off", + settings: GuardedSettings{EnableSSHRoot: &off}, + want: func(t *testing.T, req *proto.SetConfigRequest) { + require.NotNil(t, req.EnableSSHRoot, "an explicit false must survive, not read as absent") + assert.False(t, *req.EnableSSHRoot) + }, + }, + { + name: "ssh auth off", + settings: GuardedSettings{DisableSSHAuth: &on}, + want: func(t *testing.T, req *proto.SetConfigRequest) { + require.NotNil(t, req.DisableSSHAuth) + assert.True(t, *req.DisableSSHAuth) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := parseRendered(t, tt.settings) + tt.want(t, req) + }) + } +} + +// A setting nobody asked about must not arrive at the daemon at all: sending its +// zero value would change it. +func TestGuardedFieldsCarryOnlyWhatWasAsked(t *testing.T) { + on := true + req := parseRendered(t, GuardedSettings{ProfileName: "work", EnableSSHRoot: &on}) + + assert.Equal(t, "work", req.GetProfileName(), "profile") + require.NotNil(t, req.EnableSSHRoot) + assert.Nil(t, req.ServerSSHAllowed, "untouched setting") + assert.Nil(t, req.DisableSSHAuth, "untouched setting") + assert.Empty(t, req.GetManagementUrl(), "untouched setting") +} + +func TestPrivilegedRequestRejectsAnEmptyChange(t *testing.T) { + _, err := privilegedRequest("default", "vma", make([]fieldValue, len(guardedFields))) + require.Error(t, err, "nothing to apply is not a request worth sending as root") +} + +// A value the table cannot parse is refused rather than guessed at. +func TestPrivilegedRequestRejectsAnUnparseableValue(t *testing.T) { + values := make([]fieldValue, len(guardedFields)) + for i, field := range guardedFields { + if field.flag != FlagEnableSSHRoot { + continue + } + require.NoError(t, values[i].Set("perhaps")) + } + + _, err := privilegedRequest("default", "vma", values) + require.Error(t, err) + assert.Contains(t, err.Error(), FlagEnableSSHRoot, "which flag was wrong") +} + +// parseRendered puts the settings through both ends: rendered as the arguments the +// elevated process is given, then parsed by a flag set registered from the same +// table, which is what the one-shot itself parses them with. Anything hand-rolled +// here would pin down a parser nothing uses. +func parseRendered(t *testing.T, p GuardedSettings) *proto.SetConfigRequest { + t.Helper() + + rendered := guardedSettings(p) + require.NotEmpty(t, rendered, "nothing rendered for %+v", p) + + args := make([]string, 0, len(rendered)) + for _, setting := range rendered { + args = append(args, setting.arg) + } + + fs := flag.NewFlagSet(t.Name(), flag.ContinueOnError) + values := make([]fieldValue, len(guardedFields)) + for i, field := range guardedFields { + fs.Var(&values[i], field.flag, field.usage) + } + require.NoError(t, fs.Parse(args), "the one-shot's own flag set must accept %v", args) + + req, err := privilegedRequest(p.ProfileName, p.Username, values) + require.NoError(t, err) + return req +} diff --git a/client/ui/services/preferences.go b/client/ui/services/preferences.go new file mode 100644 index 000000000..77faa4ef6 --- /dev/null +++ b/client/ui/services/preferences.go @@ -0,0 +1,40 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + + "github.com/netbirdio/netbird/client/ui/i18n" + "github.com/netbirdio/netbird/client/ui/preferences" +) + +// Preferences is the Wails-bound facade over preferences.Store; the context.Context-first +// signatures are what the binding generator requires. +type Preferences struct { + store *preferences.Store +} + +func NewPreferences(store *preferences.Store) *Preferences { + return &Preferences{store: store} +} + +func (s *Preferences) Get(_ context.Context) (preferences.UIPreferences, error) { + return s.store.Get(), nil +} + +func (s *Preferences) SetLanguage(_ context.Context, lang i18n.LanguageCode) error { + return s.store.SetLanguage(lang) +} + +func (s *Preferences) SetViewMode(_ context.Context, mode preferences.ViewMode) error { + return s.store.SetViewMode(mode) +} + +func (s *Preferences) SetOnboardingCompleted(_ context.Context, done bool) error { + return s.store.SetOnboardingCompleted(done) +} + +func (s *Preferences) SetKeepConnectedOnQuit(_ context.Context, keep bool) error { + return s.store.SetKeepConnectedOnQuit(keep) +} diff --git a/client/ui/services/profile.go b/client/ui/services/profile.go new file mode 100644 index 000000000..e76ab3db6 --- /dev/null +++ b/client/ui/services/profile.go @@ -0,0 +1,202 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "os/user" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/proto" +) + +type Profile struct { + // ID is the daemon-generated on-disk identity of the profile. Display + // names can collide and be renamed, so the ID is the stable handle the + // daemon resolves switch/remove/logout requests against. + ID string `json:"id"` + Name string `json:"name"` + IsActive bool `json:"isActive"` + // Email is read from the user-owned per-profile state file (CLI writes it + // after SSO login), not via ListProfiles: the daemon runs as root and can't + // reach it, while the UI runs as the logged-in user. + Email string `json:"email"` +} + +type ProfileRef struct { + ProfileName string `json:"profileName"` + Username string `json:"username"` +} + +type ActiveProfile struct { + // ID is the active profile's stable on-disk identity. Use it (not the + // display name) as the handle for daemon requests and active-profile + // comparisons, since names can collide. + ID string `json:"id"` + ProfileName string `json:"profileName"` + Username string `json:"username"` +} + +// RenameProfileParams selects a profile by handle and carries its new display +// name. +type RenameProfileParams struct { + // Handle selects the profile to rename: an exact ID, a unique ID prefix, + // or a unique display name. The daemon resolves it server-side. + Handle string `json:"handle"` + // NewName is the new free-form display name. The daemon sanitizes it + // (strips control characters, trims, caps length) but keeps spaces, emoji, + // punctuation, and non-ASCII letters. + NewName string `json:"newName"` + + Username string `json:"username"` +} + +type Profiles struct { + conn DaemonConn +} + +func NewProfiles(conn DaemonConn) *Profiles { + return &Profiles{conn: conn} +} + +// Username returns the OS username the daemon expects for profile lookups. +func (s *Profiles) Username() (string, error) { + u, err := user.Current() + if err != nil { + return "", err + } + return u.Username, nil +} + +func (s *Profiles) List(ctx context.Context, username string) ([]Profile, error) { + cli, err := s.conn.Client() + if err != nil { + return nil, err + } + resp, err := cli.ListProfiles(ctx, &proto.ListProfilesRequest{Username: username}) + if err != nil { + return nil, err + } + pm := profilemanager.NewProfileManager() + out := make([]Profile, 0, len(resp.GetProfiles())) + for _, p := range resp.GetProfiles() { + prof := Profile{ID: p.GetId(), Name: p.GetName(), IsActive: p.GetIsActive()} + if state, err := pm.GetProfileState(profilemanager.ID(p.GetId())); err == nil { + prof.Email = state.Email + } + out = append(out, prof) + } + return out, nil +} + +func (s *Profiles) GetActive(ctx context.Context) (ActiveProfile, error) { + cli, err := s.conn.Client() + if err != nil { + return ActiveProfile{}, err + } + resp, err := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}) + if err != nil { + return ActiveProfile{}, err + } + return ActiveProfile{ + ID: resp.GetId(), + ProfileName: resp.GetProfileName(), + Username: resp.GetUsername(), + }, nil +} + +// Switch sends a profile switch to the daemon and returns the resolved +// on-disk ID of the now-active profile. ProfileName is treated as a handle +// (exact ID, unique ID prefix, or unique display name); the daemon resolves +// it server-side and echoes back the canonical ID. +func (s *Profiles) Switch(ctx context.Context, p ProfileRef) (string, error) { + cli, err := s.conn.Client() + if err != nil { + return "", err + } + req := &proto.SwitchProfileRequest{} + if p.ProfileName != "" { + req.ProfileName = ptrStr(p.ProfileName) + } + if p.Username != "" { + req.Username = ptrStr(p.Username) + } + resp, err := cli.SwitchProfile(ctx, req) + if err != nil { + return "", err + } + return resp.GetId(), nil +} + +// Add creates a profile with the given display name and returns its +// daemon-generated on-disk ID, so callers can address the new profile by ID +// (e.g. to write config or switch to it) without re-resolving the name. +func (s *Profiles) Add(ctx context.Context, p ProfileRef) (string, error) { + cli, err := s.conn.Client() + if err != nil { + return "", err + } + resp, err := cli.AddProfile(ctx, &proto.AddProfileRequest{ + ProfileName: p.ProfileName, + Username: p.Username, + }) + if err != nil { + return "", err + } + return resp.GetId(), nil +} + +func (s *Profiles) Remove(ctx context.Context, p ProfileRef) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + resp, err := cli.RemoveProfile(ctx, &proto.RemoveProfileRequest{ + ProfileName: p.ProfileName, + Username: p.Username, + }) + if err != nil { + return err + } + + // The daemon deletes what it owns but runs as root, so it leaves the + // user-owned state file holding the account email behind. Logout keeps the + // email on purpose so later logins can pass it as the login_hint; profile + // removal is what deletes it. Legacy profiles are keyed by name rather than by a + // generated ID, so a recreated profile of the same name would inherit the + // deleted one's email and offer it as the login_hint. + // + // Keyed on the ID the daemon resolved, not on the request handle: that may + // have been a display name or an ID prefix, which would name a different + // file (or none). + if id := resp.GetId(); id != "" { + if err := profilemanager.NewProfileManager().RemoveProfileState(id); err != nil { + // Non-fatal: the profile itself is gone. + log.Warnf("failed to remove profile state for %s: %v", id, err) + } + } + + return nil +} + +// Rename changes a profile's display name. The on-disk ID is unaffected, so +// the active profile and any ID-based references stay valid (the default +// profile can be renamed too — only its display name changes). Returns the +// profile's previous display name as confirmation. +func (s *Profiles) Rename(ctx context.Context, p RenameProfileParams) (string, error) { + cli, err := s.conn.Client() + if err != nil { + return "", err + } + resp, err := cli.RenameProfile(ctx, &proto.RenameProfileRequest{ + Username: p.Username, + Handle: p.Handle, + NewProfileName: p.NewName, + }) + if err != nil { + return "", err + } + return resp.GetOldProfileName(), nil +} diff --git a/client/ui/services/profileswitcher.go b/client/ui/services/profileswitcher.go new file mode 100644 index 000000000..727b2473f --- /dev/null +++ b/client/ui/services/profileswitcher.go @@ -0,0 +1,105 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "fmt" + "strings" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/profilemanager" +) + +// ProfileSwitcher holds the switch policy shared by the tray and React +// frontend so both flip profiles identically. SwitchActive (plain selection: +// header dropdown, tray submenu) always connects after the switch; +// SwitchActiveNoConnect (manage-profiles screen) never does, so the user can +// still adjust the management URL before connecting. prevStatus from +// DaemonFeed.Get at entry only decides the teardown: +// +// Connected/Connecting/NeedsLogin/LoginFailed/SessionExpired → Down first. +// Idle → no Down. +type ProfileSwitcher struct { + profiles *Profiles + connection *Connection + feed *DaemonFeed +} + +func NewProfileSwitcher(profiles *Profiles, connection *Connection, feed *DaemonFeed) *ProfileSwitcher { + return &ProfileSwitcher{profiles: profiles, connection: connection, feed: feed} +} + +// SwitchActive switches to the named profile and always connects afterwards. +func (s *ProfileSwitcher) SwitchActive(ctx context.Context, p ProfileRef) error { + return s.switchActive(ctx, p, true) +} + +// SwitchActiveNoConnect switches to the named profile without connecting, +// tearing down any existing connection first. +func (s *ProfileSwitcher) SwitchActiveNoConnect(ctx context.Context, p ProfileRef) error { + return s.switchActive(ctx, p, false) +} + +func (s *ProfileSwitcher) switchActive(ctx context.Context, p ProfileRef, connect bool) error { + prevStatus := "" + if s.feed != nil { + if st, err := s.feed.Get(ctx); err == nil { + prevStatus = st.Status + } else { + log.Warnf("profileswitcher: get status: %v", err) + } + } + + needsDown := strings.EqualFold(prevStatus, StatusConnected) || + strings.EqualFold(prevStatus, StatusConnecting) || + strings.EqualFold(prevStatus, StatusNeedsLogin) || + strings.EqualFold(prevStatus, StatusLoginFailed) || + strings.EqualFold(prevStatus, StatusSessionExpired) + + log.Infof("profileswitcher: switch profile=%q prevStatus=%q connect=%v needsDown=%v", + p.ProfileName, prevStatus, connect, needsDown) + + // Optimistic Connecting paint plus stale-push suppression during Down (see + // DaemonFeed suppression table); also arms the login-watch that pops + // browser-login when the new profile turns out to need SSO. + if connect && s.feed != nil { + s.feed.BeginProfileSwitch() + } + + resolvedID, err := s.profiles.Switch(ctx, p) + if err != nil { + return fmt.Errorf("switch profile %q: %w", p.ProfileName, err) + } + + // Mirror into the user-side ProfileManager state: the CLI's `netbird up` + // reads this file and sends the ID back in the Up RPC, so if it diverges + // the daemon reverts the UI switch on the next CLI `up`. Best-effort — the + // daemon is authoritative; a failure only leaves the CLI's view stale. + // Use the daemon-resolved ID rather than the handle we sent, since the + // on-disk state is keyed by ID, not display name. + if err := profilemanager.NewProfileManager().SwitchProfile(profilemanager.ID(resolvedID)); err != nil { + log.Warnf("profileswitcher: mirror to user-side ProfileManager failed: %v", err) + } + + if needsDown { + if err := s.connection.Down(ctx); err != nil { + log.Errorf("profileswitcher: Down: %v", err) + } + } + + if connect { + if err := s.connection.Up(ctx, UpParams(p)); err != nil { + return fmt.Errorf("connect %q: %w", p.ProfileName, err) + } + } + + // The daemon emits no profile event, so fan out ourselves or the React + // ProfileContext stays on the old profile after a tray-initiated switch. + if s.feed != nil && s.feed.emitter != nil { + s.feed.emitter.Emit(EventProfileChanged, p) + } + + return nil +} diff --git a/client/ui/services/session.go b/client/ui/services/session.go new file mode 100644 index 000000000..facb8e898 --- /dev/null +++ b/client/ui/services/session.go @@ -0,0 +1,48 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + + "github.com/netbirdio/netbird/client/ui/authsession" +) + +// Re-exports so generated bindings reference services.* without importing authsession. +type ( + ExtendStartParams = authsession.ExtendStartParams + ExtendStartResult = authsession.ExtendStartResult + ExtendWaitParams = authsession.ExtendWaitParams + ExtendResult = authsession.ExtendResult +) + +// Session wraps authsession.Session, exposing only the subset the React frontend +// calls; the tray uses authsession.Session directly, keeping the generated TS surface minimal. +type Session struct { + inner *authsession.Session + classifier errorClassifier +} + +// NewSession wraps inner; the caller retains ownership and may use it directly. +// translator or prefs may be nil, in which case errors fall back to the bare code key. +func NewSession(inner *authsession.Session, translator ErrorTranslator, prefs LanguagePreference) *Session { + return &Session{inner: inner, classifier: errorClassifier{translator: translator, prefs: prefs}} +} + +// RequestExtend starts the SSO session-extension flow; the result carries the verification URI to open. +func (s *Session) RequestExtend(ctx context.Context, p ExtendStartParams) (ExtendStartResult, error) { + res, err := s.inner.RequestExtend(ctx, p) + if err != nil { + return ExtendStartResult{}, s.classifier.classify(err) + } + return res, nil +} + +// WaitExtend blocks until the RequestExtend flow completes; the deadline is nil when the peer is ineligible. +func (s *Session) WaitExtend(ctx context.Context, p ExtendWaitParams) (ExtendResult, error) { + res, err := s.inner.WaitExtend(ctx, p) + if err != nil { + return ExtendResult{}, s.classifier.classify(err) + } + return res, nil +} diff --git a/client/ui/services/settings.go b/client/ui/services/settings.go new file mode 100644 index 000000000..7c20184bd --- /dev/null +++ b/client/ui/services/settings.go @@ -0,0 +1,406 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "fmt" + "reflect" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/daemonaddr" + "github.com/netbirdio/netbird/client/internal/ipcauth" + "github.com/netbirdio/netbird/client/mdm" + "github.com/netbirdio/netbird/client/proto" +) + +// MDMFields is the shared per-key MDM enforcement snapshot; see mdm.Fields. +type MDMFields = mdm.Fields + +// Features is the shared feature-gate snapshot; see mdm.Features. +type Features = mdm.Features + +// Restrictions is the shared UI enforcement snapshot; see mdm.Restrictions. +type Restrictions = mdm.Restrictions + +// Privilege tells the frontend whether this process may perform the changes the +// daemon restricts to root/administrator, whether it can ask the operating +// system for the privileges instead, and the command for each so a control that +// can do neither can still show the way. +type Privilege struct { + Privileged bool `json:"privileged"` + // ActorKey identifies the principal the operation requires without wording it, + // so the frontend can name it in the user's language: see + // ipcauth.PrivilegedActorKey. The words are not sent, because English ones + // cannot be dropped into a translated sentence. + ActorKey string `json:"actorKey"` + // CanElevate reports whether a guarded control can offer to authorize the + // change through the platform's own prompt: see SetGuardedSettings. + CanElevate bool `json:"canElevate"` + // Commands equivalent to the settings the daemon guards, ready to copy. + AllowSSHServer string `json:"allowSshServer"` + EnableSSHRoot string `json:"enableSshRoot"` + DisableSSHAuth string `json:"disableSshAuth"` +} + +type ConfigParams struct { + ProfileName string `json:"profileName"` + Username string `json:"username"` +} + +type Config struct { + ManagementURL string `json:"managementUrl"` + AdminURL string `json:"adminUrl"` + ConfigFile string `json:"configFile"` + LogFile string `json:"logFile"` + PreSharedKeySet bool `json:"preSharedKeySet"` + InterfaceName string `json:"interfaceName"` + WireguardPort int64 `json:"wireguardPort"` + MTU int64 `json:"mtu"` + DisableAutoConnect bool `json:"disableAutoConnect"` + ServerSSHAllowed bool `json:"serverSshAllowed"` + RosenpassEnabled bool `json:"rosenpassEnabled"` + RosenpassPermissive bool `json:"rosenpassPermissive"` + DisableNotifications bool `json:"disableNotifications"` + BlockInbound bool `json:"blockInbound"` + NetworkMonitor bool `json:"networkMonitor"` + DisableClientRoutes bool `json:"disableClientRoutes"` + DisableServerRoutes bool `json:"disableServerRoutes"` + DisableDNS bool `json:"disableDns"` + DisableIPv6 bool `json:"disableIpv6"` + BlockLANAccess bool `json:"blockLanAccess"` + EnableSSHRoot bool `json:"enableSshRoot"` + EnableSSHSFTP bool `json:"enableSshSftp"` + EnableSSHLocalPortForwarding bool `json:"enableSshLocalPortForwarding"` + EnableSSHRemotePortForwarding bool `json:"enableSshRemotePortForwarding"` + DisableSSHAuth bool `json:"disableSshAuth"` + SSHJWTCacheTTL int32 `json:"sshJwtCacheTtl"` +} + +// SetConfigParams is a partial update — only non-nil pointer fields are sent +// to the daemon; nil fields are preserved. +type SetConfigParams struct { + ProfileName string `json:"profileName"` + Username string `json:"username"` + ManagementURL string `json:"managementUrl"` + AdminURL string `json:"adminUrl"` + InterfaceName *string `json:"interfaceName,omitempty"` + WireguardPort *int64 `json:"wireguardPort,omitempty"` + MTU *int64 `json:"mtu,omitempty"` + PreSharedKey *string `json:"preSharedKey,omitempty"` + DisableAutoConnect *bool `json:"disableAutoConnect,omitempty"` + ServerSSHAllowed *bool `json:"serverSshAllowed,omitempty"` + RosenpassEnabled *bool `json:"rosenpassEnabled,omitempty"` + RosenpassPermissive *bool `json:"rosenpassPermissive,omitempty"` + DisableNotifications *bool `json:"disableNotifications,omitempty"` + BlockInbound *bool `json:"blockInbound,omitempty"` + NetworkMonitor *bool `json:"networkMonitor,omitempty"` + DisableClientRoutes *bool `json:"disableClientRoutes,omitempty"` + DisableServerRoutes *bool `json:"disableServerRoutes,omitempty"` + DisableDNS *bool `json:"disableDns,omitempty"` + DisableIPv6 *bool `json:"disableIpv6,omitempty"` + DisableFirewall *bool `json:"disableFirewall,omitempty"` + BlockLANAccess *bool `json:"blockLanAccess,omitempty"` + EnableSSHRoot *bool `json:"enableSshRoot,omitempty"` + EnableSSHSFTP *bool `json:"enableSshSftp,omitempty"` + EnableSSHLocalPortForwarding *bool `json:"enableSshLocalPortForwarding,omitempty"` + EnableSSHRemotePortForwarding *bool `json:"enableSshRemotePortForwarding,omitempty"` + DisableSSHAuth *bool `json:"disableSshAuth,omitempty"` + SSHJWTCacheTTL *int32 `json:"sshJwtCacheTtl,omitempty"` +} + +type Settings struct { + conn DaemonConn + classifier errorClassifier + // daemonAddr is where the daemon listens, used to tell whether it runs as + // this user and would therefore authorize us: see Privilege. + daemonAddr string + // elevator raises the platform's privilege prompt when a change needs more + // rights than this process has. + elevator elevator +} + +func NewSettings(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference, daemonAddr string) *Settings { + return &Settings{ + conn: conn, + classifier: errorClassifier{translator: translator, prefs: prefs}, + daemonAddr: daemonAddr, + elevator: osElevator{}, + } +} + +func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error) { + cli, err := s.conn.Client() + if err != nil { + return Config{}, err + } + resp, err := cli.GetConfig(ctx, &proto.GetConfigRequest{ + ProfileName: p.ProfileName, + Username: p.Username, + }) + if err != nil { + return Config{}, err + } + return Config{ + ManagementURL: resp.GetManagementUrl(), + AdminURL: resp.GetAdminURL(), + ConfigFile: resp.GetConfigFile(), + LogFile: resp.GetLogFile(), + PreSharedKeySet: resp.GetPreSharedKey() != "", + InterfaceName: resp.GetInterfaceName(), + WireguardPort: resp.GetWireguardPort(), + MTU: resp.GetMtu(), + DisableAutoConnect: resp.GetDisableAutoConnect(), + ServerSSHAllowed: resp.GetServerSSHAllowed(), + RosenpassEnabled: resp.GetRosenpassEnabled(), + RosenpassPermissive: resp.GetRosenpassPermissive(), + DisableNotifications: resp.GetDisableNotifications(), + BlockInbound: resp.GetBlockInbound(), + NetworkMonitor: resp.GetNetworkMonitor(), + DisableClientRoutes: resp.GetDisableClientRoutes(), + DisableServerRoutes: resp.GetDisableServerRoutes(), + DisableDNS: resp.GetDisableDns(), + DisableIPv6: resp.GetDisableIpv6(), + BlockLANAccess: resp.GetBlockLanAccess(), + EnableSSHRoot: resp.GetEnableSSHRoot(), + EnableSSHSFTP: resp.GetEnableSSHSFTP(), + EnableSSHLocalPortForwarding: resp.GetEnableSSHLocalPortForwarding(), + EnableSSHRemotePortForwarding: resp.GetEnableSSHRemotePortForwarding(), + DisableSSHAuth: resp.GetDisableSSHAuth(), + SSHJWTCacheTTL: resp.GetSshJWTCacheTTL(), + }, nil +} + +func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) (SaveOutcome, error) { + cli, err := s.conn.Client() + if err != nil { + return SaveOutcome{}, err + } + req := &proto.SetConfigRequest{ + ProfileName: p.ProfileName, + Username: p.Username, + ManagementUrl: p.ManagementURL, + AdminURL: p.AdminURL, + InterfaceName: p.InterfaceName, + WireguardPort: p.WireguardPort, + Mtu: p.MTU, + OptionalPreSharedKey: p.PreSharedKey, + DisableAutoConnect: p.DisableAutoConnect, + ServerSSHAllowed: p.ServerSSHAllowed, + RosenpassEnabled: p.RosenpassEnabled, + RosenpassPermissive: p.RosenpassPermissive, + DisableNotifications: p.DisableNotifications, + BlockInbound: p.BlockInbound, + NetworkMonitor: p.NetworkMonitor, + DisableClientRoutes: p.DisableClientRoutes, + DisableServerRoutes: p.DisableServerRoutes, + DisableDns: p.DisableDNS, + DisableIpv6: p.DisableIPv6, + DisableFirewall: p.DisableFirewall, + BlockLanAccess: p.BlockLANAccess, + EnableSSHRoot: p.EnableSSHRoot, + EnableSSHSFTP: p.EnableSSHSFTP, + EnableSSHLocalPortForwarding: p.EnableSSHLocalPortForwarding, + EnableSSHRemotePortForwarding: p.EnableSSHRemotePortForwarding, + DisableSSHAuth: p.DisableSSHAuth, + SshJWTCacheTTL: p.SSHJWTCacheTTL, + } + if _, err := cli.SetConfig(ctx, req); err != nil { + if _, refused := privilegeErrorInfo(err); refused { + return s.setConfigElevated(ctx, p, req, err) + } + // Classified so the frontend gets the daemon's guidance instead of the + // gRPC envelope. + return SaveOutcome{}, s.classifier.classify(err) + } + return SaveOutcome{}, nil +} + +// setConfigElevated answers a request the daemon refused for want of privileges by +// asking the user to authorize it, and sending it again if they do. It is the same +// offer the SSH settings make up front, for the changes a control cannot know are +// guarded until it is told: repointing a profile at another management server is +// only privileged while that host runs the SSH server. +// +// Two steps, because the elevated one-shot deliberately understands only the +// settings the daemon guards: it applies those, and the original request then goes +// through as this user, its privileged parts now asking for nothing that is not +// already stored. Nothing was applied by the refused attempt — the daemon decides +// before it writes — so there is no half-applied state to undo either way. +func (s *Settings) setConfigElevated(ctx context.Context, p SetConfigParams, req *proto.SetConfigRequest, refusal error) (SaveOutcome, error) { + if !s.canElevate() { + return SaveOutcome{}, s.classifier.classify(refusal) + } + + guarded, err := s.guardedChanges(ctx, p) + if err != nil { + log.Warnf("cannot tell which guarded settings this request changes: %v", err) + return SaveOutcome{}, s.classifier.classify(refusal) + } + if len(guardedSettings(guarded)) == 0 { + // Refused over something no prompt can settle, such as a control channel + // that carries no caller identity. Report the daemon's own guidance. + return SaveOutcome{}, s.classifier.classify(refusal) + } + + outcome, err := s.SetGuardedSettings(ctx, guarded) + if err != nil || outcome.Declined { + return outcome, err + } + + cli, err := s.conn.Client() + if err != nil { + return SaveOutcome{}, err + } + if _, err := cli.SetConfig(ctx, req); err != nil { + return SaveOutcome{}, s.classifier.classify(err) + } + return SaveOutcome{}, nil +} + +// guardedChanges is the guarded part of a request, reduced to what it actually +// changes. +// +// A settings form submits every field it holds, so a request restates values the +// daemon already has. Carrying those into the elevated run would spend one +// authorization on more than the user asked for, and a value that has gone stale +// since the form was loaded would spend it on something they never asked about. +func (s *Settings) guardedChanges(ctx context.Context, p SetConfigParams) (GuardedSettings, error) { + stored, err := s.GetConfig(ctx, ConfigParams{ProfileName: p.ProfileName, Username: p.Username}) + if err != nil { + return GuardedSettings{}, fmt.Errorf("read the stored config: %w", err) + } + + guarded := GuardedSettings{ + ProfileName: p.ProfileName, + Username: p.Username, + ServerSSHAllowed: changedFlag(p.ServerSSHAllowed, stored.ServerSSHAllowed), + EnableSSHRoot: changedFlag(p.EnableSSHRoot, stored.EnableSSHRoot), + DisableSSHAuth: changedFlag(p.DisableSSHAuth, stored.DisableSSHAuth), + } + // An empty URL leaves the setting alone, which is the daemon's rule too. + if p.ManagementURL != "" && p.ManagementURL != stored.ManagementURL { + guarded.ManagementURL = p.ManagementURL + } + return guarded, nil +} + +// Privilege reports whether this UI process could carry out the changes the +// daemon restricts to root/administrator, whether it can instead ask the +// operating system for the privileges when the user wants one of them, and the +// command that performs the ones users hit in the SSH settings. It applies the +// daemon's own rule to what it can see locally, so the frontend can decide up +// front how to present those controls instead of letting a save fail. No daemon +// round-trip, so it also works while the daemon is down. +// +// Being root or an elevated administrator is one way. The other is running as the +// daemon's own user while the daemon is unprivileged, which the daemon accepts +// because such a caller can already rewrite the config it reads; that is the +// rootless-container and Windows netstack-mode case, and it is read from the +// ownership of the socket or pipe the daemon created. +func (s *Settings) Privilege() Privilege { + id, err := ipcauth.CurrentProcessIdentity() + if err != nil { + // Fail closed: report unprivileged, which only ever asks for more. + log.Warnf("cannot read this process's identity, treating it as unprivileged: %v", err) + return s.newPrivilege(false) + } + if id.IsPrivileged() { + return s.newPrivilege(true) + } + return s.newPrivilege(daemonaddr.DaemonRunsAsSelf(s.daemonAddr)) +} + +func (s *Settings) newPrivilege(privileged bool) Privilege { + return Privilege{ + Privileged: privileged, + ActorKey: ipcauth.PrivilegedActorKey(), + CanElevate: s.canElevate(), + AllowSSHServer: ipcauth.UpCommand("--allow-server-ssh"), + EnableSSHRoot: ipcauth.UpCommand("--enable-ssh-root"), + DisableSSHAuth: ipcauth.UpCommand("--disable-ssh-auth"), + } +} + +// canElevate reports whether offering the platform's elevation prompt would get +// the user anywhere. It needs a mechanism to raise the prompt with and a control +// channel that tells the daemon who is calling: on loopback TCP the daemon +// refuses these changes to everybody, root included, so a prompt there would +// only waste the user's password. +func (s *Settings) canElevate() bool { + if !daemonaddr.CarriesIdentity(s.daemonAddr) { + log.Debugf("not offering elevation: the daemon address %s carries no caller identity", s.daemonAddr) + return false + } + return s.elevator.Available() +} + +func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) { + cli, err := s.conn.Client() + if err != nil { + return Restrictions{}, err + } + active, err := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}) + if err != nil { + return Restrictions{}, fmt.Errorf("get active profile: %w", err) + } + cfgResp, err := cli.GetConfig(ctx, &proto.GetConfigRequest{ + ProfileName: active.GetId(), + Username: active.GetUsername(), + }) + if err != nil { + return Restrictions{}, err + } + featResp, err := cli.GetFeatures(ctx, &proto.GetFeaturesRequest{}) + if err != nil { + return Restrictions{}, err + } + r := Restrictions{ + Features: Features{ + DisableProfiles: featResp.GetDisableProfiles(), + DisableNetworks: featResp.GetDisableNetworks(), + DisableUpdateSettings: featResp.GetDisableUpdateSettings(), + }, + } + applyMDMRestrictions(&r.MDM, cfgResp) + r.MDM.DisableAdvancedView = featResp.DisableAdvancedView + return r, nil +} + +// changedFlag returns requested only when it differs from what is stored, so a +// setting the request merely restates is left out of the elevated run. +func changedFlag(requested *bool, stored bool) *bool { + if requested == nil || *requested == stored { + return nil + } + return requested +} + +func applyMDMRestrictions(mdm *MDMFields, cfgResp *proto.GetConfigResponse) { + managed := cfgResp.GetMDMManagedFields() + if len(managed) == 0 { + return + } + set := make(map[string]struct{}, len(managed)) + for _, k := range managed { + set[k] = struct{}{} + } + v := reflect.ValueOf(mdm).Elem() + t := v.Type() + for i := 0; i < t.NumField(); i++ { + if v.Field(i).Kind() != reflect.Bool { + continue + } + if _, ok := set[t.Field(i).Tag.Get("json")]; ok { + v.Field(i).SetBool(true) + } + } + if _, ok := set["managementURL"]; ok { + mdm.ManagementURL = cfgResp.GetManagementUrl() + } + if _, ok := set["allowServerSSH"]; ok { + allowed := cfgResp.GetServerSSHAllowed() + mdm.AllowServerSSH = &allowed + } +} diff --git a/client/ui/services/shutdown.go b/client/ui/services/shutdown.go new file mode 100644 index 000000000..0da51940c --- /dev/null +++ b/client/ui/services/shutdown.go @@ -0,0 +1,24 @@ +package services + +import "sync/atomic" + +var ( + sessionEnding atomic.Bool + quitting atomic.Bool +) + +func BeginSessionEnd() { + sessionEnding.Store(true) +} + +func AbortSessionEnd() { + sessionEnding.Store(false) +} + +func BeginShutdown() { + quitting.Store(true) +} + +func ShuttingDown() bool { + return sessionEnding.Load() || quitting.Load() +} diff --git a/client/ui/services/uilog.go b/client/ui/services/uilog.go new file mode 100644 index 000000000..0d794c137 --- /dev/null +++ b/client/ui/services/uilog.go @@ -0,0 +1,36 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + + log "github.com/sirupsen/logrus" +) + +// UILog forwards frontend console output into logrus, tagging the JS origin +// as the "ui" field to stay distinct from logrus's Go-caller source. +type UILog struct{} + +func NewUILog() *UILog { return &UILog{} } + +// Log maps an unrecognised level to info; empty source becomes "unknown". +func (s *UILog) Log(_ context.Context, level, source, msg string) { + origin := "unknown" + if source != "" { + origin = source + } + entry := log.WithField("ui", origin) + switch level { + case "trace": + entry.Trace(msg) + case "debug": + entry.Debug(msg) + case "warn", "warning": + entry.Warn(msg) + case "error": + entry.Error(msg) + default: + entry.Info(msg) + } +} diff --git a/client/ui/services/update.go b/client/ui/services/update.go new file mode 100644 index 000000000..b743b9858 --- /dev/null +++ b/client/ui/services/update.go @@ -0,0 +1,80 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "time" + + "github.com/wailsapp/wails/v3/pkg/application" + + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/client/ui/updater" + "github.com/netbirdio/netbird/version" +) + +// UpdateResult mirrors TriggerUpdateResponse. +type UpdateResult struct { + Success bool `json:"success"` + ErrorMsg string `json:"errorMsg"` +} + +// Update is the Wails-bound facade over the daemon's update RPCs. The state +// machine and push event live in client/ui/updater. +type Update struct { + conn DaemonConn + holder *updater.Holder +} + +func NewUpdate(conn DaemonConn, holder *updater.Holder) *Update { + return &Update{conn: conn, holder: holder} +} + +func (s *Update) GetState() updater.State { + return s.holder.Get() +} + +// DownloadURL returns the platform-appropriate installer download link for +// manual (non-enforced) updates. +func (s *Update) DownloadURL() string { + return version.DownloadUrl() +} + +// Quit exits the app. Scheduled off the calling goroutine so the JS caller's +// response returns before the runtime tears down. +func (s *Update) Quit() { + go func() { + time.Sleep(100 * time.Millisecond) + application.Get().Quit() + }() +} + +func (s *Update) Trigger(ctx context.Context) (UpdateResult, error) { + cli, err := s.conn.Client() + if err != nil { + return UpdateResult{}, err + } + resp, err := cli.TriggerUpdate(ctx, &proto.TriggerUpdateRequest{}) + if err != nil { + return UpdateResult{}, err + } + return UpdateResult{ + Success: resp.GetSuccess(), + ErrorMsg: resp.GetErrorMsg(), + }, nil +} + +func (s *Update) GetInstallerResult(ctx context.Context) (UpdateResult, error) { + cli, err := s.conn.Client() + if err != nil { + return UpdateResult{}, err + } + resp, err := cli.GetInstallerResult(ctx, &proto.InstallerResultRequest{}) + if err != nil { + return UpdateResult{}, err + } + return UpdateResult{ + Success: resp.GetSuccess(), + ErrorMsg: resp.GetErrorMsg(), + }, nil +} diff --git a/client/ui/services/version.go b/client/ui/services/version.go new file mode 100644 index 000000000..8caea9f72 --- /dev/null +++ b/client/ui/services/version.go @@ -0,0 +1,22 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + + "github.com/netbirdio/netbird/version" +) + +// Version reports only the GUI's own version; the daemon version comes from +// the status feed's DaemonVersion field. +type Version struct{} + +func NewVersion() *Version { + return &Version{} +} + +// GUI returns the UI binary's version, stamped via ldflags ("development" if un-stamped). +func (v *Version) GUI(_ context.Context) string { + return version.NetbirdVersion() +} diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go new file mode 100644 index 000000000..94dba6038 --- /dev/null +++ b/client/ui/services/windowmanager.go @@ -0,0 +1,946 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "net/url" + "strconv" + "sync" + "time" + + log "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" + + "github.com/netbirdio/netbird/client/ui/i18n" + "github.com/netbirdio/netbird/client/ui/preferences" +) + +// LanguageSubscriber delivers UI preference changes so window titles follow the language. +type LanguageSubscriber interface { + Subscribe() (<-chan preferences.UIPreferences, func()) +} + +// EventTriggerLogin asks the frontend's startLogin() to begin an SSO flow. +const EventTriggerLogin = "trigger-login" + +// EventBrowserLoginCancel signals the user dismissed the BrowserLogin popup. +const EventBrowserLoginCancel = "browser-login:cancel" + +// EventSettingsOpen tells the mounted settings window which tab to show. +const EventSettingsOpen = "netbird:settings:open" + +const EventWindowPainted = "netbird:window-painted" + +const paintedFallback = 2 * time.Second + +const headlessTeardownDelay = 2 * time.Second + +var WindowBackgroundColour = application.NewRGB(24, 26, 29) // bg-nb-gray-950 + +// WindowHeight is shared by the main and Settings windows. +const WindowHeight = 660 + +// Wails reads CustomTheme colours as 0x00BBGGRR (RGB byte order reversed). +var microsoftWindowsTheme = &application.WindowTheme{ + BorderColour: u32ptr(0x00211E1C), + TitleBarColour: u32ptr(0x00211E1C), + TitleTextColour: u32ptr(0x00E9E7E4), +} + +// MicrosoftWindowsAppearanceOptions is the shared Windows chrome (Mica + dark + custom title bar). +func MicrosoftWindowsAppearanceOptions() application.WindowsWindow { + return application.WindowsWindow{ + BackdropType: application.Mica, + Theme: application.Dark, + CustomTheme: application.ThemeSettings{ + DarkModeActive: microsoftWindowsTheme, + DarkModeInactive: microsoftWindowsTheme, + LightModeActive: microsoftWindowsTheme, + LightModeInactive: microsoftWindowsTheme, + }, + } +} + +// AppleMacOSAppearanceOptions is the shared macOS chrome; FullScreenNone keeps the fixed-size layout. +func AppleMacOSAppearanceOptions() application.MacWindow { + return application.MacWindow{ + InvisibleTitleBarHeight: 38, + Backdrop: application.MacBackdropNormal, + TitleBar: application.MacTitleBarHiddenInset, + CollectionBehavior: application.MacWindowCollectionBehaviorFullScreenNone, + } +} + +// LinuxAppearanceOptions is the shared Linux chrome; opaque so fake-translucency compositors paint it. +func LinuxAppearanceOptions(icon []byte) application.LinuxWindow { + return application.LinuxWindow{ + Icon: icon, + WindowIsTranslucent: false, + } +} + +// DialogWindowOptions is the baseline for every auxiliary dialog window; callers override per-dialog. +func DialogWindowOptions(name, title, url string, linuxIcon []byte) application.WebviewWindowOptions { + return application.WebviewWindowOptions{ + Name: name, + Title: title, + Width: 360, + Height: 320, + DisableResize: true, + AlwaysOnTop: true, + Hidden: true, + MinimiseButtonState: application.ButtonHidden, + MaximiseButtonState: application.ButtonHidden, + CloseButtonState: application.ButtonEnabled, + BackgroundColour: WindowBackgroundColour, + URL: url, + Mac: AppleMacOSAppearanceOptions(), + Windows: MicrosoftWindowsAppearanceOptions(), + Linux: LinuxAppearanceOptions(linuxIcon), + } +} + +type WindowManager struct { + app *application.App + mainWindow *application.WebviewWindow + translator ErrorTranslator + prefs LanguagePreference + linuxIcon []byte + settings *application.WebviewWindow + browserLogin *application.WebviewWindow + sessionExpiration *application.WebviewWindow + installProgress *application.WebviewWindow + welcome *application.WebviewWindow + errorDialog *application.WebviewWindow + // hiddenForLogin holds windows hidden while the BrowserLogin popup is open, restored on close. + hiddenForLogin []application.Window + mu sync.Mutex + createMu sync.Mutex + newMain func(startURL string) *application.WebviewWindow + ready map[uint]bool + showPending map[uint]bool + pendingTab map[uint]string + pendingEmits map[uint][]string + fallbackTimers map[uint]*time.Timer + headlessMain bool + headlessTimer *time.Timer + // recenterOnShow is set only on the minimal-WM/XEmbed path, where the WM neither centers nor + // restores position; nil on full desktops so re-centering can't fight a user-moved window. + recenterOnShow func() bool +} + +func NewWindowManager(app *application.App, mainWindow *application.WebviewWindow, translator ErrorTranslator, prefs LanguagePreference, linuxIcon []byte) *WindowManager { + s := &WindowManager{ + app: app, + mainWindow: mainWindow, + translator: translator, + prefs: prefs, + linuxIcon: linuxIcon, + ready: map[uint]bool{}, + showPending: map[uint]bool{}, + pendingTab: map[uint]string{}, + pendingEmits: map[uint][]string{}, + fallbackTimers: map[uint]*time.Timer{}, + } + s.watchPainted() + s.watchTriggerLogin() + // Re-title live windows on language flip. Wired internally so the binding generator + // doesn't try to expose the interface param. + if sub, ok := prefs.(LanguageSubscriber); ok && sub != nil { + ch, _ := sub.Subscribe() + go func() { + var last i18n.LanguageCode + for p := range ch { + if p.Language == "" || p.Language == last { + continue + } + last = p.Language + s.retitleAll() + } + }() + } + return s +} + +func (s *WindowManager) newSettingsWindow() *application.WebviewWindow { + w := s.app.Window.NewWithOptions(application.WebviewWindowOptions{ + Name: "settings", + Title: s.title("window.title.settings"), + Width: 900, + Height: WindowHeight, + Hidden: true, + DisableResize: true, + MinimiseButtonState: application.ButtonHidden, + MaximiseButtonState: application.ButtonHidden, + CloseButtonState: application.ButtonEnabled, + BackgroundColour: WindowBackgroundColour, + URL: "/#/settings", + Mac: AppleMacOSAppearanceOptions(), + Windows: MicrosoftWindowsAppearanceOptions(), + Linux: LinuxAppearanceOptions(s.linuxIcon), + }) + w.RegisterHook(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.mu.Lock() + s.settings = nil + s.forgetWindowLocked(w) + s.mu.Unlock() + }) + return w +} + +// OpenSettings shows the settings window on tab (empty → General), switching tab via +// EventSettingsOpen rather than SetURL (which would remount the provider tree). +func (s *WindowManager) OpenSettings(tab string) { + target := tab + if target == "" { + target = "general" + } + + w, _ := s.ensureWindow(&s.settings, s.newSettingsWindow) + + s.mu.Lock() + ready := s.ready[w.ID()] + if !ready { + s.pendingTab[w.ID()] = target + } + s.mu.Unlock() + + if ready { + s.app.Event.Emit(EventSettingsOpen, target) + } + s.showWhenReady(w) +} + +// OpenBrowserLogin shows the SSO popup, creating it on first use. +func (s *WindowManager) OpenBrowserLogin(uri string) { + s.mu.Lock() + defer s.mu.Unlock() + if s.browserLogin == nil { + startURL := "/#/dialog/browser-login" + if uri != "" { + startURL = "/#/dialog/browser-login?uri=" + url.QueryEscape(uri) + } + s.hideOtherWindowsLocked("browser-login") + opts := DialogWindowOptions("browser-login", s.title("window.title.signIn"), startURL, s.linuxIcon) + // Not always-on-top: it would obscure the browser tab the user logs in through. + opts.AlwaysOnTop = false + opts.InitialPosition = application.WindowCentered + // Open on the active (where users cursor is) display, like the session-expiration dialog. + opts.Screen = s.getScreenBasedOnCursorPosition() + s.browserLogin = s.app.Window.NewWithOptions(opts) + bl := s.browserLogin + bl.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.mu.Lock() + // Only a live user red-X still has this registered; programmatic closers + // nil s.browserLogin first and clean up themselves. Guarding here stops a + // stale close event from wiping a replacement popup's state. + userClosed := s.browserLogin == bl + if userClosed { + s.browserLogin = nil + s.restoreHiddenWindowsLocked() + } + s.mu.Unlock() + if userClosed { + s.app.Event.Emit(EventBrowserLoginCancel) + } + }) + s.centerOnCursorScreen(s.browserLogin) + return + } + if uri != "" { + s.browserLogin.SetURL("/#/dialog/browser-login?uri=" + url.QueryEscape(uri)) + } + s.centerOnCursorScreen(s.browserLogin) + s.browserLogin.Show() + s.browserLogin.Focus() +} + +// BrowserLoginWindow returns the live SSO popup, or nil. While non-nil it is the +// app's focal window: tray "Open" and dock activation hand off to it, not the main window. +func (s *WindowManager) BrowserLoginWindow() *application.WebviewWindow { + s.mu.Lock() + defer s.mu.Unlock() + return s.browserLogin +} + +// InstallProgressWindow returns the live install-progress window, or nil. Same focal-window +// contract as BrowserLoginWindow; install supersedes everything, so check this first. +func (s *WindowManager) InstallProgressWindow() *application.WebviewWindow { + s.mu.Lock() + defer s.mu.Unlock() + return s.installProgress +} + +func (s *WindowManager) CloseBrowserLogin() { + s.mu.Lock() + w := s.browserLogin + s.browserLogin = nil + // The WindowClosing hook no-ops on a programmatic close, so restore here — + // but only if a popup was actually open. The frontend calls this even when no + // popup was ever shown (e.g. resetDialog() after an early RequestExtend failure, + // or connection.ts's catch path), and hiddenForLogin is shared with + // OpenInstallProgress, so an unconditional restore could re-show windows a + // still-running install-progress is hiding. + if w != nil { + s.restoreHiddenWindowsLocked() + } + s.mu.Unlock() + if w != nil { + w.Close() + } +} + +// OpenSessionExpiration shows the countdown warning on the cursor's display; seconds seeds +// the countdown and deadlineUnixMilli (0 when unknown) is the absolute deadline the dialog +// compares renewal snapshots against. Singleton, destroyed on close. +func (s *WindowManager) OpenSessionExpiration(seconds int, deadlineUnixMilli int64) { + s.mu.Lock() + defer s.mu.Unlock() + startURL := "/#/dialog/session-expiration?seconds=" + strconv.Itoa(seconds) + if deadlineUnixMilli > 0 { + startURL += "&deadline=" + strconv.FormatInt(deadlineUnixMilli, 10) + } + if s.sessionExpiration == nil { + opts := DialogWindowOptions("session-expiration", s.title("window.title.sessionExpiration"), startURL, s.linuxIcon) + opts.Screen = s.getScreenBasedOnCursorPosition() + opts.InitialPosition = application.WindowCentered + s.sessionExpiration = s.app.Window.NewWithOptions(opts) + s.sessionExpiration.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.mu.Lock() + s.sessionExpiration = nil + s.mu.Unlock() + }) + s.centerOnCursorScreen(s.sessionExpiration) + return + } + s.sessionExpiration.SetURL(startURL) + s.centerOnCursorScreen(s.sessionExpiration) + s.sessionExpiration.Show() + s.sessionExpiration.Focus() +} + +func (s *WindowManager) CloseSessionExpiration() { + s.mu.Lock() + w := s.sessionExpiration + s.sessionExpiration = nil + s.mu.Unlock() + if w != nil { + w.Close() + } +} + +// CloseRenewFlow tears down the SSO session-renewal UI in a single call: it +// closes the browser-login popup and the session-expiration window together. +func (s *WindowManager) CloseRenewFlow() { + s.mu.Lock() + bl := s.browserLogin + se := s.sessionExpiration + s.browserLogin = nil + s.sessionExpiration = nil + if se != nil { + kept := s.hiddenForLogin[:0] + for _, w := range s.hiddenForLogin { + if w != se { + kept = append(kept, w) + } + } + s.hiddenForLogin = kept + } + s.restoreHiddenWindowsLocked() + s.mu.Unlock() + + // Close after unlock so the re-entrant handlers can take s.mu. + if bl != nil { + bl.Close() + } + if se != nil { + se.Close() + } +} + +// OpenInstallProgress shows the install-progress window and hides the rest for the duration +// (restored on close). It owns its own result polling since the daemon restarts mid-install. +func (s *WindowManager) OpenInstallProgress(version string) { + s.mu.Lock() + defer s.mu.Unlock() + startURL := "/#/dialog/install-progress" + if version != "" { + startURL = "/#/dialog/install-progress?version=" + url.QueryEscape(version) + } + if s.installProgress == nil { + s.hideOtherWindowsLocked("install-progress") + s.installProgress = s.app.Window.NewWithOptions( + DialogWindowOptions("install-progress", s.title("window.title.updating"), startURL, s.linuxIcon), + ) + s.installProgress.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.mu.Lock() + s.installProgress = nil + s.restoreHiddenWindowsLocked() + s.mu.Unlock() + }) + s.centerWhenReady(s.installProgress) + return + } + s.installProgress.SetURL(startURL) + s.installProgress.Show() + s.installProgress.Focus() + s.centerWhenReady(s.installProgress) +} + +func (s *WindowManager) CloseInstallProgress() { + s.mu.Lock() + w := s.installProgress + s.installProgress = nil + s.mu.Unlock() + if w != nil { + w.Close() + } +} + +// OpenWelcome shows the first-launch onboarding window. Singleton, destroyed on close. +func (s *WindowManager) OpenWelcome() { + s.mu.Lock() + defer s.mu.Unlock() + if s.welcome == nil { + opts := DialogWindowOptions("welcome", s.title("window.title.welcome"), "/#/dialog/welcome", s.linuxIcon) + opts.Width = 420 + opts.InitialPosition = application.WindowCentered + s.welcome = s.app.Window.NewWithOptions(opts) + w := s.welcome + w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.mu.Lock() + s.welcome = nil + s.mu.Unlock() + }) + s.centerWhenReady(s.welcome) + return + } + s.welcome.Show() + s.welcome.Focus() + s.centerWhenReady(s.welcome) +} + +func (s *WindowManager) CloseWelcome() { + s.mu.Lock() + w := s.welcome + s.welcome = nil + s.mu.Unlock() + if w != nil { + w.Close() + } +} + +// OpenError shows the custom error dialog; title/message/command are pre-localised +// and ride in the start URL. command is optional and, when set, is offered for +// copying so the user can run the operation the daemon refused. A second error +// replaces the open one via SetURL. Singleton, destroyed on close. +func (s *WindowManager) OpenError(title, message, command string) { + if ShuttingDown() { + return + } + s.mu.Lock() + defer s.mu.Unlock() + startURL := errorDialogURL(title, message, command) + if s.errorDialog == nil { + s.errorDialog = s.app.Window.NewWithOptions( + DialogWindowOptions("error", s.title("window.title.error"), startURL, s.linuxIcon), + ) + s.errorDialog.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.mu.Lock() + s.errorDialog = nil + s.mu.Unlock() + }) + s.centerWhenReady(s.errorDialog) + return + } + s.errorDialog.SetURL(startURL) + s.errorDialog.Show() + s.errorDialog.Focus() + s.centerWhenReady(s.errorDialog) +} + +func (s *WindowManager) CloseError() { + s.mu.Lock() + w := s.errorDialog + s.errorDialog = nil + s.mu.Unlock() + if w != nil { + w.Close() + } +} + +// OpenMain brings the main window forward; the welcome handoff uses it instead of the tray. +func (s *WindowManager) OpenMain() { + s.ShowMain() +} + +// ShowMain brings the main window forward (re-centering on minimal WMs). The single entry +// point every surface (tray, SIGUSR1, welcome) should use so centering applies uniformly. +func (s *WindowManager) ShowMain() { + s.showWhenReady(s.MainWindow()) +} + +// ShowMainAndEmit brings the main window forward and emits event once its frontend is ready. +func (s *WindowManager) ShowMainAndEmit(event string) { + w := s.MainWindow() + if w == nil { + return + } + + id := w.ID() + s.mu.Lock() + ready := s.ready[id] + if !ready { + s.pendingEmits[id] = append(s.pendingEmits[id], event) + } + s.mu.Unlock() + + s.showWhenReady(w) + if ready { + s.app.Event.Emit(event) + } +} + +func (s *WindowManager) MainWindow() *application.WebviewWindow { + w, _ := s.ensureMain("/") + return w +} + +func (s *WindowManager) ensureMain(startURL string) (*application.WebviewWindow, bool) { + s.mu.Lock() + factory := s.newMain + s.mu.Unlock() + if factory == nil { + return s.ensureWindow(&s.mainWindow, nil) + } + return s.ensureWindow(&s.mainWindow, func() *application.WebviewWindow { + return factory(startURL) + }) +} + +func (s *WindowManager) ensureWindow(slot **application.WebviewWindow, factory func() *application.WebviewWindow) (*application.WebviewWindow, bool) { + s.createMu.Lock() + defer s.createMu.Unlock() + + s.mu.Lock() + w := *slot + s.mu.Unlock() + if w != nil || factory == nil { + return w, false + } + + w = factory() + s.armReady(w) + + s.mu.Lock() + *slot = w + s.mu.Unlock() + return w, true +} + +func (s *WindowManager) armReady(w *application.WebviewWindow) { + if w == nil { + return + } + w.RegisterHook(events.Common.WindowRuntimeReady, func(_ *application.WindowEvent) { + timer := time.AfterFunc(paintedFallback, func() { + log.Warnf("window %q never reported a first render, showing it anyway", w.Name()) + s.markReady(w) + }) + s.mu.Lock() + s.fallbackTimers[w.ID()] = timer + s.mu.Unlock() + }) +} + +func (s *WindowManager) watchPainted() { + s.app.Event.On(EventWindowPainted, func(e *application.CustomEvent) { + if w := s.windowByName(e.Sender); w != nil { + s.markReady(w) + } + }) +} + +func (s *WindowManager) watchTriggerLogin() { + s.app.Event.On(EventTriggerLogin, func(_ *application.CustomEvent) { + s.mu.Lock() + if s.headlessTimer != nil { + s.headlessTimer.Stop() + s.headlessTimer = nil + } + w := s.mainWindow + ready := w != nil && s.ready[w.ID()] + s.mu.Unlock() + if ready { + return + } + + w, created := s.ensureMain("/") + if w == nil { + return + } + + s.mu.Lock() + if created { + s.headlessMain = true + } + pending := !s.ready[w.ID()] + if pending { + s.pendingEmits[w.ID()] = append(s.pendingEmits[w.ID()], EventTriggerLogin) + } + s.mu.Unlock() + + if !pending { + s.app.Event.Emit(EventTriggerLogin) + } + }) + + s.app.Event.On(EventBrowserLoginCancel, func(_ *application.CustomEvent) { + s.scheduleHeadlessTeardown() + }) + + s.app.Event.On(EventStatusSnapshot, func(e *application.CustomEvent) { + st, ok := e.Data.(Status) + if !ok { + return + } + switch st.Status { + case StatusConnected, StatusLoginFailed, StatusDaemonUnavailable: + s.scheduleHeadlessTeardown() + } + }) +} + +func (s *WindowManager) scheduleHeadlessTeardown() { + s.mu.Lock() + defer s.mu.Unlock() + if !s.headlessMain || s.mainWindow == nil { + return + } + if s.headlessTimer != nil { + s.headlessTimer.Stop() + } + s.headlessTimer = time.AfterFunc(headlessTeardownDelay, s.closeHeadlessMain) +} + +func (s *WindowManager) closeHeadlessMain() { + s.mu.Lock() + w := s.mainWindow + headless := s.headlessMain + s.headlessTimer = nil + s.mu.Unlock() + if !headless || w == nil { + return + } + w.Close() +} + +func (s *WindowManager) forgetWindowLocked(w *application.WebviewWindow) { + if w == nil { + return + } + + id := w.ID() + if timer := s.fallbackTimers[id]; timer != nil { + timer.Stop() + } + delete(s.fallbackTimers, id) + delete(s.ready, id) + delete(s.showPending, id) + delete(s.pendingTab, id) + delete(s.pendingEmits, id) + + kept := s.hiddenForLogin[:0] + for _, hidden := range s.hiddenForLogin { + if hidden != application.Window(w) { + kept = append(kept, hidden) + } + } + s.hiddenForLogin = kept +} + +func (s *WindowManager) windowByName(name string) *application.WebviewWindow { + s.mu.Lock() + defer s.mu.Unlock() + switch name { + case "main": + return s.mainWindow + case "settings": + return s.settings + default: + return nil + } +} + +func (s *WindowManager) markReady(w *application.WebviewWindow) { + id := w.ID() + s.mu.Lock() + already := s.ready[id] + s.ready[id] = true + wanted := s.showPending[id] + tab, hasTab := s.pendingTab[id] + emits := s.pendingEmits[id] + if timer := s.fallbackTimers[id]; timer != nil { + timer.Stop() + delete(s.fallbackTimers, id) + } + delete(s.showPending, id) + delete(s.pendingTab, id) + delete(s.pendingEmits, id) + s.mu.Unlock() + + if already { + return + } + + if hasTab { + s.app.Event.Emit(EventSettingsOpen, tab) + } + + if wanted { + s.showNow(w) + } + + for _, event := range emits { + s.app.Event.Emit(event) + } +} + +func (s *WindowManager) showWhenReady(w *application.WebviewWindow) { + if w == nil { + return + } + + id := w.ID() + s.mu.Lock() + ready := s.ready[id] + if !ready { + s.showPending[id] = true + } + s.mu.Unlock() + + if ready { + s.showNow(w) + } +} + +func (s *WindowManager) showNow(w *application.WebviewWindow) { + s.mu.Lock() + if w == s.mainWindow { + s.headlessMain = false + if s.headlessTimer != nil { + s.headlessTimer.Stop() + s.headlessTimer = nil + } + } + s.mu.Unlock() + w.Show() + w.Focus() + s.centerWhenReady(w) +} + +func (s *WindowManager) ShowMainAt(url string) { + w, created := s.ensureMain(url) + if w == nil { + return + } + if !created { + w.SetURL(url) + } + s.showWhenReady(w) +} + +func (s *WindowManager) SetMainFactory(f func(startURL string) *application.WebviewWindow) { + s.mu.Lock() + defer s.mu.Unlock() + s.newMain = f +} + +func (s *WindowManager) ForgetMain() { + s.mu.Lock() + defer s.mu.Unlock() + s.forgetWindowLocked(s.mainWindow) + s.mainWindow = nil + s.headlessMain = false + if s.headlessTimer != nil { + s.headlessTimer.Stop() + s.headlessTimer = nil + } +} + +// SetRecenterOnShow installs the recenterOnShow predicate (see the field). +func (s *WindowManager) SetRecenterOnShow(pred func() bool) { + s.recenterOnShow = pred +} + +// centerWhenReady centers w only on minimal WMs (recenterOnShow); elsewhere it +// returns so it never fights a user-moved window. On GTK4 an inline Center() +// no-ops until the GdkSurface is realized (async, after Show) and InvokeAsync +// would deadlock, so a background goroutine retries until Position is non-zero, +// bounded so a window genuinely at the origin can't spin forever. +func (s *WindowManager) centerWhenReady(w *application.WebviewWindow) { + if w == nil || s.recenterOnShow == nil || !s.recenterOnShow() { + return + } + go func() { + for i := 0; i < 50; i++ { // ~1s budget at 20ms steps + w.Center() + if x, y := w.Position(); x != 0 || y != 0 { + return // surface realized + } + time.Sleep(20 * time.Millisecond) + } + }() +} + +// centerOnCursorScreen centers w on the cursor's display; guards no-op on headless sessions. +// On minimal WMs it uses the same realize-detection retry loop as centerWhenReady. +func (s *WindowManager) centerOnCursorScreen(w *application.WebviewWindow) { + if w == nil { + return + } + place := func() { + screen := s.getScreenBasedOnCursorPosition() + if screen == nil { + return + } + width, height := w.Size() + if width <= 0 || height <= 0 { + return + } + wa := screen.WorkArea + if wa.Width <= 0 || wa.Height <= 0 { + return + } + w.SetPosition(wa.X+(wa.Width-width)/2, wa.Y+(wa.Height-height)/2) + } + place() + if s.recenterOnShow == nil || !s.recenterOnShow() { + return + } + go func() { + for i := 0; i < 50; i++ { + place() + if x, y := w.Position(); x != 0 || y != 0 { + return + } + time.Sleep(20 * time.Millisecond) + } + }() +} + +// title resolves a window-title i18n key in the current language, or the raw key if unavailable. +func (s *WindowManager) title(key string) string { + if s.translator == nil { + return key + } + lang := i18n.DefaultLanguage + if s.prefs != nil { + if pref := s.prefs.Get().Language; pref != "" { + lang = pref + } + } + return s.translator.Translate(lang, key) +} + +// retitleAll re-applies the localised title to every live auxiliary window. Pointers are +// snapshotted under s.mu; SetTitle is then safe to call after releasing the lock. +func (s *WindowManager) retitleAll() { + s.mu.Lock() + type pair struct { + win *application.WebviewWindow + key string + } + wins := []pair{ + {s.settings, "window.title.settings"}, + {s.browserLogin, "window.title.signIn"}, + {s.sessionExpiration, "window.title.sessionExpiration"}, + {s.installProgress, "window.title.updating"}, + {s.welcome, "window.title.welcome"}, + {s.errorDialog, "window.title.error"}, + } + s.mu.Unlock() + for _, p := range wins { + if p.win != nil { + p.win.SetTitle(s.title(p.key)) + } + } +} + +// hideOtherWindowsLocked hides every visible window except keepName, recording +// them in hiddenForLogin for restoreHiddenWindowsLocked. Caller must hold s.mu. +func (s *WindowManager) hideOtherWindowsLocked(keepName string) { + for _, w := range s.app.Window.GetAll() { + if w == nil || w.Name() == keepName { + continue + } + if !w.IsVisible() { + continue + } + w.Hide() + s.hiddenForLogin = append(s.hiddenForLogin, w) + } +} + +// restoreHiddenWindowsLocked re-shows windows hidden by hideOtherWindowsLocked +// (caller holds s.mu). If the main window was among them, raiseToForeground +// lifts it above the SSO browser, which still owns the foreground — a plain +// Show/Focus would be demoted to a taskbar flash and leave it stranded behind. +func (s *WindowManager) restoreHiddenWindowsLocked() { + mainRestored := false + for _, w := range s.hiddenForLogin { + if w == nil { + continue + } + w.Show() + if w == s.mainWindow { + mainRestored = true + } + } + s.hiddenForLogin = nil + if mainRestored && s.mainWindow != nil { + raiseToForeground(s.mainWindow) + } +} + +// getScreenBasedOnCursorPosition returns the cursor's display, falling back to the +// main-window screen, then nil (OS-default placement). +func (s *WindowManager) getScreenBasedOnCursorPosition() *application.Screen { + if s.app == nil || s.app.Screen == nil { + return nil + } + if p, ok := getCursorPosition(s.app); ok { + if sc := s.app.Screen.ScreenNearestDipPoint(p); sc != nil { + return sc + } + } + if s.mainWindow != nil { + if sc, err := s.mainWindow.GetScreen(); err == nil { + return sc + } + } + return nil +} + +// errorDialogURL builds the error window's start URL with title/message/command as escaped query params. +func errorDialogURL(title, message, command string) string { + q := url.Values{} + if title != "" { + q.Set("title", title) + } + if message != "" { + q.Set("message", message) + } + if command != "" { + q.Set("command", command) + } + startURL := "/#/dialog/error" + if enc := q.Encode(); enc != "" { + startURL += "?" + enc + } + return startURL +} + +// u32ptr returns a pointer to v, for the optional *uint32 Wails theme fields. +func u32ptr(v uint32) *uint32 { return &v } diff --git a/client/ui/services/windowtheme_windows.go b/client/ui/services/windowtheme_windows.go new file mode 100644 index 000000000..7dbc1164b --- /dev/null +++ b/client/ui/services/windowtheme_windows.go @@ -0,0 +1,14 @@ +package services + +import "github.com/wailsapp/wails/v3/pkg/w32" + +// Wails assigns w32.AllowDarkModeForWindow only on builds >= 18334 but calls it +// without a nil check when a window requests the Dark theme, crashing older +// builds such as Windows Server 2019 (17763). Those builds still get a dark +// title bar via the pre-20H1 DWM attribute that w32.SetTheme applies, so a +// no-op stub keeps the Dark theme fully working there. +func init() { + if w32.AllowDarkModeForWindow == nil { + w32.AllowDarkModeForWindow = func(w32.HWND, bool) uintptr { return 0 } + } +} diff --git a/client/ui/shutdown_other.go b/client/ui/shutdown_other.go new file mode 100644 index 000000000..6e617233f --- /dev/null +++ b/client/ui/shutdown_other.go @@ -0,0 +1,7 @@ +//go:build !windows && !android && !ios && !freebsd && !js + +package main + +func endSessionInterceptor() func(hwnd uintptr, msg uint32, wParam, lParam uintptr) (uintptr, bool) { + return nil +} diff --git a/client/ui/shutdown_windows.go b/client/ui/shutdown_windows.go new file mode 100644 index 000000000..fbb92a518 --- /dev/null +++ b/client/ui/shutdown_windows.go @@ -0,0 +1,36 @@ +//go:build windows + +package main + +import ( + "os" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/ui/services" +) + +const ( + wmQueryEndSession = 0x0011 + wmEndSession = 0x0016 +) + +func endSessionInterceptor() func(hwnd uintptr, msg uint32, wParam, lParam uintptr) (uintptr, bool) { + return func(_ uintptr, msg uint32, wParam, _ uintptr) (uintptr, bool) { + switch msg { + case wmQueryEndSession: + services.BeginSessionEnd() + return 1, true + case wmEndSession: + if wParam == 0 { + services.AbortSessionEnd() + return 0, true + } + log.Info("windows session is ending; exiting immediately") + os.Exit(0) + return 0, true + default: + return 0, false + } + } +} diff --git a/client/ui/signal_unix.go b/client/ui/signal_unix.go index 99de99f0f..876c5dd6e 100644 --- a/client/ui/signal_unix.go +++ b/client/ui/signal_unix.go @@ -1,76 +1,31 @@ -//go:build !windows && !(linux && 386) +//go:build !windows && !android && !ios && !freebsd && !js package main import ( "context" "os" - "os/exec" "os/signal" "syscall" log "github.com/sirupsen/logrus" ) -// setupSignalHandler sets up a signal handler to listen for SIGUSR1. -// When received, it opens the quick actions window. -func (s *serviceClient) setupSignalHandler(ctx context.Context) { - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGUSR1) +// listenForShowSignal lets external tools surface the running UI by signalling its pid (SIGUSR1). +func listenForShowSignal(ctx context.Context, tray *Tray) { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGUSR1) go func() { for { select { case <-ctx.Done(): + signal.Stop(sigCh) return - case <-sigChan: - log.Info("received SIGUSR1 signal, opening quick actions window") - s.openQuickActions() + case <-sigCh: + log.Debug("SIGUSR1 received, showing window") + tray.ShowWindow() } } }() } - -// openQuickActions opens the quick actions window by spawning a new process. -func (s *serviceClient) openQuickActions() { - proc, err := os.Executable() - if err != nil { - log.Errorf("get executable path: %v", err) - return - } - - cmd := exec.CommandContext(s.ctx, proc, - "--quick-actions=true", - "--daemon-addr="+s.addr, - ) - - if out := s.attachOutput(cmd); out != nil { - defer func() { - if err := out.Close(); err != nil { - log.Errorf("close log file %s: %v", s.logFile, err) - } - }() - } - - log.Infof("running command: %s --quick-actions=true --daemon-addr=%s", proc, s.addr) - - if err := cmd.Start(); err != nil { - log.Errorf("start quick actions window: %v", err) - return - } - - go func() { - if err := cmd.Wait(); err != nil { - log.Debugf("quick actions window exited: %v", err) - } - }() -} - -// sendShowWindowSignal sends SIGUSR1 to the specified PID. -func sendShowWindowSignal(pid int32) error { - process, err := os.FindProcess(int(pid)) - if err != nil { - return err - } - return process.Signal(syscall.SIGUSR1) -} diff --git a/client/ui/signal_windows.go b/client/ui/signal_windows.go index 58f46374f..86caa6d0c 100644 --- a/client/ui/signal_windows.go +++ b/client/ui/signal_windows.go @@ -5,9 +5,6 @@ package main import ( "context" "errors" - "fmt" - "os" - "os/exec" "time" log "github.com/sirupsen/logrus" @@ -17,155 +14,65 @@ import ( const ( quickActionsTriggerEventName = `Global\NetBirdQuickActionsTriggerEvent` waitTimeout = 5 * time.Second - // SYNCHRONIZE is needed for WaitForSingleObject, EVENT_MODIFY_STATE for ResetEvent. - desiredAccesses = windows.SYNCHRONIZE | windows.EVENT_MODIFY_STATE + desiredAccesses = windows.SYNCHRONIZE | windows.EVENT_MODIFY_STATE + + // WAIT_TIMEOUT return code; not exposed by golang.org/x/sys/windows. + waitTimeoutCode uint32 = 0x00000102 ) -func getEventNameUint16Pointer() (*uint16, error) { - eventNamePtr, err := windows.UTF16PtrFromString(quickActionsTriggerEventName) - if err != nil { - log.Errorf("Failed to convert event name '%s' to UTF16: %v", quickActionsTriggerEventName, err) - return nil, err - } - - return eventNamePtr, nil -} - -// setupSignalHandler sets up signal handling for Windows. -// Windows doesn't support SIGUSR1, so this uses a similar approach using windows.Events. -func (s *serviceClient) setupSignalHandler(ctx context.Context) { - eventNamePtr, err := getEventNameUint16Pointer() +// listenForShowSignal shows the main window when an external process pulses the named event. +func listenForShowSignal(ctx context.Context, tray *Tray) { + namePtr, err := windows.UTF16PtrFromString(quickActionsTriggerEventName) if err != nil { + log.Errorf("trigger event name: %v", err) return } - eventHandle, err := windows.CreateEvent(nil, 1, 0, eventNamePtr) - + handle, err := windows.CreateEvent(nil, 1, 0, namePtr) if err != nil { - if errors.Is(err, windows.ERROR_ALREADY_EXISTS) { - log.Warnf("Quick actions trigger event '%s' already exists. Attempting to open.", quickActionsTriggerEventName) - eventHandle, err = windows.OpenEvent(desiredAccesses, false, eventNamePtr) - if err != nil { - log.Errorf("Failed to open existing quick actions trigger event '%s': %v", quickActionsTriggerEventName, err) - return - } - log.Infof("Successfully opened existing quick actions trigger event '%s'.", quickActionsTriggerEventName) - } else { - log.Errorf("Failed to create quick actions trigger event '%s': %v", quickActionsTriggerEventName, err) + if !errors.Is(err, windows.ERROR_ALREADY_EXISTS) { + log.Errorf("create trigger event %q: %v", quickActionsTriggerEventName, err) + return + } + handle, err = windows.OpenEvent(desiredAccesses, false, namePtr) + if err != nil { + log.Errorf("open trigger event %q: %v", quickActionsTriggerEventName, err) return } } - if eventHandle == windows.InvalidHandle { - log.Errorf("Obtained an invalid handle for quick actions trigger event '%s'", quickActionsTriggerEventName) + if handle == windows.InvalidHandle { + log.Errorf("invalid handle for trigger event %q", quickActionsTriggerEventName) return } - log.Infof("Quick actions handler waiting for signal on event: %s", quickActionsTriggerEventName) - - go s.waitForEvent(ctx, eventHandle) + go waitForTrigger(ctx, handle, tray) } -func (s *serviceClient) waitForEvent(ctx context.Context, eventHandle windows.Handle) { +func waitForTrigger(ctx context.Context, handle windows.Handle, tray *Tray) { defer func() { - if err := windows.CloseHandle(eventHandle); err != nil { - log.Errorf("Failed to close quick actions event handle '%s': %v", quickActionsTriggerEventName, err) + if err := windows.CloseHandle(handle); err != nil { + log.Errorf("close trigger event handle: %v", err) } }() + timeoutMs := uint32(waitTimeout / time.Millisecond) for { if ctx.Err() != nil { return } - - status, err := windows.WaitForSingleObject(eventHandle, uint32(waitTimeout.Milliseconds())) - - switch status { - case windows.WAIT_OBJECT_0: - log.Info("Received signal on quick actions event. Opening quick actions window.") - - // reset the event so it can be triggered again later (manual reset == 1) - if err := windows.ResetEvent(eventHandle); err != nil { - log.Errorf("Failed to reset quick actions event '%s': %v", quickActionsTriggerEventName, err) - } - - s.openQuickActions() - case uint32(windows.WAIT_TIMEOUT): - - default: - if isDone := logUnexpectedStatus(ctx, status, err); isDone { - return + ev, err := windows.WaitForSingleObject(handle, timeoutMs) + switch { + case err != nil: + log.Errorf("wait trigger event: %v", err) + return + case ev == waitTimeoutCode: + continue + case ev == windows.WAIT_OBJECT_0: + if err := windows.ResetEvent(handle); err != nil { + log.Errorf("reset trigger event: %v", err) } + tray.ShowWindow() } } } - -func logUnexpectedStatus(ctx context.Context, status uint32, err error) bool { - log.Errorf("Unexpected status %d from WaitForSingleObject for quick actions event '%s': %v", - status, quickActionsTriggerEventName, err) - select { - case <-time.After(5 * time.Second): - return false - case <-ctx.Done(): - return true - } -} - -// openQuickActions opens the quick actions window by spawning a new process. -func (s *serviceClient) openQuickActions() { - proc, err := os.Executable() - if err != nil { - log.Errorf("get executable path: %v", err) - return - } - - cmd := exec.CommandContext(s.ctx, proc, - "--quick-actions=true", - "--daemon-addr="+s.addr, - ) - - if out := s.attachOutput(cmd); out != nil { - defer func() { - if err := out.Close(); err != nil { - log.Errorf("close log file %s: %v", s.logFile, err) - } - }() - } - - log.Infof("running command: %s --quick-actions=true --daemon-addr=%s", proc, s.addr) - - if err := cmd.Start(); err != nil { - log.Errorf("error starting quick actions window: %v", err) - return - } - - go func() { - if err := cmd.Wait(); err != nil { - log.Debugf("quick actions window exited: %v", err) - } - }() -} - -func sendShowWindowSignal(pid int32) error { - _, err := os.FindProcess(int(pid)) - if err != nil { - return err - } - - eventNamePtr, err := getEventNameUint16Pointer() - if err != nil { - return err - } - - eventHandle, err := windows.OpenEvent(desiredAccesses, false, eventNamePtr) - if err != nil { - return err - } - - err = windows.SetEvent(eventHandle) - if err != nil { - return fmt.Errorf("error setting event: %w", err) - } - - return nil -} diff --git a/client/ui/tray.go b/client/ui/tray.go new file mode 100644 index 000000000..c392a0b62 --- /dev/null +++ b/client/ui/tray.go @@ -0,0 +1,592 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + "runtime" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" + "github.com/wailsapp/wails/v3/pkg/services/notifications" + + "github.com/netbirdio/netbird/client/ui/authsession" + "github.com/netbirdio/netbird/client/ui/i18n" + "github.com/netbirdio/netbird/client/ui/preferences" + "github.com/netbirdio/netbird/client/ui/services" + "github.com/netbirdio/netbird/version" +) + +// Notification IDs are OS dedup keys that coalesce duplicate toasts; +// statusError is a tray-only sentinel for the error-icon state. +const ( + notifyIDUpdatePrefix = "netbird-update-" + notifyIDEvent = "netbird-event-" + notifyIDTrayError = "netbird-tray-error" + notifyIDMDMPolicy = "netbird-mdm-policy" + + statusError = "Error" + + quitDownTimeout = 5 * time.Second + + urlGitHubRepo = "https://github.com/netbirdio/netbird" + urlDocs = "https://docs.netbird.io" +) + +// TrayServices bundles the services the tray menu needs, grouped so NewTray +// stays under the linter's parameter-count threshold. +type TrayServices struct { + Connection *services.Connection + Settings *services.Settings + Profiles *services.Profiles + Networks *services.Networks + DaemonFeed *services.DaemonFeed + Notifier *Notifier + Update *services.Update + ProfileSwitcher *services.ProfileSwitcher + WindowManager *services.WindowManager + // Session is bound to authsession directly because the services wrapper + // only re-exposes the React subset. + Session *authsession.Session + Localizer *Localizer + Preferences *preferences.Store +} + +type Tray struct { + app *application.App + tray *application.SystemTray + window *application.WebviewWindow + svc TrayServices + // panelDark reports whether the desktop panel uses a dark scheme, so + // iconForState can pick the black vs white mono tray icon on Linux. Set + // by startTrayTheme (Linux only); nil elsewhere, where panelIsDark falls + // back to its default. + panelDark func() bool + loc *Localizer + + // menu and the *Item/*Submenu fields below are reassigned by buildMenu + // on every relayout — touch them only with menuMu held. Exceptions: + // the Connect/Disconnect OnClick closures capture their own item, and + // refreshSessionExpiresLabel snapshots its item under menuMu. + menu *application.Menu + statusItem *application.MenuItem + // sessionExpiresItem shows the SSO deadline as a remaining-time label, + // repainted by a 30s ticker. + sessionExpiresItem *application.MenuItem + upItem *application.MenuItem + downItem *application.MenuItem + exitNodeItem *application.MenuItem + exitNodeSubmenu *application.Menu + profileSubmenu *application.Menu + profileSubmenuItem *application.MenuItem + profileEmailItem *application.MenuItem + settingsItem *application.MenuItem + daemonVersionItem *application.MenuItem + + updater *trayUpdater + + // statusMu guards the daemon-status core mirrored on the tray. One mutex + // covers these fields because applyStatus writes them together on every + // Status push and the menu painters read them. + statusMu sync.Mutex + connected bool + lastStatus string + lastDaemonVersion string + // lastNetworksRevision is the daemon's routed-networks revision; a bump (or + // a connect/disconnect transition) gates the refreshExitNodes re-fetch so + // ListNetworks runs only when routes change. The peer-status route list + // can't substitute: it carries only actively-routed routes, not candidate + // exit nodes. + lastNetworksRevision uint64 + // pendingConnectLogin is set when handleConnect fires an Up on an idle + // daemon. The daemon flips to NeedsLogin if the peer is SSO-tracked with + // no cached token; applyStatus consumes the flag on that transition to + // open the browser-login flow, saving a second Connect click. + // Profile-switch reconnects are handled separately by + // DaemonFeed.statusStreamLoop. + pendingConnectLogin bool + + // sessionMu guards the cached SSO deadline used by the session row. + // Independent of statusMu so the 30s ticker reader and the Status-push + // writer don't block each other. + sessionMu sync.Mutex + sessionExpiresAt time.Time + + // profileMu guards the profile-domain state (active identity, the + // notifications gate, the in-flight switch cancel). Independent of + // statusMu so a long-running switch holding switchCancel doesn't block a + // Status-push reader of t.connected. + profileMu sync.Mutex + activeProfile string + activeUsername string + notificationsEnabled bool + switchCancel context.CancelFunc + + // profileLoadMu serializes loadProfiles so the applyStatus refresh can't + // race the ApplicationStarted seed or the post-switch reload — all + // manipulate profileSubmenu + SetMenu, which Wails isn't concurrency-safe + // against. + profileLoadMu sync.Mutex + + // profilesMu guards the cached profile rows that relayoutMenu repaints + // into a freshly built Profiles submenu, kept separate from the live + // submenu so a relayout always has a source to repaint from without + // re-hitting the daemon. + profilesMu sync.Mutex + profiles []services.Profile + profilesUser string + + // menuMu serialises relayoutMenu (buildMenu + SetMenu) and guards the + // menu/item-pointer fields above. relayoutMenu is the only post-startup + // SetMenu call site — a menu snapshot pushed outside the lock could + // reinstall a stale tree. + menuMu sync.Mutex + + // exitNodesMu guards the exitNodes row cache so relayoutMenu's read (and + // the Repaint copy) doesn't contend with status-push readers of statusMu. + exitNodesMu sync.Mutex + exitNodes []exitNodeEntry + // exitNodesRebuildMu serialises the ListNetworks fetch + submenu rebuild + + // SetMenu cycle so back-to-back Status pushes can't run it concurrently + // with itself. + exitNodesRebuildMu sync.Mutex + + // featureMu guards the daemon feature kill switches mirrored on the tray. + // Fetched at startup and refreshed on every config_changed event (the + // daemon re-applies MDM policy per engine spawn), so featuresDisabled can + // grey out menus without polling GetFeatures. + featureMu sync.Mutex + disableProfiles bool + disableNetworks bool +} + +func NewTray(app *application.App, window *application.WebviewWindow, svc TrayServices) *Tray { + t := &Tray{ + app: app, + window: window, + svc: svc, + notificationsEnabled: true, + // Localizer is constructed by main so the first menu render is already + // in the right locale — no English flash then re-paint. + loc: svc.Localizer, + } + t.updater = newTrayUpdater(app, t.showMainAt, svc.Update, svc.Notifier, t.loc, func() { t.applyIcon() }, func() { t.relayoutMenu() }) + t.tray = app.SystemTray.New() + // Seed panel-theme detection before the first paint so the initial icon + // matches the panel's light/dark scheme (Linux only). + t.startTrayTheme() + t.applyIcon() + t.tray.SetTooltip(t.loc.T("tray.tooltip")) + // On Linux the SNI hover tooltip rides on the systray label, not + // SetTooltip (a no-op there); without a label Wails shows the literal + // "Wails". macOS/Windows are skipped (label paints visible text on + // macOS; Windows uses SetTooltip above). + if runtime.GOOS == "linux" { + t.tray.SetLabel(t.loc.T("tray.tooltip")) + } + t.menu = t.buildMenu() + t.tray.SetMenu(t.menu) + // macOS/Linux give click→menu natively, so bindTrayClick is a no-op there + // (binding OnClick→OpenMenu on macOS would freeze the tray); Windows has no + // native left-click handler so it wires one to open the main window, leaving + // the menu on right-click (see tray_click_*.go). On Linux AttachWindow is + // skipped — with applySmartDefaults it would pop the window alongside the + // menu (e.g. GNOME Shell AppIndicator). + bindTrayClick(t) + + app.Event.On(services.EventStatusSnapshot, t.onStatusEvent) + app.Event.On(services.EventDaemonNotification, t.onSystemEvent) + // Refresh the Profiles submenu on ProfileSwitcher's change event. A + // switch on an idle daemon drives no status transition, so without this + // hook a React-initiated switch leaves the tray's submenu stale. + app.Event.On(services.EventProfileChanged, func(*application.CustomEvent) { + go t.loadProfiles() + }) + // Defer the first profile load until the menu impl is live — Menu.Update() + // short-circuits while app.running is false, and AppKit's main queue isn't + // ready earlier (see d23ef34 InvokeSync nil-deref). + app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { + go t.loadProfiles() + go t.refreshRestrictions() + go t.runSessionExpiryTicker() + // Category registration must run after the notifications service + // Startup populates appName/registry path on Windows; before app.Run() + // the category lookup silently falls back to a plain notification. + t.registerSessionWarningCategory() + }) + + t.loc.Watch(func(i18n.LanguageCode) { t.applyLanguage() }) + + go t.loadConfig() + return t +} + +// ShowWindow brings the main window forward — used by SIGUSR1 / Windows event. +// Show() alone is not enough on macOS (makeKeyAndOrderFront skips activation, +// so the window pops up behind the active app); Focus() additionally calls +// activateIgnoringOtherApps:YES on macOS and SetForegroundWindow on Windows. +func (t *Tray) ShowWindow() { + // An install supersedes every other flow, so check it before BrowserLogin. + if w := t.svc.WindowManager.InstallProgressWindow(); w != nil { + w.Show() + w.Focus() + return + } + if w := t.svc.WindowManager.BrowserLoginWindow(); w != nil { + w.Show() + w.Focus() + return + } + // Route through WindowManager so the main window is centered on first + // show — minimal WMs (fluxbox, the XEmbed tray path) otherwise drop it in + // the top-left corner. + if t.svc.WindowManager != nil { + t.svc.WindowManager.ShowMain() + return + } + if w := t.mainWindow(); w != nil { + w.Show() + w.Focus() + } +} + +func (t *Tray) mainWindow() *application.WebviewWindow { + if t.svc.WindowManager == nil { + return t.window + } + return t.svc.WindowManager.MainWindow() +} + +func (t *Tray) showMainAt(url string) { + if t.svc.WindowManager != nil { + t.svc.WindowManager.ShowMainAt(url) + return + } + if w := t.mainWindow(); w != nil { + w.SetURL(url) + w.Show() + w.Focus() + } +} + +func (t *Tray) showMain() { + if t.svc.WindowManager != nil { + t.svc.WindowManager.ShowMain() + return + } + if w := t.mainWindow(); w != nil { + w.Show() + w.Focus() + } +} + +func (t *Tray) showMainAndEmit(event string) { + if t.svc.WindowManager != nil { + t.svc.WindowManager.ShowMainAndEmit(event) + return + } + t.showMain() + t.app.Event.Emit(event) +} + +// applyLanguage re-renders every translated surface in the Localizer's current +// language. Wails dispatches menu/tray APIs onto the UI thread internally, so +// calling them from the Localizer's background goroutine is safe; profileLoadMu +// prevents loadProfiles from racing the rebuild. +func (t *Tray) applyLanguage() { + t.tray.SetTooltip(t.loc.T("tray.tooltip")) + // Mirror the Linux label fix from NewTray (the SNI tooltip rides on the + // label). + if runtime.GOOS == "linux" { + t.tray.SetLabel(t.loc.T("tray.tooltip")) + } + t.relayoutMenu() +} + +// relayoutMenu rebuilds the entire tray menu, repaints the cached +// status/session/profile/exit-node state into the fresh items, and pushes the +// whole tree with a single SetMenu. +// +// A full rebuild is required because on KDE/Plasma the StatusNotifierItem host +// caches a submenu's layout on first open (GetLayout for that submenu id) and +// never re-fetches it on a LayoutUpdated(parent=0) signal — so Clear()+Add() +// into the same container froze both the visible rows and the click→id mapping, +// and stale ids no-op'd. buildMenu allocates a fresh submenu container id each +// time, which Plasma treats as unseen and re-queries (confirmed via +// dbus-monitor). This also covers the darwin detached-NSMenu workaround, since +// it rebuilds the whole tree against the cached top-level pointer. +// +// Rows come from the profilesMu/exitNodes caches, so it never re-hits the +// daemon or recurses back into loadProfiles. +func (t *Tray) relayoutMenu() { + t.menuMu.Lock() + defer t.menuMu.Unlock() + + t.menu = t.buildMenu() + + t.statusMu.Lock() + connected := t.connected + lastStatus := t.lastStatus + daemonVersion := t.lastDaemonVersion + t.statusMu.Unlock() + + t.sessionMu.Lock() + sessionDeadline := t.sessionExpiresAt + t.sessionMu.Unlock() + + t.exitNodesMu.Lock() + exitNodeEntries := append([]exitNodeEntry(nil), t.exitNodes...) + t.exitNodesMu.Unlock() + + disableProfiles, disableNetworks := t.featuresDisabled() + + daemonUnavailable := strings.EqualFold(lastStatus, services.StatusDaemonUnavailable) + connecting := strings.EqualFold(lastStatus, services.StatusConnecting) + + if t.statusItem != nil && lastStatus != "" { + t.statusItem.SetLabel(t.loc.StatusLabel(lastStatus)) + t.statusItem.SetEnabled(statusRowEnabled()) + t.applyStatusIndicator(lastStatus) + } + if t.sessionExpiresItem != nil { + if sessionDeadline.IsZero() { + t.sessionExpiresItem.SetHidden(true) + } else { + t.sessionExpiresItem.SetLabel(t.sessionRowLabel(sessionDeadline)) + t.sessionExpiresItem.SetHidden(false) + } + } + if t.upItem != nil { + // Connect stays visible in the NeedsLogin states too — Up drives + // the SSO re-auth flow; hidden only when it would be a no-op. + t.upItem.SetHidden(connected || connecting || daemonUnavailable) + t.upItem.SetEnabled(!connected && !connecting && !daemonUnavailable) + } + if t.downItem != nil { + // Disconnect doubles as the Connecting abort path. + t.downItem.SetHidden(!connected && !connecting) + t.downItem.SetEnabled(connected || connecting) + } + if t.exitNodeItem != nil { + t.exitNodeItem.SetEnabled(connected && len(exitNodeEntries) > 0 && !disableNetworks) + } + if t.settingsItem != nil { + t.settingsItem.SetEnabled(!daemonUnavailable) + } + if t.profileSubmenuItem != nil { + t.profileSubmenuItem.SetEnabled(!daemonUnavailable && !disableProfiles) + } + if daemonVersion != "" && t.daemonVersionItem != nil { + t.daemonVersionItem.SetLabel(t.loc.T("tray.menu.daemonVersion", "version", daemonVersion)) + } + if t.updater != nil { + t.updater.applyLanguage() + } + // buildMenu recreated empty submenus, so repaint both from their caches + // before SetMenu. Neither fill re-fetches. Do NOT re-take + // exitNodesRebuildMu here — refreshExitNodes already holds it when it + // calls relayoutMenu. + t.fillExitNodeSubmenu(exitNodeEntries) + t.fillProfileSubmenu() + + // Single push of the whole tree: on Linux one LayoutUpdated with fresh + // container ids; on darwin an NSMenu rebuild against the cached pointer. + t.tray.SetMenu(t.menu) +} + +func (t *Tray) buildMenu() *application.Menu { + menu := application.NewMenu() + + // Enabled state is platform-dependent (see statusRowEnabled): Windows keeps + // it enabled because the disabled mask would desaturate the coloured status + // dot; macOS/Linux disable it so the greyed label signals it isn't + // clickable. + t.statusItem = menu.Add(t.loc.T("tray.status.disconnected")). + SetEnabled(statusRowEnabled()). + SetBitmap(iconMenuDotIdle) + + menu.AddSeparator() + + // The OnClick closures capture the local item because t.upItem/t.downItem + // are menuMu-guarded and must not be read from the click goroutine. + upItem := menu.Add(t.loc.T("tray.menu.connect")) + upItem.OnClick(func(*application.Context) { t.handleConnect(upItem) }) + t.upItem = upItem + downItem := menu.Add(t.loc.T("tray.menu.disconnect")) + downItem.OnClick(func(*application.Context) { t.handleDisconnect(downItem) }) + downItem.SetHidden(true) + t.downItem = downItem + + menu.AddSeparator() + + // Populated asynchronously once the app has started — Menu.Update() is a + // no-op before app.running is true, so the initial fill is gated on the + // ApplicationStarted hook. + profilesLabel := t.loc.T("tray.menu.profiles") + t.profileSubmenu = menu.AddSubmenu(profilesLabel) + // AddSubmenu returns the child *Menu, so retrieve the parent *MenuItem via + // FindByLabel. + t.profileSubmenuItem = menu.FindByLabel(profilesLabel) + t.profileEmailItem = menu.Add("").SetEnabled(false) + t.profileEmailItem.SetHidden(true) + // Click opens the SessionExpiration window so the user can extend ahead of + // the daemon's T-FinalWarningLead auto-prompt. + t.sessionExpiresItem = menu.Add("").OnClick(func(*application.Context) { t.openSessionExtendFlow() }) + t.sessionExpiresItem.SetHidden(true) + + menu.AddSeparator() + // Accelerators on the Settings/Quit entries below are a no-op on Windows in + // Wails v3 alpha.95 (impl commented out in menuitem_windows.go); still set + // for forward compatibility. macOS/GTK render and fire them. + menu.Add(t.loc.T("tray.menu.open")).OnClick(func(*application.Context) { t.ShowWindow() }) + + menu.AddSeparator() + + // exitNodeSubmenu hosts one row per peer advertising a default route + // (0.0.0.0/0 or ::/0). FindByLabel grabs the parent so applyStatus can flip + // its enabled state independently of the children. + exitNodeLabel := t.loc.T("tray.menu.exitNode") + t.exitNodeSubmenu = menu.AddSubmenu(exitNodeLabel) + t.exitNodeItem = menu.FindByLabel(exitNodeLabel) + t.exitNodeItem.SetEnabled(false) + + menu.AddSeparator() + + // The label's trailing ellipsis follows the macOS HIG convention for items + // that open a window. + t.settingsItem = menu.Add(t.loc.T("tray.menu.settings")). + SetAccelerator("CmdOrCtrl+,"). + OnClick(func(*application.Context) { t.svc.WindowManager.OpenSettings("") }) + + aboutLabel := menuLabel(t.loc.T("tray.menu.about")) + about := menu.AddSubmenu(aboutLabel) + about.Add(t.loc.T("tray.menu.github")).OnClick(func(*application.Context) { + _ = t.app.Browser.OpenURL(urlGitHubRepo) + }) + about.Add(t.loc.T("tray.menu.documentation")).OnClick(func(*application.Context) { + _ = t.app.Browser.OpenURL(urlDocs) + }) + about.Add(t.loc.T("tray.menu.troubleshoot")).OnClick(func(*application.Context) { + t.svc.WindowManager.OpenSettings("troubleshooting") + }) + about.AddSeparator() + about.Add(t.loc.T("tray.menu.guiVersion", "version", version.NetbirdVersion())).SetEnabled(false) + t.daemonVersionItem = about.Add(t.loc.T("tray.menu.daemonVersion", "version", t.loc.T("tray.menu.versionUnknown"))).SetEnabled(false) + // trayUpdater rewrites the label between downloadLatest (opt-in) and + // installVersion (enforced) and drives the click. + updateItem := about.Add(t.loc.T("tray.menu.downloadLatest")). + OnClick(func(*application.Context) { t.updater.handleClick() }) + updateItem.SetHidden(true) + t.updater.attach(updateItem) + + menu.AddSeparator() + menu.Add(t.loc.T("tray.menu.quit")). + SetAccelerator("CmdOrCtrl+Q"). + OnClick(func(*application.Context) { t.handleQuit() }) + + return menu +} + +func (t *Tray) handleQuit() { + services.BeginShutdown() + t.profileMu.Lock() + if t.switchCancel != nil { + t.switchCancel() + t.switchCancel = nil + } + t.profileMu.Unlock() + t.svc.DaemonFeed.CancelProfileSwitch() + + if t.svc.Preferences == nil || !t.svc.Preferences.Get().KeepConnectedOnQuit { + ctx, cancel := context.WithTimeout(context.Background(), quitDownTimeout) + defer cancel() + if err := t.svc.Connection.Down(ctx); err != nil { + log.Errorf("disconnect on quit: %v", err) + } + } + t.app.Quit() +} + +// handleConnect receives the clicked item from the buildMenu closure — +// t.upItem is menuMu-guarded and must not be read here. +func (t *Tray) handleConnect(upItem *application.MenuItem) { + // NeedsLogin/SessionExpired/LoginFailed won't honor a plain Up RPC — they + // need the Login → WaitSSOLogin → Up sequence. Emit EventTriggerLogin so + // the React startLogin() (which owns the BrowserLogin popup) drives it; + // the WindowManager materialises a hidden main webview when none is live, + // so only the popup shows. + t.statusMu.Lock() + needsLogin := strings.EqualFold(t.lastStatus, services.StatusNeedsLogin) || + strings.EqualFold(t.lastStatus, services.StatusSessionExpired) || + strings.EqualFold(t.lastStatus, services.StatusLoginFailed) + t.statusMu.Unlock() + if needsLogin { + t.app.Event.Emit(services.EventTriggerLogin) + return + } + upItem.SetEnabled(false) + // Arm the SSO auto-handoff: Up() is async and the daemon may flip to + // NeedsLogin on an SSO peer with no cached token. applyStatus consumes the + // flag on that transition to trigger browser-login without a second Connect + // click, and clears it on any terminal state. + t.statusMu.Lock() + t.pendingConnectLogin = true + t.statusMu.Unlock() + go func() { + if err := t.svc.Connection.Up(context.Background(), services.UpParams{}); err != nil { + log.Errorf("connect: %v", err) + t.notifyError(t.loc.T("notify.error.connect")) + t.statusMu.Lock() + t.pendingConnectLogin = false + t.statusMu.Unlock() + upItem.SetEnabled(true) + } + }() +} + +// handleDisconnect aborts any in-flight profile switch before sending Down — +// otherwise the switcher's queued Up would reconnect right after, making the +// click a no-op. Also clears Peers' optimistic-Connecting guard so the daemon's +// Idle push paints through instead of being swallowed by the suppression filter. +// Receives the clicked item from the buildMenu closure (see handleConnect). +func (t *Tray) handleDisconnect(downItem *application.MenuItem) { + downItem.SetEnabled(false) + t.profileMu.Lock() + if t.switchCancel != nil { + t.switchCancel() + t.switchCancel = nil + } + t.profileMu.Unlock() + t.svc.DaemonFeed.CancelProfileSwitch() + go func() { + if err := t.svc.Connection.Down(context.Background()); err != nil { + log.Errorf("disconnect: %v", err) + t.notifyError(t.loc.T("notify.error.disconnect")) + downItem.SetEnabled(true) + } + }() +} + +// notify wraps the Wails notification service with the tray's standard +// id-prefix scheme and swallows errors (notifications are best-effort). +func (t *Tray) notify(title, body, id string) { + if t.svc.Notifier == nil { + return + } + _ = safeSendNotification(t.svc.Notifier.SendNotification, title, notifications.NotificationOptions{ + ID: id, + Title: title, + Body: body, + }) +} + +// notifyError fires a generic "Error" notification for tray-driven action +// failures. Each tray click site already logs the underlying error; this +// adds the user-visible toast. +func (t *Tray) notifyError(message string) { + t.notify(t.loc.T("notify.error.title"), message, notifyIDTrayError) +} diff --git a/client/ui/tray_click_linux.go b/client/ui/tray_click_linux.go new file mode 100644 index 000000000..95f5dfe85 --- /dev/null +++ b/client/ui/tray_click_linux.go @@ -0,0 +1,40 @@ +//go:build linux && !(linux && 386) + +package main + +// bindTrayClick wires the tray icon's left-click handler on Linux. +// +// Expected behaviour per tray host: +// +// Host Left click Right click +// KDE Plasma, Waybar main window (Activate) menu (host-rendered) +// GNOME Shell + AppIndicator menu only menu only +// Minimal WMs via XEmbed host main window (Activate) XEmbed GTK popup +// +// OnClick fires only on org.kde.StatusNotifierItem.Activate — a real left +// click. KDE/Waybar send it over D-Bus; the in-process XEmbed host +// (xembed_host_linux.go) maps a Button1 press to the same Activate call. +// +// GNOME Shell + AppIndicator never sends Activate: it renders the dbusmenu +// on ANY click and only reports the menu opening via dbusmenu +// Event("opened"). Upstream Wails treated that event as a click, so on GNOME +// both buttons raised the main window on top of the menu, and on KDE/Waybar +// a right click raised it over the freshly opened menu. The netbirdio/wails +// fork (go.mod replace) drops that heuristic: a menu open never fires +// OnClick. On GNOME the main window is reached via the "Open NetBird" menu +// entry; left-click-opens-window is not achievable there anyway, since the +// host always opens the menu itself. +// +// We do NOT register OnDoubleClick: Wails' Linux SNI backend never fires it +// (unlike Windows). And we deliberately skip AttachWindow — it plus Wails3's +// applySmartDefaults would pop the window alongside the menu on GNOME Shell +// with the AppIndicator extension (see the bindTrayClick comment in tray.go). +// +// ShowWindow() is the same dispatcher the explicit "Open NetBird" menu entry +// and SIGUSR1 use: it brings the install-progress / browser-login window +// forward when one of those flows is active, otherwise routes through +// WindowManager.ShowMain so the window re-centers on minimal WMs / the XEmbed +// path instead of landing in the top-left corner. +func bindTrayClick(t *Tray) { + t.tray.OnClick(func() { t.ShowWindow() }) +} diff --git a/client/ui/tray_click_other.go b/client/ui/tray_click_other.go new file mode 100644 index 000000000..e6a29e419 --- /dev/null +++ b/client/ui/tray_click_other.go @@ -0,0 +1,13 @@ +//go:build !windows && !android && !ios && !freebsd && !js && (!linux || (linux && 386)) + +package main + +func bindTrayClick(*Tray) { + // No-op: macOS's native NSStatusItem opens the menu on click itself, and + // binding OnClick→anything blocking there froze the tray historically + // (see tray_click_windows.go). Windows wires an explicit handler + // (tray_click_windows.go); Linux opens the window on left-click + // (tray_click_linux.go). The (linux && 386) arm keeps a no-op fallback for + // the i386 Linux build, which excludes the cgo XEmbed/SNI files that + // tray_click_linux.go's build tag matches. +} diff --git a/client/ui/tray_click_windows.go b/client/ui/tray_click_windows.go new file mode 100644 index 000000000..17a6dc5df --- /dev/null +++ b/client/ui/tray_click_windows.go @@ -0,0 +1,8 @@ +//go:build windows + +package main + +// Open application window on left click, right click opens the tray menu +func bindTrayClick(t *Tray) { + t.tray.OnClick(func() { t.ShowWindow() }) +} diff --git a/client/ui/tray_events.go b/client/ui/tray_events.go new file mode 100644 index 000000000..f23b5d715 --- /dev/null +++ b/client/ui/tray_events.go @@ -0,0 +1,138 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "fmt" + "strings" + + log "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v3/pkg/application" + + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/client/ui/authsession" + "github.com/netbirdio/netbird/client/ui/services" +) + +// onSystemEvent fires an OS notification for daemon SystemEvents that carry a +// user-facing message. Gated by the "Notifications" toggle; critical events bypass it. +func (t *Tray) onSystemEvent(ev *application.CustomEvent) { + se, ok := ev.Data.(services.SystemEvent) + if !ok { + return + } + // config_changed carries no UserMessage, so handle it before the message gate below. + if se.Category == "system" && se.Metadata[proto.MetadataTypeKey] == proto.MetadataTypeConfigChanged { + log.Infof("config_changed event received (source=%s); refreshing tray restrictions", se.Metadata[proto.MetadataSourceKey]) + go t.refreshRestrictions() + go t.loadConfig() + // MDM gets a localised toast here; the daemon's English "policy_applied" + // event is suppressed in shouldSkipSystemEvent. Other sources stay silent. + if se.Metadata[proto.MetadataSourceKey] == proto.MetadataSourceMDM { + t.profileMu.Lock() + enabled := t.notificationsEnabled + t.profileMu.Unlock() + if enabled { + t.notify( + t.loc.T("notify.mdm.policyApplied.title"), + t.loc.T("notify.mdm.policyApplied.body"), + notifyIDMDMPolicy, + ) + } + } + return + } + // Session-warning and deadline-rejected events build their body locally from + // metadata; every other event needs a UserMessage. + isSessionWarning := se.Metadata[authsession.MetaWarning] == "true" + isDeadlineRejected := se.Metadata[authsession.MetaDeadlineRejected] != "" + if !isSessionWarning && !isDeadlineRejected && se.UserMessage == "" { + return + } + if shouldSkipSystemEvent(se) { + return + } + + critical := strings.EqualFold(se.Severity, services.SeverityCritical) + t.profileMu.Lock() + enabled := t.notificationsEnabled + t.profileMu.Unlock() + if !enabled && !critical { + return + } + + // Session-warning events route via stable metadata flags rather than + // category/severity so a daemon-side reword still lands here. Final warning + // auto-opens the SessionExpiration dialog with no notification (the dialog is + // the last-chance reminder; doubling up would be noise). + if isDeadlineRejected { + t.notify( + t.loc.T("notify.sessionDeadlineRejected.title"), + t.loc.T("notify.sessionDeadlineRejected.body"), + notifyIDSessionExpired, + ) + return + } + + if se.Metadata != nil && se.Metadata[authsession.MetaWarning] == "true" { + if se.Metadata[authsession.MetaFinal] == "true" { + deadline, _ := authsession.ParseExpiresAt(se.Metadata[authsession.MetaExpiresAt]) + t.openSessionExpiration(deadline) + return + } + t.notifySessionWarning( + t.loc.T("notify.sessionWarning.title"), + t.buildSessionWarningBody(se.Metadata), + ) + return + } + + body := se.UserMessage + if id := se.Metadata["id"]; id != "" { + body += fmt.Sprintf(" ID: %s", id) + } + t.notify(eventTitle(se), body, notifyIDEvent+se.ID) +} + +// eventTitle composes a notification title, e.g. "Critical: DNS", "Warning: Authentication". +func eventTitle(e services.SystemEvent) string { + prefix := titleCase(e.Severity) + if prefix == "" { + prefix = "Info" + } + category := titleCase(e.Category) + if category == "" { + category = "System" + } + return prefix + ": " + category +} + +func titleCase(s string) string { + if s == "" { + return "" + } + return strings.ToUpper(s[:1]) + strings.ToLower(s[1:]) +} + +// shouldSkipSystemEvent reports whether a daemon SystemEvent must not surface as +// a tray notification: +// - update-available announcements (trayUpdater emits its own) +// - install-progress signals (consumed by the install-progress window) +// - the ::/0 partner of an exit-node default route (0.0.0.0/0 already toasted) +func shouldSkipSystemEvent(se services.SystemEvent) bool { + // "policy_applied" carries a hardcoded English message; the localised toast + // fires on the paired config_changed (source=mdm) event instead. + if se.Metadata[proto.MetadataTypeKey] == proto.MetadataTypePolicyApplied { + return true + } + if _, isUpdate := se.Metadata["new_version_available"]; isUpdate { + return true + } + if _, isProgress := se.Metadata["progress_window"]; isProgress { + return true + } + if se.Category == "network" && se.Metadata["network"] == "::/0" { + return true + } + return false +} diff --git a/client/ui/tray_exitnodes.go b/client/ui/tray_exitnodes.go new file mode 100644 index 000000000..3e6f30842 --- /dev/null +++ b/client/ui/tray_exitnodes.go @@ -0,0 +1,148 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + "net/netip" + "sort" + "strings" + "time" + + log "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v3/pkg/application" + + "github.com/netbirdio/netbird/client/ui/services" +) + +// exitNodeEntry is one Exit Node submenu row; ID is the network's NetID, the Select/Deselect argument. +type exitNodeEntry struct { + ID string + Selected bool +} + +// fillExitNodeSubmenu uses a "✓ " prefix with plain Add, not AddCheckbox: Wails +// auto-toggles a checkbox on click before OnClick runs, so the deselect/select +// round-trip would briefly show two checked rows. Callers must hold exitNodesRebuildMu. +func (t *Tray) fillExitNodeSubmenu(nodes []exitNodeEntry) { + if t.exitNodeSubmenu == nil { + return + } + t.exitNodeSubmenu.Clear() + for _, n := range nodes { + id := n.ID + selected := n.Selected + label := id + if selected { + label = "✓ " + id + } + t.exitNodeSubmenu.Add(label).OnClick(func(*application.Context) { + t.toggleExitNode(id, selected) + }) + } +} + +// refreshExitNodes sources rows from Networks.List() rather than the Status stream +// because only ListNetworks carries the NetID + selected state Select/Deselect need. +// Serialized by exitNodesRebuildMu against overlapping Status pushes. +func (t *Tray) refreshExitNodes() { + t.exitNodesRebuildMu.Lock() + defer t.exitNodesRebuildMu.Unlock() + + t.statusMu.Lock() + connected := t.connected + t.statusMu.Unlock() + + var nodes []exitNodeEntry + if connected { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + list, err := t.svc.Networks.List(ctx) + cancel() + if err != nil { + log.Debugf("tray list networks: %v", err) + return + } + nodes = exitNodesFromNetworks(list) + } + + log.Infof("tray refreshExitNodes: %d exit node(s)", len(nodes)) + for _, n := range nodes { + log.Infof("tray exit node: id=%q selected=%v", n.ID, n.Selected) + } + + t.exitNodesMu.Lock() + changed := !equalExitNodes(nodes, t.exitNodes) + t.exitNodes = nodes + t.exitNodesMu.Unlock() + + // relayoutMenu repaints from the cached entries, so the old exitNodeItem needs no poking here. + if changed { + t.relayoutMenu() + } +} + +// toggleExitNode uses append=true: append=false would drop the whole current +// selection (default-on semantics), turning off every other routed network the +// user had enabled. Mutual exclusion of exit nodes is enforced daemon-side. +func (t *Tray) toggleExitNode(id string, selected bool) { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + params := services.SelectNetworksParams{NetworkIDs: []string{id}, Append: true, All: false} + var err error + if selected { + err = t.svc.Networks.Deselect(ctx, params) + } else { + err = t.svc.Networks.Select(ctx, params) + } + if err != nil { + log.Errorf("tray toggle exit node %q: %v", id, err) + t.notifyError(t.loc.T("notify.error.exitNode", "name", id)) + return + } + t.refreshExitNodes() + }() +} + +// exitNodesFromNetworks keeps only networks whose range is a default route: those are the exit-node candidates. +func exitNodesFromNetworks(networks []services.Network) []exitNodeEntry { + out := []exitNodeEntry{} + for _, n := range networks { + if !rangeIsDefaultRoute(n.Range) { + continue + } + out = append(out, exitNodeEntry{ID: n.ID, Selected: n.Selected}) + } + sort.Slice(out, func(i, j int) bool { + return strings.ToLower(out[i].ID) < strings.ToLower(out[j].ID) + }) + return out +} + +// rangeIsDefaultRoute reports whether r contains a default route. The daemon may +// comma-join a v4+v6 pair ("0.0.0.0/0, ::/0"), so each part is parsed rather than string-compared. +func rangeIsDefaultRoute(r string) bool { + for _, part := range strings.Split(r, ",") { + pref, err := netip.ParsePrefix(strings.TrimSpace(part)) + if err != nil { + continue + } + if pref.Bits() == 0 && pref.Addr().IsUnspecified() { + return true + } + } + return false +} + +func equalExitNodes(a, b []exitNodeEntry) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/client/ui/tray_features.go b/client/ui/tray_features.go new file mode 100644 index 000000000..2ff99e2be --- /dev/null +++ b/client/ui/tray_features.go @@ -0,0 +1,37 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + + log "github.com/sirupsen/logrus" +) + +// refreshRestrictions re-reads the operator-disabled UI flags and re-gates the +// menu. Must run on every config_changed event: the daemon re-applies its MDM +// policy on each engine spawn. +func (t *Tray) refreshRestrictions() { + r, err := t.svc.Settings.GetRestrictions(context.Background()) + if err != nil { + log.Debugf("get restrictions: %v", err) + return + } + t.featureMu.Lock() + changed := t.disableProfiles != r.Features.DisableProfiles || + t.disableNetworks != r.Features.DisableNetworks + t.disableProfiles = r.Features.DisableProfiles + t.disableNetworks = r.Features.DisableNetworks + t.featureMu.Unlock() + // relayoutMenu rebuilds the whole tree, so skip the no-op refresh (common case). + if changed { + t.relayoutMenu() + } +} + +// featuresDisabled returns the cached flags under featureMu. +func (t *Tray) featuresDisabled() (profiles, networks bool) { + t.featureMu.Lock() + defer t.featureMu.Unlock() + return t.disableProfiles, t.disableNetworks +} diff --git a/client/ui/tray_icon.go b/client/ui/tray_icon.go new file mode 100644 index 000000000..6c7b85d79 --- /dev/null +++ b/client/ui/tray_icon.go @@ -0,0 +1,136 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "runtime" + "strings" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/ui/services" +) + +func (t *Tray) applyIcon() { + t.statusMu.Lock() + connected := t.connected + statusLabel := t.lastStatus + t.statusMu.Unlock() + hasUpdate := false + if t.updater != nil { + hasUpdate = t.updater.hasUpdate() + } + + log.Infof("tray applyIcon: connected=%v hasUpdate=%v status=%q goos=%s", + connected, hasUpdate, statusLabel, runtime.GOOS) + + icon, dark := t.iconForState() + if runtime.GOOS == "darwin" { + t.tray.SetTemplateIcon(icon) + return + } + if runtime.GOOS == "linux" { + // Wails' Linux SNI backend ignores SetDarkModeIcon (last write wins + // over SetIcon), so iconForState already picked the silhouette by + // panel theme; push that single icon. + t.tray.SetIcon(icon) + return + } + t.tray.SetIcon(icon) + if dark != nil { + t.tray.SetDarkModeIcon(dark) + } +} + +// panelIsDark defaults to true when no detector is wired (panelDark nil — +// non-Linux or portal unavailable), matching the common dark Linux panel. +func (t *Tray) panelIsDark() bool { + if t.panelDark == nil { + return true + } + return t.panelDark() +} + +func (t *Tray) iconForState() (icon, dark []byte) { + t.statusMu.Lock() + connected := t.connected + statusLabel := t.lastStatus + t.statusMu.Unlock() + hasUpdate := false + if t.updater != nil { + hasUpdate = t.updater.hasUpdate() + } + + connecting := strings.EqualFold(statusLabel, services.StatusConnecting) + errored := strings.EqualFold(statusLabel, statusError) || + strings.EqualFold(statusLabel, services.StatusDaemonUnavailable) + needsLogin := strings.EqualFold(statusLabel, services.StatusNeedsLogin) || + strings.EqualFold(statusLabel, services.StatusSessionExpired) || + strings.EqualFold(statusLabel, services.StatusLoginFailed) + + if runtime.GOOS == "darwin" { + switch { + case connecting: + return iconConnectingMacOS, nil + case errored: + return iconErrorMacOS, nil + case needsLogin: + return iconNeedsLoginMacOS, nil + case connected && hasUpdate: + return iconUpdateConnectedMacOS, nil + case connected: + return iconConnectedMacOS, nil + case hasUpdate: + return iconUpdateDisconnectedMacOS, nil + default: + return iconDisconnectedMacOS, nil + } + } + + if runtime.GOOS == "linux" { + // Theme resolved here (black for light panel, white for dark) since + // the SNI backend can't switch per theme (see applyIcon); second + // return is unused on Linux. + dark := t.panelIsDark() + pick := func(black, white []byte) ([]byte, []byte) { + if dark { + return white, nil + } + return black, nil + } + switch { + case connecting: + return pick(iconConnectingMono, iconConnectingMonoDark) + case errored: + return pick(iconErrorMono, iconErrorMonoDark) + case needsLogin: + return pick(iconNeedsLoginMono, iconNeedsLoginMonoDark) + case connected && hasUpdate: + return pick(iconUpdateConnectedMono, iconUpdateConnectedMonoDark) + case connected: + return pick(iconConnectedMono, iconConnectedMonoDark) + case hasUpdate: + return pick(iconUpdateDisconnectedMono, iconUpdateDisconnectedMonoDark) + default: + return pick(iconDisconnectedMono, iconDisconnectedMonoDark) + } + } + + // Windows: colored PNGs. + switch { + case connecting: + return iconConnecting, iconConnectingDark + case errored: + return iconError, iconErrorDark + case needsLogin: + return iconNeedsLogin, iconNeedsLogin + case connected && hasUpdate: + return iconUpdateConnected, iconUpdateConnectedDark + case connected: + return iconConnected, iconConnectedDark + case hasUpdate: + return iconUpdateDisconnected, iconUpdateDisconnectedDark + default: + return iconDisconnected, iconDisconnected + } +} diff --git a/client/ui/tray_label_other.go b/client/ui/tray_label_other.go new file mode 100644 index 000000000..e104bc8e8 --- /dev/null +++ b/client/ui/tray_label_other.go @@ -0,0 +1,7 @@ +//go:build !windows && !android && !ios && !freebsd && !js + +package main + +// menuLabel is the identity on macOS/Linux, which render "&" literally; +// Windows escapes it separately (tray_label_windows.go) to dodge the Win32 mnemonic. +func menuLabel(s string) string { return s } diff --git a/client/ui/tray_label_windows.go b/client/ui/tray_label_windows.go new file mode 100644 index 000000000..8c27ae9ea --- /dev/null +++ b/client/ui/tray_label_windows.go @@ -0,0 +1,11 @@ +//go:build windows + +package main + +import "strings" + +// menuLabel doubles ampersands so Win32 draws a literal "&" instead of +// consuming it as the menu mnemonic prefix (Wails passes the label unescaped). +func menuLabel(s string) string { + return strings.ReplaceAll(s, "&", "&&") +} diff --git a/client/ui/tray_linux.go b/client/ui/tray_linux.go new file mode 100644 index 000000000..1d6e0a48b --- /dev/null +++ b/client/ui/tray_linux.go @@ -0,0 +1,71 @@ +//go:build linux && !386 + +package main + +import ( + "os" + "strings" +) + +// init runs before Wails' own init(), so the env vars are set in time. +func init() { + disableDMABUFRenderer() + disableCompositingMode() + disableWebKitSandboxIfNeeded() +} + +func disableDMABUFRenderer() { + if os.Getenv("WEBKIT_DISABLE_DMABUF_RENDERER") != "" { + return + } + + // WebKitGTK's DMA-BUF renderer leaves a blank-white window on many setups + // (VMs, containers, minimal WMs). Wails only disables it for NVIDIA+Wayland, + // but the issue is broader; software rendering is fine for a small UI. + _ = os.Setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1") +} + +func disableCompositingMode() { + if os.Getenv("WEBKIT_DISABLE_COMPOSITING_MODE") != "" { + return + } + // Disabling the DMA-BUF renderer alone isn't enough on some Intel setups: the + // GL compositor still hits Mesa's unimplemented DRM-format-modifier paths and + // SIGSEGVs inside g_application_run before the first frame. + _ = os.Setenv("WEBKIT_DISABLE_COMPOSITING_MODE", "1") +} + +// disableWebKitSandboxIfNeeded works around WebKitGTK crashing at startup when +// its bwrap sandbox can't create an unprivileged user namespace (containers/VMs, +// or Ubuntu 24.04+ AppArmor restrictions). +func disableWebKitSandboxIfNeeded() { + if _, set := os.LookupEnv("WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS"); set { + return + } + if unprivilegedUsernsAllowed() { + return + } + _ = os.Setenv("WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS", "1") +} + +// unprivilegedUsernsAllowed reports whether the kernel permits unprivileged +// user namespaces (needed by WebKit's bwrap sandbox). Absent knobs are treated +// as allowed, to avoid needlessly weakening the sandbox. +func unprivilegedUsernsAllowed() bool { + // Debian/Ubuntu legacy switch: 0 disables unprivileged user namespaces. + if v, err := os.ReadFile("/proc/sys/kernel/unprivileged_userns_clone"); err == nil { + if strings.TrimSpace(string(v)) == "0" { + return false + } + } + // Ubuntu 24.04+ AppArmor restriction: non-zero restricts/blocks them. + if v, err := os.ReadFile("/proc/sys/kernel/apparmor_restrict_unprivileged_userns"); err == nil { + if strings.TrimSpace(string(v)) != "0" { + return false + } + } + return true +} + +// Linux's tray provider needs the menu recreated rather than updated in place; +// tray.go's rebuildExitNodeMenu already does this, so no extra workaround here. diff --git a/client/ui/tray_notify.go b/client/ui/tray_notify.go new file mode 100644 index 000000000..5b2629419 --- /dev/null +++ b/client/ui/tray_notify.go @@ -0,0 +1,61 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + + log "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v3/pkg/services/notifications" + + "github.com/netbirdio/netbird/client/ui/services" +) + +const notifyIDDaemonOutdated = "netbird-daemon-outdated" + +// sendFn fits both NotificationService.SendNotification and SendNotificationWithActions. +type sendFn func(notifications.NotificationOptions) error + +// safeSendNotification sends a best-effort OS notification, swallowing errors and panics. +// +// The panic guard is load-bearing on Linux: when Wails' notifier fails to +// connect the session bus at startup (headless, unreachable +// DBUS_SESSION_BUS_ADDRESS) it stays registered with a nil *dbus.Conn, so the +// next send nil-derefs inside godbus. Because sends run on a Wails +// event-dispatch goroutine that panic is fatal process-wide; recover() turns +// it into a logged no-op. +func safeSendNotification(send sendFn, what string, opts notifications.NotificationOptions) (err error) { + if services.ShuttingDown() { + return nil + } + defer func() { + if r := recover(); r != nil { + log.Errorf("notify %s: recovered from panic (notification bus unavailable): %v", what, r) + err = nil + } + }() + if err := send(opts); err != nil { + log.Errorf("notify %s: %v", what, err) + return err + } + return nil +} + +// notifyIfDaemonOutdated probes the daemon once and fires an OS toast when it +// is reachable but too old for this UI. A probe error means the daemon isn't +// reachable (not outdated), so it is left to the normal connection flow. +func notifyIfDaemonOutdated(compat *services.Compat, notifier *Notifier, loc *Localizer) { + ready, err := compat.DaemonReady(context.Background()) + if err != nil { + log.Debugf("daemon compatibility probe: %v", err) + return + } + if ready { + return + } + _ = safeSendNotification(notifier.SendNotification, "daemon-outdated", notifications.NotificationOptions{ + ID: notifyIDDaemonOutdated, + Title: loc.T("notify.daemonOutdated.title"), + Body: loc.T("notify.daemonOutdated.body"), + }) +} diff --git a/client/ui/tray_profiles.go b/client/ui/tray_profiles.go new file mode 100644 index 000000000..5251c138e --- /dev/null +++ b/client/ui/tray_profiles.go @@ -0,0 +1,194 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + "fmt" + "sort" + + log "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v3/pkg/application" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/ui/services" +) + +// formatProfileLabel returns the display label for a profile. Profiles can +// share the same Name, so when more than one profile in profiles carries this +// Name, a short form of the ID is appended to disambiguate the entries. +func formatProfileLabel(profile services.Profile, profiles []services.Profile) string { + count := 0 + for _, p := range profiles { + if p.Name == profile.Name { + count++ + } + } + if count <= 1 { + return profile.Name + } + return fmt.Sprintf("%s (%s)", profile.Name, profilemanager.ID(profile.ID).ShortID()) +} + +// loadConfig caches the active-profile identity and the notifications gate. +// Runs in a startup goroutine so a slow daemon does not block menu construction. +func (t *Tray) loadConfig() { + ctx := context.Background() + + active, err := t.svc.Profiles.GetActive(ctx) + if err != nil { + log.Debugf("get active profile: %v", err) + return + } + // Address the active profile by ID (the daemon resolves it as a handle), + // since display names can collide. ConfigParams no longer matches + // ActiveProfile's shape for a struct conversion now that it carries an ID. + cfg, err := t.svc.Settings.GetConfig(ctx, services.ConfigParams{ + ProfileName: active.ID, + Username: active.Username, + }) + if err != nil { + log.Debugf("get config: %v", err) + return + } + + t.profileMu.Lock() + t.activeProfile = active.ProfileName + t.activeUsername = active.Username + t.notificationsEnabled = !cfg.DisableNotifications + t.profileMu.Unlock() +} + +// loadProfiles fetches the profile list and relayouts the menu. Also called +// from applyStatus to catch flips from another channel (CLI, autoconnect), +// since the daemon emits no active-profile event. Full relayout (not +// Clear()+Add()) is required for KDE/Plasma — see relayoutMenu's doc comment. +func (t *Tray) loadProfiles() { + t.profileLoadMu.Lock() + defer t.profileLoadMu.Unlock() + ctx := context.Background() + + username, err := t.svc.Profiles.Username() + if err != nil { + log.Debugf("get current user: %v", err) + return + } + profiles, err := t.svc.Profiles.List(ctx, username) + if err != nil { + log.Debugf("list profiles: %v", err) + return + } + + t.profilesMu.Lock() + t.profiles = profiles + t.profilesUser = username + t.profilesMu.Unlock() + + t.relayoutMenu() +} + +// fillProfileSubmenu paints cached profile rows into the freshly built submenu. +// Pure UI: never fetches, never calls SetMenu (relayoutMenu owns the SetMenu). +func (t *Tray) fillProfileSubmenu() { + if t.profileSubmenu == nil { + return + } + t.profilesMu.Lock() + profiles := append([]services.Profile(nil), t.profiles...) + username := t.profilesUser + t.profilesMu.Unlock() + + sort.Slice(profiles, func(i, j int) bool { + if profiles[i].Name != profiles[j].Name { + return profiles[i].Name < profiles[j].Name + } + return profiles[i].ID < profiles[j].ID + }) + + // Wails' systray does not reliably propagate a disabled parent to its + // children on every platform, so disable each row explicitly. + disableProfiles, _ := t.featuresDisabled() + + t.profileSubmenu.Clear() + var activeName, activeEmail string + for _, p := range profiles { + id := p.ID + // Display names can collide, so disambiguate with a short ID suffix. + display := formatProfileLabel(p, profiles) + active := p.IsActive + // Add, not AddCheckbox: Wails auto-toggles a checkbox on click before + // OnClick fires, so both old and new would briefly show checked during + // the switch. A plain item with a "✓ " prefix avoids the race. + label := display + if active { + label = "✓ " + display + } + item := t.profileSubmenu.Add(label) + item.OnClick(func(*application.Context) { + log.Infof("tray profile click: profile=%q id=%q wasActive=%v", display, id, active) + if active { + return + } + t.switchProfile(id, display) + }) + item.SetEnabled(!disableProfiles) + if active { + activeName = display + activeEmail = p.Email + } + } + t.profileSubmenu.AddSeparator() + manageProfiles := t.profileSubmenu.Add(t.loc.T("tray.menu.manageProfiles")) + manageProfiles.OnClick(func(*application.Context) { + t.svc.WindowManager.OpenSettings("profiles") + }) + manageProfiles.SetEnabled(!disableProfiles) + log.Infof("tray fillProfileSubmenu: %d profile(s) for user %q, active=%q", len(profiles), username, activeName) + if t.profileSubmenuItem != nil && activeName != "" { + t.profileSubmenuItem.SetLabel(activeName) + } + if t.profileEmailItem != nil { + if activeEmail != "" { + t.profileEmailItem.SetLabel(fmt.Sprintf("(%s)", activeEmail)) + t.profileEmailItem.SetHidden(false) + } else { + t.profileEmailItem.SetHidden(true) + } + } +} + +// switchProfile cancels any in-flight switch before starting a new one, so +// rapid clicks converge to the last selected profile. Optimistic paint and +// event suppression live in ProfileSwitcher, shared with the React Status page. +// switchProfile sends handle (the profile's ID) to the daemon, which resolves +// it precisely even when display names collide. display is used only for the +// failure notification. +func (t *Tray) switchProfile(handle, display string) { + t.profileMu.Lock() + if t.switchCancel != nil { + t.switchCancel() + } + ctx, cancel := context.WithCancel(context.Background()) + t.switchCancel = cancel + t.profileMu.Unlock() + + go func() { + username, err := t.svc.Profiles.Username() + if err != nil { + log.Errorf("tray switchProfile: get current user: %v", err) + return + } + if err := t.svc.ProfileSwitcher.SwitchActive(ctx, services.ProfileRef{ + ProfileName: handle, + Username: username, + }); err != nil { + if ctx.Err() != nil { + return + } + log.Errorf("tray switchProfile: %v", err) + t.notifyError(t.loc.T("notify.error.switchProfile", "profile", display)) + return + } + t.loadProfiles() + }() +} diff --git a/client/ui/tray_session.go b/client/ui/tray_session.go new file mode 100644 index 000000000..91c38be08 --- /dev/null +++ b/client/ui/tray_session.go @@ -0,0 +1,325 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + "strconv" + "time" + + log "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v3/pkg/services/notifications" + + nbstatus "github.com/netbirdio/netbird/client/status" + "github.com/netbirdio/netbird/client/ui/authsession" + "github.com/netbirdio/netbird/client/ui/services" +) + +const ( + notifyIDSessionExpired = "netbird-session-expired" + notifyIDSessionWarning = "netbird-session-warning" + + notifyCategorySessionWarning = "netbird-session-warning" + notifyActionExtendNow = "extend-now" + notifyActionDismiss = "dismiss" + + // finalWarningCountdownSeconds must stay in sync by hand with sessionwatch.FinalWarningLead. + finalWarningCountdownSeconds = 120 +) + +// handleSessionExpired notifies and brings the window forward so the user can reconnect. +func (t *Tray) handleSessionExpired() { + t.notify(t.loc.T("notify.sessionExpired.title"), t.loc.T("notify.sessionExpired.body"), notifyIDSessionExpired) + t.showMain() +} + +// applySessionExpiry refreshes the cached SSO deadline and reports whether it changed. +// Cache-only; the caller relayouts when this returns true. +func (t *Tray) applySessionExpiry(deadline *time.Time, connected bool) bool { + var d time.Time + if connected && deadline != nil { + d = *deadline + } + + t.sessionMu.Lock() + changed := !t.sessionExpiresAt.Equal(d) + t.sessionExpiresAt = d + t.sessionMu.Unlock() + + if changed { + switch { + case deadline == nil: + log.Infof("tray applySessionExpiry: deadline= connected=%v → row hidden", connected) + case deadline.IsZero(): + log.Infof("tray applySessionExpiry: deadline= connected=%v → row hidden", connected) + default: + log.Infof("tray applySessionExpiry: deadline=%s (in %s) connected=%v", + deadline.Format(time.RFC3339), time.Until(*deadline), connected) + } + } + return changed +} + +// runSessionExpiryTicker recomputes the "Expires in …" row label until process exit. +// The interval scales with the remaining time: coarse when the deadline is far off, +// down to 10s in the final two minutes so the label doesn't lag the ceiling-rounded +// countdown near expiry. The cached deadline is re-read every iteration, so an extend +// or reconnect that moves it is picked up on the next tick. +func (t *Tray) runSessionExpiryTicker() { + tm := time.NewTimer(sessionRefreshInterval(t.sessionRemaining())) + defer tm.Stop() + for range tm.C { + t.refreshSessionExpiresLabel() + tm.Reset(sessionRefreshInterval(t.sessionRemaining())) + } +} + +// sessionRemaining returns the time left on the cached SSO deadline, or 0 when unknown. +func (t *Tray) sessionRemaining() time.Duration { + t.sessionMu.Lock() + deadline := t.sessionExpiresAt + t.sessionMu.Unlock() + if deadline.IsZero() { + return 0 + } + return time.Until(deadline) +} + +// sessionRefreshInterval picks how long to wait before the next label recompute. +func sessionRefreshInterval(remaining time.Duration) time.Duration { + switch { + case remaining <= 0: + return 30 * time.Second + case remaining <= 2*time.Minute: + return 10 * time.Second + case remaining <= time.Hour: + return 30 * time.Second + default: + return time.Minute + } +} + +// refreshSessionExpiresLabel updates only the countdown label, no relayout, to avoid disturbing an open menu. +// The item is snapshotted under menuMu since buildMenu reassigns it on every relayout. +func (t *Tray) refreshSessionExpiresLabel() { + t.menuMu.Lock() + item := t.sessionExpiresItem + t.menuMu.Unlock() + if item == nil { + return + } + t.sessionMu.Lock() + deadline := t.sessionExpiresAt + t.sessionMu.Unlock() + if deadline.IsZero() { + return + } + item.SetLabel(t.sessionRowLabel(deadline)) +} + +func (t *Tray) sessionRowLabel(deadline time.Time) string { + remaining := time.Until(deadline) + if remaining <= 0 { + return t.loc.T("tray.status.sessionExpired") + } + return t.loc.T("tray.session.expiresIn", "remaining", t.formatSessionRemaining(remaining)) +} + +// formatSessionRemaining renders d as a localised long-form string picking the largest non-zero unit. +// Each unit is rounded up so the label never claims less time than actually remains, matching the +// upper-bound sense of the sub-minute "less than a minute" fragment. +// Singular/plural keys are split per language for proper translation. +func (t *Tray) formatSessionRemaining(d time.Duration) string { + switch { + case d < time.Minute: + return t.loc.T("tray.session.unit.lessThanMinute") + case d <= 59*time.Minute: + m := ceilDiv(d, time.Minute) + if m == 1 { + return t.loc.T("tray.session.unit.minute") + } + return t.loc.T("tray.session.unit.minutes", "count", strconv.Itoa(m)) + case d <= 23*time.Hour: + h := ceilDiv(d, time.Hour) + if h == 1 { + return t.loc.T("tray.session.unit.hour") + } + return t.loc.T("tray.session.unit.hours", "count", strconv.Itoa(h)) + default: + days := ceilDiv(d, 24*time.Hour) + if days == 1 { + return t.loc.T("tray.session.unit.day") + } + return t.loc.T("tray.session.unit.days", "count", strconv.Itoa(days)) + } +} + +// ceilDiv divides d by unit rounding up, assuming d > 0. +func ceilDiv(d, unit time.Duration) int { + return int((d + unit - time.Nanosecond) / unit) +} + +// registerSessionWarningCategory wires the OS notification category and response handler for the expiry warning. +// Errors are swallowed since the worst case is a plain notification without buttons. +func (t *Tray) registerSessionWarningCategory() { + if t.svc.Notifier == nil { + return + } + if err := t.svc.Notifier.RegisterNotificationCategory(notifications.NotificationCategory{ + ID: notifyCategorySessionWarning, + Actions: []notifications.NotificationAction{ + {ID: notifyActionExtendNow, Title: t.loc.T("notify.sessionWarning.extend")}, + {ID: notifyActionDismiss, Title: t.loc.T("notify.sessionWarning.dismiss")}, + }, + }); err != nil { + log.Debugf("register session-warning notification category: %v", err) + } + t.svc.Notifier.OnNotificationResponse(func(result notifications.NotificationResult) { + if result.Error != nil { + log.Debugf("notification response error: %v", result.Error) + return + } + if result.Response.CategoryID != notifyCategorySessionWarning { + return + } + switch result.Response.ActionIdentifier { + case notifyActionExtendNow, notifications.DefaultActionIdentifier: + // DefaultActionIdentifier is the body-click on platforms with no separate buttons; treat as Extend. + go t.runExtendSession() + case notifyActionDismiss: + go t.dismissSessionWarning() + } + }) +} + +// buildSessionWarningBody composes the localised notification body from the daemon's metadata. +// The daemon has no locale, so it ships an RFC3339 deadline the tray turns into a user-language sentence. +// Falls back to a generic string when metadata is missing or unparsable. +func (t *Tray) buildSessionWarningBody(meta map[string]string) string { + if meta == nil { + return t.loc.T("notify.sessionWarning.bodyGeneric") + } + raw := meta[authsession.MetaExpiresAt] + if raw == "" { + return t.loc.T("notify.sessionWarning.bodyGeneric") + } + deadline, err := authsession.ParseExpiresAt(raw) + if err != nil { + return t.loc.T("notify.sessionWarning.bodyGeneric") + } + remaining := nbstatus.FormatRemainingDuration(time.Until(deadline)) + return t.loc.T("notify.sessionWarning.body", "remaining", remaining) +} + +// notifySessionWarning sends the interactive expiry notification, falling back to plain notify when the +// with-actions variant is unavailable (older platform impls, or a bare Notifier in tests). +func (t *Tray) notifySessionWarning(title, body string) { + if t.svc.Notifier == nil { + return + } + err := safeSendNotification(t.svc.Notifier.SendNotificationWithActions, "session-warning with actions", notifications.NotificationOptions{ + ID: notifyIDSessionWarning, + Title: title, + Body: body, + CategoryID: notifyCategorySessionWarning, + }) + if err != nil { + // A recovered panic returns nil err, so a dead bus correctly skips this fallback (it would panic too). + t.notify(title, body, notifyIDSessionWarning) + } +} + +// runExtendSession drives the daemon's RequestExtend + WaitExtend pair, opening the browser via Connection.OpenURL. +// Errors surface as notifyError rather than foreground UI, since the warning may fire while the window is closed. +func (t *Tray) runExtendSession() { + if t.svc.Session == nil || t.svc.Connection == nil { + log.Debugf("session-warning: extend requested but services not wired") + return + } + ctx := context.Background() + + start, err := t.svc.Session.RequestExtend(ctx, services.ExtendStartParams{}) + if err != nil { + log.Warnf("session-warning: RequestExtend failed: %v", err) + t.notifyError(t.loc.T("notify.sessionWarning.failed")) + return + } + + uri := start.VerificationURIComplete + if uri == "" { + uri = start.VerificationURI + } + if uri != "" { + if err := t.svc.Connection.OpenURL(uri); err != nil { + log.Debugf("session-warning: opening verification URL: %v", err) + } + } + + result, err := t.svc.Session.WaitExtend(ctx, services.ExtendWaitParams{ + DeviceCode: start.DeviceCode, + UserCode: start.UserCode, + }) + if err != nil { + log.Warnf("session-warning: WaitExtend failed: %v", err) + t.notifyError(t.loc.T("notify.sessionWarning.failed")) + return + } + if result.Preempted { + // Another UI surface owns the flow; stay silent so the user only sees the surviving flow's outcome. + log.Debugf("session-warning: WaitExtend preempted by a newer flow") + return + } + t.notify(t.loc.T("notify.sessionWarning.successTitle"), t.loc.T("notify.sessionWarning.successBody"), notifyIDSessionWarning) +} + +// dismissSessionWarning tells the daemon to silence the fallback dialog for the current deadline. +// Best-effort: a failure only means the dialog will still appear. +func (t *Tray) dismissSessionWarning() { + if t.svc.Session == nil { + return + } + if err := t.svc.Session.DismissWarning(context.Background()); err != nil { + log.Debugf("session-warning: DismissWarning failed: %v", err) + } +} + +// openSessionExpiration fires the fallback dialog when the earlier warning notification wasn't dismissed. +// deadline is the absolute expiry from the warning event's metadata; when zero (older daemon, +// malformed metadata) the cached status-snapshot deadline fills in. Idempotent on the +// WindowManager side. +func (t *Tray) openSessionExpiration(deadline time.Time) { + if t.svc.WindowManager == nil { + return + } + if deadline.IsZero() { + t.sessionMu.Lock() + deadline = t.sessionExpiresAt + t.sessionMu.Unlock() + } + var deadlineMs int64 + if !deadline.IsZero() { + deadlineMs = deadline.UnixMilli() + } + t.svc.WindowManager.OpenSessionExpiration(finalWarningCountdownSeconds, deadlineMs) +} + +// openSessionExtendFlow opens the SessionExpiration window seeded with the cached deadline's remaining time, +// for the "Expires in …" tray row. Once the deadline has elapsed the row reads "Session expired" and the +// click routes to the login flow instead. No-op when the deadline is unknown. +func (t *Tray) openSessionExtendFlow() { + t.sessionMu.Lock() + deadline := t.sessionExpiresAt + t.sessionMu.Unlock() + if deadline.IsZero() { + return + } + seconds := int(time.Until(deadline).Seconds()) + if seconds <= 0 { + t.showMainAndEmit(services.EventTriggerLogin) + return + } + if t.svc.WindowManager == nil { + return + } + t.svc.WindowManager.OpenSessionExpiration(seconds, deadline.UnixMilli()) +} diff --git a/client/ui/tray_status.go b/client/ui/tray_status.go new file mode 100644 index 000000000..793f1785a --- /dev/null +++ b/client/ui/tray_status.go @@ -0,0 +1,126 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "strings" + + "github.com/wailsapp/wails/v3/pkg/application" + + "github.com/netbirdio/netbird/client/ui/services" +) + +func (t *Tray) onStatusEvent(ev *application.CustomEvent) { + st, ok := ev.Data.(services.Status) + if !ok { + return + } + t.applyStatus(st) +} + +// applyStatus repaints the tray from a daemon snapshot. Icon refresh is skipped +// when no icon-relevant input changed: the daemon emits rapid SubscribeStatus +// bursts during health probes that would otherwise spam Shell_NotifyIcon. +func (t *Tray) applyStatus(st services.Status) { + t.statusMu.Lock() + connected := strings.EqualFold(st.Status, services.StatusConnected) + iconChanged := connected != t.connected || st.Status != t.lastStatus + // The daemon re-emits SessionExpired on every snapshot while expired; act + // only on the transition into it so the notification fires once. + sessionExpiredEnter := strings.EqualFold(st.Status, services.StatusSessionExpired) && + !strings.EqualFold(t.lastStatus, services.StatusSessionExpired) + + triggerLogin := t.consumePendingConnectLogin(st.Status) + + daemonVersionChanged := st.DaemonVersion != "" && st.DaemonVersion != t.lastDaemonVersion + t.connected = connected + t.lastStatus = st.Status + if daemonVersionChanged { + t.lastDaemonVersion = st.DaemonVersion + } + + revisionChanged := st.NetworksRevision != t.lastNetworksRevision + t.lastNetworksRevision = st.NetworksRevision + t.statusMu.Unlock() + + if triggerLogin { + t.app.Event.Emit(services.EventTriggerLogin) + } + + // Cache-only; the row is painted by the relayout below. + sessionChanged := t.applySessionExpiry(st.SessionExpiresAt, connected) + + if iconChanged { + t.applyIcon() + } + // All repainting goes through relayoutMenu (menuMu-serialised): applyStatus + // runs concurrently with itself and with relayouts, so in-place item + // mutation would race the buildMenu pointer swap. + if iconChanged || daemonVersionChanged || sessionChanged { + t.relayoutMenu() + } + // The revision is the only reliable signal: candidate routes never appear + // in the peer-status snapshot, so a removed exit node would go unnoticed. + if iconChanged || revisionChanged { + go t.refreshExitNodes() + } + // The daemon emits no active-profile event, so profile flips driven + // elsewhere (CLI, autoconnect) surface via status transitions. + if iconChanged { + go t.loadProfiles() + } + if sessionExpiredEnter { + t.handleSessionExpired() + } +} + +// consumePendingConnectLogin acts on the SSO auto-handoff flag armed by +// handleConnect. Returns true on NeedsLogin so the browser-login flow starts +// without a second Connect click; clears the flag on any terminal state so a +// stale flag can't fire on a later daemon flip. Must hold statusMu. +func (t *Tray) consumePendingConnectLogin(status string) bool { + if !t.pendingConnectLogin { + return false + } + switch { + case strings.EqualFold(status, services.StatusNeedsLogin): + t.pendingConnectLogin = false + return true + case strings.EqualFold(status, services.StatusConnected), + strings.EqualFold(status, services.StatusIdle), + strings.EqualFold(status, services.StatusLoginFailed), + strings.EqualFold(status, services.StatusSessionExpired), + strings.EqualFold(status, services.StatusDaemonUnavailable): + t.pendingConnectLogin = false + } + return false +} + +// applyStatusIndicator sets the status dot bitmap. Call only from relayoutMenu +// (menuMu held): on macOS the bitmap repaints via the relayout's trailing +// SetMenu, not here — the tree is half-built. +func (t *Tray) applyStatusIndicator(status string) { + if t.statusItem == nil { + return + } + t.statusItem.SetBitmap(statusIndicatorBitmap(status)) +} + +func statusIndicatorBitmap(status string) []byte { + switch { + case strings.EqualFold(status, services.StatusConnected): + return iconMenuDotConnected + case strings.EqualFold(status, services.StatusConnecting): + return iconMenuDotConnecting + case strings.EqualFold(status, services.StatusNeedsLogin), + strings.EqualFold(status, services.StatusSessionExpired): + return iconMenuDotConnecting + case strings.EqualFold(status, services.StatusLoginFailed), + strings.EqualFold(status, statusError): + return iconMenuDotError + case strings.EqualFold(status, services.StatusDaemonUnavailable): + return iconMenuDotOffline + default: + return iconMenuDotIdle + } +} diff --git a/client/ui/tray_status_enabled_linux.go b/client/ui/tray_status_enabled_linux.go new file mode 100644 index 000000000..ab7f869b8 --- /dev/null +++ b/client/ui/tray_status_enabled_linux.go @@ -0,0 +1,8 @@ +//go:build linux + +package main + +// statusRowEnabled keeps the top status row enabled on Linux: a disabled row +// paints greyed-out, washing out the status dot. The row has no OnClick, so +// enabling only affects drawing. +func statusRowEnabled() bool { return true } diff --git a/client/ui/tray_status_enabled_other.go b/client/ui/tray_status_enabled_other.go new file mode 100644 index 000000000..606a7d702 --- /dev/null +++ b/client/ui/tray_status_enabled_other.go @@ -0,0 +1,7 @@ +//go:build !windows && !linux && !android && !ios && !freebsd && !js + +package main + +// statusRowEnabled is false on macOS: disabling the row dims the label (signalling +// non-clickable) while keeping the bitmap opaque, so the coloured dot stays visible. +func statusRowEnabled() bool { return false } diff --git a/client/ui/tray_status_enabled_windows.go b/client/ui/tray_status_enabled_windows.go new file mode 100644 index 000000000..06750b7c0 --- /dev/null +++ b/client/ui/tray_status_enabled_windows.go @@ -0,0 +1,7 @@ +//go:build windows + +package main + +// statusRowEnabled is always true on Windows: the Win32 disabled-state mask +// desaturates the row's HBITMAP, which would grey out the coloured status dot. +func statusRowEnabled() bool { return true } diff --git a/client/ui/tray_theme_linux.go b/client/ui/tray_theme_linux.go new file mode 100644 index 000000000..a3249e57a --- /dev/null +++ b/client/ui/tray_theme_linux.go @@ -0,0 +1,134 @@ +//go:build linux && !(linux && 386) + +package main + +// Wails v3's Linux SNI backend ignores SetDarkModeIcon (it just calls setIcon, +// last write wins) and SNI carries no panel dark/light hint, so we detect the +// desktop colour scheme ourselves and pick the silhouette in iconForState. +// The live watcher is in tray_theme_watcher_linux.go. + +import ( + "bufio" + "os" + "path/filepath" + "strconv" + "strings" + + log "github.com/sirupsen/logrus" +) + +// startTrayTheme seeds t.panelDark and repaints on colour-scheme flips. Must +// run before the first applyIcon so the initial paint uses the right silhouette. +func (t *Tray) startTrayTheme() { + w := startThemeWatcher(func() { t.applyIcon() }) + t.panelDark = w.IsDark +} + +// isKDE reports whether the current desktop is KDE Plasma. XDG_CURRENT_DESKTOP +// is a colon-separated list (e.g. "ubuntu:KDE"), so match per token. +func isKDE() bool { + for _, d := range strings.Split(os.Getenv("XDG_CURRENT_DESKTOP"), ":") { + if strings.EqualFold(strings.TrimSpace(d), "KDE") { + return true + } + } + return false +} + +// kdeglobalsPath returns the user kdeglobals path. We read only this file, not +// the full XDG_CONFIG_DIRS cascade: Plasma writes the active scheme here, and a +// missing Complementary group falls back to the portal. +func kdeglobalsPath() string { + if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" { + return filepath.Join(dir, "kdeglobals") + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".config", "kdeglobals") +} + +// kdePanelIsDark reports whether the KDE Plasma panel is dark by the luma of +// its "Complementary" background (the colour Plasma paints the tray with). ok +// is false when this isn't KDE or the colour can't be read, so the caller falls +// through to the portal/GTK path. +func kdePanelIsDark() (dark, ok bool) { + if !isKDE() { + return false, false + } + path := kdeglobalsPath() + if path == "" { + return false, false + } + rgb, ok := readKdeComplementaryBackground(path) + if !ok { + return false, false + } + return isDarkRGB(rgb[0], rgb[1], rgb[2]), true +} + +// readKdeComplementaryBackground parses kdeglobals for +// [Colors:Complementary] BackgroundNormal and returns its R,G,B (0-255). +func readKdeComplementaryBackground(path string) (rgb [3]uint8, ok bool) { + f, err := os.Open(path) + if err != nil { + log.Debugf("tray theme: kdeglobals open failed, using portal: %v", err) + return rgb, false + } + defer func() { _ = f.Close() }() + + const group = "[Colors:Complementary]" + inGroup := false + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "[") { + inGroup = line == group + continue + } + if !inGroup { + continue + } + key, val, found := strings.Cut(line, "=") + if !found || strings.TrimSpace(key) != "BackgroundNormal" { + continue + } + return parseRGB(strings.TrimSpace(val)) + } + return rgb, false +} + +// parseRGB parses KDE's "r,g,b" colour triple into bytes. +func parseRGB(s string) (rgb [3]uint8, ok bool) { + parts := strings.Split(s, ",") + if len(parts) != 3 { + return rgb, false + } + for i, p := range parts { + n, err := strconv.Atoi(strings.TrimSpace(p)) + if err != nil || n < 0 || n > 255 { + return rgb, false + } + rgb[i] = uint8(n) + } + return rgb, true +} + +// isDarkRGB reports whether a colour is dark via Rec. 601 luma, split at the +// 128 midpoint. +func isDarkRGB(r, g, b uint8) bool { + luma := (299*int(r) + 587*int(g) + 114*int(b)) / 1000 + return luma < 128 +} + +// gtkThemeIsDark inspects the GTK_THEME env var. Empty (no override) is treated +// as dark to match the default-dark fallback used elsewhere. +func gtkThemeIsDark() bool { + theme := os.Getenv("GTK_THEME") + if theme == "" { + return true + } + // GTK_THEME is "Name[:variant]"; the dark variant is ":dark". + return strings.Contains(strings.ToLower(theme), ":dark") +} diff --git a/client/ui/tray_theme_linux_test.go b/client/ui/tray_theme_linux_test.go new file mode 100644 index 000000000..f14f08d7f --- /dev/null +++ b/client/ui/tray_theme_linux_test.go @@ -0,0 +1,86 @@ +//go:build linux && !(linux && 386) + +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReadKdeComplementaryBackground(t *testing.T) { + // Mirrors the KDE test VM's kdeglobals: Window light, Complementary dark. + // The tray sits on the panel, which Plasma paints from Complementary, so + // the panel is dark even though the global color-scheme is Light. + content := `[Colors:Window] +BackgroundNormal=239,240,241 + +[Colors:Complementary] +BackgroundAlternate=27,30,32 +BackgroundNormal=42,46,50 + +[General] +ColorSchemeHash=0be804dba87e3512aeb4be3d78ed981f59f0f2f4 +` + path := filepath.Join(t.TempDir(), "kdeglobals") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + rgb, ok := readKdeComplementaryBackground(path) + if !ok { + t.Fatal("expected to find Complementary BackgroundNormal") + } + if rgb != [3]uint8{42, 46, 50} { + t.Fatalf("rgb = %v, want [42 46 50]", rgb) + } + if !isDarkRGB(rgb[0], rgb[1], rgb[2]) { + t.Fatal("panel colour 42,46,50 should be dark") + } + // The Window background (what color-scheme reflects) is light — the bug + // this fix addresses is picking the icon from that instead of the panel. + if isDarkRGB(239, 240, 241) { + t.Fatal("window colour 239,240,241 should be light") + } +} + +func TestReadKdeComplementaryBackgroundMissingGroup(t *testing.T) { + path := filepath.Join(t.TempDir(), "kdeglobals") + if err := os.WriteFile(path, []byte("[Colors:Window]\nBackgroundNormal=1,2,3\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, ok := readKdeComplementaryBackground(path); ok { + t.Fatal("expected not-ok when Complementary group is absent") + } +} + +func TestParseRGB(t *testing.T) { + if _, ok := parseRGB("1,2"); ok { + t.Fatal("two components should fail") + } + if _, ok := parseRGB("300,0,0"); ok { + t.Fatal("out-of-range should fail") + } + if _, ok := parseRGB("a,b,c"); ok { + t.Fatal("non-numeric should fail") + } + rgb, ok := parseRGB(" 10 , 20 , 30 ") + if !ok || rgb != [3]uint8{10, 20, 30} { + t.Fatalf("parseRGB = %v ok=%v, want [10 20 30] true", rgb, ok) + } +} + +func TestIsDarkRGB(t *testing.T) { + if !isDarkRGB(0, 0, 0) { + t.Fatal("black is dark") + } + if isDarkRGB(255, 255, 255) { + t.Fatal("white is light") + } + if !isDarkRGB(42, 46, 50) { + t.Fatal("Breeze panel grey is dark") + } + if isDarkRGB(239, 240, 241) { + t.Fatal("Breeze window grey is light") + } +} diff --git a/client/ui/tray_theme_other.go b/client/ui/tray_theme_other.go new file mode 100644 index 000000000..3f85e1603 --- /dev/null +++ b/client/ui/tray_theme_other.go @@ -0,0 +1,7 @@ +//go:build (!linux || (linux && 386)) && !android && !ios && !freebsd && !js + +package main + +func (t *Tray) startTrayTheme() { + // No-op off Linux: leaves panelDark nil so panelIsDark uses its default. +} diff --git a/client/ui/tray_theme_watcher_linux.go b/client/ui/tray_theme_watcher_linux.go new file mode 100644 index 000000000..b9bafe30b --- /dev/null +++ b/client/ui/tray_theme_watcher_linux.go @@ -0,0 +1,246 @@ +//go:build linux && !(linux && 386) + +package main + +// Sources: the freedesktop Settings portal's SettingChanged signal, and on KDE +// the kdeglobals file (the portal's color-scheme doesn't track the panel's +// Complementary colour — see readDarkMode). The dark/light decision lives in +// tray_theme_linux.go; this file owns the session-bus connection and subscriptions. + +import ( + "path/filepath" + "sync" + + "github.com/fsnotify/fsnotify" + "github.com/godbus/dbus/v5" + log "github.com/sirupsen/logrus" +) + +const ( + portalBusName = "org.freedesktop.portal.Desktop" + portalObjectPath = "/org/freedesktop/portal/desktop" + portalSettings = "org.freedesktop.portal.Settings" + + appearanceNamespace = "org.freedesktop.appearance" + colorSchemeKey = "color-scheme" + + colorSchemeNoPreference = 0 + colorSchemePreferDark = 1 + colorSchemePreferLight = 2 +) + +// themeWatcher owns a private session-bus connection so its signal subscription +// is isolated from the SNI watcher's. +type themeWatcher struct { + conn *dbus.Conn + onChange func() + + mu sync.Mutex + darkMode bool +} + +// startThemeWatcher returns nil if the session bus is unavailable; callers treat +// a nil watcher as "no preference", keeping the default-dark icon. +func startThemeWatcher(onChange func()) *themeWatcher { + conn, err := dbus.SessionBusPrivate() + if err != nil { + log.Debugf("tray theme: session bus unavailable, defaulting to dark icons: %v", err) + return nil + } + if err := conn.Auth(nil); err != nil { + _ = conn.Close() + log.Debugf("tray theme: dbus auth failed: %v", err) + return nil + } + if err := conn.Hello(); err != nil { + _ = conn.Close() + log.Debugf("tray theme: dbus hello failed: %v", err) + return nil + } + + w := &themeWatcher{conn: conn, onChange: onChange} + w.darkMode = w.readDarkMode() + + if err := w.subscribe(); err != nil { + log.Debugf("tray theme: SettingChanged subscription failed, theme is static: %v", err) + // Keep the connection: the seeded darkMode value is still useful. + } + + // The portal's signal doesn't track KDE's panel Complementary colour. + if isKDE() { + w.watchKdeglobals() + } + + log.Infof("tray theme: panel dark mode = %v", w.IsDark()) + return w +} + +// IsDark reports true for a nil watcher, so the icon defaults to the white +// silhouette suiting the common dark Linux panel. +func (w *themeWatcher) IsDark() bool { + if w == nil { + return true + } + w.mu.Lock() + defer w.mu.Unlock() + return w.darkMode +} + +// readDarkMode resolves whether the panel the tray icon sits on is dark. +// +// On KDE the freedesktop color-scheme is the application preference, not the +// panel's: Plasma paints its panel from the Breeze "Complementary" group, which +// stays dark even under a Light global scheme, so we read the panel background +// from kdeglobals first and decide by its luma. Off KDE the color-scheme portal +// is the source; on "no preference" (0) or when unavailable we fall back to +// GTK_THEME (":dark" suffix ⇒ dark), then default to dark. +func (w *themeWatcher) readDarkMode() bool { + if dark, ok := kdePanelIsDark(); ok { + return dark + } + switch w.readColorScheme() { + case colorSchemePreferDark: + return true + case colorSchemePreferLight: + return false + default: + return gtkThemeIsDark() + } +} + +// readColorScheme returns the raw freedesktop color-scheme value, or +// colorSchemeNoPreference when the portal can't be reached. +func (w *themeWatcher) readColorScheme() uint32 { + obj := w.conn.Object(portalBusName, portalObjectPath) + call := obj.Call(portalSettings+".Read", 0, appearanceNamespace, colorSchemeKey) + if call.Err != nil { + log.Debugf("tray theme: portal Read failed, falling back to GTK_THEME: %v", call.Err) + return colorSchemeNoPreference + } + + var v dbus.Variant + if err := call.Store(&v); err != nil { + log.Debugf("tray theme: portal Read decode failed, falling back to GTK_THEME: %v", err) + return colorSchemeNoPreference + } + + return variantToColorScheme(v) +} + +func (w *themeWatcher) subscribe() error { + if err := w.conn.AddMatchSignal( + dbus.WithMatchObjectPath(portalObjectPath), + dbus.WithMatchInterface(portalSettings), + dbus.WithMatchMember("SettingChanged"), + ); err != nil { + return err + } + + sigs := make(chan *dbus.Signal, 8) + w.conn.Signal(sigs) + go w.loop(sigs) + return nil +} + +func (w *themeWatcher) loop(sigs chan *dbus.Signal) { + for sig := range sigs { + if sig.Name != portalSettings+".SettingChanged" { + continue + } + // Signal body: (namespace string, key string, value variant). + if len(sig.Body) < 3 { + continue + } + namespace, _ := sig.Body[0].(string) + key, _ := sig.Body[1].(string) + if namespace != appearanceNamespace || key != colorSchemeKey { + continue + } + if _, ok := sig.Body[2].(dbus.Variant); !ok { + continue + } + + // Re-resolve via readDarkMode, not the signal value: under KDE the panel + // colour comes from kdeglobals, so the signal value would be wrong. + w.update() + } +} + +func (w *themeWatcher) update() { + dark := w.readDarkMode() + w.mu.Lock() + changed := dark != w.darkMode + w.darkMode = dark + w.mu.Unlock() + + if changed && w.onChange != nil { + log.Infof("tray theme: panel dark mode changed to %v", dark) + w.onChange() + } +} + +// watchKdeglobals watches the parent directory, not the file: KDE rewrites +// kdeglobals atomically (write-temp + rename), which would drop an inotify watch +// on the original inode. Filtering by name re-arms implicitly. +func (w *themeWatcher) watchKdeglobals() { + path := kdeglobalsPath() + if path == "" { + return + } + dir, name := filepath.Split(path) + + fw, err := fsnotify.NewWatcher() + if err != nil { + log.Debugf("tray theme: kdeglobals watcher unavailable, theme is static: %v", err) + return + } + if err := fw.Add(filepath.Clean(dir)); err != nil { + log.Debugf("tray theme: watching %s failed, theme is static: %v", dir, err) + _ = fw.Close() + return + } + + go func() { + defer func() { _ = fw.Close() }() + for { + select { + case event, ok := <-fw.Events: + if !ok { + return + } + if filepath.Base(event.Name) != name { + continue + } + if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename) == 0 { + continue + } + w.update() + case err, ok := <-fw.Errors: + if !ok { + return + } + log.Debugf("tray theme: kdeglobals watch error: %v", err) + } + } + }() +} + +// variantToColorScheme unwraps the color-scheme variant; the portal nests it one level. +func variantToColorScheme(v dbus.Variant) uint32 { + inner := v.Value() + if nested, ok := inner.(dbus.Variant); ok { + inner = nested.Value() + } + + switch n := inner.(type) { + case uint32: + return n + case int32: + return uint32(n) + case uint8: + return uint32(n) + default: + log.Debugf("tray theme: unexpected color-scheme type %T, assuming no preference", inner) + return colorSchemeNoPreference + } +} diff --git a/client/ui/tray_update.go b/client/ui/tray_update.go new file mode 100644 index 000000000..3ce1f9600 --- /dev/null +++ b/client/ui/tray_update.go @@ -0,0 +1,197 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + neturl "net/url" + "sync" + "time" + + log "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/services/notifications" + + "github.com/netbirdio/netbird/client/ui/services" + "github.com/netbirdio/netbird/client/ui/updater" + "github.com/netbirdio/netbird/version" +) + +// trayUpdater owns the tray UI that reacts to auto-update. Composed inside Tray. +type trayUpdater struct { + app *application.App + showMainAt func(url string) + update *services.Update + notifier *Notifier + loc *Localizer + onIconChange func() + // onMenuChange drives a full tray relayout: the update row lives in the + // About submenu, which KDE/Plasma caches on first open and never re-fetches + // on a plain SetLabel/SetHidden — only a relayout (fresh submenu ids) repaints. + onMenuChange func() + + mu sync.Mutex + item *application.MenuItem + state updater.State + notifiedVersion string + progressWindowOpen bool +} + +func newTrayUpdater(app *application.App, showMainAt func(url string), update *services.Update, notifier *Notifier, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater { + u := &trayUpdater{ + app: app, + showMainAt: showMainAt, + update: update, + notifier: notifier, + loc: loc, + onIconChange: onIconChange, + onMenuChange: onMenuChange, + } + app.Event.On(updater.EventStateChanged, u.onStateEvent) + // Seed from cached state to cover an event that fired before wiring completed. + u.state = update.GetState() + return u +} + +// attach (re)binds the menu item on each Tray.buildMenu run. The caller owns the +// item's OnClick handler. +func (u *trayUpdater) attach(item *application.MenuItem) { + u.mu.Lock() + u.item = item + state := u.state + u.mu.Unlock() + u.refreshMenuItem(state) +} + +// hasUpdate reports whether the tray should paint the "update available" icon. +func (u *trayUpdater) hasUpdate() bool { + u.mu.Lock() + defer u.mu.Unlock() + return u.state.Available +} + +// applyLanguage re-renders the menu item label after a locale switch. +func (u *trayUpdater) applyLanguage() { + u.mu.Lock() + state := u.state + u.mu.Unlock() + u.refreshMenuItem(state) +} + +// handleClick opens the installer download link when not Enforced, otherwise +// shows the progress page and asks the daemon to start the installer. +func (u *trayUpdater) handleClick() { + u.mu.Lock() + state := u.state + u.mu.Unlock() + + if !state.Enforced { + _ = u.app.Browser.OpenURL(version.DownloadUrl()) + return + } + + u.openProgressWindow(state.Version) + + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if _, err := u.update.Trigger(ctx); err != nil { + log.Errorf("trigger update: %v", err) + } + }() +} + +func (u *trayUpdater) onStateEvent(ev *application.CustomEvent) { + st, ok := ev.Data.(updater.State) + if !ok { + log.Warnf("update state event payload not UpdateState: %T", ev.Data) + return + } + u.applyState(st) +} + +// applyState diffs st against the cached state and drives the resulting side +// effects: icon repaint, menu refresh, new-version notification, progress window. +func (u *trayUpdater) applyState(st updater.State) { + u.mu.Lock() + prev := u.state + u.state = st + + sendNotify := st.Available && st.Version != "" && st.Version != u.notifiedVersion + if sendNotify { + u.notifiedVersion = st.Version + } + + showWindow := st.Installing && !u.progressWindowOpen + if showWindow { + u.progressWindowOpen = true + } else if !st.Installing { + u.progressWindowOpen = false + } + u.mu.Unlock() + + // Full relayout rather than in-place: KDE layout-caches the About submenu, so + // a direct SetLabel/SetHidden wouldn't paint. Fall back if no hook was wired. + if u.onMenuChange != nil { + u.onMenuChange() + } else { + u.refreshMenuItem(st) + } + if prev.Available != st.Available && u.onIconChange != nil { + u.onIconChange() + } + if sendNotify { + u.sendUpdateNotification(st) + } + if showWindow { + u.openProgressWindow(st.Version) + } +} + +func (u *trayUpdater) refreshMenuItem(st updater.State) { + u.mu.Lock() + item := u.item + u.mu.Unlock() + if item == nil { + return + } + + if !st.Available { + item.SetHidden(true) + return + } + if st.Enforced { + item.SetLabel(u.loc.T("tray.menu.installVersion", "version", st.Version)) + } else { + item.SetLabel(u.loc.T("tray.menu.downloadLatest")) + } + item.SetHidden(false) +} + +func (u *trayUpdater) sendUpdateNotification(st updater.State) { + if u.notifier == nil { + return + } + body := u.loc.T("notify.update.body", "version", st.Version) + if st.Enforced { + body += u.loc.T("notify.update.enforcedSuffix") + } + _ = safeSendNotification(u.notifier.SendNotification, "update", notifications.NotificationOptions{ + ID: notifyIDUpdatePrefix + st.Version, + Title: u.loc.T("notify.update.title"), + Body: body, + }) +} + +// openProgressWindow points the main window at the /update progress page and +// brings it forward. +func (u *trayUpdater) openProgressWindow(version string) { + if u.showMainAt == nil { + return + } + url := "/#/update" + if version != "" { + url += "?version=" + neturl.QueryEscape(version) + } + u.showMainAt(url) +} diff --git a/client/ui/tray_watcher_linux.go b/client/ui/tray_watcher_linux.go new file mode 100644 index 000000000..38476179a --- /dev/null +++ b/client/ui/tray_watcher_linux.go @@ -0,0 +1,176 @@ +//go:build linux && !(linux && 386) + +package main + +// In-process org.kde.StatusNotifierWatcher for minimal WMs (Fluxbox, OpenBox, +// i3) that ship no watcher. When an XEmbed tray exists (_NET_SYSTEM_TRAY_S0), +// an in-process XEmbed host bridges the SNI icon into it. + +import ( + "sync" + "time" + + "github.com/godbus/dbus/v5" + log "github.com/sirupsen/logrus" +) + +const ( + watcherName = "org.kde.StatusNotifierWatcher" + watcherPath = "/StatusNotifierWatcher" + watcherIface = "org.kde.StatusNotifierWatcher" + + // The UI is often autostarted before the panel on minimal WMs, so a single + // startup probe would miss a tray that appears a second later. + watcherProbeInterval = 500 * time.Millisecond + watcherProbeTimeout = 10 * time.Second +) + +type statusNotifierWatcher struct { + conn *dbus.Conn + items []string + hosts map[string]*xembedHost + hostsMu sync.Mutex +} + +// RegisterStatusNotifierItem is the D-Bus method called by tray clients. +// sender is injected by godbus and is not part of the D-Bus signature. +func (w *statusNotifierWatcher) RegisterStatusNotifierItem(sender dbus.Sender, service string) *dbus.Error { + for _, s := range w.items { + if s == service { + return nil + } + } + w.items = append(w.items, service) + log.Debugf("StatusNotifierWatcher: registered item %q from %s", service, sender) + + go w.tryStartXembedHost(string(sender), dbus.ObjectPath(service)) + return nil +} + +// RegisterStatusNotifierHost is required by the protocol but unused here. +func (w *statusNotifierWatcher) RegisterStatusNotifierHost(service string) *dbus.Error { + log.Debugf("StatusNotifierWatcher: host registered %q", service) + return nil +} + +// tryStartXembedHost is a no-op when no XEmbed tray manager is available. +func (w *statusNotifierWatcher) tryStartXembedHost(busName string, objPath dbus.ObjectPath) { + w.hostsMu.Lock() + defer w.hostsMu.Unlock() + + if _, exists := w.hosts[busName]; exists { + return + } + + // Private session bus so our signal subscriptions don't reach Wails' + // signal handler, which panics on unexpected signals. + sessionConn, err := dbus.SessionBusPrivate() + if err != nil { + log.Debugf("StatusNotifierWatcher: cannot open private session bus for XEmbed host: %v", err) + return + } + if err := sessionConn.Auth(nil); err != nil { + log.Debugf("StatusNotifierWatcher: XEmbed host auth failed: %v", err) + closeBus(sessionConn) + return + } + if err := sessionConn.Hello(); err != nil { + log.Debugf("StatusNotifierWatcher: XEmbed host Hello failed: %v", err) + closeBus(sessionConn) + return + } + + host, err := newXembedHost(sessionConn, busName, objPath) + if err != nil { + log.Debugf("StatusNotifierWatcher: XEmbed host not started: %v", err) + closeBus(sessionConn) + return + } + + w.hosts[busName] = host + go host.run() + log.Infof("StatusNotifierWatcher: XEmbed tray icon created for %s", busName) +} + +// startStatusNotifierWatcher claims org.kde.StatusNotifierWatcher only as a +// bridge to an XEmbed tray on minimal WMs. The watcher is a stub that never +// relays items to a real StatusNotifierHost, so claiming the name on a desktop +// with a real host (e.g. Hyprland + Waybar) would dead-end every other tray +// app's icon. It gates on the actual presence of an XEmbed tray rather than +// GetNameOwner, which can't win a login-order race; without one it stays off +// the bus so the real watcher owns the name. The XEmbed tray may come up after +// the UI, so it re-probes for a grace period rather than deciding once. +// Safe to call unconditionally. +func startStatusNotifierWatcher() { + go func() { + deadline := time.Now().Add(watcherProbeTimeout) + for { + if xembedTrayAvailable() { + claimStatusNotifierWatcher() + return + } + if time.Now().After(deadline) { + log.Debugf("StatusNotifierWatcher: no XEmbed tray appeared within %s, leaving the watcher to the desktop", watcherProbeTimeout) + return + } + time.Sleep(watcherProbeInterval) + } + }() +} + +// claimStatusNotifierWatcher takes ownership of org.kde.StatusNotifierWatcher +// on a private session bus and exports the stub watcher. The GetNameOwner / +// DoNotQueue guards back off if a real watcher already holds the name. +func claimStatusNotifierWatcher() { + conn, err := dbus.SessionBusPrivate() + if err != nil { + log.Debugf("StatusNotifierWatcher: cannot open private session bus: %v", err) + return + } + if err := conn.Auth(nil); err != nil { + log.Debugf("StatusNotifierWatcher: auth failed: %v", err) + closeBus(conn) + return + } + if err := conn.Hello(); err != nil { + log.Debugf("StatusNotifierWatcher: Hello failed: %v", err) + closeBus(conn) + return + } + + var owner string + callErr := conn.BusObject().Call("org.freedesktop.DBus.GetNameOwner", 0, watcherName).Store(&owner) + if callErr == nil && owner != "" { + log.Debugf("StatusNotifierWatcher: already owned by %s, skipping", owner) + closeBus(conn) + return + } + + reply, err := conn.RequestName(watcherName, dbus.NameFlagDoNotQueue) + if err != nil || reply != dbus.RequestNameReplyPrimaryOwner { + log.Debugf("StatusNotifierWatcher: could not claim name (reply=%v err=%v)", reply, err) + closeBus(conn) + return + } + + w := &statusNotifierWatcher{ + conn: conn, + hosts: make(map[string]*xembedHost), + } + if err := conn.ExportAll(w, dbus.ObjectPath(watcherPath), watcherIface); err != nil { + log.Errorf("StatusNotifierWatcher: export failed: %v", err) + closeBus(conn) + return + } + + log.Infof("StatusNotifierWatcher: active on session bus (enables tray on minimal WMs)") + // Connection kept open for the process lifetime. +} + +// closeBus closes a private session bus opened on a back-off path, logging a +// warning rather than swallowing the error. +func closeBus(conn *dbus.Conn) { + if err := conn.Close(); err != nil { + log.Warnf("StatusNotifierWatcher: closing session bus failed: %v", err) + } +} diff --git a/client/ui/tray_watcher_other.go b/client/ui/tray_watcher_other.go new file mode 100644 index 000000000..ef8a1bb52 --- /dev/null +++ b/client/ui/tray_watcher_other.go @@ -0,0 +1,9 @@ +//go:build (!linux || (linux && 386)) && !freebsd && !android && !ios && !js + +package main + +// startStatusNotifierWatcher is a no-op stub so main.go can call it across all +// build targets; only minimal Linux WMs need the real watcher (tray_watcher_linux.go). +func startStatusNotifierWatcher() { + // Intentionally empty: only minimal Linux WMs need the real SNI watcher. +} diff --git a/client/ui/uilogpath.go b/client/ui/uilogpath.go new file mode 100644 index 000000000..6fa400e01 --- /dev/null +++ b/client/ui/uilogpath.go @@ -0,0 +1,35 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "os" + "path/filepath" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/configs" + "github.com/netbirdio/netbird/client/ui/guilog" +) + +// uiLogPath returns the GUI log path with native separators, since the daemon +// opens it directly for debug-bundle collection. The file name comes from +// configs.UILogFile so the daemon validates and collects the same name. +func uiLogPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "netbird", configs.UILogFile), nil +} + +// newDebugLog builds the GUI debug log, disabled when userSetLogFile is set +// (manual --log-file override) or the config dir can't be resolved. +func newDebugLog(userSetLogFile bool) *guilog.DebugLog { + path, err := uiLogPath() + if err != nil { + log.Warnf("resolve GUI log path: %v; GUI file logging disabled", err) + return guilog.NewDebugLog("", false) + } + return guilog.NewDebugLog(path, !userSetLogFile) +} diff --git a/client/ui/update.go b/client/ui/update.go deleted file mode 100644 index 25c317bdf..000000000 --- a/client/ui/update.go +++ /dev/null @@ -1,140 +0,0 @@ -//go:build !(linux && 386) - -package main - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/widget" - log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/proto" -) - -func (s *serviceClient) showUpdateProgress(ctx context.Context, version string) { - log.Infof("show installer progress window: %s", version) - s.wUpdateProgress = s.app.NewWindow("Automatically updating client") - - statusLabel := widget.NewLabel("Updating...") - infoLabel := widget.NewLabel(fmt.Sprintf("Your client version is older than the auto-update version set in Management.\nUpdating client to: %s.", version)) - content := container.NewVBox(infoLabel, statusLabel) - s.wUpdateProgress.SetContent(content) - s.wUpdateProgress.CenterOnScreen() - s.wUpdateProgress.SetFixedSize(true) - s.wUpdateProgress.SetCloseIntercept(func() { - // this is empty to lock window until result known - }) - s.wUpdateProgress.RequestFocus() - s.wUpdateProgress.Show() - - updateWindowCtx, cancel := context.WithTimeout(ctx, 15*time.Minute) - - // Initialize dot updater - updateText := dotUpdater() - - // Channel to receive the result from RPC call - resultErrCh := make(chan error, 1) - resultOkCh := make(chan struct{}, 1) - - // Start RPC call in background - go func() { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - log.Infof("backend not reachable, upgrade in progress: %v", err) - close(resultOkCh) - return - } - - resp, err := conn.GetInstallerResult(updateWindowCtx, &proto.InstallerResultRequest{}) - if err != nil { - log.Infof("backend stopped responding, upgrade in progress: %v", err) - close(resultOkCh) - return - } - - if !resp.Success { - resultErrCh <- mapInstallError(resp.ErrorMsg) - return - } - - // Success - close(resultOkCh) - }() - - // Update UI with dots and wait for result - go func() { - ticker := time.NewTicker(time.Second) - defer ticker.Stop() - defer cancel() - - // allow closing update window after 10 sec - timerResetCloseInterceptor := time.NewTimer(10 * time.Second) - defer timerResetCloseInterceptor.Stop() - - for { - select { - case <-updateWindowCtx.Done(): - s.showInstallerResult(statusLabel, updateWindowCtx.Err()) - return - case err := <-resultErrCh: - s.showInstallerResult(statusLabel, err) - return - case <-resultOkCh: - log.Info("backend exited, upgrade in progress, closing all UI") - killParentUIProcess() - s.app.Quit() - return - case <-ticker.C: - statusLabel.SetText(updateText()) - case <-timerResetCloseInterceptor.C: - s.wUpdateProgress.SetCloseIntercept(nil) - } - } - }() -} - -func (s *serviceClient) showInstallerResult(statusLabel *widget.Label, err error) { - s.wUpdateProgress.SetCloseIntercept(nil) - switch { - case errors.Is(err, context.DeadlineExceeded): - log.Warn("update watcher timed out") - statusLabel.SetText("Update timed out. Please try again.") - case errors.Is(err, context.Canceled): - log.Info("update watcher canceled") - statusLabel.SetText("Update canceled.") - case err != nil: - log.Errorf("update failed: %v", err) - statusLabel.SetText("Update failed: " + err.Error()) - default: - s.wUpdateProgress.Close() - } -} - -// dotUpdater returns a closure that cycles through dots for a loading animation. -func dotUpdater() func() string { - dotCount := 0 - return func() string { - dotCount = (dotCount + 1) % 4 - return fmt.Sprintf("%s%s", "Updating", strings.Repeat(".", dotCount)) - } -} - -func mapInstallError(msg string) error { - msg = strings.ToLower(strings.TrimSpace(msg)) - - switch { - case strings.Contains(msg, "deadline exceeded"), strings.Contains(msg, "timeout"): - return context.DeadlineExceeded - case strings.Contains(msg, "canceled"), strings.Contains(msg, "cancelled"): - return context.Canceled - case msg == "": - return errors.New("unknown update error") - default: - return errors.New(msg) - } -} diff --git a/client/ui/update_notwindows.go b/client/ui/update_notwindows.go deleted file mode 100644 index 5766f18f7..000000000 --- a/client/ui/update_notwindows.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build !windows && !(linux && 386) - -package main - -func killParentUIProcess() { - // No-op on non-Windows platforms -} diff --git a/client/ui/update_windows.go b/client/ui/update_windows.go deleted file mode 100644 index 1b03936f9..000000000 --- a/client/ui/update_windows.go +++ /dev/null @@ -1,44 +0,0 @@ -//go:build windows - -package main - -import ( - log "github.com/sirupsen/logrus" - "golang.org/x/sys/windows" - - nbprocess "github.com/netbirdio/netbird/client/ui/process" -) - -// killParentUIProcess finds and kills the parent systray UI process on Windows. -// This is a workaround in case the MSI installer fails to properly terminate the UI process. -// The installer should handle this via util:CloseApplication with TerminateProcess, but this -// provides an additional safety mechanism to ensure the UI is closed before the upgrade proceeds. -func killParentUIProcess() { - pid, running, err := nbprocess.IsAnotherProcessRunning() - if err != nil { - log.Warnf("failed to check for parent UI process: %v", err) - return - } - - if !running { - log.Debug("no parent UI process found to kill") - return - } - - log.Infof("killing parent UI process (PID: %d)", pid) - - // Open the process with terminate rights - handle, err := windows.OpenProcess(windows.PROCESS_TERMINATE, false, uint32(pid)) - if err != nil { - log.Warnf("failed to open parent process %d: %v", pid, err) - return - } - defer func() { - _ = windows.CloseHandle(handle) - }() - - // Terminate the process with exit code 0 - if err := windows.TerminateProcess(handle, 0); err != nil { - log.Warnf("failed to terminate parent process %d: %v", pid, err) - } -} diff --git a/client/ui/updater/state.go b/client/ui/updater/state.go new file mode 100644 index 000000000..606f8b401 --- /dev/null +++ b/client/ui/updater/state.go @@ -0,0 +1,97 @@ +//go:build !android && !ios && !freebsd && !js + +// Package updater holds the auto-update domain: the typed State, the +// daemon-SystemEvent metadata schema, and the Holder that caches the latest +// state and broadcasts changes. No Wails dependency. +package updater + +import ( + "sync" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/proto" +) + +// EventStateChanged carries the full State snapshot as payload. +const EventStateChanged = "netbird:update:state" + +// State is the typed snapshot of the daemon's update situation. Installing is +// driven only by the daemon's progress_window:show event; a UI-side +// Update.Trigger() does not flip it. +type State struct { + Available bool `json:"available"` + Version string `json:"version"` + Enforced bool `json:"enforced"` + Installing bool `json:"installing"` +} + +// Emitter is the broadcast dependency Holder needs; the Wails app.Event +// processor satisfies it. +type Emitter interface { + Emit(name string, data ...any) bool +} + +// Holder caches the latest update State and broadcasts changes. +type Holder struct { + emitter Emitter + + mu sync.Mutex + state State +} + +// NewHolder constructs an empty-state Holder. A nil emitter skips the broadcast. +func NewHolder(emitter Emitter) *Holder { + return &Holder{emitter: emitter} +} + +// Get returns a copy of the cached State. +func (h *Holder) Get() State { + h.mu.Lock() + defer h.mu.Unlock() + return h.state +} + +// OnSystemEvent folds update-related metadata into the cached state, emitting +// EventStateChanged only on an actual change so repeated daemon snapshots +// don't produce redundant pushes. +func (h *Holder) OnSystemEvent(ev *proto.SystemEvent) { + md := ev.GetMetadata() + if len(md) == 0 { + return + } + + h.mu.Lock() + changed := false + if v, ok := md["new_version_available"]; ok { + _, enforced := md["enforced"] + if !h.state.Available || h.state.Version != v || h.state.Enforced != enforced { + h.state.Available = true + h.state.Version = v + h.state.Enforced = enforced + changed = true + } + } + if md["progress_window"] == "show" { + if !h.state.Installing { + h.state.Installing = true + changed = true + } + if v, ok := md["version"]; ok && v != "" && h.state.Version != v { + h.state.Version = v + h.state.Available = true + changed = true + } + } + snap := h.state + h.mu.Unlock() + + if !changed { + return + } + log.Infof("update state: available=%v version=%q enforced=%v installing=%v", + snap.Available, snap.Version, snap.Enforced, snap.Installing) + if h.emitter != nil { + h.emitter.Emit(EventStateChanged, snap) + } +} diff --git a/client/ui/xembed_host_gtk3_linux.go b/client/ui/xembed_host_gtk3_linux.go new file mode 100644 index 000000000..b1f18cc90 --- /dev/null +++ b/client/ui/xembed_host_gtk3_linux.go @@ -0,0 +1,40 @@ +//go:build linux && gtk3 && !(linux && 386) + +package main + +import ( + "errors" + + "github.com/godbus/dbus/v5" +) + +// The legacy GTK3 / WebKit2GTK 4.1 build (-tags gtk3) drops the in-process +// XEmbed StatusNotifierWatcher entirely. The real implementation +// (xembed_host_linux.go + xembed_tray_linux.c) links GTK4 and uses GTK4-only +// popup-menu APIs that have no drop-in GTK3 equivalent, so rather than port the +// C layer we stub the host out on gtk3 builds. The tray still works on every +// desktop that ships its own StatusNotifierWatcher (KDE, GNOME+AppIndicator, +// Cinnamon/xapp, XFCE, …); only the minimal-WM fallback (Fluxbox/OpenBox/i3/ +// dwm/vanilla GNOME) is unavailable on gtk3 packages. See LINUX-TRAY.md. + +// xembedHost is a placeholder so the package compiles on gtk3 builds; the real +// type (with X11/GTK4 state) lives in xembed_host_linux.go. It is never +// instantiated here because xembedTrayAvailable always reports false. +type xembedHost struct{} + +// run satisfies the call in tray_watcher_linux.go; unreachable on gtk3 because +// newXembedHost never returns a non-nil host. +func (*xembedHost) run() {} + +// xembedTrayAvailable always reports false on gtk3 builds, so the watcher probe +// loop in startStatusNotifierWatcher exits immediately and newXembedHost is +// never reached. recenter_linux.go's predicate becomes a harmless no-op too. +func xembedTrayAvailable() bool { + return false +} + +// newXembedHost exists only to satisfy the reference in tray_watcher_linux.go; +// it is unreachable because xembedTrayAvailable returns false on gtk3. +func newXembedHost(conn *dbus.Conn, busName string, objPath dbus.ObjectPath) (*xembedHost, error) { + return nil, errors.New("xembed host unsupported on gtk3 build") +} diff --git a/client/ui/xembed_host_linux.go b/client/ui/xembed_host_linux.go new file mode 100644 index 000000000..5551cd02a --- /dev/null +++ b/client/ui/xembed_host_linux.go @@ -0,0 +1,443 @@ +//go:build linux && !gtk3 && !(linux && 386) + +package main + +/* +#cgo pkg-config: x11 gtk4 gtk4-x11 cairo cairo-xlib +#cgo LDFLAGS: -lX11 +#include "xembed_tray_linux.h" +#include +#include +#include +*/ +import "C" + +import ( + "errors" + "sync" + "time" + "unsafe" + + "github.com/godbus/dbus/v5" + log "github.com/sirupsen/logrus" +) + +// activeMenuHost holds the popup owner; C callbacks cannot carry Go pointers. +var ( + activeMenuHost *xembedHost + activeMenuHostMu sync.Mutex +) + +// menuItemInfo is a dbusMenuLayout entry flattened for the C popup builder. +type menuItemInfo struct { + id int32 + label string + enabled bool + isCheck bool + checked bool + isSeparator bool + children []menuItemInfo +} + +// dbusMenuLayout mirrors the (ia{sv}av) result of com.canonical.dbusmenu.GetLayout. +// Each Children variant wraps a nested dbusMenuLayout, decoded in flattenMenu. +type dbusMenuLayout struct { + ID int32 + Properties map[string]dbus.Variant + Children []dbus.Variant +} + +// xembedHost manages one XEmbed tray icon for an SNI item. +type xembedHost struct { + conn *dbus.Conn + busName string + objPath dbus.ObjectPath + + dpy *C.Display + trayMgr C.Window + iconWin C.Window + iconSize int + + mu sync.Mutex + iconData []byte + iconW int + iconH int + + stopCh chan struct{} +} + +// newXembedHost creates an XEmbed tray icon for the given SNI item. +// Errors when no XEmbed tray manager is available, so callers can fall back. +func newXembedHost(conn *dbus.Conn, busName string, objPath dbus.ObjectPath) (*xembedHost, error) { + dpy := C.XOpenDisplay(nil) + if dpy == nil { + return nil, errors.New("cannot open X display") + } + C.xembed_install_error_handlers() + + screen := C.xembed_default_screen(dpy) + trayMgr := C.xembed_find_tray(dpy, screen) + if trayMgr == 0 { + C.XCloseDisplay(dpy) + return nil, errors.New("no XEmbed system tray found") + } + + iconSize := int(C.xembed_get_icon_size(dpy, trayMgr)) + if iconSize <= 0 { + iconSize = 24 // fallback + } + + iconWin := C.xembed_create_icon(dpy, screen, C.int(iconSize), trayMgr) + if iconWin == 0 { + C.XCloseDisplay(dpy) + return nil, errors.New("failed to create icon window") + } + + if C.xembed_dock(dpy, trayMgr, iconWin) != 0 { + C.xembed_destroy_icon(dpy, iconWin) + C.XCloseDisplay(dpy) + return nil, errors.New("failed to dock icon") + } + + h := &xembedHost{ + conn: conn, + busName: busName, + objPath: objPath, + dpy: dpy, + trayMgr: trayMgr, + iconWin: iconWin, + iconSize: iconSize, + stopCh: make(chan struct{}), + } + + h.fetchAndDrawIcon() + return h, nil +} + +func (h *xembedHost) fetchAndDrawIcon() { + obj := h.conn.Object(h.busName, h.objPath) + variant, err := obj.GetProperty("org.kde.StatusNotifierItem.IconPixmap") + if err != nil { + log.Debugf("xembed: failed to get IconPixmap: %v", err) + return + } + + // IconPixmap has D-Bus signature a(iiay). + type px struct { + W int32 + H int32 + Pix []byte + } + + var icons []px + if err := variant.Store(&icons); err != nil { + log.Debugf("xembed: failed to decode IconPixmap: %v", err) + return + } + + if len(icons) == 0 { + log.Debug("xembed: IconPixmap is empty") + return + } + + icon := icons[0] + if icon.W <= 0 || icon.H <= 0 || len(icon.Pix) < int(icon.W*icon.H*4) { + log.Debug("xembed: invalid IconPixmap data") + return + } + + h.mu.Lock() + h.iconData = icon.Pix + h.iconW = int(icon.W) + h.iconH = int(icon.H) + h.mu.Unlock() + + h.drawIcon() +} + +func (h *xembedHost) drawIcon() { + h.mu.Lock() + data := h.iconData + w := h.iconW + ht := h.iconH + h.mu.Unlock() + + if data == nil || w <= 0 || ht <= 0 { + return + } + + cData := C.CBytes(data) + defer C.free(cData) + + C.xembed_draw_icon(h.dpy, h.iconWin, C.int(h.iconSize), + (*C.uchar)(cData), C.int(w), C.int(ht)) +} + +// run is the event loop: polls X11 events and D-Bus NewIcon signals until stopped. +func (h *xembedHost) run() { + matchRule := "type='signal',interface='org.kde.StatusNotifierItem',member='NewIcon',sender='" + h.busName + "'" + if err := h.conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, matchRule).Err; err != nil { + log.Debugf("xembed: failed to add signal match: %v", err) + } + + sigCh := make(chan *dbus.Signal, 16) + h.conn.Signal(sigCh) + defer h.conn.RemoveSignal(sigCh) + + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-h.stopCh: + return + + case sig := <-sigCh: + if sig == nil { + continue + } + if sig.Name == "org.kde.StatusNotifierItem.NewIcon" { + h.fetchAndDrawIcon() + } + + case <-ticker.C: + var outX, outY C.int + result := C.xembed_poll_event(h.dpy, h.iconWin, &outX, &outY) + + switch result { + case 1: // left click + go h.activate(int32(outX), int32(outY)) + case 2: // right click + go h.contextMenu(int32(outX), int32(outY)) + case 3: // expose + h.drawIcon() + case 4: // configure (resize) + newSize := int(outX) + if newSize > 0 && newSize != h.iconSize { + h.iconSize = newSize + h.drawIcon() + } + case -1: // tray died + log.Info("xembed: tray manager destroyed, cleaning up") + return + } + } + } +} + +func (h *xembedHost) activate(x, y int32) { + obj := h.conn.Object(h.busName, h.objPath) + if err := obj.Call("org.kde.StatusNotifierItem.Activate", 0, x, y).Err; err != nil { + log.Debugf("xembed: Activate call failed: %v", err) + } +} + +func (h *xembedHost) contextMenu(x, y int32) { + menuPath := dbus.ObjectPath("/StatusNotifierMenu") + + menuObj := h.conn.Object(h.busName, menuPath) + var revision uint32 + var layout dbusMenuLayout + err := menuObj.Call("com.canonical.dbusmenu.GetLayout", 0, + int32(0), // parentId (root) + int32(-1), // recursionDepth (all) + []string{}, // propertyNames (all) + ).Store(&revision, &layout) + if err != nil { + log.Debugf("xembed: GetLayout failed: %v", err) + return + } + + items := h.flattenMenu(layout) + log.Debugf("xembed: menu has %d items (revision %d)", len(items), revision) + if len(items) == 0 { + return + } + + var allocs []unsafe.Pointer + cItems := buildCItems(items, &allocs) + defer func() { + for _, p := range allocs { + C.free(p) + } + }() + + // C callback reaches us through this global. + activeMenuHostMu.Lock() + activeMenuHost = h + activeMenuHostMu.Unlock() + + C.xembed_show_popup_menu(cItems, C.int(len(items)), + nil, C.int(x), C.int(y)) +} + +func (h *xembedHost) flattenMenu(layout dbusMenuLayout) []menuItemInfo { + var items []menuItemInfo + + for _, childVar := range layout.Children { + var child dbusMenuLayout + if err := dbus.Store([]interface{}{childVar.Value()}, &child); err != nil { + continue + } + if mi, ok := h.menuItemFromLayout(child); ok { + items = append(items, mi) + } + } + + return items +} + +// menuItemFromLayout decodes one dbusmenu child; ok is false for hidden items (drop them). +func (h *xembedHost) menuItemFromLayout(child dbusMenuLayout) (menuItemInfo, bool) { + mi := menuItemInfo{id: child.ID, enabled: true} + + if propString(child.Properties, "type") == "separator" { + mi.isSeparator = true + return mi, true + } + + if vis, ok := propBool(child.Properties, "visible"); ok && !vis { + return menuItemInfo{}, false + } + + mi.label = propString(child.Properties, "label") + if en, ok := propBool(child.Properties, "enabled"); ok { + mi.enabled = en + } + if propString(child.Properties, "toggle-type") == "checkmark" { + mi.isCheck = true + } + if n, ok := propInt32(child.Properties, "toggle-state"); ok && n == 1 { + mi.checked = true + } + + // children are already present from the recursionDepth=-1 GetLayout. + if propString(child.Properties, "children-display") == "submenu" { + mi.children = h.flattenMenu(child) + } + + return mi, true +} + +func (h *xembedHost) sendMenuEvent(id int32) { + menuPath := dbus.ObjectPath("/StatusNotifierMenu") + menuObj := h.conn.Object(h.busName, menuPath) + data := dbus.MakeVariant("") + err := menuObj.Call("com.canonical.dbusmenu.Event", 0, + id, "clicked", data, uint32(0)).Err + if err != nil { + log.Debugf("xembed: menu Event call failed: %v", err) + } +} + +func (h *xembedHost) stop() { + select { + case <-h.stopCh: + return + default: + close(h.stopCh) + } + + C.xembed_destroy_icon(h.dpy, h.iconWin) + C.XCloseDisplay(h.dpy) +} + +// buildCItems builds a C-allocated xembed_menu_item tree. Every malloc is +// appended to *allocs for the caller to free once the C side has deep-copied it. +func buildCItems(items []menuItemInfo, allocs *[]unsafe.Pointer) *C.xembed_menu_item { + if len(items) == 0 { + return nil + } + size := C.size_t(len(items)) * C.size_t(unsafe.Sizeof(C.xembed_menu_item{})) + arr := C.malloc(size) + *allocs = append(*allocs, arr) + C.memset(arr, 0, size) + + slice := (*[1 << 16]C.xembed_menu_item)(arr)[:len(items):len(items)] + for i, mi := range items { + slice[i].id = C.int(mi.id) + slice[i].enabled = boolToInt(mi.enabled) + slice[i].is_check = boolToInt(mi.isCheck) + slice[i].checked = boolToInt(mi.checked) + slice[i].is_separator = boolToInt(mi.isSeparator) + if mi.label != "" { + cstr := C.CString(mi.label) + *allocs = append(*allocs, unsafe.Pointer(cstr)) + slice[i].label = cstr + } + if len(mi.children) > 0 { + slice[i].children = buildCItems(mi.children, allocs) + slice[i].child_count = C.int(len(mi.children)) + } + } + + return (*C.xembed_menu_item)(arr) +} + +// xembedTrayAvailable reports whether an XEmbed tray manager (_NET_SYSTEM_TRAY_S0) +// owns the default screen. Side-effect-free probe. Gates the in-process +// StatusNotifierWatcher: when a real SNI host already owns the tray (e.g. Waybar +// on Wayland) we must not claim org.kde.StatusNotifierWatcher and shadow it. +// Returns false when there is no X display (pure Wayland). +func xembedTrayAvailable() bool { + dpy := C.XOpenDisplay(nil) + if dpy == nil { + return false + } + C.xembed_install_error_handlers() + defer C.XCloseDisplay(dpy) + screen := C.xembed_default_screen(dpy) + return C.xembed_find_tray(dpy, screen) != 0 +} + +// goMenuItemClicked is the C callback fired from the GTK main thread on popup +// activation. The host is looked up via activeMenuHost since C callbacks can't +// carry Go pointers; //export requires this to live in package main. +// +//export goMenuItemClicked +func goMenuItemClicked(id C.int) { + activeMenuHostMu.Lock() + h := activeMenuHost + activeMenuHostMu.Unlock() + + if h != nil { + go h.sendMenuEvent(int32(id)) + } +} + +func boolToInt(b bool) C.int { + if b { + return 1 + } + return 0 +} + +// propString returns the property's string value, or "" if absent or not a string. +func propString(props map[string]dbus.Variant, key string) string { + if v, ok := props[key]; ok { + if s, ok := v.Value().(string); ok { + return s + } + } + return "" +} + +// propBool returns the property's bool value; ok is false if absent or not a bool. +func propBool(props map[string]dbus.Variant, key string) (value, ok bool) { + if v, present := props[key]; present { + if b, isBool := v.Value().(bool); isBool { + return b, true + } + } + return false, false +} + +// propInt32 returns the property's int32 value; ok is false if absent or not an int32. +func propInt32(props map[string]dbus.Variant, key string) (value int32, ok bool) { + if v, present := props[key]; present { + if n, isInt := v.Value().(int32); isInt { + return n, true + } + } + return 0, false +} diff --git a/client/ui/xembed_tray_linux.c b/client/ui/xembed_tray_linux.c new file mode 100644 index 000000000..86da8bf70 --- /dev/null +++ b/client/ui/xembed_tray_linux.c @@ -0,0 +1,710 @@ +//go:build linux && !gtk3 && !(linux && 386) + +#include "xembed_tray_linux.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define SYSTEM_TRAY_REQUEST_DOCK 0 +#define XEMBED_MAPPED (1 << 0) + +/* Xlib's default protocol-error handler calls exit() on any async X error, + killing the whole UI process. Handlers are process-global (not + per-Display), so a single install covers our raw tray Display and GDK's. + Tray work is full of races the X server reports asynchronously — the tray + manager window dying between xembed_find_tray and xembed_dock (BadWindow + on XSendEvent), or x11_move_window touching a popup the WM already + destroyed — and the default handler would take us down for any of them. + Returning 0 here makes the error a logged no-op instead. */ +static int xembed_x_error_handler(Display *dpy, XErrorEvent *ev) { + char buf[256]; + XGetErrorText(dpy, ev->error_code, buf, sizeof(buf)); + fprintf(stderr, + "xembed: X error (ignored): %s (code=%d, request=%d.%d, resource=0x%lx)\n", + buf, ev->error_code, ev->request_code, ev->minor_code, + ev->resourceid); + return 0; +} + +/* The I/O error handler fires when the X connection itself drops (server + gone, socket closed). Xlib treats this as fatal and exits even if we + return, so this can't keep the process alive — it only logs a clearer + line than Xlib's terse default before the unavoidable exit. */ +static int xembed_x_io_error_handler(Display *dpy) { + (void)dpy; + fprintf(stderr, "xembed: X I/O error (connection lost)\n"); + return 0; +} + +/* Install the process-global handlers. Idempotent and cheap, so callers may + invoke it after every XOpenDisplay without tracking prior installs. */ +void xembed_install_error_handlers(void) { + XSetErrorHandler(xembed_x_error_handler); + XSetIOErrorHandler(xembed_x_io_error_handler); +} + +Window xembed_find_tray(Display *dpy, int screen) { + char atom_name[64]; + snprintf(atom_name, sizeof(atom_name), "_NET_SYSTEM_TRAY_S%d", screen); + Atom sel = XInternAtom(dpy, atom_name, False); + return XGetSelectionOwner(dpy, sel); +} + +int xembed_get_icon_size(Display *dpy, Window tray_mgr) { + Atom atom = XInternAtom(dpy, "_NET_SYSTEM_TRAY_ICON_SIZE", False); + Atom actual_type; + int actual_format; + unsigned long nitems, bytes_after; + unsigned char *prop = NULL; + int size = 0; + + if (XGetWindowProperty(dpy, tray_mgr, atom, 0, 1, False, + XA_CARDINAL, &actual_type, &actual_format, + &nitems, &bytes_after, &prop) == Success) { + if (prop && nitems == 1 && actual_format == 32) { + size = (int)(*(unsigned long *)prop); + } + if (prop) + XFree(prop); + } + return size; +} + +Window xembed_create_icon(Display *dpy, int screen, int size, + Window tray_mgr) { + (void)tray_mgr; /* unused; kept in signature for caller symmetry */ + Window root = RootWindow(dpy, screen); + + /* Inherit visual & depth from the parent (tray manager / root) so + ParentRelative background works on every tray. Many minimal + toolbars (Fluxbox slit, OpenBox, etc.) only offer a 24-bit + default visual and do not composite alpha; ParentRelative makes + the X server texture this window's background from the parent, + so transparent pixels in the icon show the toolbar beneath + instead of solid black. ARGB-aware trays still work because the + cairo OVER blend in xembed_draw_icon honours per-pixel alpha + against whatever base the X server painted underneath. */ + XSetWindowAttributes attrs; + memset(&attrs, 0, sizeof(attrs)); + attrs.event_mask = ButtonPressMask | StructureNotifyMask | ExposureMask; + attrs.background_pixmap = ParentRelative; + unsigned long mask = CWEventMask | CWBackPixmap; + + Window win = XCreateWindow( + dpy, root, + 0, 0, size, size, + 0, /* border width */ + CopyFromParent, /* depth */ + InputOutput, + CopyFromParent, /* visual */ + mask, + &attrs + ); + + /* Set _XEMBED_INFO: version=0, flags=XEMBED_MAPPED */ + Atom xembed_info = XInternAtom(dpy, "_XEMBED_INFO", False); + unsigned long info[2] = { 0, XEMBED_MAPPED }; + XChangeProperty(dpy, win, xembed_info, xembed_info, + 32, PropModeReplace, (unsigned char *)info, 2); + + return win; +} + +int xembed_dock(Display *dpy, Window tray_mgr, Window icon_win) { + Atom opcode = XInternAtom(dpy, "_NET_SYSTEM_TRAY_OPCODE", False); + + XClientMessageEvent ev; + memset(&ev, 0, sizeof(ev)); + ev.type = ClientMessage; + ev.window = tray_mgr; + ev.message_type = opcode; + ev.format = 32; + ev.data.l[0] = CurrentTime; + ev.data.l[1] = SYSTEM_TRAY_REQUEST_DOCK; + ev.data.l[2] = (long)icon_win; + + XSendEvent(dpy, tray_mgr, False, NoEventMask, (XEvent *)&ev); + XFlush(dpy); + return 0; +} + +void xembed_draw_icon(Display *dpy, Window icon_win, int win_size, + const unsigned char *data, int img_w, int img_h) { + if (!data || img_w <= 0 || img_h <= 0 || win_size <= 0) + return; + + /* Query the window's actual visual and depth so cairo composites + through the matching ARGB pipeline. */ + XWindowAttributes wa; + if (!XGetWindowAttributes(dpy, icon_win, &wa)) + return; + + /* Build a CAIRO_FORMAT_ARGB32 source surface from the SNI IconPixmap + bytes. SNI ships the pixels as [A,R,G,B,...] in network byte + order; cairo's ARGB32 stores native uint32 with B in the lowest + byte on little-endian hosts. Repack into native order with + pre-multiplied alpha so cairo can composite without tonemapping. */ + int stride = cairo_format_stride_for_width(CAIRO_FORMAT_ARGB32, img_w); + unsigned char *buf = (unsigned char *)calloc(stride * img_h, 1); + if (!buf) + return; + + for (int y = 0; y < img_h; y++) { + unsigned int *row = (unsigned int *)(buf + y * stride); + for (int x = 0; x < img_w; x++) { + int idx = (y * img_w + x) * 4; + unsigned int a = data[idx + 0]; + unsigned int r = data[idx + 1]; + unsigned int g = data[idx + 2]; + unsigned int b = data[idx + 3]; + + if (a == 0) { + row[x] = 0; + } else if (a == 255) { + row[x] = (a << 24) | (r << 16) | (g << 8) | b; + } else { + unsigned int pr = r * a / 255; + unsigned int pg = g * a / 255; + unsigned int pb = b * a / 255; + row[x] = (a << 24) | (pr << 16) | (pg << 8) | pb; + } + } + } + + cairo_surface_t *src = cairo_image_surface_create_for_data( + buf, CAIRO_FORMAT_ARGB32, img_w, img_h, stride); + if (cairo_surface_status(src) != CAIRO_STATUS_SUCCESS) { + cairo_surface_destroy(src); + free(buf); + return; + } + + /* Wrap the X11 window in a cairo XLib surface using its real visual. */ + cairo_surface_t *dst = cairo_xlib_surface_create( + dpy, icon_win, wa.visual, win_size, win_size); + if (cairo_surface_status(dst) != CAIRO_STATUS_SUCCESS) { + cairo_surface_destroy(dst); + cairo_surface_destroy(src); + free(buf); + return; + } + + /* Repaint the ParentRelative background first — without this the + window keeps the previously-drawn icon underneath when an icon + update arrives, and cairo's OVER blend would composite the new + icon on top of the stale one. XClearWindow forces the X server + to retexture from the parent (tray toolbar), giving us a clean + opaque base. */ + XClearWindow(dpy, icon_win); + + cairo_t *cr = cairo_create(dst); + + /* Scale the source onto the window with alpha compositing (default + OPERATOR_OVER). Transparent pixels keep the toolbar's pixels + visible underneath. */ + double sx = (double)win_size / img_w; + double sy = (double)win_size / img_h; + cairo_scale(cr, sx, sy); + cairo_set_source_surface(cr, src, 0, 0); + cairo_paint(cr); + + cairo_destroy(cr); + cairo_surface_destroy(dst); + cairo_surface_destroy(src); + free(buf); + XFlush(dpy); +} + +void xembed_destroy_icon(Display *dpy, Window icon_win) { + if (icon_win) + XDestroyWindow(dpy, icon_win); + XFlush(dpy); +} + +int xembed_poll_event(Display *dpy, Window icon_win, + int *out_x, int *out_y) { + *out_x = 0; + *out_y = 0; + + while (XPending(dpy) > 0) { + XEvent ev; + XNextEvent(dpy, &ev); + + switch (ev.type) { + case ButtonPress: + if (ev.xbutton.window == icon_win) { + *out_x = ev.xbutton.x_root; + *out_y = ev.xbutton.y_root; + if (ev.xbutton.button == Button1) + return 1; + if (ev.xbutton.button == Button3) + return 2; + } + break; + + case Expose: + if (ev.xexpose.window == icon_win && ev.xexpose.count == 0) + return 3; + break; + + case DestroyNotify: + if (ev.xdestroywindow.window == icon_win) + return -1; + break; + + case ConfigureNotify: + if (ev.xconfigure.window == icon_win) { + *out_x = ev.xconfigure.width; + *out_y = ev.xconfigure.height; + return 4; + } + break; + + case ReparentNotify: + /* Tray manager reparented us — this is expected after docking. */ + break; + + default: + break; + } + } + + return 0; +} + +/* --- GTK4 popup window menu support --- */ + +/* Implemented in Go via //export */ +extern void goMenuItemClicked(int id); + +/* The top-level popup window, reused across invocations. Submenu + popups are tracked in a separate list so they all close when the + top-level closes. */ +static GtkWidget *popup_win = NULL; +static GList *submenu_popups = NULL; /* list of GtkWidget* */ + +typedef struct { + xembed_menu_item *items; + int count; + int x, y; +} popup_data; + +/* Deep-free a heap-owned xembed_menu_item array (label + children). */ +static void free_items(xembed_menu_item *items, int count) { + if (!items) return; + for (int i = 0; i < count; i++) { + free((void *)items[i].label); + free_items(items[i].children, items[i].child_count); + } + free(items); +} + +static void free_popup_data(popup_data *pd) { + if (!pd) return; + free_items(pd->items, pd->count); + free(pd); +} + + +/* Close every popup window — top-level plus any open submenus. + Called when the user clicks an actionable item or focus leaves the + menu tree. */ +static void close_all_popups(void) { + for (GList *l = submenu_popups; l; l = l->next) { + gtk_window_destroy(GTK_WINDOW(l->data)); + } + g_list_free(submenu_popups); + submenu_popups = NULL; + + if (popup_win) { + gtk_widget_set_visible(popup_win, FALSE); + } +} + +static void on_button_clicked(GtkButton *btn, gpointer user_data) { + (void)btn; + int id = GPOINTER_TO_INT(user_data); + close_all_popups(); + goMenuItemClicked(id); +} + +static void on_check_toggled(GtkCheckButton *btn, gpointer user_data) { + (void)btn; + int id = GPOINTER_TO_INT(user_data); + close_all_popups(); + goMenuItemClicked(id); +} + +/* The popup is a regular WM-managed window (not override-redirect), + so the WM hands keyboard focus to it on map. When focus moves + elsewhere — the user clicked somewhere else, switched apps, etc. — + the focus controller's "leave" signal fires and we tear down the + menu tree. Submenus open from inside the top-level popup, so we + defer the actual close to an idle callback: that gives the new + submenu a chance to take focus first, and we only close if none of + our windows still has it. */ +static gboolean any_popup_has_focus(void) { + if (popup_win && gtk_window_is_active(GTK_WINDOW(popup_win))) + return TRUE; + for (GList *l = submenu_popups; l; l = l->next) { + if (gtk_window_is_active(GTK_WINDOW(l->data))) + return TRUE; + } + return FALSE; +} + +static gboolean focus_out_recheck(gpointer user_data) { + (void)user_data; + if (!any_popup_has_focus()) + close_all_popups(); + return G_SOURCE_REMOVE; +} + +static void on_popup_focus_leave(GtkEventControllerFocus *ctrl, + gpointer user_data) { + (void)ctrl; (void)user_data; + g_idle_add(focus_out_recheck, NULL); +} + +/* Attach a focus controller that fires close_all_popups on focus loss. */ +static void attach_outside_click_close(GtkWidget *win) { + GtkEventController *focus = gtk_event_controller_focus_new(); + g_signal_connect(focus, "leave", + G_CALLBACK(on_popup_focus_leave), NULL); + gtk_widget_add_controller(win, focus); +} + +/* Move a GtkWindow at the X11 level. GTK4 removed gtk_window_move(); the + GdkSurface is mapped to a real X11 Window we can reposition with + XMoveWindow. Must be called after the window has been realized (i.e. + after gtk_widget_set_visible TRUE). + + The popup is **not** override-redirect — the WM keeps managing it so + focus tracking still works (focus-out fires when the user clicks + elsewhere). We tag the window with a stack of EWMH hints that make + sane WMs (fluxbox, openbox, i3, kwin, mutter) render it like a + floating menu: above the tray panel, skipped from taskbar/pager, + no decorations. */ +static void x11_move_window(GtkWidget *win, int x, int y) { + GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(win)); + if (!surface || !GDK_IS_X11_SURFACE(surface)) + return; + Window xid = gdk_x11_surface_get_xid(surface); + GdkDisplay *display = gdk_surface_get_display(surface); + Display *xdpy = gdk_x11_display_get_xdisplay(GDK_X11_DISPLAY(display)); + + /* These calls poke a window the WM may have already destroyed (a popup + torn down between scheduling and this idle callback). On GDK's Display + use GDK's own error trap rather than our global handler — push/pop is + the spec-correct way to make untrapped BadWindow/BadMatch from these + raw Xlib calls non-fatal, independent of whichever process-global + handler happens to be installed. */ + gdk_x11_display_error_trap_push(display); + + /* _NET_WM_WINDOW_TYPE_POPUP_MENU: makes fluxbox / openbox / etc + render the window above panels and skip decorations. Must be + set before the window is mapped to be honoured by some WMs; + on already-mapped windows it works for most modern WMs but a + few need an unmap/map cycle to re-read the property. */ + Atom wm_type = XInternAtom(xdpy, "_NET_WM_WINDOW_TYPE", False); + Atom wm_type_popup = XInternAtom(xdpy, "_NET_WM_WINDOW_TYPE_POPUP_MENU", False); + XChangeProperty(xdpy, xid, wm_type, XA_ATOM, 32, + PropModeReplace, (unsigned char *)&wm_type_popup, 1); + + /* _NET_WM_STATE_ABOVE + SKIP_TASKBAR + SKIP_PAGER. Bundled into + one property write. */ + Atom wm_state = XInternAtom(xdpy, "_NET_WM_STATE", False); + Atom state_above = XInternAtom(xdpy, "_NET_WM_STATE_ABOVE", False); + Atom state_skip_tb = XInternAtom(xdpy, "_NET_WM_STATE_SKIP_TASKBAR", False); + Atom state_skip_pg = XInternAtom(xdpy, "_NET_WM_STATE_SKIP_PAGER", False); + Atom states[3] = { state_above, state_skip_tb, state_skip_pg }; + XChangeProperty(xdpy, xid, wm_state, XA_ATOM, 32, + PropModeReplace, (unsigned char *)states, 3); + + XMoveWindow(xdpy, xid, x, y); + XRaiseWindow(xdpy, xid); + + /* POPUP_MENU windows aren't given keyboard focus by most WMs (the + spec says they're "menus", which traditionally use a grab rather + than focus). Without focus GtkEventControllerFocus's leave signal + never fires, so we'd have no way to notice the user clicking + elsewhere. Ask the WM to activate us via _NET_ACTIVE_WINDOW + (source=2 means "pager / pseudo-user request" which most WMs + honour without timestamp checks). This is safer than calling + XSetInputFocus directly — that races the X server with the + not-yet-fully-mapped window and trips BadMatch. */ + Atom net_active = XInternAtom(xdpy, "_NET_ACTIVE_WINDOW", False); + XClientMessageEvent ev; + memset(&ev, 0, sizeof(ev)); + ev.type = ClientMessage; + ev.window = xid; + ev.message_type = net_active; + ev.format = 32; + ev.data.l[0] = 2; /* source: pager */ + ev.data.l[1] = CurrentTime; + XSendEvent(xdpy, DefaultRootWindow(xdpy), False, + SubstructureRedirectMask | SubstructureNotifyMask, + (XEvent *)&ev); + + XFlush(xdpy); + gdk_x11_display_error_trap_pop_ignored(display); +} + +/* Forward declaration — submenu buttons need to schedule a child popup. */ +static GtkWidget *build_menu_box(xembed_menu_item *items, int count); + +typedef struct { + xembed_menu_item *items; + int count; + GtkWidget *anchor; /* the submenu button — used to position the popup */ +} submenu_open_data; + +static void on_submenu_button_clicked(GtkButton *btn, gpointer user_data) { + submenu_open_data *sd = (submenu_open_data *)user_data; + + GtkWidget *win = gtk_window_new(); + gtk_window_set_decorated(GTK_WINDOW(win), FALSE); + gtk_window_set_resizable(GTK_WINDOW(win), FALSE); + + attach_outside_click_close(win); + + GtkWidget *vbox = build_menu_box(sd->items, sd->count); + gtk_window_set_child(GTK_WINDOW(win), vbox); + + /* Need the anchor button's position in root coordinates. GTK4 + removed gtk_widget_translate_coordinates(); compute via the + button's bounds within its native widget plus the native + surface's screen origin via X11. */ + graphene_rect_t bounds; + if (!gtk_widget_compute_bounds(GTK_WIDGET(btn), + GTK_WIDGET(gtk_widget_get_native(GTK_WIDGET(btn))), + &bounds)) { + bounds.origin.x = 0; + bounds.origin.y = 0; + bounds.size.width = 0; + bounds.size.height = 0; + } + GdkSurface *anchor_surface = + gtk_native_get_surface(gtk_widget_get_native(GTK_WIDGET(btn))); + int ox = 0, oy = 0; + if (anchor_surface && GDK_IS_X11_SURFACE(anchor_surface)) { + Window axid = gdk_x11_surface_get_xid(anchor_surface); + GdkDisplay *display = gdk_surface_get_display(anchor_surface); + Display *xdpy = gdk_x11_display_get_xdisplay(GDK_X11_DISPLAY(display)); + Window child; + /* Trap BadWindow in case the anchor's surface is torn down between + the click and this handler running. */ + gdk_x11_display_error_trap_push(display); + XTranslateCoordinates(xdpy, axid, DefaultRootWindow(xdpy), + 0, 0, &ox, &oy, &child); + gdk_x11_display_error_trap_pop_ignored(display); + } + int ax = ox + (int)bounds.origin.x; + int ay = oy + (int)bounds.origin.y; + + gtk_widget_set_visible(win, TRUE); + + int sw, sh; + gtk_window_get_default_size(GTK_WINDOW(win), &sw, &sh); + if (sw <= 0 || sh <= 0) { + /* default_size returns -1,-1 if never explicitly set; fall back + to the measured preferred size. */ + GtkRequisition req; + gtk_widget_get_preferred_size(win, NULL, &req); + sw = req.width; + sh = req.height; + } + + /* The parent popup grows upward from the tray, so submenu items + sit closer to the bottom of the screen than to the top. Align + the submenu's BOTTOM to the anchor button's bottom: the popup + grows upward, level with the row that opened it. */ + int final_x = ax + (int)bounds.size.width; + int final_y = ay + (int)bounds.size.height - sh; + + /* Horizontal flip against the monitor under the anchor button. */ + GdkDisplay *display = gtk_widget_get_display(win); + GListModel *monitors = gdk_display_get_monitors(display); + guint n = g_list_model_get_n_items(monitors); + for (guint i = 0; i < n; i++) { + GdkMonitor *m = (GdkMonitor *)g_list_model_get_item(monitors, i); + GdkRectangle geom; + gdk_monitor_get_geometry(m, &geom); + if (ax >= geom.x && ax < geom.x + geom.width && + ay >= geom.y && ay < geom.y + geom.height) { + if (final_x + sw > geom.x + geom.width) + final_x = ax - sw; /* flip to the left */ + g_object_unref(m); + break; + } + g_object_unref(m); + } + + x11_move_window(win, final_x, final_y); + gtk_window_present(GTK_WINDOW(win)); + + submenu_popups = g_list_prepend(submenu_popups, win); +} + +/* Build a vbox of GtkWidgets for the supplied items. Used for both the + top-level popup and each submenu popup. The submenu_open_data attached + to submenu buttons is freed when the button is destroyed. */ +static void on_button_destroy_free_data(GtkWidget *widget, gpointer user_data) { + (void)widget; + free(user_data); +} + +static GtkWidget *build_menu_box(xembed_menu_item *items, int count) { + GtkWidget *vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + + for (int i = 0; i < count; i++) { + xembed_menu_item *mi = &items[i]; + + if (mi->is_separator) { + GtkWidget *sep = gtk_separator_new(GTK_ORIENTATION_HORIZONTAL); + gtk_widget_set_margin_top(sep, 2); + gtk_widget_set_margin_bottom(sep, 2); + gtk_box_append(GTK_BOX(vbox), sep); + continue; + } + + if (mi->is_check) { + GtkWidget *chk = gtk_check_button_new_with_label( + mi->label ? mi->label : ""); + gtk_check_button_set_active(GTK_CHECK_BUTTON(chk), mi->checked); + gtk_widget_set_sensitive(chk, mi->enabled); + g_signal_connect(chk, "toggled", + G_CALLBACK(on_check_toggled), + GINT_TO_POINTER(mi->id)); + gtk_box_append(GTK_BOX(vbox), chk); + continue; + } + + /* Plain button (leaf) or submenu opener. Show "Label ▸" for + submenu folders so users see they're nested. */ + const char *label_text = mi->label ? mi->label : ""; + char *display_label = NULL; + if (mi->child_count > 0 && mi->children) { + /* Compose "label ▸" (BLACK RIGHT-POINTING SMALL TRIANGLE). */ + size_t n = strlen(label_text) + 8; /* ascii + " ▸" + NUL */ + display_label = (char *)malloc(n); + snprintf(display_label, n, "%s \xE2\x96\xB8", label_text); + label_text = display_label; + } + + GtkWidget *btn = gtk_button_new_with_label(label_text); + gtk_widget_set_sensitive(btn, mi->enabled); + gtk_button_set_has_frame(GTK_BUTTON(btn), FALSE); + GtkWidget *lbl = gtk_button_get_child(GTK_BUTTON(btn)); + if (GTK_IS_LABEL(lbl)) + gtk_label_set_xalign(GTK_LABEL(lbl), 0.0); + + free(display_label); + + if (mi->child_count > 0 && mi->children) { + submenu_open_data *sd = + (submenu_open_data *)calloc(1, sizeof(submenu_open_data)); + sd->items = mi->children; + sd->count = mi->child_count; + sd->anchor = btn; + g_signal_connect(btn, "clicked", + G_CALLBACK(on_submenu_button_clicked), sd); + g_signal_connect(btn, "destroy", + G_CALLBACK(on_button_destroy_free_data), sd); + } else { + g_signal_connect(btn, "clicked", + G_CALLBACK(on_button_clicked), + GINT_TO_POINTER(mi->id)); + } + gtk_box_append(GTK_BOX(vbox), btn); + } + + return vbox; +} + +static gboolean popup_menu_idle(gpointer user_data) { + popup_data *pd = (popup_data *)user_data; + + /* Destroy old top-level (and orphan submenus) before rebuilding. */ + close_all_popups(); + if (popup_win) { + gtk_window_destroy(GTK_WINDOW(popup_win)); + popup_win = NULL; + } + + popup_win = gtk_window_new(); + gtk_window_set_decorated(GTK_WINDOW(popup_win), FALSE); + gtk_window_set_resizable(GTK_WINDOW(popup_win), FALSE); + + attach_outside_click_close(popup_win); + + GtkWidget *vbox = build_menu_box(pd->items, pd->count); + gtk_window_set_child(GTK_WINDOW(popup_win), vbox); + + gtk_widget_set_visible(popup_win, TRUE); + + /* Position the window above the click point (menu grows upward + from tray). Use measured preferred size — default_size is -1 + until set. */ + GtkRequisition req; + gtk_widget_get_preferred_size(popup_win, NULL, &req); + int win_w = req.width; + int win_h = req.height; + + int final_x = pd->x - win_w / 2; + int final_y = pd->y - win_h; + if (final_x < 0) final_x = 0; + if (final_y < 0) final_y = pd->y; /* fallback: below click */ + x11_move_window(popup_win, final_x, final_y); + + gtk_window_present(GTK_WINDOW(popup_win)); + + /* The vbox+children retain pointers into pd->items (via submenu + click handlers). free_popup_data() walks the array recursively + to release labels and children buffers — but we need to keep + the items alive while the popup is open. Defer the free until + the popup window is destroyed. */ + g_object_set_data_full(G_OBJECT(popup_win), "popup_data", pd, + (GDestroyNotify)free_popup_data); + return G_SOURCE_REMOVE; +} + +/* Recursively deep-copy a Go-supplied items array into freshly-allocated + C memory. Each label is strdup'd, each children array is calloc'd. */ +static xembed_menu_item *copy_items(xembed_menu_item *src, int count) { + if (count <= 0 || !src) return NULL; + xembed_menu_item *dst = + (xembed_menu_item *)calloc(count, sizeof(xembed_menu_item)); + for (int i = 0; i < count; i++) { + dst[i] = src[i]; + if (src[i].label) + dst[i].label = strdup(src[i].label); + if (src[i].child_count > 0 && src[i].children) { + dst[i].children = copy_items(src[i].children, src[i].child_count); + dst[i].child_count = src[i].child_count; + } else { + dst[i].children = NULL; + dst[i].child_count = 0; + } + } + return dst; +} + +void xembed_show_popup_menu(xembed_menu_item *items, int count, + xembed_menu_click_cb cb, int x, int y) { + (void)cb; + popup_data *pd = (popup_data *)calloc(1, sizeof(popup_data)); + pd->items = copy_items(items, count); + pd->count = count; + pd->x = x; + pd->y = y; + + g_idle_add(popup_menu_idle, pd); +} diff --git a/client/ui/xembed_tray_linux.h b/client/ui/xembed_tray_linux.h new file mode 100644 index 000000000..18a77c4c0 --- /dev/null +++ b/client/ui/xembed_tray_linux.h @@ -0,0 +1,81 @@ +#ifndef XEMBED_TRAY_H +#define XEMBED_TRAY_H + +#include + +// xembed_default_screen wraps the DefaultScreen macro for CGo. +static inline int xembed_default_screen(Display *dpy) { + return DefaultScreen(dpy); +} + +// xembed_install_error_handlers replaces Xlib's default protocol- and +// I/O-error handlers (which call exit()) with logging handlers, so an async +// X error from a tray race doesn't kill the UI process. Process-global and +// idempotent; safe to call after every XOpenDisplay. +void xembed_install_error_handlers(void); + +// xembed_find_tray returns the selection owner window for +// _NET_SYSTEM_TRAY_S{screen}, or 0 if no XEmbed tray manager exists. +Window xembed_find_tray(Display *dpy, int screen); + +// xembed_get_icon_size queries _NET_SYSTEM_TRAY_ICON_SIZE from the tray +// manager window. Returns the size in pixels, or 0 if not set. +int xembed_get_icon_size(Display *dpy, Window tray_mgr); + +// xembed_create_icon creates a tray icon window of the given size, +// sets _XEMBED_INFO, and returns the window ID. +// tray_mgr is the tray manager window; its _NET_SYSTEM_TRAY_VISUAL +// property is queried to obtain a 32-bit ARGB visual for transparency. +Window xembed_create_icon(Display *dpy, int screen, int size, Window tray_mgr); + +// xembed_dock sends _NET_SYSTEM_TRAY_OPCODE SYSTEM_TRAY_REQUEST_DOCK +// to the tray manager to embed our icon window. +int xembed_dock(Display *dpy, Window tray_mgr, Window icon_win); + +// xembed_draw_icon draws ARGB pixel data onto the icon window. +// data is in [A,R,G,B] byte order per pixel (SNI IconPixmap format). +// img_w, img_h are the source image dimensions. +// win_size is the target window dimension (square). +void xembed_draw_icon(Display *dpy, Window icon_win, int win_size, + const unsigned char *data, int img_w, int img_h); + +// xembed_destroy_icon destroys the icon window. +void xembed_destroy_icon(Display *dpy, Window icon_win); + +// xembed_poll_event processes pending X11 events. Returns: +// 0 = no actionable event +// 1 = left button press (out_x, out_y filled) +// 2 = right button press (out_x, out_y filled) +// 3 = expose (needs redraw) +// 4 = configure (resize; out_x=width, out_y=height) +// -1 = DestroyNotify on icon window (tray died) +int xembed_poll_event(Display *dpy, Window icon_win, + int *out_x, int *out_y); + +// Callback type for menu item clicks. Called with the item's dbusmenu ID. +typedef void (*xembed_menu_click_cb)(int id); + +// xembed_popup_menu builds and shows a GTK3 popup menu. +// items is an array of menu item descriptors, count is the number of items. +// cb is called (from the GTK main thread) when an item is clicked. +// x, y are root coordinates for positioning the popup. +// This must be called from the GTK main thread (use g_idle_add). + +typedef struct xembed_menu_item { + int id; // dbusmenu item ID + const char *label; // display label (NULL for separator) + int enabled; // whether the item is clickable + int is_check; // whether this is a checkbox item + int checked; // checkbox state (0 or 1) + int is_separator;// 1 if this is a separator + // children + child_count populate when this item is a submenu folder + // (dbusmenu's children-display=="submenu"). NULL/0 means leaf item. + struct xembed_menu_item *children; + int child_count; +} xembed_menu_item; + +// Schedule a GTK popup menu on the main thread. +void xembed_show_popup_menu(xembed_menu_item *items, int count, + xembed_menu_click_cb cb, int x, int y); + +#endif diff --git a/client/wasm/cmd/main.go b/client/wasm/cmd/main.go index d8e50ab6d..260a528f0 100644 --- a/client/wasm/cmd/main.go +++ b/client/wasm/cmd/main.go @@ -5,6 +5,9 @@ package main import ( "context" "fmt" + "net" + "strconv" + "sync" "syscall/js" "time" @@ -14,9 +17,11 @@ import ( netbird "github.com/netbirdio/netbird/client/embed" sshdetection "github.com/netbirdio/netbird/client/ssh/detection" nbstatus "github.com/netbirdio/netbird/client/status" + wasmcapture "github.com/netbirdio/netbird/client/wasm/internal/capture" "github.com/netbirdio/netbird/client/wasm/internal/http" "github.com/netbirdio/netbird/client/wasm/internal/rdp" "github.com/netbirdio/netbird/client/wasm/internal/ssh" + nbwebsocket "github.com/netbirdio/netbird/client/wasm/internal/websocket" "github.com/netbirdio/netbird/util" ) @@ -26,6 +31,7 @@ const ( pingTimeout = 10 * time.Second defaultLogLevel = "warn" defaultSSHDetectionTimeout = 20 * time.Second + dialWebSocketTimeout = 30 * time.Second icmpEchoRequest = 8 icmpCodeEcho = 0 @@ -50,8 +56,7 @@ func startClient(ctx context.Context, nbClient *netbird.Client) error { // parseClientOptions extracts NetBird options from JavaScript object func parseClientOptions(jsOptions js.Value) (netbird.Options, error) { options := netbird.Options{ - DeviceName: "dashboard-client", - LogLevel: defaultLogLevel, + LogLevel: defaultLogLevel, } if jwtToken := jsOptions.Get("jwtToken"); !jwtToken.IsNull() && !jwtToken.IsUndefined() { @@ -81,9 +86,41 @@ func parseClientOptions(jsOptions js.Value) (netbird.Options, error) { options.DeviceName = deviceName.String() } + disableIPv6, err := boolOption(jsOptions, "disableIPv6") + if err != nil { + return options, err + } + if disableIPv6 != nil { + options.DisableIPv6 = *disableIPv6 + } + + // The caller decides whether this client uses lazy connections; left unset it + // defers to the management feature flag. A short-lived, interactive caller + // turns it off so its sessions reach the few peers their grant covers eagerly, + // instead of the first request waiting for the connection to be established. + lazyConnectionEnabled, err := boolOption(jsOptions, "lazyConnectionEnabled") + if err != nil { + return options, err + } + options.LazyConnectionEnabled = lazyConnectionEnabled + return options, nil } +// boolOption reads a boolean option, returning nil when the caller left it out. +// js.Value.Bool panics on any other type, so a wrong type is reported instead. +func boolOption(jsOptions js.Value, name string) (*bool, error) { + v := jsOptions.Get(name) + if v.IsNull() || v.IsUndefined() { + return nil, nil + } + if v.Type() != js.TypeBoolean { + return nil, fmt.Errorf("option %s must be a boolean, got %s", name, v.Type()) + } + b := v.Bool() + return &b, nil +} + // createStartMethod creates the start method for the client func createStartMethod(client *netbird.Client) js.Func { return js.FuncOf(func(this js.Value, args []js.Value) any { @@ -161,39 +198,58 @@ func createSSHMethod(client *netbird.Client) js.Func { }) } - var jwtToken string - if len(args) > 3 && !args[3].IsNull() && !args[3].IsUndefined() { - jwtToken = args[3].String() - } + jwtToken, ipVersion := parseSSHOptions(args) return createPromise(func(resolve, reject js.Value) { - sshClient := ssh.NewClient(client) - - if err := sshClient.Connect(host, port, username, jwtToken); err != nil { + jsInterface, err := connectSSH(client, host, port, username, jwtToken, ipVersion) + if err != nil { reject.Invoke(err.Error()) return } - - if err := sshClient.StartSession(80, 24); err != nil { - if closeErr := sshClient.Close(); closeErr != nil { - log.Errorf("Error closing SSH client: %v", closeErr) - } - reject.Invoke(err.Error()) - return - } - - jsInterface := ssh.CreateJSInterface(sshClient) resolve.Invoke(jsInterface) }) }) } -func performPing(client *netbird.Client, hostname string) { +func parseSSHOptions(args []js.Value) (jwtToken string, ipVersion int) { + if len(args) > 3 && !args[3].IsNull() && !args[3].IsUndefined() { + jwtToken = args[3].String() + } + if len(args) > 4 { + ipVersion = jsIPVersion(args[4]) + } + return +} + +func connectSSH(client *netbird.Client, host string, port int, username, jwtToken string, ipVersion int) (js.Value, error) { + sshClient := ssh.NewClient(client) + + if err := sshClient.Connect(host, port, username, jwtToken, ipVersion); err != nil { + return js.Undefined(), err + } + + if err := sshClient.StartSession(80, 24); err != nil { + if closeErr := sshClient.Close(); closeErr != nil { + log.Errorf("Error closing SSH client: %v", closeErr) + } + return js.Undefined(), err + } + + return ssh.CreateJSInterface(sshClient), nil +} + +func performPing(client *netbird.Client, hostname string, ipVersion int) { ctx, cancel := context.WithTimeout(context.Background(), pingTimeout) defer cancel() + // Default to ping4 to avoid dual-stack ICMP endpoint issues in wireguard-go netstack. + network := "ping4" + if ipVersion == 6 { + network = "ping6" + } + start := time.Now() - conn, err := client.Dial(ctx, "ping", hostname) + conn, err := client.Dial(ctx, network, hostname) if err != nil { js.Global().Get("console").Call("log", fmt.Sprintf("Ping to %s failed: %v", hostname, err)) return @@ -220,27 +276,39 @@ func performPing(client *netbird.Client, hostname string) { } latency := time.Since(start) - js.Global().Get("console").Call("log", fmt.Sprintf("Ping to %s: %dms", hostname, latency.Milliseconds())) + remote := conn.RemoteAddr().String() + msg := fmt.Sprintf("Ping to %s: %dms", hostname, latency.Milliseconds()) + if remote != hostname { + msg += fmt.Sprintf(" (via %s)", remote) + } + js.Global().Get("console").Call("log", msg) } -func performPingTCP(client *netbird.Client, hostname string, port int) { +func performPingTCP(client *netbird.Client, hostname string, port, ipVersion int) { ctx, cancel := context.WithTimeout(context.Background(), pingTimeout) defer cancel() - address := fmt.Sprintf("%s:%d", hostname, port) + network := ipVersionNetwork("tcp", ipVersion) + + address := net.JoinHostPort(hostname, fmt.Sprintf("%d", port)) start := time.Now() - conn, err := client.Dial(ctx, "tcp", address) + conn, err := client.Dial(ctx, network, address) if err != nil { js.Global().Get("console").Call("log", fmt.Sprintf("TCP ping to %s failed: %v", address, err)) return } latency := time.Since(start) + remote := conn.RemoteAddr().String() if err := conn.Close(); err != nil { log.Debugf("failed to close TCP connection: %v", err) } - js.Global().Get("console").Call("log", fmt.Sprintf("TCP ping to %s succeeded: %dms", address, latency.Milliseconds())) + msg := fmt.Sprintf("TCP ping to %s succeeded: %dms", address, latency.Milliseconds()) + if remote != address { + msg += fmt.Sprintf(" (via %s)", remote) + } + js.Global().Get("console").Call("log", msg) } // createPingMethod creates the ping method @@ -257,8 +325,12 @@ func createPingMethod(client *netbird.Client) js.Func { } hostname := args[0].String() + var ipVersion int + if len(args) > 1 { + ipVersion = jsIPVersion(args[1]) + } return createPromise(func(resolve, reject js.Value) { - performPing(client, hostname) + performPing(client, hostname, ipVersion) resolve.Invoke(js.Undefined()) }) }) @@ -285,8 +357,12 @@ func createPingTCPMethod(client *netbird.Client) js.Func { hostname := args[0].String() port := args[1].Int() + var ipVersion int + if len(args) > 2 { + ipVersion = jsIPVersion(args[2]) + } return createPromise(func(resolve, reject js.Value) { - performPingTCP(client, hostname, port) + performPingTCP(client, hostname, port, ipVersion) resolve.Invoke(js.Undefined()) }) }) @@ -459,6 +535,120 @@ func createSetLogLevelMethod(client *netbird.Client) js.Func { }) } +// ipVersionNetwork appends "4" or "6" to a base network string (e.g. "tcp" -> "tcp4"). +func ipVersionNetwork(base string, ipVersion int) string { + switch ipVersion { + case 4: + return base + "4" + case 6: + return base + "6" + default: + return base + } +} + +// jsIPVersion extracts an IP version (4 or 6) from a JS string or number. +func jsIPVersion(v js.Value) int { + switch v.Type() { + case js.TypeNumber: + return v.Int() + case js.TypeString: + n, _ := strconv.Atoi(v.String()) + return n + default: + return 0 + } +} + +// createStartCaptureMethod creates the programmable packet capture method. +// Returns a JS interface with onpacket callback and stop() method. +// +// Usage from JavaScript: +// +// const cap = await client.startCapture({ filter: "tcp port 443", verbose: true }) +// cap.onpacket = (line) => console.log(line) +// const stats = cap.stop() +func createStartCaptureMethod(client *netbird.Client) js.Func { + return js.FuncOf(func(_ js.Value, args []js.Value) any { + var opts js.Value + if len(args) > 0 { + opts = args[0] + } + + return createPromise(func(resolve, reject js.Value) { + iface, err := wasmcapture.Start(client, opts) + if err != nil { + reject.Invoke(js.ValueOf(fmt.Sprintf("start capture: %v", err))) + return + } + resolve.Invoke(iface) + }) + }) +} + +// captureMethods returns capture() and stopCapture() that share state for +// the console-log shortcut. capture() logs packets to the browser console +// and stopCapture() ends it, like Ctrl+C on the CLI. +// +// Usage from browser devtools console: +// +// await client.capture() // capture all packets +// await client.capture("tcp") // capture with filter +// await client.capture({filter: "host 10.0.0.1", verbose: true}) +// client.stopCapture() // stop and print stats +func captureMethods(client *netbird.Client) (startFn, stopFn js.Func) { + var mu sync.Mutex + var active *wasmcapture.Handle + + startFn = js.FuncOf(func(_ js.Value, args []js.Value) any { + var opts js.Value + if len(args) > 0 { + opts = args[0] + } + + return createPromise(func(resolve, reject js.Value) { + mu.Lock() + defer mu.Unlock() + + if active != nil { + active.Stop() + active = nil + } + + h, err := wasmcapture.StartConsole(client, opts) + if err != nil { + reject.Invoke(js.ValueOf(fmt.Sprintf("start capture: %v", err))) + return + } + active = h + + console := js.Global().Get("console") + console.Call("log", "[capture] started, call client.stopCapture() to stop") + resolve.Invoke(js.Undefined()) + }) + }) + + stopFn = js.FuncOf(func(_ js.Value, _ []js.Value) any { + mu.Lock() + defer mu.Unlock() + + if active == nil { + js.Global().Get("console").Call("log", "[capture] no active capture") + return js.Undefined() + } + + stats := active.Stop() + active = nil + + console := js.Global().Get("console") + console.Call("log", fmt.Sprintf("[capture] stopped: %d packets, %d bytes, %d dropped", + stats.Packets, stats.Bytes, stats.Dropped)) + return js.Undefined() + }) + + return startFn, stopFn +} + // createPromise is a helper to create JavaScript promises func createPromise(handler func(resolve, reject js.Value)) js.Value { return js.Global().Get("Promise").New(js.FuncOf(func(_ js.Value, promiseArgs []js.Value) any { @@ -516,15 +706,89 @@ func createClientObject(client *netbird.Client) js.Value { obj["createSSHConnection"] = createSSHMethod(client) obj["proxyRequest"] = createProxyRequestMethod(client) obj["createRDPProxy"] = createRDPProxyMethod(client) + obj["dialWebSocket"] = createDialWebSocketMethod(client) obj["status"] = createStatusMethod(client) obj["statusSummary"] = createStatusSummaryMethod(client) obj["statusDetail"] = createStatusDetailMethod(client) obj["getSyncResponse"] = createGetSyncResponseMethod(client) obj["setLogLevel"] = createSetLogLevelMethod(client) + obj["startCapture"] = createStartCaptureMethod(client) + + capStart, capStop := captureMethods(client) + obj["capture"] = capStart + obj["stopCapture"] = capStop return js.ValueOf(obj) } +func createDialWebSocketMethod(client *netbird.Client) js.Func { + return js.FuncOf(func(_ js.Value, args []js.Value) any { + url, protocols, timeout, errVal := parseDialWebSocketArgs(args) + if !errVal.IsUndefined() { + return errVal + } + + return createPromise(func(resolve, reject js.Value) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + conn, err := nbwebsocket.Dial(ctx, client, url, protocols) + if err != nil { + reject.Invoke(js.ValueOf(fmt.Sprintf("dial websocket: %v", err))) + return + } + + resolve.Invoke(nbwebsocket.NewJSInterface(conn)) + }) + }) +} + +func parseDialWebSocketArgs(args []js.Value) (url string, protocols []string, timeout time.Duration, errVal js.Value) { + if len(args) < 1 || args[0].Type() != js.TypeString { + return "", nil, 0, js.ValueOf("error: dialWebSocket requires a URL string argument") + } + url = args[0].String() + + if len(args) >= 2 && !args[1].IsNull() && !args[1].IsUndefined() { + arr, err := jsStringArray(args[1]) + if err != nil { + return "", nil, 0, js.ValueOf(fmt.Sprintf("error: protocols: %v", err)) + } + protocols = arr + } + + timeout = dialWebSocketTimeout + if len(args) >= 3 && !args[2].IsNull() && !args[2].IsUndefined() { + if args[2].Type() != js.TypeNumber { + return "", nil, 0, js.ValueOf("error: timeoutMs must be a number") + } + timeoutMs := args[2].Int() + if timeoutMs <= 0 { + return "", nil, 0, js.ValueOf("error: timeout must be positive") + } + timeout = time.Duration(timeoutMs) * time.Millisecond + } + + return url, protocols, timeout, js.Undefined() +} + +// jsStringArray converts a JS array of strings to a Go []string. +func jsStringArray(v js.Value) ([]string, error) { + if !v.InstanceOf(js.Global().Get("Array")) { + return nil, fmt.Errorf("expected array") + } + n := v.Length() + out := make([]string, n) + for i := 0; i < n; i++ { + el := v.Index(i) + if el.Type() != js.TypeString { + return nil, fmt.Errorf("element %d is not a string", i) + } + out[i] = el.String() + } + return out, nil +} + // netBirdClientConstructor acts as a JavaScript constructor function func netBirdClientConstructor(_ js.Value, args []js.Value) any { return js.Global().Get("Promise").New(js.FuncOf(func(_ js.Value, promiseArgs []js.Value) any { diff --git a/client/wasm/cmd/main_test.go b/client/wasm/cmd/main_test.go new file mode 100644 index 000000000..3ec5a8f6a --- /dev/null +++ b/client/wasm/cmd/main_test.go @@ -0,0 +1,64 @@ +//go:build js + +package main + +import ( + "syscall/js" + "testing" +) + +// TestParseClientOptionsBooleans covers the boolean options against the value +// kinds a JS caller can pass: js.Value.Bool panics on anything but a boolean, +// so a wrong type has to be rejected before it reaches the client. +func TestParseClientOptionsBooleans(t *testing.T) { + t.Run("unset leaves the lazy override empty", func(t *testing.T) { + options, err := parseClientOptions(js.Global().Get("Object").New()) + if err != nil { + t.Fatalf("parse options: %v", err) + } + if options.LazyConnectionEnabled != nil { + t.Errorf("lazy override should stay unset, got %v", *options.LazyConnectionEnabled) + } + if options.DisableIPv6 { + t.Error("disableIPv6 should default to false") + } + }) + + t.Run("null defers to the management flag", func(t *testing.T) { + jsOptions := js.Global().Get("Object").New() + jsOptions.Set("lazyConnectionEnabled", js.Null()) + options, err := parseClientOptions(jsOptions) + if err != nil { + t.Fatalf("parse options: %v", err) + } + if options.LazyConnectionEnabled != nil { + t.Errorf("lazy override should stay unset, got %v", *options.LazyConnectionEnabled) + } + }) + + t.Run("booleans are carried through", func(t *testing.T) { + jsOptions := js.Global().Get("Object").New() + jsOptions.Set("lazyConnectionEnabled", false) + jsOptions.Set("disableIPv6", true) + options, err := parseClientOptions(jsOptions) + if err != nil { + t.Fatalf("parse options: %v", err) + } + if options.LazyConnectionEnabled == nil || *options.LazyConnectionEnabled { + t.Errorf("lazy override should be false, got %v", options.LazyConnectionEnabled) + } + if !options.DisableIPv6 { + t.Error("disableIPv6 should be true") + } + }) + + t.Run("a non-boolean is rejected", func(t *testing.T) { + for _, value := range []any{"true", 1, js.Global().Get("Object").New()} { + jsOptions := js.Global().Get("Object").New() + jsOptions.Set("lazyConnectionEnabled", value) + if _, err := parseClientOptions(jsOptions); err == nil { + t.Errorf("value %v should be rejected", value) + } + } + }) +} diff --git a/client/wasm/internal/capture/capture.go b/client/wasm/internal/capture/capture.go new file mode 100644 index 000000000..53e43c45e --- /dev/null +++ b/client/wasm/internal/capture/capture.go @@ -0,0 +1,176 @@ +//go:build js + +// Package capture bridges the util/capture package to JavaScript via syscall/js. +package capture + +import ( + "strings" + "sync" + "syscall/js" + + netbird "github.com/netbirdio/netbird/client/embed" +) + +// Handle holds a running capture session so it can be stopped later. +type Handle struct { + cs *netbird.CaptureSession + stopFn js.Func + stopped bool +} + +// Stop ends the capture and returns stats. +func (h *Handle) Stop() netbird.CaptureStats { + if h.stopped { + return h.cs.Stats() + } + h.stopped = true + h.stopFn.Release() + + h.cs.Stop() + return h.cs.Stats() +} + +func statsToJS(s netbird.CaptureStats) js.Value { + obj := js.Global().Get("Object").Call("create", js.Null()) + obj.Set("packets", js.ValueOf(s.Packets)) + obj.Set("bytes", js.ValueOf(s.Bytes)) + obj.Set("dropped", js.ValueOf(s.Dropped)) + return obj +} + +// parseOpts extracts filter/verbose/ascii from a JS options value. +func parseOpts(jsOpts js.Value) (filter string, verbose, ascii bool) { + if jsOpts.IsNull() || jsOpts.IsUndefined() { + return + } + if jsOpts.Type() == js.TypeString { + filter = jsOpts.String() + return + } + if jsOpts.Type() != js.TypeObject { + return + } + if f := jsOpts.Get("filter"); !f.IsUndefined() && !f.IsNull() { + filter = f.String() + } + if v := jsOpts.Get("verbose"); !v.IsUndefined() { + verbose = v.Truthy() + } + if a := jsOpts.Get("ascii"); !a.IsUndefined() { + ascii = a.Truthy() + } + return +} + +// Start creates a capture session and returns a JS interface for streaming text +// output. The returned object exposes: +// +// onpacket(callback) - set callback(string) for each text line +// stop() - stop capture and return stats { packets, bytes, dropped } +// +// Options: { filter: string, verbose: bool, ascii: bool } or just a filter string. +func Start(client *netbird.Client, jsOpts js.Value) (js.Value, error) { + filter, verbose, ascii := parseOpts(jsOpts) + + cb := &jsCallbackWriter{} + + cs, err := client.StartCapture(netbird.CaptureOptions{ + TextOutput: cb, + Filter: filter, + Verbose: verbose, + ASCII: ascii, + }) + if err != nil { + return js.Undefined(), err + } + + handle := &Handle{cs: cs} + + iface := js.Global().Get("Object").Call("create", js.Null()) + handle.stopFn = js.FuncOf(func(_ js.Value, _ []js.Value) any { + return statsToJS(handle.Stop()) + }) + iface.Set("stop", handle.stopFn) + iface.Set("onpacket", js.Undefined()) + cb.setInterface(iface) + + return iface, nil +} + +// StartConsole starts a capture that logs every packet line to console.log. +// Returns a Handle so the caller can stop it later. +func StartConsole(client *netbird.Client, jsOpts js.Value) (*Handle, error) { + filter, verbose, ascii := parseOpts(jsOpts) + + cb := &jsCallbackWriter{} + + cs, err := client.StartCapture(netbird.CaptureOptions{ + TextOutput: cb, + Filter: filter, + Verbose: verbose, + ASCII: ascii, + }) + if err != nil { + return nil, err + } + + handle := &Handle{cs: cs} + handle.stopFn = js.FuncOf(func(_ js.Value, _ []js.Value) any { + return statsToJS(handle.Stop()) + }) + + iface := js.Global().Get("Object").Call("create", js.Null()) + console := js.Global().Get("console") + iface.Set("onpacket", console.Get("log").Call("bind", console, js.ValueOf("[capture]"))) + cb.setInterface(iface) + + return handle, nil +} + +// jsCallbackWriter is an io.Writer that buffers text until a newline, then +// invokes the JS onpacket callback with each complete line. +type jsCallbackWriter struct { + mu sync.Mutex + iface js.Value + buf strings.Builder +} + +func (w *jsCallbackWriter) setInterface(iface js.Value) { + w.mu.Lock() + defer w.mu.Unlock() + w.iface = iface +} + +func (w *jsCallbackWriter) Write(p []byte) (int, error) { + w.mu.Lock() + w.buf.Write(p) + + var lines []string + for { + str := w.buf.String() + idx := strings.IndexByte(str, '\n') + if idx < 0 { + break + } + lines = append(lines, str[:idx]) + w.buf.Reset() + if idx+1 < len(str) { + w.buf.WriteString(str[idx+1:]) + } + } + + iface := w.iface + w.mu.Unlock() + + if iface.IsUndefined() { + return len(p), nil + } + cb := iface.Get("onpacket") + if cb.IsUndefined() || cb.IsNull() { + return len(p), nil + } + for _, line := range lines { + cb.Invoke(js.ValueOf(line)) + } + return len(p), nil +} diff --git a/client/wasm/internal/rdp/cert_validation.go b/client/wasm/internal/rdp/cert_validation.go index 1678c3996..47e30b6e6 100644 --- a/client/wasm/internal/rdp/cert_validation.go +++ b/client/wasm/internal/rdp/cert_validation.go @@ -6,6 +6,7 @@ import ( "crypto/tls" "crypto/x509" "fmt" + "sync" "syscall/js" "time" @@ -13,7 +14,7 @@ import ( ) const ( - certValidationTimeout = 60 * time.Second + certValidationTimeout = 5 * time.Minute ) func (p *RDCleanPathProxy) validateCertificateWithJS(conn *proxyConnection, certChain [][]byte) (bool, error) { @@ -46,17 +47,31 @@ func (p *RDCleanPathProxy) validateCertificateWithJS(conn *proxyConnection, cert promise := conn.wsHandlers.Call("onCertificateRequest", certInfo) - resultChan := make(chan bool) - errorChan := make(chan error) + resultChan := make(chan bool, 1) + errorChan := make(chan error, 1) - promise.Call("then", js.FuncOf(func(this js.Value, args []js.Value) interface{} { - result := args[0].Bool() - resultChan <- result + // Release from inside the callbacks so a post-timeout promise resolution + // does not invoke an already-released func. + var thenFn, catchFn js.Func + var releaseOnce sync.Once + release := func() { + releaseOnce.Do(func() { + thenFn.Release() + catchFn.Release() + }) + } + thenFn = js.FuncOf(func(this js.Value, args []js.Value) interface{} { + defer release() + resultChan <- args[0].Bool() return nil - })).Call("catch", js.FuncOf(func(this js.Value, args []js.Value) interface{} { + }) + catchFn = js.FuncOf(func(this js.Value, args []js.Value) interface{} { + defer release() errorChan <- fmt.Errorf("certificate validation failed") return nil - })) + }) + + promise.Call("then", thenFn).Call("catch", catchFn) select { case result := <-resultChan: diff --git a/client/wasm/internal/rdp/rdcleanpath.go b/client/wasm/internal/rdp/rdcleanpath.go index 16bf63bb9..3d8be8950 100644 --- a/client/wasm/internal/rdp/rdcleanpath.go +++ b/client/wasm/internal/rdp/rdcleanpath.go @@ -11,6 +11,7 @@ import ( "io" "net" "sync" + "sync/atomic" "syscall/js" "time" @@ -22,7 +23,7 @@ const ( RDCleanPathProxyHost = "rdcleanpath.proxy.local" RDCleanPathProxyScheme = "ws" - rdpDialTimeout = 15 * time.Second + rdpDialTimeout = 30 * time.Second GeneralErrorCode = 1 WSAETimedOut = 10060 @@ -57,6 +58,8 @@ type RDCleanPathProxy struct { } activeConnections map[string]*proxyConnection destinations map[string]string + pendingHandlers map[string]js.Func + nextID atomic.Uint64 mu sync.Mutex } @@ -66,8 +69,15 @@ type proxyConnection struct { rdpConn net.Conn tlsConn *tls.Conn wsHandlers js.Value - ctx context.Context - cancel context.CancelFunc + // Go-side callbacks exposed to JS. js.FuncOf pins the Go closure in a + // global handle map and MUST be released, otherwise every connection + // leaks the Go memory the closure captures. + wsHandlerFn js.Func + onMessageFn js.Func + onCloseFn js.Func + cleanupOnce sync.Once + ctx context.Context + cancel context.CancelFunc } // NewRDCleanPathProxy creates a new RDCleanPath proxy @@ -80,15 +90,19 @@ func NewRDCleanPathProxy(client interface { } } -// CreateProxy creates a new proxy endpoint for the given destination +// CreateProxy creates a new proxy endpoint for the given destination. +// The registered handler fn and its destinations/pendingHandlers entries are +// only released once a connection is established and cleanupConnection runs. +// If a caller invokes CreateProxy but never connects to the returned URL, +// those entries stay pinned for the lifetime of the page. func (p *RDCleanPathProxy) CreateProxy(hostname, port string) js.Value { - destination := fmt.Sprintf("%s:%s", hostname, port) + destination := net.JoinHostPort(hostname, port) return js.Global().Get("Promise").New(js.FuncOf(func(_ js.Value, args []js.Value) any { resolve := args[0] go func() { - proxyID := fmt.Sprintf("proxy_%d", len(p.activeConnections)) + proxyID := fmt.Sprintf("proxy_%d", p.nextID.Add(1)) p.mu.Lock() if p.destinations == nil { @@ -100,7 +114,7 @@ func (p *RDCleanPathProxy) CreateProxy(hostname, port string) js.Value { proxyURL := fmt.Sprintf("%s://%s/%s", RDCleanPathProxyScheme, RDCleanPathProxyHost, proxyID) // Register the WebSocket handler for this specific proxy - js.Global().Set(fmt.Sprintf("handleRDCleanPathWebSocket_%s", proxyID), js.FuncOf(func(_ js.Value, args []js.Value) any { + handlerFn := js.FuncOf(func(_ js.Value, args []js.Value) any { if len(args) < 1 { return js.ValueOf("error: requires WebSocket argument") } @@ -108,7 +122,14 @@ func (p *RDCleanPathProxy) CreateProxy(hostname, port string) js.Value { ws := args[0] p.HandleWebSocketConnection(ws, proxyID) return nil - })) + }) + p.mu.Lock() + if p.pendingHandlers == nil { + p.pendingHandlers = make(map[string]js.Func) + } + p.pendingHandlers[proxyID] = handlerFn + p.mu.Unlock() + js.Global().Set(fmt.Sprintf("handleRDCleanPathWebSocket_%s", proxyID), handlerFn) log.Infof("Created RDCleanPath proxy endpoint: %s for destination: %s", proxyURL, destination) resolve.Invoke(proxyURL) @@ -142,6 +163,10 @@ func (p *RDCleanPathProxy) HandleWebSocketConnection(ws js.Value, proxyID string p.mu.Lock() p.activeConnections[proxyID] = conn + if fn, ok := p.pendingHandlers[proxyID]; ok { + conn.wsHandlerFn = fn + delete(p.pendingHandlers, proxyID) + } p.mu.Unlock() p.setupWebSocketHandlers(ws, conn) @@ -150,7 +175,7 @@ func (p *RDCleanPathProxy) HandleWebSocketConnection(ws js.Value, proxyID string } func (p *RDCleanPathProxy) setupWebSocketHandlers(ws js.Value, conn *proxyConnection) { - ws.Set("onGoMessage", js.FuncOf(func(this js.Value, args []js.Value) any { + conn.onMessageFn = js.FuncOf(func(this js.Value, args []js.Value) any { if len(args) < 1 { return nil } @@ -158,13 +183,15 @@ func (p *RDCleanPathProxy) setupWebSocketHandlers(ws js.Value, conn *proxyConnec data := args[0] go p.handleWebSocketMessage(conn, data) return nil - })) + }) + ws.Set("onGoMessage", conn.onMessageFn) - ws.Set("onGoClose", js.FuncOf(func(_ js.Value, args []js.Value) any { + conn.onCloseFn = js.FuncOf(func(_ js.Value, args []js.Value) any { log.Debug("WebSocket closed by JavaScript") conn.cancel() return nil - })) + }) + ws.Set("onGoClose", conn.onCloseFn) } func (p *RDCleanPathProxy) handleWebSocketMessage(conn *proxyConnection, data js.Value) { @@ -261,25 +288,49 @@ func (p *RDCleanPathProxy) handleDirectRDP(conn *proxyConnection, firstPacket [] } func (p *RDCleanPathProxy) cleanupConnection(conn *proxyConnection) { - log.Debugf("Cleaning up connection %s", conn.id) - conn.cancel() - if conn.tlsConn != nil { - log.Debug("Closing TLS connection") - if err := conn.tlsConn.Close(); err != nil { - log.Debugf("Error closing TLS connection: %v", err) + conn.cleanupOnce.Do(func() { + log.Debugf("Cleaning up connection %s", conn.id) + conn.cancel() + if conn.tlsConn != nil { + log.Debug("Closing TLS connection") + if err := conn.tlsConn.Close(); err != nil { + log.Debugf("Error closing TLS connection: %v", err) + } + conn.tlsConn = nil } - conn.tlsConn = nil - } - if conn.rdpConn != nil { - log.Debug("Closing TCP connection") - if err := conn.rdpConn.Close(); err != nil { - log.Debugf("Error closing TCP connection: %v", err) + if conn.rdpConn != nil { + log.Debug("Closing TCP connection") + if err := conn.rdpConn.Close(); err != nil { + log.Debugf("Error closing TCP connection: %v", err) + } + conn.rdpConn = nil } - conn.rdpConn = nil - } - p.mu.Lock() - delete(p.activeConnections, conn.id) - p.mu.Unlock() + js.Global().Delete(fmt.Sprintf("handleRDCleanPathWebSocket_%s", conn.id)) + + // Detach before releasing so late JS calls surface as TypeError instead + // of silent "call to released function". + if conn.wsHandlers.Truthy() { + conn.wsHandlers.Set("onGoMessage", js.Undefined()) + conn.wsHandlers.Set("onGoClose", js.Undefined()) + } + + // wsHandlerFn may be zero-value if the pending handler lookup missed. + if conn.wsHandlerFn.Truthy() { + conn.wsHandlerFn.Release() + } + if conn.onMessageFn.Truthy() { + conn.onMessageFn.Release() + } + if conn.onCloseFn.Truthy() { + conn.onCloseFn.Release() + } + + p.mu.Lock() + delete(p.activeConnections, conn.id) + delete(p.destinations, conn.id) + delete(p.pendingHandlers, conn.id) + p.mu.Unlock() + }) } func (p *RDCleanPathProxy) sendToWebSocket(conn *proxyConnection, data []byte) { diff --git a/client/wasm/internal/ssh/client.go b/client/wasm/internal/ssh/client.go index 568437e56..28ae95ec0 100644 --- a/client/wasm/internal/ssh/client.go +++ b/client/wasm/internal/ssh/client.go @@ -6,6 +6,7 @@ import ( "context" "fmt" "io" + "net" "sync" "time" @@ -45,9 +46,10 @@ func NewClient(nbClient *netbird.Client) *Client { } } -// Connect establishes an SSH connection through NetBird network -func (c *Client) Connect(host string, port int, username, jwtToken string) error { - addr := fmt.Sprintf("%s:%d", host, port) +// Connect establishes an SSH connection through NetBird network. +// ipVersion may be 4, 6, or 0 for automatic selection. +func (c *Client) Connect(host string, port int, username, jwtToken string, ipVersion int) error { + addr := net.JoinHostPort(host, fmt.Sprintf("%d", port)) logrus.Infof("SSH: Connecting to %s as %s", addr, username) authMethods, err := c.getAuthMethods(jwtToken) @@ -62,21 +64,28 @@ func (c *Client) Connect(host string, port int, username, jwtToken string) error Timeout: sshDialTimeout, } + network := "tcp" + switch ipVersion { + case 4: + network = "tcp4" + case 6: + network = "tcp6" + } + ctx, cancel := context.WithTimeout(context.Background(), sshDialTimeout) defer cancel() - conn, err := c.nbClient.Dial(ctx, "tcp", addr) + conn, err := c.nbClient.Dial(ctx, network, addr) if err != nil { return fmt.Errorf("dial %s: %w", addr, err) } - sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config) + sshClient, err := nbssh.Handshake(ctx, conn, addr, config) if err != nil { - closeWithLog(conn, "connection after handshake error") - return fmt.Errorf("SSH handshake: %w", err) + return err } - c.sshClient = ssh.NewClient(sshConn, chans, reqs) + c.sshClient = sshClient logrus.Infof("SSH: Connected to %s", addr) return nil @@ -109,57 +118,26 @@ func (c *Client) getAuthMethods(jwtToken string) ([]ssh.AuthMethod, error) { return []ssh.AuthMethod{ssh.PublicKeys(signer)}, nil } -// StartSession starts an SSH session with PTY +// StartSession starts an SSH session with PTY. It holds the client lock for +// the whole startup so Close cannot tear the client down mid-setup and the +// new session cannot be installed into an already closed client. func (c *Client) StartSession(cols, rows int) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.sshClient == nil { return fmt.Errorf("SSH client not connected") } - session, err := c.sshClient.NewSession() + pty, err := nbssh.StartPTYSession(c.sshClient, cols, rows) if err != nil { - return fmt.Errorf("create session: %w", err) + return err } - c.mu.Lock() - defer c.mu.Unlock() - c.session = session - - modes := ssh.TerminalModes{ - ssh.ECHO: 1, - ssh.TTY_OP_ISPEED: 14400, - ssh.TTY_OP_OSPEED: 14400, - ssh.VINTR: 3, - ssh.VQUIT: 28, - ssh.VERASE: 127, - } - - if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil { - closeWithLog(session, "session after PTY error") - return fmt.Errorf("PTY request: %w", err) - } - - c.stdin, err = session.StdinPipe() - if err != nil { - closeWithLog(session, "session after stdin error") - return fmt.Errorf("get stdin: %w", err) - } - - c.stdout, err = session.StdoutPipe() - if err != nil { - closeWithLog(session, "session after stdout error") - return fmt.Errorf("get stdout: %w", err) - } - - c.stderr, err = session.StderrPipe() - if err != nil { - closeWithLog(session, "session after stderr error") - return fmt.Errorf("get stderr: %w", err) - } - - if err := session.Shell(); err != nil { - closeWithLog(session, "session after shell error") - return fmt.Errorf("start shell: %w", err) - } + c.session = pty.Session + c.stdin = pty.Stdin + c.stdout = pty.Stdout + c.stderr = pty.Stderr logrus.Info("SSH: Session started with PTY") return nil diff --git a/client/wasm/internal/ssh/handlers.go b/client/wasm/internal/ssh/handlers.go index ea64eb0aa..6d33916a5 100644 --- a/client/wasm/internal/ssh/handlers.go +++ b/client/wasm/internal/ssh/handlers.go @@ -13,7 +13,7 @@ import ( func CreateJSInterface(client *Client) js.Value { jsInterface := js.Global().Get("Object").Call("create", js.Null()) - jsInterface.Set("write", js.FuncOf(func(this js.Value, args []js.Value) any { + writeFunc := js.FuncOf(func(this js.Value, args []js.Value) any { if len(args) < 1 { return js.ValueOf(false) } @@ -32,9 +32,10 @@ func CreateJSInterface(client *Client) js.Value { _, err := client.Write(bytes) return js.ValueOf(err == nil) - })) + }) + jsInterface.Set("write", writeFunc) - jsInterface.Set("resize", js.FuncOf(func(this js.Value, args []js.Value) any { + resizeFunc := js.FuncOf(func(this js.Value, args []js.Value) any { if len(args) < 2 { return js.ValueOf(false) } @@ -42,14 +43,26 @@ func CreateJSInterface(client *Client) js.Value { rows := args[1].Int() err := client.Resize(cols, rows) return js.ValueOf(err == nil) - })) + }) + jsInterface.Set("resize", resizeFunc) - jsInterface.Set("close", js.FuncOf(func(this js.Value, args []js.Value) any { + closeFunc := js.FuncOf(func(this js.Value, args []js.Value) any { client.Close() return js.Undefined() - })) + }) + jsInterface.Set("close", closeFunc) - go readLoop(client, jsInterface) + go func() { + readLoop(client, jsInterface) + // Detach before releasing so late JS calls surface as TypeError instead + // of silent "call to released function". + jsInterface.Set("write", js.Undefined()) + jsInterface.Set("resize", js.Undefined()) + jsInterface.Set("close", js.Undefined()) + writeFunc.Release() + resizeFunc.Release() + closeFunc.Release() + }() return jsInterface } diff --git a/client/wasm/internal/websocket/websocket.go b/client/wasm/internal/websocket/websocket.go new file mode 100644 index 000000000..19ddaa38c --- /dev/null +++ b/client/wasm/internal/websocket/websocket.go @@ -0,0 +1,304 @@ +//go:build js + +package websocket + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "sync" + "syscall/js" + + "github.com/gobwas/ws" + "github.com/gobwas/ws/wsutil" + netbird "github.com/netbirdio/netbird/client/embed" + log "github.com/sirupsen/logrus" +) + +type closeError struct { + code uint16 + reason string +} + +func (e *closeError) Error() string { + return fmt.Sprintf("websocket closed: %d %s", e.code, e.reason) +} + +// bufferedConn fronts a net.Conn with a reader that serves any bytes buffered +// during the WebSocket handshake before falling through to the raw conn. +type bufferedConn struct { + net.Conn + r io.Reader +} + +func (c *bufferedConn) Read(p []byte) (int, error) { return c.r.Read(p) } + +// Conn wraps a WebSocket connection over a NetBird TCP connection. +type Conn struct { + conn net.Conn + mu sync.Mutex + closed chan struct{} + closeOnce sync.Once + closeErr error +} + +// Dial establishes a WebSocket connection to the given URL through the NetBird network. +// Optional protocols are sent via the Sec-WebSocket-Protocol header. +func Dial(ctx context.Context, client *netbird.Client, rawURL string, protocols []string) (*Conn, error) { + d := ws.Dialer{ + NetDial: client.Dial, + Protocols: protocols, + } + + conn, br, _, err := d.Dial(ctx, rawURL) + if err != nil { + return nil, fmt.Errorf("websocket dial: %w", err) + } + + // br is non-nil when the server pushed frames alongside the handshake + // response; those bytes live in the bufio.Reader and must be drained + // before reading from conn, otherwise we'd skip the first frames. + if br != nil { + if br.Buffered() > 0 { + conn = &bufferedConn{Conn: conn, r: io.MultiReader(br, conn)} + } else { + ws.PutReader(br) + } + } + + return &Conn{ + conn: conn, + closed: make(chan struct{}), + }, nil +} + +// ReadMessage reads the next WebSocket message, handling control frames automatically. +func (c *Conn) ReadMessage() (ws.OpCode, []byte, error) { + for { + msgs, err := wsutil.ReadServerMessage(c.conn, nil) + if err != nil { + return 0, nil, err + } + + for _, msg := range msgs { + if msg.OpCode.IsControl() { + if err := c.handleControl(msg); err != nil { + return 0, nil, err + } + continue + } + return msg.OpCode, msg.Payload, nil + } + } +} + +func (c *Conn) handleControl(msg wsutil.Message) error { + switch msg.OpCode { + case ws.OpPing: + c.mu.Lock() + defer c.mu.Unlock() + return wsutil.WriteClientMessage(c.conn, ws.OpPong, msg.Payload) + case ws.OpClose: + code, reason := parseClosePayload(msg.Payload) + return &closeError{code: code, reason: reason} + default: + return nil + } +} + +// WriteText sends a text WebSocket message. +func (c *Conn) WriteText(data []byte) error { + c.mu.Lock() + defer c.mu.Unlock() + return wsutil.WriteClientMessage(c.conn, ws.OpText, data) +} + +// WriteBinary sends a binary WebSocket message. +func (c *Conn) WriteBinary(data []byte) error { + c.mu.Lock() + defer c.mu.Unlock() + return wsutil.WriteClientMessage(c.conn, ws.OpBinary, data) +} + +// Close sends a close frame with StatusNormalClosure and closes the underlying connection. +func (c *Conn) Close() error { + return c.closeWith(ws.StatusNormalClosure, "") +} + +// closeWith sends a close frame with the given code/reason and closes the underlying connection. +// Used to echo the server's code when responding to a server-initiated close per RFC 6455 §5.5.1. +func (c *Conn) closeWith(code ws.StatusCode, reason string) error { + var first bool + c.closeOnce.Do(func() { + first = true + close(c.closed) + + c.mu.Lock() + _ = wsutil.WriteClientMessage(c.conn, ws.OpClose, ws.NewCloseFrameBody(code, reason)) + c.mu.Unlock() + + c.closeErr = c.conn.Close() + }) + + if !first { + return net.ErrClosed + } + return c.closeErr +} + +// NewJSInterface creates a JavaScript object wrapping the WebSocket connection. +// It exposes: send(string|Uint8Array), close(), and callback properties +// onmessage, onclose, onerror. +// +// Callback properties may be set from the JS thread while the read loop +// goroutine reads them. In WASM this is safe because Go and JS share a +// single thread, but the design would need synchronization on +// multi-threaded runtimes. +func NewJSInterface(conn *Conn) js.Value { + obj := js.Global().Get("Object").Call("create", js.Null()) + + sendFunc := js.FuncOf(func(_ js.Value, args []js.Value) any { + if len(args) < 1 { + log.Errorf("websocket send requires a data argument") + return js.ValueOf(false) + } + + data := args[0] + switch data.Type() { + case js.TypeString: + if err := conn.WriteText([]byte(data.String())); err != nil { + log.Errorf("failed to send websocket text: %v", err) + return js.ValueOf(false) + } + default: + buf, err := jsToBytes(data) + if err != nil { + log.Errorf("failed to convert js value to bytes: %v", err) + return js.ValueOf(false) + } + if err := conn.WriteBinary(buf); err != nil { + log.Errorf("failed to send websocket binary: %v", err) + return js.ValueOf(false) + } + } + return js.ValueOf(true) + }) + obj.Set("send", sendFunc) + + closeFunc := js.FuncOf(func(_ js.Value, _ []js.Value) any { + if err := conn.Close(); err != nil { + log.Debugf("failed to close websocket: %v", err) + } + return js.Undefined() + }) + obj.Set("close", closeFunc) + + go func() { + defer func() { + if err := conn.Close(); err != nil && !errors.Is(err, net.ErrClosed) { + log.Debugf("close websocket on readLoop exit: %v", err) + } + }() + readLoop(conn, obj) + // Undefining before Release turns post-close JS calls into TypeError + // instead of a silent "call to released function". + obj.Set("send", js.Undefined()) + obj.Set("close", js.Undefined()) + sendFunc.Release() + closeFunc.Release() + }() + + return obj +} + +func jsToBytes(data js.Value) ([]byte, error) { + var uint8Array js.Value + switch { + case data.InstanceOf(js.Global().Get("Uint8Array")): + uint8Array = data + case data.InstanceOf(js.Global().Get("ArrayBuffer")): + uint8Array = js.Global().Get("Uint8Array").New(data) + default: + return nil, fmt.Errorf("send: unsupported data type, use string, Uint8Array, or ArrayBuffer") + } + + buf := make([]byte, uint8Array.Get("length").Int()) + js.CopyBytesToGo(buf, uint8Array) + return buf, nil +} + +func readLoop(conn *Conn, obj js.Value) { + var ce *closeError + defer func() { invokeOnClose(obj, ce) }() + + for { + select { + case <-conn.closed: + return + default: + } + + op, payload, err := conn.ReadMessage() + if err != nil { + ce = handleReadError(conn, obj, err) + return + } + + dispatchMessage(obj, op, payload) + } +} + +func handleReadError(conn *Conn, obj js.Value, err error) *closeError { + var ce *closeError + if errors.As(err, &ce) { + if cerr := conn.closeWith(ws.StatusCode(ce.code), ce.reason); cerr != nil { + log.Debugf("failed to close websocket after server close frame: %v", cerr) + } + return ce + } + if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) { + return nil + } + if onerror := obj.Get("onerror"); onerror.Truthy() { + onerror.Invoke(js.ValueOf(err.Error())) + } + return nil +} + +func invokeOnClose(obj js.Value, ce *closeError) { + onclose := obj.Get("onclose") + if !onclose.Truthy() { + return + } + if ce != nil { + onclose.Invoke(js.ValueOf(int(ce.code)), js.ValueOf(ce.reason)) + return + } + onclose.Invoke() +} + +func dispatchMessage(obj js.Value, op ws.OpCode, payload []byte) { + onmessage := obj.Get("onmessage") + if !onmessage.Truthy() { + return + } + switch op { + case ws.OpText: + onmessage.Invoke(js.ValueOf(string(payload))) + case ws.OpBinary: + uint8Array := js.Global().Get("Uint8Array").New(len(payload)) + js.CopyBytesToJS(uint8Array, payload) + onmessage.Invoke(uint8Array) + } +} + +func parseClosePayload(payload []byte) (uint16, string) { + if len(payload) < 2 { + return 1005, "" // RFC 6455: No Status Rcvd + } + code := binary.BigEndian.Uint16(payload[:2]) + return code, string(payload[2:]) +} diff --git a/combined/Dockerfile b/combined/Dockerfile index 357e10cf8..ac88b8509 100644 --- a/combined/Dockerfile +++ b/combined/Dockerfile @@ -2,4 +2,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 +ARG TARGETPLATFORM +COPY ${TARGETPLATFORM}/netbird-server /go/bin/netbird-server diff --git a/combined/Dockerfile.multistage b/combined/Dockerfile.multistage index ef3d68c6e..011379c2f 100644 --- a/combined/Dockerfile.multistage +++ b/combined/Dockerfile.multistage @@ -1,16 +1,20 @@ -FROM golang:1.25-bookworm AS builder +FROM golang:1.26.7-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 +RUN --mount=type=cache,target=/go/pkg/mod go mod download COPY . . -# Build with version info from git (matching goreleaser ldflags) -RUN CGO_ENABLED=1 GOOS=linux go build \ +# Build with version info from git (matching goreleaser ldflags). +# BuildKit cache mounts persist the module + build caches across image builds, +# so a source change recompiles incrementally instead of from scratch. +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + 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') \ diff --git a/combined/cmd/admin.go b/combined/cmd/admin.go new file mode 100644 index 000000000..66fac4ac9 --- /dev/null +++ b/combined/cmd/admin.go @@ -0,0 +1,151 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/dexidp/dex/storage" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "github.com/netbirdio/netbird/formatter/hook" + admincmd "github.com/netbirdio/netbird/management/cmd/admin" + tokencmd "github.com/netbirdio/netbird/management/cmd/token" + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + "github.com/netbirdio/netbird/management/server/activity" + activitystore "github.com/netbirdio/netbird/management/server/activity/store" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/util" +) + +// newAdminCommands creates the admin command tree with combined-specific resource openers. +func newAdminCommands() *cobra.Command { + return admincmd.NewCommands(admincmd.Openers{ + Resources: withAdminResources, + Store: withAdminStoreOnly, + IDP: withAdminIDPOnly, + }) +} + +func newLegacyTokenCommand() *cobra.Command { + cmd := tokencmd.NewCommands(tokencmd.StoreOpener(withAdminStoreOnly)) + cmd.Deprecated = "use 'admin token' instead" + return cmd +} + +// withAdminResources loads the combined YAML config, initializes stores, and calls fn. +func withAdminResources(cmd *cobra.Command, fn func(ctx context.Context, resources admincmd.Resources) error) error { + return withAdminConfig(cmd, func(ctx context.Context, cfg *CombinedConfig) error { + mgmtConfig, err := adminManagementConfig(cfg) + if err != nil { + return err + } + + managementStore, err := openAdminStore(ctx, cfg) + if err != nil { + return err + } + defer admincmd.CloseStore(ctx, managementStore) + + idpStorage, idpStorageFile, err := admincmd.OpenIDPStorage(mgmtConfig) + if err != nil { + return err + } + defer admincmd.CloseIDPStorage(idpStorage) + + eventStore, esErr := openAdminEventStore(ctx, cfg, mgmtConfig) + if esErr != nil { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: audit events will not be recorded: %v\n", esErr) + } + if eventStore != nil { + defer func() { + if err := eventStore.Close(ctx); err != nil { + log.Debugf("close activity event store: %v", err) + } + }() + } + + return fn(ctx, admincmd.Resources{Store: managementStore, IDPStorage: idpStorage, IDPStorageFile: idpStorageFile, EventStore: eventStore}) + }) +} + +// withAdminStoreOnly opens only the management store for admin subcommands that do not +// need embedded IdP storage. +func withAdminStoreOnly(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error { + return withAdminConfig(cmd, func(ctx context.Context, cfg *CombinedConfig) error { + managementStore, err := openAdminStore(ctx, cfg) + if err != nil { + return err + } + defer admincmd.CloseStore(ctx, managementStore) + + return fn(ctx, managementStore) + }) +} + +func withAdminIDPOnly(cmd *cobra.Command, fn func(ctx context.Context, idpStorage storage.Storage, storageFile string) error) error { + return withAdminConfig(cmd, func(ctx context.Context, cfg *CombinedConfig) error { + mgmtConfig, err := adminManagementConfig(cfg) + if err != nil { + return err + } + idpStorage, idpStorageFile, err := admincmd.OpenIDPStorage(mgmtConfig) + if err != nil { + return err + } + defer admincmd.CloseIDPStorage(idpStorage) + + return fn(ctx, idpStorage, idpStorageFile) + }) +} + +func withAdminConfig(cmd *cobra.Command, fn func(ctx context.Context, cfg *CombinedConfig) 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) + } + cfg.ApplyAdminDefaults() + applyServerStoreEnv(cfg.Server.Store) + + return fn(ctx, cfg) +} + +func adminManagementConfig(cfg *CombinedConfig) (*nbconfig.Config, error) { + mgmtConfig, err := cfg.ToManagementConfig() + if err != nil { + return nil, fmt.Errorf("create management config: %w", err) + } + return mgmtConfig, nil +} + +func openAdminStore(ctx context.Context, cfg *CombinedConfig) (store.Store, error) { + managementStore, err := store.NewStore(ctx, types.Engine(cfg.Management.Store.Engine), cfg.Management.DataDir, nil, true) + if err != nil { + return nil, fmt.Errorf("create store: %w", err) + } + return managementStore, nil +} + +func openAdminEventStore(ctx context.Context, cfg *CombinedConfig, config *nbconfig.Config) (activity.Store, error) { + if config.DataStoreEncryptionKey == "" { + return nil, fmt.Errorf("data store encryption key is not configured") + } + if err := applyActivityStoreEnv(cfg.Server.ActivityStore); err != nil { + return nil, fmt.Errorf("configure activity event store: %w", err) + } + eventStore, err := activitystore.NewSqlStore(ctx, config.Datadir, config.DataStoreEncryptionKey) + if err != nil { + return nil, fmt.Errorf("open activity event store: %w", err) + } + if eventStore == nil { + return nil, fmt.Errorf("open activity event store: returned nil store") + } + return eventStore, nil +} diff --git a/combined/cmd/admin_config_test.go b/combined/cmd/admin_config_test.go new file mode 100644 index 000000000..ff7045d38 --- /dev/null +++ b/combined/cmd/admin_config_test.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/require" + + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" +) + +func TestApplyAdminDefaultsCopiesServerStoreWithoutExposedAddress(t *testing.T) { + cfg := DefaultConfig() + cfg.Server.ExposedAddress = "" + cfg.Server.DataDir = "/srv/netbird" + cfg.Server.Store = StoreConfig{ + Engine: "postgres", + DSN: "postgres://user:pass@example.com/netbird", + } + + cfg.ApplyAdminDefaults() + + require.Equal(t, "/srv/netbird", cfg.Management.DataDir) + require.Equal(t, "postgres", cfg.Management.Store.Engine) + require.Equal(t, cfg.Server.Store.DSN, cfg.Management.Store.DSN) +} + +func TestOpenAdminEventStoreMissingEncryptionKeyReturnsNilInterface(t *testing.T) { + eventStore, err := openAdminEventStore(context.Background(), &CombinedConfig{}, &nbconfig.Config{}) + require.Error(t, err) + require.Contains(t, err.Error(), "encryption key") + require.Nil(t, eventStore) +} + +func TestApplyServerStoreEnv(t *testing.T) { + t.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", "") + t.Setenv("NB_STORE_ENGINE_MYSQL_DSN", "") + t.Setenv("NB_STORE_ENGINE_SQLITE_FILE", "") + + applyServerStoreEnv(StoreConfig{Engine: "postgres", DSN: "postgres-dsn", File: "store.db"}) + require.Equal(t, "postgres-dsn", os.Getenv("NB_STORE_ENGINE_POSTGRES_DSN")) + require.Equal(t, "store.db", os.Getenv("NB_STORE_ENGINE_SQLITE_FILE")) + + applyServerStoreEnv(StoreConfig{Engine: "mysql", DSN: "mysql-dsn"}) + require.Equal(t, "mysql-dsn", os.Getenv("NB_STORE_ENGINE_MYSQL_DSN")) +} diff --git a/combined/cmd/config.go b/combined/cmd/config.go index ce4df8394..890c86876 100644 --- a/combined/cmd/config.go +++ b/combined/cmd/config.go @@ -6,8 +6,7 @@ import ( "net" "net/netip" "os" - "path" - "path/filepath" + filePath "path/filepath" "strings" "time" @@ -74,6 +73,16 @@ type ServerConfig struct { ActivityStore StoreConfig `yaml:"activityStore"` AuthStore StoreConfig `yaml:"authStore"` ReverseProxy ReverseProxyConfig `yaml:"reverseProxy"` + + SupportedSyncMessageVersions *int `yaml:"supportedSyncMessageVersions,omitempty"` + PerAccountSupportedSyncMessageVersions map[string]int `yaml:"perAccountSupportedSyncMessageVersions,omitempty"` + + AgentNetwork AgentNetworkConfig `yaml:"agentNetwork"` +} + +// AgentNetworkConfig contains agent-network (LLM gateway) configuration. +type AgentNetworkConfig struct { + PricingDefaultsFile string `yaml:"pricingDefaultsFile"` } // TLSConfig contains TLS/HTTPS settings @@ -133,13 +142,19 @@ type ManagementConfig struct { // 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"` + Issuer string `yaml:"issuer"` + LocalAuthDisabled bool `yaml:"localAuthDisabled"` + SignKeyRefreshEnabled bool `yaml:"signKeyRefreshEnabled"` + MfaSessionMaxLifetime string `yaml:"mfaSessionMaxLifetime"` + MfaSessionIdleTimeout string `yaml:"mfaSessionIdleTimeout"` + MfaSessionRememberMe bool `yaml:"mfaSessionRememberMe"` + SessionCookieEncryptionKey string `yaml:"sessionCookieEncryptionKey"` + Storage AuthStorageConfig `yaml:"storage"` + DashboardRedirectURIs []string `yaml:"dashboardRedirectURIs"` + CLIRedirectURIs []string `yaml:"cliRedirectURIs"` + Owner *AuthOwnerConfig `yaml:"owner,omitempty"` + DashboardPostLogoutRedirectURIs []string `yaml:"dashboardPostLogoutRedirectURIs"` + GrantTypes []string `yaml:"grantTypes"` } // AuthStorageConfig contains auth storage settings @@ -294,6 +309,19 @@ func (c *CombinedConfig) ApplySimplifiedDefaults() { c.autoConfigureClientSettings(exposedProto, exposedHost, exposedHostPort, hasExternalStuns, hasExternalRelay, hasExternalSignal) } +// ApplyAdminDefaults applies the management settings needed by admin commands even +// when the full server config is invalid and ApplySimplifiedDefaults cannot run. +func (c *CombinedConfig) ApplyAdminDefaults() { + if c.Management.DataDir == "" || c.Management.DataDir == "/var/lib/netbird/" { + c.Management.DataDir = c.Server.DataDir + } + if c.Management.Store.Engine == "" || c.Management.Store.Engine == "sqlite" { + if c.Server.Store.Engine != "" || c.Server.Store.File != "" || c.Server.Store.DSN != "" { + c.Management.Store = c.Server.Store + } + } +} + // applyRelayDefaults configures the relay service if no external relay is configured. func (c *CombinedConfig) applyRelayDefaults(exposedProto, exposedHostPort string, hasExternalRelay, hasExternalStuns bool) { if hasExternalRelay { @@ -380,7 +408,7 @@ func (c *CombinedConfig) autoConfigureClientSettings(exposedProto, exposedHost, // 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), + URI: "stun:" + net.JoinHostPort(strings.Trim(exposedHost, "[]"), fmt.Sprintf("%d", port)), }) } } @@ -571,20 +599,24 @@ func (c *CombinedConfig) buildEmbeddedIdPConfig(mgmt ManagementConfig) (*idp.Emb return nil, fmt.Errorf("authStore.dsn is required when authStore.engine is postgres") } } else { - authStorageFile = path.Join(mgmt.DataDir, "idp.db") + authStorageFile = filePath.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) + 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, + Enabled: true, + Issuer: mgmt.Auth.Issuer, + LocalAuthDisabled: mgmt.Auth.LocalAuthDisabled, + SignKeyRefreshEnabled: mgmt.Auth.SignKeyRefreshEnabled, + MfaSessionMaxLifetime: mgmt.Auth.MfaSessionMaxLifetime, + MfaSessionIdleTimeout: mgmt.Auth.MfaSessionIdleTimeout, + MfaSessionRememberMe: mgmt.Auth.MfaSessionRememberMe, + SessionCookieEncryptionKey: mgmt.Auth.SessionCookieEncryptionKey, Storage: idp.EmbeddedStorageConfig{ Type: authStorageType, Config: idp.EmbeddedStorageTypeConfig{ @@ -592,8 +624,10 @@ func (c *CombinedConfig) buildEmbeddedIdPConfig(mgmt ManagementConfig) (*idp.Emb DSN: authStorageDSN, }, }, - DashboardRedirectURIs: mgmt.Auth.DashboardRedirectURIs, - CLIRedirectURIs: mgmt.Auth.CLIRedirectURIs, + DashboardRedirectURIs: mgmt.Auth.DashboardRedirectURIs, + CLIRedirectURIs: mgmt.Auth.CLIRedirectURIs, + DashboardPostLogoutRedirectURIs: mgmt.Auth.DashboardPostLogoutRedirectURIs, + GrantTypes: mgmt.Auth.GrantTypes, } if mgmt.Auth.Owner != nil && mgmt.Auth.Owner.Email != "" { @@ -684,16 +718,21 @@ func (c *CombinedConfig) ToManagementConfig() (*nbconfig.Config, error) { 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, + Stuns: stuns, + Relay: relayConfig, + Signal: signalConfig, + Datadir: mgmt.DataDir, + DataStoreEncryptionKey: mgmt.Store.EncryptionKey, + HttpConfig: httpConfig, + StoreConfig: storeConfig, + ReverseProxy: reverseProxy, + DisableDefaultPolicy: mgmt.DisableDefaultPolicy, + EmbeddedIdP: embeddedIdP, + HighestSupportedSyncMessageVersion: c.Server.SupportedSyncMessageVersions, + PerAccountHighestSupportedSyncMessageVersion: c.Server.PerAccountSupportedSyncMessageVersions, + AgentNetwork: nbconfig.AgentNetwork{ + PricingDefaultsFile: c.Server.AgentNetwork.PricingDefaultsFile, + }, }, nil } @@ -717,7 +756,7 @@ func ApplyEmbeddedIdPConfig(ctx context.Context, cfg *nbconfig.Config, mgmtPort cfg.EmbeddedIdP.Storage.Type = "sqlite3" } if cfg.EmbeddedIdP.Storage.Config.File == "" && cfg.Datadir != "" { - cfg.EmbeddedIdP.Storage.Config.File = path.Join(cfg.Datadir, "idp.db") + cfg.EmbeddedIdP.Storage.Config.File = filePath.Join(cfg.Datadir, "idp.db") } issuer := cfg.EmbeddedIdP.Issuer diff --git a/combined/cmd/root.go b/combined/cmd/root.go index db986b4d4..3e583ef20 100644 --- a/combined/cmd/root.go +++ b/combined/cmd/root.go @@ -10,6 +10,7 @@ import ( "net/http" "os" "os/signal" + "path/filepath" "strconv" "strings" "sync" @@ -24,6 +25,7 @@ import ( "google.golang.org/grpc" "github.com/netbirdio/netbird/encryption" + agentnetworkpricing "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing" mgmtServer "github.com/netbirdio/netbird/management/internals/server" nbconfig "github.com/netbirdio/netbird/management/internals/server/config" "github.com/netbirdio/netbird/management/server/telemetry" @@ -31,6 +33,7 @@ import ( relayServer "github.com/netbirdio/netbird/relay/server" "github.com/netbirdio/netbird/relay/server/listener" "github.com/netbirdio/netbird/relay/server/listener/ws" + syncgrpc "github.com/netbirdio/netbird/shared/management/grpc" sharedMetrics "github.com/netbirdio/netbird/shared/metrics" "github.com/netbirdio/netbird/shared/relay/auth" "github.com/netbirdio/netbird/shared/signal/proto" @@ -64,7 +67,12 @@ func init() { rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "path to YAML configuration file (required)") _ = rootCmd.MarkPersistentFlagRequired("config") - rootCmd.AddCommand(newTokenCommands()) + rootCmd.AddCommand(newAdminCommands()) + rootCmd.AddCommand(newLegacyTokenCommand()) +} + +func RootCmd() *cobra.Command { + return rootCmd } func Execute() error { @@ -118,6 +126,37 @@ func execute(cmd *cobra.Command, _ []string) error { } // initializeConfig loads and validates the configuration, then initializes logging. +func applyServerStoreEnv(storeConfig StoreConfig) { + if dsn := storeConfig.DSN; dsn != "" { + switch strings.ToLower(storeConfig.Engine) { + case "postgres": + os.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", dsn) + case "mysql": + os.Setenv("NB_STORE_ENGINE_MYSQL_DSN", dsn) + } + } + if file := storeConfig.File; file != "" { + os.Setenv("NB_STORE_ENGINE_SQLITE_FILE", file) + } +} + +func applyActivityStoreEnv(storeConfig StoreConfig) error { + if engine := storeConfig.Engine; engine != "" { + engineLower := strings.ToLower(engine) + if engineLower == "postgres" && storeConfig.DSN == "" { + return fmt.Errorf("activityStore.dsn is required when activityStore.engine is postgres") + } + os.Setenv("NB_ACTIVITY_EVENT_STORE_ENGINE", engineLower) + if dsn := storeConfig.DSN; dsn != "" { + os.Setenv("NB_ACTIVITY_EVENT_POSTGRES_DSN", dsn) + } + } + if file := storeConfig.File; file != "" { + os.Setenv("NB_ACTIVITY_EVENT_SQLITE_FILE", file) + } + return nil +} + func initializeConfig() error { var err error config, err = LoadConfig(configPath) @@ -133,30 +172,10 @@ func initializeConfig() error { 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) - } + applyServerStoreEnv(config.Server.Store) - 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) + if err := applyActivityStoreEnv(config.Server.ActivityStore); err != nil { + return err } log.Infof("Starting combined NetBird server") @@ -168,7 +187,7 @@ func initializeConfig() error { // serverInstances holds all server instances created during startup. type serverInstances struct { relaySrv *relayServer.Server - mgmtSrv *mgmtServer.BaseServer + mgmtSrv mgmtServer.Server signalSrv *signalServer.Server healthcheck *healthcheck.Server stunServer *stun.Server @@ -222,7 +241,7 @@ func (s *serverInstances) createRelayServer(cfg *CombinedConfig, tlsSupport bool } hashedSecret := sha256.Sum256([]byte(cfg.Relay.AuthSecret)) - authenticator := auth.NewTimedHMACValidator(hashedSecret[:], 24*time.Hour) + authenticator := auth.NewTimedHMACValidator(hashedSecret[:]) relayCfg := relayServer.Config{ Meter: s.metricsServer.Meter, @@ -271,6 +290,11 @@ func (s *serverInstances) createManagementServer(ctx context.Context, cfg *Combi return fmt.Errorf("failed to ensure encryption key: %w", err) } + if err := loadAgentNetworkPricing(ctx, mgmtConfig); err != nil { + cleanupSTUNListeners(s.stunListeners) + return fmt.Errorf("failed to load agent-network pricing defaults: %w", err) + } + LogConfigInfo(mgmtConfig) s.mgmtSrv, err = createManagementServer(cfg, mgmtConfig) @@ -324,19 +348,23 @@ func setupServerHooks(servers *serverInstances, cfg *CombinedConfig) { return } - servers.mgmtSrv.AfterInit(func(s *mgmtServer.BaseServer) { - grpcSrv := s.GRPCServer() + if s, ok := servers.mgmtSrv.GetContainer(mgmtServer.ContainerKeyBaseServer); ok { + if baseServer, ok := s.(*mgmtServer.BaseServer); ok { + baseServer.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) - } + 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)") + s.SetHandlerFunc(createCombinedHandler(grpcSrv, s.APIHandler(), s.IDPHandler(), 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) { @@ -346,38 +374,32 @@ func startServers(wg *sync.WaitGroup, srv *relayServer.Server, httpHealthcheck * log.Infof("Relay WebSocket multiplexed on management port (no separate relay listener)") } - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { 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() + wg.Go(func() { 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() + wg.Go(func() { 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 { +func shutdownServers(ctx context.Context, srv *relayServer.Server, httpHealthcheck *healthcheck.Server, stunServer *stun.Server, mgmtSrv mgmtServer.Server, metricsServer *sharedMetrics.Metrics) error { var errs error if err := httpHealthcheck.Shutdown(ctx); err != nil { @@ -491,7 +513,7 @@ func handleTLSConfig(cfg *CombinedConfig) (*tls.Config, bool, error) { return nil, false, nil } -func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (*mgmtServer.BaseServer, error) { +func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (mgmtServer.Server, error) { mgmt := cfg.Management // Extract port from listen address @@ -502,11 +524,21 @@ func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (* } mgmtPort, _ := strconv.Atoi(portStr) - mgmtSrv := mgmtServer.NewServer( + if err := syncgrpc.ValidateSyncMessageVersion(mgmtConfig.HighestSupportedSyncMessageVersion); err != nil { + return nil, err + } + + for accountId, version := range mgmtConfig.PerAccountHighestSupportedSyncMessageVersion { + if err := syncgrpc.ValidateSyncMessageVersion(&version); err != nil { + return nil, fmt.Errorf("unrecognized sync message version in perAccountSupportedSyncMessageVersions for account %s %w", accountId, err) + } + } + + mgmtSrv := newServer( &mgmtServer.Config{ NbConfig: mgmtConfig, DNSDomain: "", - MgmtSingleAccModeDomain: "", + MgmtSingleAccModeDomain: mgmtServer.DefaultSelfHostedDomain, AutoResolveDomains: true, MgmtPort: mgmtPort, MgmtMetricsPort: cfg.Server.MetricsPort, @@ -521,7 +553,7 @@ func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (* } // 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 { +func createCombinedHandler(grpcServer *grpc.Server, httpHandler, idpHandler http.Handler, relaySrv *relayServer.Server, meter metric.Meter, cfg *CombinedConfig) http.Handler { wsProxy := wsproxyserver.New(grpcServer, wsproxyserver.WithOTelMeter(meter)) var relayAcceptFn func(conn listener.Conn) @@ -556,6 +588,10 @@ func createCombinedHandler(grpcServer *grpc.Server, httpHandler http.Handler, re http.Error(w, "Relay service not enabled", http.StatusNotFound) } + // Embedded IdP (Dex) + case idpHandler != nil && strings.HasPrefix(r.URL.Path, "/oauth2"): + idpHandler.ServeHTTP(w, r) + // Management HTTP API (default) default: httpHandler.ServeHTTP(w, r) @@ -592,6 +628,32 @@ func handleRelayWebSocket(w http.ResponseWriter, r *http.Request, acceptFn func( acceptFn(conn) } +// loadAgentNetworkPricing loads the management-side LLM pricing defaults +// file for the combined server and starts its periodic reloader. An +// explicitly configured PricingDefaultsFile is required to load (a typo +// must fail startup rather than silently bill with built-ins the operator +// believes they replaced); a relative path is resolved against the data +// directory so a bare filename like "pricing.yaml" lands in the datadir +// alongside the store. With no path configured, / +// is probed and may be absent (compiled-in defaults serve). +func loadAgentNetworkPricing(ctx context.Context, mgmtConfig *nbconfig.Config) error { + pricingPath := mgmtConfig.AgentNetwork.PricingDefaultsFile + required := pricingPath != "" + if !required { + pricingPath = agentnetworkpricing.DefaultFileName + } + if !filepath.IsAbs(pricingPath) { + pricingPath = filepath.Join(mgmtConfig.Datadir, pricingPath) + } + + log.Infof("loading agent-network pricing defaults from %s (required: %v)", pricingPath, required) + if err := agentnetworkpricing.LoadFile(pricingPath, required); err != nil { + return err + } + agentnetworkpricing.StartReloader(ctx, agentnetworkpricing.ReloadInterval) + return nil +} + // logConfig prints all configuration parameters for debugging func logConfig(cfg *CombinedConfig) { log.Info("=== Configuration ===") @@ -668,6 +730,25 @@ func logManagementConfig(cfg *CombinedConfig) { log.Infof(" Relay addresses: %v", cfg.Management.Relays.Addresses) log.Infof(" Relay credentials TTL: %s", cfg.Management.Relays.CredentialsTTL) } + + logAgentNetworkConfig(cfg) +} + +func logAgentNetworkConfig(cfg *CombinedConfig) { + log.Info(" Agent Network:") + pricingPath := cfg.Server.AgentNetwork.PricingDefaultsFile + configured := pricingPath != "" + if !configured { + pricingPath = agentnetworkpricing.DefaultFileName + } + if !filepath.IsAbs(pricingPath) { + pricingPath = filepath.Join(cfg.Management.DataDir, pricingPath) + } + if configured { + log.Infof(" Pricing defaults file: %s", pricingPath) + } else { + log.Infof(" Pricing defaults file: %s (default, optional)", pricingPath) + } } // logEnvVars logs all NB_ environment variables that are currently set diff --git a/combined/cmd/server.go b/combined/cmd/server.go new file mode 100644 index 000000000..f9384dfb1 --- /dev/null +++ b/combined/cmd/server.go @@ -0,0 +1,13 @@ +package cmd + +import ( + mgmtServer "github.com/netbirdio/netbird/management/internals/server" +) + +var newServer = func(cfg *mgmtServer.Config) mgmtServer.Server { + return mgmtServer.NewServer(cfg) +} + +func SetNewServer(fn func(*mgmtServer.Config) mgmtServer.Server) { + newServer = fn +} diff --git a/combined/cmd/token.go b/combined/cmd/token.go deleted file mode 100644 index 550480062..000000000 --- a/combined/cmd/token.go +++ /dev/null @@ -1,63 +0,0 @@ -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 index af85b0477..085e4344f 100644 --- a/combined/config.yaml.example +++ b/combined/config.yaml.example @@ -86,6 +86,13 @@ server: issuer: "https://example.com/oauth2" localAuthDisabled: false signKeyRefreshEnabled: false + # MFA session settings (applies when TOTP is enabled for an account) + # mfaSessionMaxLifetime: "24h" # Max duration for an MFA session from creation + # mfaSessionIdleTimeout: "1h" # MFA session expires after this idle period + # mfaSessionRememberMe: false # Pre-check "remember me" on login so the MFA session persists across tabs/restarts + # Optional AES key for encrypting embedded IdP session cookies. Can also be set via NB_IDP_SESSION_COOKIE_ENCRYPTION_KEY. + # Must be 16/24/32 raw bytes or base64-encoded to one of those lengths (for example: openssl rand -hex 16). + # sessionCookieEncryptionKey: "" # OAuth2 redirect URIs for dashboard dashboardRedirectURIs: - "https://app.example.com/nb-auth" @@ -93,6 +100,9 @@ server: # OAuth2 redirect URIs for CLI cliRedirectURIs: - "http://localhost:53000/" + # OAuth2 post-logout redirect URIs for dashboard (RP-initiated logout) + # dashboardPostLogoutRedirectURIs: + # - "https://app.example.com/" # Optional initial admin user # owner: # email: "admin@example.com" @@ -124,3 +134,16 @@ server: # trustedPeers: [] # CIDRs of trusted peer networks (e.g. ["100.64.0.0/10"]) # accessLogRetentionDays: 7 # Days to retain HTTP access logs. 0 (or unset) defaults to 7. Negative values disable cleanup (logs kept indefinitely). # accessLogCleanupIntervalHours: 24 # How often (in hours) to run the access-log cleanup job. 0 (or unset) is treated as "not set" and defaults to 24 hours; cleanup remains enabled. To disable cleanup, set accessLogRetentionDays to a negative value. + + # Agent network (LLM gateway) settings (optional) + # agentNetwork: + # # Path to the YAML file holding the default LLM pricing table. A relative + # # path is resolved against dataDir, so a bare filename like "pricing.yaml" + # # lands in the data directory. When empty, {dataDir}/defaults_llm_pricing.yaml + # # is probed; if no file is present the compiled-in defaults are used. + # # Schema: surface ("openai"/"anthropic"/"bedrock") -> model -> rates in USD + # # per 1k tokens (input_per_1k, output_per_1k, and the optional + # # cached_input_per_1k / cache_read_per_1k / cache_creation_per_1k). The file + # # is re-read periodically (mtime poll). An explicitly configured path that + # # fails to load fails startup; runtime reload errors keep the previous table. + # pricingDefaultsFile: "pricing.yaml" diff --git a/crowdin.yml b/crowdin.yml new file mode 100644 index 000000000..efdbceb8d --- /dev/null +++ b/crowdin.yml @@ -0,0 +1,11 @@ +skip_untranslated_strings: true +skip_untranslated_files: true +import_eq_suggestions: true + +files: + - source: /client/ui/i18n/locales/en/common.json + translation: /client/ui/i18n/locales/%two_letters_code%/common.json + type: chrome + languages_mapping: + two_letters_code: + zh-CN: zh-CN diff --git a/dns/nameserver.go b/dns/nameserver.go index 81c616c50..84e83e2b4 100644 --- a/dns/nameserver.go +++ b/dns/nameserver.go @@ -53,6 +53,7 @@ type NameServerGroup struct { ID string `gorm:"primaryKey"` // AccountID is a reference to Account that this object belongs AccountID string `gorm:"index"` + PublicID string `json:"-"` // Name group name Name string // Description group description diff --git a/docs/agent-networks/00-overview.md b/docs/agent-networks/00-overview.md new file mode 100644 index 000000000..0d76e44a0 --- /dev/null +++ b/docs/agent-networks/00-overview.md @@ -0,0 +1,109 @@ +# Agent Networks — overview + +Single-entry point. Feature scope, the module map, and the cross-cutting +topics worth keeping in mind, with links into every per-module guide. + +## TL;DR + +Agent Networks introduces an **LLM-aware reverse-proxy middleware system** +plus **account-level controls** (budget rules, log collection toggles, +PII redaction). The management server synthesises a per-peer middleware +chain that the proxy executes on every LLM request; the chain enforces +quotas, injects identity, redacts PII, parses tokens/cost, and emits +access-log entries. The dashboard exposes the surface as a single **AI +Observability** page with four tabs. + +- **Backend** lives in this repo, primarily under + `management/server/agentnetwork`, `proxy/internal/middleware`, and + `proxy/internal/llm`, with wire contracts in `shared/management`. +- **Dashboard** lives in the dashboard repo under + `src/modules/agent-network/` and `src/app/(dashboard)/agent-network/`. + +## Reading order + +| # | Doc | Why | +|---|-----|-----| +| 1 | [01-end-to-end-flows.md](01-end-to-end-flows.md) | Get the three big diagrams in your head first. | +| 2 | [modules/10-shared-api.md](modules/10-shared-api.md) | Wire contracts — every other module either produces or consumes these. | +| 3 | [modules/21-management-agentnetwork.md](modules/21-management-agentnetwork.md) | The largest module; everything the proxy executes originates here. | +| 4 | [modules/30-proxy-middleware-framework.md](modules/30-proxy-middleware-framework.md) | The generic plugin system on the proxy side. | +| 5 | [modules/31-proxy-middleware-builtin.md](modules/31-proxy-middleware-builtin.md) | The 8 LLM middlewares that ride on the framework. | +| 6 | Everything else in any order. | | + +## Module map + +11 modules. Each is described in detail in its own file under +[`modules/`](modules/). + +| # | Module | Risk | BC impact | +|---|--------|------|-----------| +| 10 | [shared/api](modules/10-shared-api.md) — proto + OpenAPI | Low | Additive only | +| 20 | [management/store](modules/20-management-store.md) — SQL persistence | Medium | Auto-migrate (additive) | +| 21 | [management/agentnetwork](modules/21-management-agentnetwork.md) — domain layer + synthesizer | **High** | Additive | +| 22 | [management/handlers + wiring](modules/22-management-handlers-wiring.md) — HTTP API + gRPC delivery | Medium | Additive | +| 30 | [proxy/middleware-framework](modules/30-proxy-middleware-framework.md) — generic plugin system | High | Additive | +| 31 | [proxy/middleware-builtin](modules/31-proxy-middleware-builtin.md) — 8 LLM middlewares | High | Additive | +| 32 | [proxy/llm-parsers](modules/32-proxy-llm-parsers.md) — SDK adapters + pricing | Medium | Additive | +| 33 | [proxy/runtime](modules/33-proxy-runtime.md) — translate + serve + access-log | High | Additive (touches hot path) | +| 40 | [dashboard](modules/40-dashboard.md) — UI for everything above | Medium | Sidebar reshape | +| 50 | [path-routed-providers](modules/50-path-routed-providers.md) — Vertex AI + Bedrock | Medium | Additive (new catalog entries) | + +The largest and highest-risk module is `management/agentnetwork`: it is +the single writer of the middleware chain the proxy executes. + +## Cross-cutting topics + +These are the items most likely to bite production. Each is fully +documented in the linked module guide. + +1. **Capture-pointer semantics** (`*bool` for `capture_prompt` and + `capture_completion`): nil = legacy emit, false = suppress, true = + emit. nil-vs-false must be handled at every JSON hop. See + [21-management-agentnetwork.md](modules/21-management-agentnetwork.md) + and [31-proxy-middleware-builtin.md](modules/31-proxy-middleware-builtin.md). +2. **`ProxyMapping.Private` preservation** on per-proxy live updates. + Failure mode: `auth` skips `ValidateTunnelPeer` → + `CapturedData.UserGroups` empty → `llm_router` denies. See + [33-proxy-runtime.md](modules/33-proxy-runtime.md). +3. **respInput carrying `UserEmail`/`UserGroups`/`UserGroupNames` onto + the response leg** in `reverseproxy.go`. Load-bearing wire that lets + `llm_limit_record` ship non-empty `group_ids` on `RecordLLMUsage`. See + [33-proxy-runtime.md](modules/33-proxy-runtime.md). +4. **Min-wins all-must-pass budget rule semantics**. Every matching + rule's remaining quota must be > 0 for the request to proceed; one + exhausted rule blocks the whole call. Documented in + [21-management-agentnetwork.md](modules/21-management-agentnetwork.md) + and the `llm_limit_check` middleware in + [31-proxy-middleware-builtin.md](modules/31-proxy-middleware-builtin.md). +5. **body-tap memory bounds**: per-direction 1 MiB cap, shared 256 MiB + budget, `LimitReader(r.Body, limit+1)` for truncation detection with + `replayReadCloser` fallback so upstream still sees the full body. + `cloneInputFor` deep-copies the body up to 16 times per chain — a + perf hot-spot. See + [30-proxy-middleware-framework.md](modules/30-proxy-middleware-framework.md). +6. **UpstreamRewrite.AuthHeader bypasses the header denylist** + deliberately. The runtime consumer only unpacks it via the + trusted upstream-build path. See + [30-proxy-middleware-framework.md](modules/30-proxy-middleware-framework.md). +7. **`disable_access_log` default-false semantics**: the synth target + sets it true, all other targets leave it false. See + [10-shared-api.md](modules/10-shared-api.md). +8. **String-typed `decision` / `deny_code`** on + `CheckLLMPolicyLimitsResponse` — would benefit from enum pinning + before external consumers integrate. See + [10-shared-api.md](modules/10-shared-api.md). + +## Explicit non-goals + +- **Reaper / GC pass over stale synth services** — designed but cut from + scope. +- **URL-sync for tab state on AI Observability** — read path is wired + (`?tab=`) but write path isn't. Future work. +- **CI golden-file regen-and-diff for `types.gen.go` / + `proxy_service.pb.go`** — would catch codegen drift; not yet in place. + +## Where to read the code + +Per-module file scopes are listed in each module guide. Behaviour is +covered by Go tests co-located with each package (and an end-to-end +chain integration test under `proxy/internal/proxy`). diff --git a/docs/agent-networks/01-end-to-end-flows.md b/docs/agent-networks/01-end-to-end-flows.md new file mode 100644 index 000000000..0de6b4c33 --- /dev/null +++ b/docs/agent-networks/01-end-to-end-flows.md @@ -0,0 +1,232 @@ +# End-to-end flows + +Three cross-module mermaid diagrams. Each per-module guide repeats the +slice that's relevant to its own scope — these are the canonical +top-down views. + +- [Flow A — Config → runtime (synth + deliver)](#flow-a--config--runtime-synth--deliver) +- [Flow B — Request lifecycle through the LLM chain](#flow-b--request-lifecycle-through-the-llm-chain) +- [Flow C — Budget rule feedback loop](#flow-c--budget-rule-feedback-loop) + +--- + +## Flow A — Config → runtime (synth + deliver) + +How an operator's change to a Provider, Policy, Guardrail, Budget Rule, +or Settings record ends up as live middleware on a peer's proxy. + +```mermaid +sequenceDiagram + autonumber + actor Op as Operator + participant UI as Dashboard + participant HTTP as management/handlers + participant Mgr as agentnetwork.Manager + participant Store as management/store (SQL) + participant Ctl as network_map.Controller + participant Synth as agentnetwork.SynthesizeServices + participant Grpc as management gRPC + participant Proxy as netbird-proxy + participant Xlate as middleware_translate + participant Chain as middleware.Chain + + Op->>UI: edit provider/policy/budget/settings + UI->>HTTP: REST PUT/POST /api/agent-network/* + HTTP->>Mgr: SaveProvider / SavePolicy / SaveBudgetRule / SaveSettings + Mgr->>Store: persist (gorm) + Mgr-->>Ctl: account change event (Network-Map dirty) + loop per connected peer + Ctl->>Synth: SynthesizeServices(ctx, store, accountID) + Synth->>Store: load providers, policies, guardrails, budget rules, settings + Synth-->>Synth: build per-peer Service list + Note over Synth: each Service has a middleware
                                                chain with capture_prompt /
                                                capture_completion / redact_pii
                                                baked from account settings + Synth-->>Ctl: []rpservice.Service + Ctl->>Grpc: NetworkMap push (services + middleware configs) + end + Grpc-->>Proxy: NetworkMap stream + Proxy->>Xlate: translate proto MiddlewareConfig → runtime Spec + Xlate->>Chain: register / replace per-service chain + Note over Chain: chain replacement is live
                                                (no proxy restart, in-flight
                                                requests unaffected) +``` + +**Notes on the diagram** + +- The `network_map.Controller` synthesises on every push, not on a + timer. A single config change costs O(connected peers × policies × + providers) per push. See [`modules/22-management-handlers-wiring.md`](modules/22-management-handlers-wiring.md). +- `SynthesizeServices` is the single source of truth for the wire + format the proxy executes. Anything the proxy does that the + synthesiser didn't request is a bug. See + [`modules/21-management-agentnetwork.md`](modules/21-management-agentnetwork.md). +- The translate step (step 13) is the only place that knows the + middleware-ID strings on the proxy side. It must reject unknown IDs; + silently dropping middlewares would create a security gap (e.g. + missing `llm_limit_check` ⇒ unbounded spend). See + [`modules/33-proxy-runtime.md`](modules/33-proxy-runtime.md). + +--- + +## Flow B — Request lifecycle through the LLM chain + +What happens when an agent on the client peer sends a chat-completion / +messages request through the synthesised reverse-proxy. + +```mermaid +sequenceDiagram + autonumber + actor Agent as Agent (local) + participant Px as netbird-proxy + participant Auth as auth middleware + participant Map as service-mapping + participant Req as llm_request_parser + participant Rt as llm_router + participant Chk as llm_limit_check + participant Inj as llm_identity_inject + participant Grd as llm_guardrail + participant Up as upstream LLM + participant Resp as llm_response_parser + participant Cost as cost_meter + participant Rec as llm_limit_record + participant Log as access-log + participant MgmtGrpc as management gRPC + + Agent->>Px: POST /v1/chat/completions (OpenAI / Anthropic) + Px->>Auth: identify peer (user, groups) + Auth->>Map: resolve service from Host + path + Map-->>Req: dispatch chain in slot order + + Req->>Req: parse body → provider, model, prompt, token estimate + Note over Req: capture_prompt gates raw_prompt
                                                capture (nil = legacy emit,
                                                false = drop, true = emit) + Req->>Rt: pass metadata + Rt->>Chk: route to upstream candidate + + Chk->>MgmtGrpc: CheckLLMPolicyLimits(provider, model, est_tokens, groups, user) + MgmtGrpc-->>Chk: decision = allow / deny + deny_code + alt decision == deny + Chk-->>Log: emit access-log with deny_code
                                                (if EnableLogCollection) + Chk-->>Agent: 429 (or 403 per deny_code) + else decision == allow + Chk->>Inj: continue + Inj->>Inj: inject NetBird identity headers per provider config + Inj->>Grd: continue + Grd->>Grd: enforce per-provider allowlist (fail-closed backstop) + Grd->>Up: forward (over WireGuard) + Up-->>Resp: response (JSON or SSE stream) + Resp->>Resp: parse usage tokens, completion + Note over Resp: capture_completion gates raw
                                                completion capture + Resp->>Cost: tokens + Cost->>Cost: lookup rates from config-delivered
                                                pricing table + compute cost + Cost->>Rec: tokens + cost + Rec->>MgmtGrpc: RecordLLMUsage(provider, model, prompt_t, completion_t, cost, groups, user) + Rec-->>Log: emit access-log entry
                                                (if EnableLogCollection) + Log-->>Agent: 200 + body (streamed if SSE) + end +``` + +**Notes on the diagram** + +- The chain runs in synth-defined order. Re-ordering middlewares + changes invariants — `llm_limit_check` must precede `llm_router` so + a denied request never hits upstream, and `llm_limit_record` must + pair with `llm_limit_check` so a successful check is always recorded + (or the rate-limit semantics break). See + [`modules/31-proxy-middleware-builtin.md`](modules/31-proxy-middleware-builtin.md). +- `llm_guardrail` is also where PII redaction happens + (`redact_pii = settings.RedactPii`). Phones, emails, credit cards, + PII names — see `redact.go` for the full set. See + [`modules/31-proxy-middleware-builtin.md`](modules/31-proxy-middleware-builtin.md). +- The model allowlist is enforced in TWO places. `CheckLLMPolicyLimits` + is authoritative: it resolves the policy that governs this + (provider, caller-groups) and denies (`deny_code = llm_policy.model_blocked`) + when no applicable policy permits the model — so an allowlist scoped to + one group/provider never leaks to another, and an un-guardrailed policy + is genuinely unrestricted. `llm_guardrail` is a per-provider fail-closed + backstop: it only carries an allowlist for a provider every authorising + policy restricts, and blocks unknown/undetermined models even when + management is unreachable. Because that backstop allowlist is the UNION + of every restricting policy's models, per-group narrowing lives only in + the authoritative check: during a `CheckLLMPolicyLimits` outage + `llm_limit_check` fails open, so a caller can reach any model in the + provider's union — a group scoped to model A could reach model B if + another group restricts the same provider to B. This is the documented + fail-open trade-off; a future flag may switch it to fail-closed. +- SSE streaming requires special handling on the response side; the + parser must handle partial chunks without buffering the whole + stream. See [`modules/32-proxy-llm-parsers.md`](modules/32-proxy-llm-parsers.md). +- Access-log emission is gated on `settings.EnableLogCollection`. With + it OFF, neither the deny nor the allow leg writes an entry — the + chain still runs (budget rules are still enforced) but no audit trail + is kept. See + [`modules/33-proxy-runtime.md`](modules/33-proxy-runtime.md). + +--- + +## Flow C — Budget rule feedback loop + +How an account's budget rules tighten ceilings on every request and how +consumption flows back into the dashboard. + +```mermaid +flowchart LR + subgraph Operator + DashBud[Dashboard Budget Settings tab] + end + subgraph Mgmt[Management] + Save[POST/PUT /api/agent-network/budget-rules] + Store[(SQL store)] + Synth[SynthesizeServices] + Check[CheckLLMPolicyLimits RPC] + Rec[RecordLLMUsage RPC] + Cons[/api/agent-network/consumption] + end + subgraph Proxy[Proxy] + Chk[llm_limit_check] + RecMw[llm_limit_record] + end + subgraph DashView[Dashboard Budget Dashboard tab] + Panel[AgentConsumptionPanel] + end + + DashBud -->|create / update rules| Save + Save --> Store + Store --> Synth + Synth -->|push synth-services to peer| Proxy + + Chk -->|per request| Check + Check -->|aggregate matching rules
                                                min-wins all-must-pass| Store + Check -->|allow / deny| Chk + + RecMw -->|post-response| Rec + Rec -->|tokens + cost + groups + user| Store + + Store -->|read counters| Cons + Cons --> Panel +``` + +**Notes on the diagram** + +- **min-wins all-must-pass** is the core semantic. A budget rule binds + to (group set, user set) with a (window, ceiling). At check time, + every rule that matches the caller is evaluated; if ANY rule has + zero remaining quota the request is denied. This is the most + surprising semantic for operators — see the invariants section of + [`modules/21-management-agentnetwork.md`](modules/21-management-agentnetwork.md). +- The proxy never makes its own budget decisions. It always asks + management via `CheckLLMPolicyLimits` and reports back via + `RecordLLMUsage`. This keeps account-wide accounting in one place + and avoids per-proxy drift. +- `RecordLLMUsage` must carry `group_ids` and `user_id` so the + decrement hits the right rule(s). The wire that carries those + fields onto the response leg is `respInput` in `reverseproxy.go`. See + [`modules/33-proxy-runtime.md`](modules/33-proxy-runtime.md). +- The dashboard's Budget Dashboard tab polls + `/api/agent-network/consumption` — not gRPC, not WebSocket. Poll + interval lives in `AgentConsumptionPanel.tsx`. See + [`modules/40-dashboard.md`](modules/40-dashboard.md). + +--- + +## Cross-references + +- Per-module guides: [`modules/`](modules/) +- Overview + module map: [`00-overview.md`](00-overview.md) diff --git a/docs/agent-networks/README.md b/docs/agent-networks/README.md new file mode 100644 index 000000000..a7d2d2ab5 --- /dev/null +++ b/docs/agent-networks/README.md @@ -0,0 +1,66 @@ +# Agent Networks — architecture documentation + +A self-contained set of documents describing the agent-networks feature: +an LLM-aware reverse-proxy middleware system plus account-level controls +(budget rules, log collection toggles, PII redaction). The management +server synthesises a per-peer middleware chain that the proxy executes on +every LLM request. + +## What to read first + +1. **[00-overview.md](00-overview.md)** — the single entry point. Feature + scope, the module map, and the cross-cutting topics worth keeping in + mind, with links to every per-module guide. +2. **[01-end-to-end-flows.md](01-end-to-end-flows.md)** — three + high-level mermaid diagrams: config-to-runtime synth/delivery, + per-request lifecycle through the LLM chain, and the budget-rule + feedback loop. +3. **Per-module guides** under `modules/` — one file per package. Each + describes the module boundary, the file-level layout, its own flow + diagrams, the public contracts, the invariants it relies on, and the + areas worth the closest attention. + +## Directory layout + +``` +docs/agent-networks/ +├── README.md # you are here +├── 00-overview.md # feature summary + module map +├── 01-end-to-end-flows.md # cross-module mermaid diagrams +└── modules/ + ├── 10-shared-api.md # proto + OpenAPI wire contracts + ├── 20-management-store.md # SQL persistence layer + ├── 21-management-agentnetwork.md # domain layer + synthesizer (largest) + ├── 22-management-handlers-wiring.md # HTTP API + gRPC delivery + ├── 30-proxy-middleware-framework.md # generic plugin system + ├── 31-proxy-middleware-builtin.md # 8 LLM-aware middlewares + ├── 32-proxy-llm-parsers.md # OpenAI/Anthropic/Bedrock SDKs + pricing + ├── 33-proxy-runtime.md # translate + serve + access-log + ├── 40-dashboard.md # UI for everything above (lives in the dashboard repo) + └── 50-path-routed-providers.md # Vertex AI + Bedrock (path-routed, keyfile:: creds, /bedrock prefix) +``` + +The `40-dashboard.md` module documents code that lives in the **dashboard +repo**, not in this repo. The guide is co-located here so backend readers +see the full picture in one place. + +## How the per-module guides are structured + +Every `modules/*.md` follows the same template so the docs are easy to +scan: + +- **Module boundary** — what this package owns; where it sits in the stack. +- **Files** — path / role. +- **Architecture & flow** — one or more mermaid diagrams. +- **Public contracts** — function signatures, gRPC messages, JSON shapes. +- **Invariants** — semantic guarantees the module relies on or enforces. +- **Things to scrutinize** — split by correctness / security / + concurrency / backward-compat / performance / observability. +- **Test coverage** — the test files that lock down behaviour in this + module. +- **Known limitations / non-goals** — what is intentionally out of scope. +- **Cross-references** — upstream/downstream module links + the + end-to-end flow + the overview. + +See [00-overview.md](00-overview.md) for the module map and the +cross-cutting topics. diff --git a/docs/agent-networks/modules/10-shared-api.md b/docs/agent-networks/modules/10-shared-api.md new file mode 100644 index 000000000..532927b90 --- /dev/null +++ b/docs/agent-networks/modules/10-shared-api.md @@ -0,0 +1,105 @@ +# shared/api — wire contracts (proto + OpenAPI) + +> **Risk level:** Medium — wire-format surface that every other module pins against; backward-compat hinges on field-number discipline more than on logic correctness. +> **Backward-compat impact:** Additive only (new proto fields use unallocated numbers, new RPCs default to `Unimplemented`, new OpenAPI schemas/paths are append-only; no existing field/RPC/schema removed or renumbered). + +## Module boundary +This module owns the cross-process contract surface between management, proxy, and dashboard. Two artefacts: `shared/management/proto/proxy_service.proto` (management↔proxy gRPC) and `shared/management/http/api/openapi.yml` (dashboard/CLI↔management REST). Both have generated companions checked in (`proxy_service.pb.go`, `proxy_service_grpc.pb.go`, `types.gen.go`) which must travel in lockstep with their sources. `shared/management/status/error.go` is in scope only for the four new typed `NotFound` constructors that the new HTTP handlers return. + +Everything downstream — `management/agentnetwork`, `management/server/http/handlers/*`, `proxy/internal/*`, the dashboard SDK — consumes these types verbatim. The concern here is wire stability and codegen reproducibility, not behaviour: behaviour is covered in the management and proxy module guides. + +`management.proto` and `signalexchange.proto` are unchanged. `status/error.go` only receives four additive constructors (lines 208-227); no existing error types are reshaped. + +## Files +| Path | Role | +| ---- | ---- | +| `shared/management/proto/proxy_service.proto` | Source of truth: 2 new RPCs, 1 new message group (`MiddlewareConfig` + slot enum), additive fields on `PathTargetOptions`, `AccessLog`, `RecordLLMUsageRequest` | +| `shared/management/proto/proxy_service.pb.go` | Generated (protoc-gen-go) | +| `shared/management/proto/proxy_service_grpc.pb.go` | Generated; adds `CheckLLMPolicyLimits` + `RecordLLMUsage` client/server stubs and `UnimplementedProxyServiceServer` defaults | +| `shared/management/http/api/openapi.yml` | 15 new `AgentNetwork*` schemas, 9 new path groups under `/api/agent-network/*` | +| `shared/management/http/api/types.gen.go` | Generated (oapi-codegen; see codegen note below) | +| `shared/management/status/error.go` | Four `NotFound` constructors for the new resource kinds (lines 208-227) | + +## Architecture & flow +```mermaid +sequenceDiagram + participant Dash as Dashboard / CLI + participant Mgmt as management (HTTP+gRPC) + participant Px as proxy + + Note over Dash,Mgmt: REST (OpenAPI / types.gen.go) + Dash->>Mgmt: PUT /api/agent-network/providers (AgentNetworkProviderRequest) + Dash->>Mgmt: PUT /api/agent-network/settings (AgentNetworkSettingsRequest) + Dash->>Mgmt: GET /api/agent-network/consumption -> [AgentNetworkConsumption] + + Note over Mgmt,Px: gRPC ProxyService (proxy_service.proto) + Mgmt-->>Px: SyncMappingsResponse{ ProxyMapping.path[*].options.middlewares,
                                                agent_network, disable_access_log, capture_* } + Px->>Mgmt: CheckLLMPolicyLimits(account, user, groups, provider, model) + Mgmt-->>Px: decision=allow|deny + selected_policy_id + attribution_group_id + window_seconds + Px->>Mgmt: RecordLLMUsage(account, user, group_id, group_ids, window_seconds, tokens, cost) + Px->>Mgmt: SendAccessLog(AccessLog{ agent_network=true }) +``` + +The proto changes split into three independent slices: (1) **mapping enrichment** — `PathTargetOptions` grows fields 8-13 so management can ship middleware configs, capture limits, and the agent-network / log-suppression flags down to the proxy without a second RPC; (2) **two new request/response RPCs** (`CheckLLMPolicyLimits`, `RecordLLMUsage`) for per-LLM-request budget arbitration; (3) **observability tag** — `AccessLog.agent_network` so management can route logs to the right surface. + +The OpenAPI side is a thin CRUD surface — every resource (`Provider`, `Policy`, `Guardrail`, `BudgetRule`, `Settings`) follows the same `GET-list / POST / GET / PUT / DELETE` pattern, plus a read-only `/consumption` listing and a catalog endpoint. The `*Request` variants drop server-controlled fields (id, timestamps). `AgentNetworkBudgetRule` deliberately reuses `AgentNetworkPolicyLimits` to keep wire-shape parity with policies. + +## Public contracts added +- gRPC RPCs (`proxy_service.proto:52-57`): `CheckLLMPolicyLimits(CheckLLMPolicyLimitsRequest) → CheckLLMPolicyLimitsResponse`, `RecordLLMUsage(RecordLLMUsageRequest) → RecordLLMUsageResponse`. Both unary; default `UnimplementedProxyServiceServer` returns `codes.Unimplemented` (`proxy_service_grpc.pb.go:283-289`). +- New messages (`proxy_service.proto:145-175,448-502`): `MiddlewareConfig`, `MiddlewareSlot` enum, `CheckLLMPolicyLimitsRequest`/`Response`, `RecordLLMUsageRequest`/`Response`. +- New `PathTargetOptions` fields 8-13 (`proxy_service.proto:124-140`): `capture_max_request_bytes`, `capture_max_response_bytes`, `capture_content_types`, `middlewares`, `agent_network`, `disable_access_log`. All default-false / zero; pre-existing fields 1-7 byte-for-byte unchanged. +- `AccessLog.agent_network = 18` (`proxy_service.proto:258-261`). +- `RecordLLMUsageRequest.group_ids = 8` (`proxy_service.proto:496-498`) — so the record path can fan out to every applicable budget rule's window without a re-lookup. +- 15 new OpenAPI component schemas (`openapi.yml:5072-5829`): `AgentNetworkProvider[Request|Model]`, `AgentNetworkCatalog{Model,Provider,IdentityInjection,HeaderPairInjection,JSONMetadataInjection,ExtraHeader}`, `AgentNetworkPolicy[Request|TokenLimit|BudgetLimit|Limits]`, `AgentNetworkGuardrail[Checks|Request]`, `AgentNetworkConsumption`, `AgentNetworkSettings[Request]`, `AgentNetworkBudgetRule[Request]`. +- 9 new path groups (`openapi.yml:12797-13460`): `/api/agent-network/{consumption,settings,budget-rules,budget-rules/{ruleId},catalog/providers,providers,providers/{providerId},policies,policies/{policyId},guardrails,guardrails/{guardrailId}}`. +- Four typed NotFound errors (`shared/management/status/error.go:208-227`). + +## Invariants +- **Field-number monotonicity.** Every new proto field uses a previously-unallocated number in its message: `PathTargetOptions` 8-13 (was 1-7), `AccessLog` 18 (was 1-17), `RecordLLMUsageRequest` 8. `SendStatusUpdateRequest.inbound_listener = 50` (pre-existing) reserves 50+ for observability extensions, so 8 on `RecordLLMUsageRequest` doesn't conflict. +- **Old proxies stay compatible.** Old management never sends `disable_access_log`/`middlewares`/`agent_network` (zero value → existing behaviour); old proxies that don't decode these fields just drop them silently (proto3 unknown-field semantics) — log emission stays on. No pre-existing field number changed: the proto change is insertions only. +- **Old management stays compatible.** The two new RPCs are registered on the same `management.ProxyService` descriptor; old proxies hitting them get `codes.Unimplemented` from the unimplemented embed (`proxy_service_grpc.pb.go:283-289`), which is the same fallback pattern `SyncMappings` already documents (`proxy_service.proto:20-21`). +- **OpenAPI shapes are append-only.** New schemas are placed at the end of `components.schemas` (line 5072+); new paths at the end of `paths` (line 12797+). No existing schema's `required` list, enum, or property type was changed. +- **`*Request` vs response asymmetry.** Read shapes (`AgentNetworkProvider`, `AgentNetworkPolicy`, `AgentNetworkGuardrail`, `AgentNetworkSettings`, `AgentNetworkBudgetRule`) require `created_at`/`updated_at`; the matching `*Request` shapes do not — server fills them. `AgentNetworkProviderRequest.api_key` is write-only (`openapi.yml:5158-5161` "never returned in responses"); reviewers should confirm the response schema (5072-5138) actually omits `api_key`. + +## Things to scrutinize +### Correctness +- `RecordLLMUsageRequest` carries both `group_id` (singular, the attribution group — field 3) and `group_ids` (plural, full membership — field 8). `b22d5a181` adds field 8 to drive account-budget fan-out; double-check that consumers can't accidentally key counters on the wrong one. Field comments at `proxy_service.proto:489-491` and `496-498` distinguish them but it's the kind of subtle thing a follow-up commit might collapse. +- `PathTargetOptions.disable_access_log` is the only field whose default-false meaning **changes semantics** on the proxy side: false → log (status quo), true → suppress. Synthesizer sets `DisableAccessLog = !settings.EnableLogCollection`, so a missing/default settings row yields `EnableLogCollection=false → DisableAccessLog=true → suppressed`. Worth confirming downstream (`agentnetwork.synthesizer`) that operator-defined private services never inherit this flag — the proto field default protects them, but only if synth code is explicit. +- `CheckLLMPolicyLimitsResponse.decision` is a free-form `string` (`proxy_service.proto:471`) rather than an enum. Only documented values are "allow" / "deny". An enum would prevent typo drift; consider before this RPC ships to external consumers. +- `deny_code` (`proxy_service.proto:478-481`) is documented as "a stable label" but is also a free string. Pin the allowed set somewhere observable to the proxy. + +### Security +- `AgentNetworkProvider.api_key` MUST be write-only. Schema split (request has it at line 5158; response omits it) looks correct, but a regression here leaks the upstream provider credential to every dashboard reader. Check that the handler explicitly zeros it on the response path. +- `extra_values` / `identity_header_*` headers on `AgentNetworkProvider` get stamped onto upstream requests. Description at `openapi.yml:5099` says "values not declared by the catalog are ignored at synth time" — a contract this module documents but the synthesizer must enforce. Confirm the synth module honours it. +- Cluster + subdomain on `AgentNetworkSettings` are documented immutable (`openapi.yml:5686-5694`) and the `AgentNetworkSettingsRequest` (lines 5733-5752) doesn't accept them. Verify the `PUT /api/agent-network/settings` handler can't be tricked by extra JSON keys (oapi-codegen's `additionalProperties: false` is not declared here; spec defaults to permissive). + +### Backward compatibility +- The proto change is field-number additive: every previously numbered field keeps the same name + type, and the change is insertions only (no deletions in `proxy_service.proto`), so this holds at the source-text level. +- `proxy_service_grpc.pb.go` adds two RPC handlers and registers them in `ProxyService_ServiceDesc.Methods` (lines 543-552). The existing entries are unchanged and order-preserving — gRPC method dispatch is name-keyed, so order doesn't matter, but reviewing the diff (no method renamed/dropped) is still worth a glance. +- OpenAPI 3.0 doesn't have a built-in deprecation flow for paths; if any client tooling iterates `paths.*`, the additive routes shouldn't break it, but generated SDKs (especially the dashboard's) need a regen to gain access to `AgentNetwork*`. + +### Codegen pinning +- `generate.sh` (`shared/management/http/api/generate.sh:14`) installs `oapi-codegen@latest` rather than a pinned version. **This is a reproducibility gap** — re-running the script later may produce a different `types.gen.go`. Either pin the version in `generate.sh` (e.g. `@v2.7.0`) or document the pin in a `tools.go`. +- proto codegen has the protoc / protoc-gen-go version stamped in the generated file header (`proxy_service.pb.go:3-4`). +- Regenerate locally and confirm zero diff against the committed `types.gen.go` / `proxy_service.pb.go`. + +## Test coverage +| Test file | Locks down | +| --------- | ---------- | +| None in this scope | The proto and OpenAPI sources are tested transitively by the handler tests (`shared/management/http/handlers/agentnetwork/...`) and by the synthesizer/manager tests (`management/server/agentnetwork/...`). No round-trip serialisation test exists in the `proto/` or `api/` packages themselves. | +| `shared/management/proto/*_test.go` | (absent) | +| `shared/management/http/api/*_test.go` | (absent) | + +Acceptable for codegen artefacts, but a single golden-file test that re-runs `oapi-codegen` and `protoc` in CI and diffs against the checked-in files would close the reproducibility gap noted above. + +## Known limitations / explicit non-goals +- **No deprecation surface.** Old fields/RPCs are kept silently; there is no `[deprecated = true]` annotation on anything. Acceptable here because nothing is being removed. +- **No proto-side validation.** Numeric ranges (e.g. `window_seconds >= 60`, `cost_usd >= 0`, capture-byte clamps) are enforced in the OpenAPI schema via `minimum:` and inside Go code by the proxy/management, but `proto3` itself can't express them; downstream is expected to validate every message. +- **`MiddlewareConfig.config_json` is `bytes`** (`proxy_service.proto:163`) — opaque to the proto layer. Schema validity is the middleware factory's problem. This is a deliberate tradeoff (per the comment at 161-162) but worth flagging: a corrupted/malicious config_json can only fail at proxy apply time, not at the wire-decode step. +- **No catalog endpoint schema for the catalog itself** — the catalog data ships as a `GET /api/agent-network/catalog/providers` returning `[AgentNetworkCatalogProvider]` (`openapi.yml:13024`), but the catalog source-of-truth lives in `management/server/agentnetwork/catalog`, not here. +- The reaper / GC design was cut from scope; no reaper-related types appear here. + +## Cross-references +- Downstream: [management/store](20-management-store.md), [management/agentnetwork](21-management-agentnetwork.md), [management/handlers + wiring](22-management-handlers-wiring.md), [proxy/runtime](33-proxy-runtime.md) +- End-to-end flow: [../01-end-to-end-flows.md](../01-end-to-end-flows.md) +- Top-level: [../00-overview.md](../00-overview.md) diff --git a/docs/agent-networks/modules/20-management-store.md b/docs/agent-networks/modules/20-management-store.md new file mode 100644 index 000000000..1acc12611 --- /dev/null +++ b/docs/agent-networks/modules/20-management-store.md @@ -0,0 +1,112 @@ +# management/store — persistence for agent-network entities + +> **Risk level:** Medium — six brand-new tables behind AutoMigrate, one upsert-counter table that runs on the request hot path, and one column carrying an encrypted secret. +> **Backward-compat impact:** Additive (six new tables created by AutoMigrate; the `Store` interface gains 23 methods, but no existing column/index is touched). + +## Module boundary + +This module is the persistence layer for the Agent Network feature. Everything the management server stores about LLM proxying — providers, policies, guardrails, the per-account settings row, a usage-counter table written on every proxied LLM request, and the account-budget rules — flows through the methods added to `store.Store`. The module owns six tables, six entity types from `management/server/agentnetwork/types`, and a single hot-path upsert (`IncrementAgentNetworkConsumption`) consumed by the proxy fleet. + +Out of scope here: the catalog of provider definitions (compiled-in, no DB), the synthesizer/manager built on top of these CRUDs (covered in [21-management-agentnetwork.md](21-management-agentnetwork.md)), and the HTTP handlers that translate API requests into Save/Delete calls. + +## Files + +| Path | Role | +| ---- | ---- | +| `management/server/store/sql_store_agentnetwork.go` | gorm implementations of all 23 store methods | +| `management/server/store/sql_store_agentnetwork_budgetrule_test.go` | round-trip + account-scoping coverage against a real sqlite store | +| `management/server/store/sql_store.go` | one import, six entities appended to the `AutoMigrate` slice (sql_store.go:40, sql_store.go:141-142) | +| `management/server/store/store.go` | 23 methods added to the `Store` interface (store.go:328-354) | +| `management/server/store/store_mock_agentnetwork.go` | mockgen output for the new interface surface | + +## Tables added / migrations + +All six tables are created by `db.AutoMigrate` invoked from `NewSqlStore` at sql_store.go:133-143. There is no hand-rolled SQL migration script — the schema is whatever GORM derives from the struct tags. + +- `agent_network_providers` — `Provider.TableName()` at provider.go:76. PK `id`, index on `account_id`, named index `idx_agent_network_provider` on `provider_id`. Carries an at-rest-encrypted `api_key` and ed25519 `session_private_key` (provider.go:35,56). `extra_values` and `models` are JSON blobs (`serializer:json`). +- `agent_network_policies` — `Policy.TableName()` at policy.go:70. PK `id`, index on `account_id`. JSON columns: `source_groups`, `destination_provider_ids`, `guardrail_ids`, `limits`. +- `agent_network_guardrails` — `Guardrail.TableName()` at guardrail.go:41. PK `id`, index on `account_id`. JSON `checks`. +- `agent_network_settings` — `Settings.TableName()` at settings.go:33. PK `account_id` (one row per account), named index `idx_agent_network_settings_cluster_subdomain` on `subdomain` only — the index name implies a composite, but only one column is tagged. +- `agent_network_consumption` — `Consumption.TableName()` at consumption.go:46. Composite PK across `(account_id, dim_kind, dim_id, window_seconds, window_start_utc)` — the same tuple the upsert keys on. +- `agent_network_budget_rules` — `AccountBudgetRule.TableName()` at budgetrule.go:35. PK `id`, index on `account_id`. JSON `target_groups`, `target_users`, `limits`. + +## CRUD surface added + +Provider, Policy, Guardrail, BudgetRule follow the same pattern: `GetByID`, `GetAccount` (list), `Save` (upsert), `Delete`, with account-scoping enforced by the existing `accountAndIDQueryCondition` / `accountIDCondition` constants (sql_store.go:59-62). Provider additionally exposes `GetAllAgentNetworkProviders` (cross-account, used by the synthesizer). Settings exposes `Get`/`GetByCluster`/`Save` (no delete — one row per account, created on first save). Consumption exposes the upsert `Increment`, a point `Get`, and a cross-window `List`. + +## Architecture & flow + +```mermaid +flowchart LR + handlers["HTTP handlers
                                                (management/server/agentnetwork)"] -->|Save/Delete| iface["Store interface
                                                store.go:328-354"] + manager["agentnetwork.Manager"] -->|Get*| iface + synth["synthesizer
                                                (global)"] -->|GetAllAgentNetworkProviders| iface + proxy["proxy fleet
                                                (hot path)"] -->|IncrementAgentNetworkConsumption| iface + iface --> sql["SqlStore methods
                                                sql_store_agentnetwork.go"] + iface -.gomock.-> mock["MockStore
                                                store_mock_agentnetwork.go"] + sql --> gorm["gorm.DB"] + gorm --> tables[("6 tables
                                                agent_network_*")] + sql --> enc["crypt.FieldEncrypt
                                                (provider only)"] +``` + +Reads decrypt provider secrets in-place; writes do `provider.Copy().EncryptSensitiveData(...)` before `db.Save` so the caller's in-memory object keeps the plaintext `api_key` (sql_store_agentnetwork.go:88-102). Every list/get takes a `LockingStrength` and applies `clause.Locking{Strength: ...}` when non-`None` — matching the rest of the store. The upsert path uses `clause.OnConflict` with `gorm.Expr` server-side increments so concurrent proxy nodes converge without read-modify-write races (sql_store_agentnetwork.go:321-335). + +## Invariants enforced at the store layer + +- **Account scoping.** Every entity-by-ID method keys on `account_id = ? and id = ?`; no cross-tenant leak path through the API is reachable as long as callers always pass the auth'd `accountID` (sql_store_agentnetwork.go:70,141,201,429). +- **NotFound mapping.** `gorm.ErrRecordNotFound` is translated to typed `status.NewAgentNetwork*NotFoundError`; `Delete*` returns NotFound when `RowsAffected == 0` (sql_store_agentnetwork.go:111-113,171-173,231-233,461-463). +- **Provider secret encryption at rest.** `SaveAgentNetworkProvider` always encrypts before persist; `Get*` always decrypts after read. The plaintext `api_key` never reaches the DB through this layer (sql_store_agentnetwork.go:31,54,80,90). +- **Consumption monotonicity.** The upsert only ever issues `col = col + ?` for the three counter columns — no decrement path exists (sql_store_agentnetwork.go:330-332). +- **Window alignment is the caller's responsibility.** The store stamps `WindowStartUTC` as-passed; alignment to epoch happens in `types.WindowStart` at consumption.go:51-58. +- **Settings has no Delete.** Intentional — one row per account, created on first save; the row sticks around for the account lifetime. + +## Things to scrutinize + +### Correctness +- `SaveAgentNetworkProvider` saves the copy (sql_store_agentnetwork.go:95). The caller's in-memory pointer therefore keeps plaintext `api_key` and any `CreatedAt`/`UpdatedAt` gorm autofills land on the copy, not the original. Callers that need synced timestamps must re-fetch. +- `IncrementAgentNetworkConsumption`'s `Create` provides initial counter values (`TokensInput: tokensIn`, etc.) in the row, and on conflict the assignments add the same deltas to the existing values. The insert-vs-update arithmetic is consistent. Cross-check that no engine in use (sqlite, postgres, mysql) silently rejects the `OnConflict` clause — GORM emits engine-specific SQL but `ON DUPLICATE KEY UPDATE` (mysql) vs `ON CONFLICT (...)` (sqlite/postgres) need their unique constraint to match the composite PK on `agent_network_consumption`; it does, by construction. +- `IncrementAgentNetworkConsumption` writes `updated_at: time.Now().UTC()` literally inside the assignments map (sql_store_agentnetwork.go:333) — fine, but it's a Go-side timestamp captured at call time, not a DB-side `now()`. Acceptable for an audit field. +- `GetAgentNetworkConsumption` returns a zero-valued non-nil row on `ErrRecordNotFound` (sql_store_agentnetwork.go:364-371). Document or rename — a typed sentinel error would be more orthodox; callers must know not to error-check. + +### Concurrency / transactions +- Hot-path `IncrementAgentNetworkConsumption` runs outside any explicit transaction; concurrency safety relies entirely on the DB serialising the `ON CONFLICT` upsert against the composite PK. This is correct for postgres and mysql; for sqlite it serialises behind the single writer. +- `SaveAgentNetworkSettings` is a blind upsert with no version/etag — concurrent writes from two operators last-write-wins on the collection-toggle flags (settings.go:23-25). Acceptable for admin-curated state but worth flagging. +- `Save*Provider` uses `db.Save` on a struct with a PK already set — GORM emits UPDATE or INSERT based on row existence. No upsert clause is attached, so a race between two creates with the same generated `xid` (vanishingly unlikely) would surface as a PK violation. + +### Migration safety +- All six tables ride `AutoMigrate` (sql_store.go:141-142). AutoMigrate is additive: new columns get added, but it never drops columns nor narrows types. Three `bool` columns on `agent_network_settings` (`EnableLogCollection`, `EnablePromptCollection`, `RedactPii`) default to false at the GORM/DDL layer for existing rows; the test at sql_store_agentnetwork_budgetrule_test.go:83-112 locks that down on a fresh sqlite. Verify postgres/mysql produce the same default. +- The named index `idx_agent_network_settings_cluster_subdomain` on settings.go:15 is declared on only `subdomain`. Either the cluster column also needs `gorm:"index:idx_agent_network_settings_cluster_subdomain"` to make it composite, or the name is misleading. +- The named index `idx_agent_network_provider` on `Provider.ProviderID` (provider.go:30) is *not* unique and not scoped to account — two providers in the same account with the same `provider_id` are permitted at the DB layer; uniqueness, if any, must live above the store. + +### Backward compatibility +- Net additive. No removed methods, no renamed columns, no schema change to existing tables. Existing deployments running a prior binary continue to work; the first boot of the new binary creates the six tables. +- The `Store` interface grows by 23 methods (store.go:330-354); any non-mock external implementer of `store.Store` will fail to compile. The repo only has `SqlStore` + `MockStore`, both updated. + +### Performance (indexes, N+1) +- All by-account list queries hit the `idx_account_id` per-table index. No N+1: list methods return the full slice in one query. +- `GetAgentNetworkSettingsByCluster` (sql_store_agentnetwork.go:263-277) does a tablescan on `cluster` — no index. Tolerable for the bootstrap label generator (one-shot at provisioning) but worth noting if the call moves onto a hot path. +- `ListAgentNetworkConsumption` returns every row ever recorded for the account (sql_store_agentnetwork.go:382-400) — unbounded growth, no `LIMIT`, no time filter. With one row per (dim, window) per request burst, this table grows fastest of the six; a retention job + a paginated list method are obvious follow-ups. + +## Test coverage + +| Test file | Locks down | +| --------- | ---------- | +| `sql_store_agentnetwork_budgetrule_test.go::TestAgentNetworkBudgetRule_RealStore_RoundTrip` | full save → reload of `AccountBudgetRule` including the JSON-serialised `PolicyLimits`, target slices, double-delete returns NotFound (lines 18-59) | +| `sql_store_agentnetwork_budgetrule_test.go::TestAgentNetworkBudgetRule_RealStore_ScopedByAccount` | cross-account isolation for budget rules (lines 63-78) | +| `sql_store_agentnetwork_budgetrule_test.go::TestAgentNetworkSettings_RealStore_CollectionTogglesRoundTrip` | collection toggles default off, survive save/reload at the set values (lines 83-112) | + +Gap: there is no store-level test for providers (encryption round-trip), policies, guardrails, or `IncrementAgentNetworkConsumption` (concurrent upsert, window-key uniqueness). The consumption upsert is the most performance-sensitive method in this module and the only one without a real-sqlite test. + +## Known limitations / explicit non-goals + +- No retention / GC for `agent_network_consumption`. +- No `Delete` for `Settings` (one row per account, cleared with the account). +- No DB-engine-specific tuning — the same struct tags drive sqlite, mysql, postgres. +- Provider `extra_values` and `models` are JSON blobs; querying inside them is not supported by design. +- `GetAgentNetworkConsumption` "not-found = zero row" contract is convenient but unconventional. + +## Cross-references + +- Upstream: [shared/api](10-shared-api.md), [management/agentnetwork](21-management-agentnetwork.md) +- End-to-end flow: [../01-end-to-end-flows.md](../01-end-to-end-flows.md) +- Top-level: [../00-overview.md](../00-overview.md) diff --git a/docs/agent-networks/modules/21-management-agentnetwork.md b/docs/agent-networks/modules/21-management-agentnetwork.md new file mode 100644 index 000000000..f91c369f7 --- /dev/null +++ b/docs/agent-networks/modules/21-management-agentnetwork.md @@ -0,0 +1,321 @@ +# management/agentnetwork — domain layer + synth pipeline + +> **Risk level:** High — central business logic + budget enforcement + the source of every middleware-chain change the proxy executes. +> **Backward-compat impact:** Additive within the agent-network surface; one **behavioural difference for opted-out accounts** in parser capture (the capture flag is stamped explicitly false instead of being absent — see capture-pointer semantics below). Non-agent-network proxy services are untouched (the synth chain only ships on `agent-net-svc-*` targets). + +## Module boundary + +`management/server/agentnetwork` owns every agent-network entity (providers, policies, guardrails, account budget rules, per-account settings, consumption rows) and **translates them into the in-memory `*rpservice.Service` that the reverse-proxy controller turns into `proto.ProxyMapping`s and pushes to clusters**. It is the *only* writer of the agent-network middleware chain. + +Inside the package: `manager.go` is the CRUD + permissions-gated facade; `synthesizer.go` walks settings + providers + policies + guardrails and emits the per-account service plus every middleware's JSON config; `policyselect.go` runs per-request attribution (min-wins account ceiling, then "drain bigger pool first"); `reconcile.go` diffs successive synth outputs and emits precise Create/Update/Delete proxy-mapping updates plus a peer-map refresh. `labelgen/` mints DNS-safe subdomain labels; `catalog/` is the static provider catalogue; `types/` carries gorm entity structs. The `_realstack_test.go` files in the parent `management/server/` directory exercise the manager + network-map controller end-to-end with no mocks. + +## Files + +| Path | Role | +| ---- | ---- | +| `agentnetwork/manager.go` | Manager interface + CRUD + permission gates + bootstrap-settings + reconcile trigger | +| `agentnetwork/synthesizer.go` | Settings/policy → wire-format synthesis; sole writer of the proxy middleware chain | +| `agentnetwork/synthesizer_pricing.go` | `buildCostMeterConfigJSON` — default table + per-provider prices → `cost_meter` config | +| `agentnetwork/pricing/defaults.go` | Default pricing table derived from the catalog + supplementals; `DefaultTable`, `LookupDefault`, wire `Entry` | +| `agentnetwork/pricing/override.go` | `LoadFile`/`StartReloader` for `AgentNetwork.PricingDefaultsFile` (mtime poll, merge over compiled-in base) | +| `agentnetwork/pricing/{exampleyaml,gen}.go` | Generates `defaults_llm_pricing.example.yaml` from the compiled-in table (golden-tested) | +| `agentnetwork/policyselect.go` | Per-request policy attribution + account-budget ceiling (min-wins) | +| `agentnetwork/reconcile.go` | Per-account synth diff vs in-memory cache → Create/Update/Delete | +| `agentnetwork/catalog/catalog.go` | Static provider catalogue (auth headers, identity-injection shapes) | +| `agentnetwork/labelgen/{labelgen,words}.go` | DNS-safe subdomain picker + curated wordlist | +| `agentnetwork/types/provider.go` | Provider entity + APIKey + Models + ExtraValues + SessionKeys | +| `agentnetwork/types/policy.go` | Policy entity + `PolicyLimits` (token + budget) | +| `agentnetwork/types/guardrail.go` | Guardrail entity (`ModelAllowlist`, `PromptCapture`) | +| `agentnetwork/types/budgetrule.go` | `AccountBudgetRule` (reuses `PolicyLimits`) | +| `agentnetwork/types/settings.go` | Per-account `Settings` (Cluster, Subdomain, 3 toggles) | +| `agentnetwork/types/consumption.go` | `Consumption` row + `WindowStart` aligner | +| `agentnetwork/{synthesizer,policyselect,reconcile,wire_shape}_*test.go` | See test coverage table | +| `agentnetwork/types/consumption_test.go` | `WindowStart` alignment proofs | +| `agentnetwork/labelgen/labelgen_test.go` | Deterministic picks + exhaustion + fallback | +| `management/server/agentnetwork_realstack_test.go` | No-mock provider CRUD → network-map fan-out | +| `management/server/agentnetwork_budgetrule_realstack_test.go` | No-mock budget-rule CRUD + settings preserve-immutable | + +## Architecture & flow + +### Synthesis (settings/policy → wire format) + +```mermaid +flowchart TD + A[Mutation: provider/policy/guardrail/settings] --> B[managerImpl.reconcile accountID] + B --> C{proxyController nil?} + C -- yes --> D[accountManager.UpdateAccountPeers only] + C -- no --> E[SynthesizeServices] + E --> F[loadSettings — NotFound returns ok=false, no synth] + F --> G[filterEnabledProviders sorted by CreatedAt] + G --> H[filterEnabledPolicies] + H --> I[backfillProviderSessionKeys if missing] + I --> J[indexProviderGroups: providerID -> sorted source groups] + J --> K[buildRouterConfigJSON drops orphan providers] + J --> L[buildIdentityInjectConfigJSON per catalog entry] + J --> K2[buildCostMeterConfigJSON: default table + per-provider prices] + K2 --> P + H --> M[mergeGuardrails: union allowlist, OR redact] + M --> N[applyAccountCollectionControls account toggle = SOLE capture control] + N --> O[marshalGuardrailConfig] + K --> P[buildMiddlewareChain 8 middleware entries] + L --> P + O --> P + P --> Q[buildAccountService: AccessGroups=union source groups, noop.invalid target] + Q --> R[reconcile.diffMappings vs cache] + R --> S[SendServiceUpdateToCluster CREATE/MODIFY/REMOVE] + R --> T[accountManager.UpdateAccountPeers — fans synth ACLs into network map] +``` + +### LLM pricing (management is the sole authority) + +**The proxy carries no price list.** Management synthesizes the entire pricing +table and ships it inside `cost_meter`'s `ConfigJSON`, so a price change reaches +the proxies as an ordinary mapping push — the chain rebuild installs a fresh +table and there is nothing to reload on the proxy side. + +```mermaid +flowchart TD + A[catalog.All — PricingSurfaces x Models] --> B[buildDefaultTable + supplementalDefaults] + B --> C{AgentNetwork.PricingDefaultsFile} + C -- absent --> D[compiled-in table serves] + C -- loaded --> E[LoadFile: merge file entries WHOLE over compiled base] + E --> F[mergedTable atomic.Pointer] + D --> G[DefaultTable] + F --> G + G --> H[buildCostMeterConfigJSON — pricing.defaults] + I[types.Provider.Models operator prices] --> J[normalizePricingModelID
                                                bedrock ARN/region/version, vertex @version] + J --> K[materializeEntry: default entry as base,
                                                operator input/output verbatim,
                                                cache pointers only when non-nil] + K --> L[pricing.providers keyed by provider record ID] + H --> M[cost_meter ConfigJSON] + L --> M + G --> N[GET /catalog — applyDefaultPricing prefills dashboard rows] + O[StartReloader: mtime poll every ReloadInterval 1m] --> E +``` + +**Two tiers, resolved per request on the proxy** (`synthesizer_pricing.go:22-35`): + +- `pricing.defaults` — surface (`openai`/`anthropic`/`bedrock`) → normalized model + id → rates. The **full** default table ships to every account: it is small + (~10 KB) and it is what keeps gateway-style providers (which enumerate no + models, so they claim every model) priced. +- `pricing.providers` — provider **record** id → normalized model id → rates, + matched against the `llm.resolved_provider_id` the router stamps. Entries are + **fully materialized here**, at synth time: `materializeEntry` starts from the + default entry for that model so cache rates the operator didn't state are + inherited, overlays operator `input`/`output` verbatim (**including an explicit + 0**, which prices a self-hosted or internal endpoint as free rather than + silently reverting to list price), and overlays cache-rate **pointers only when + non-nil** — `nil` means "inherit the default", an explicit `0` means "no + discount, bill this bucket at the input rate". The proxy therefore does two map + lookups and no merging. + +Same orphan rule as the router: a provider no enabled policy authorises is +unreachable, so its prices aren't shipped. Model ids are normalized with the +**same** functions the request parser uses (`NormalizeBedrockModel` / +`NormalizeVertexModel`), which is what makes the per-record lookup key compare +equal to the `llm.model` the proxy meters. Post-normalization duplicates resolve +first-occurrence-wins, matching the routing dedup order. + +**`AgentNetwork.PricingDefaultsFile`** (`config.go:190-207`) lets an operator +replace default rates without a rebuild. Schema is `surface → model → rates` +(`input_per_1k`, `output_per_1k`, and optional `cached_input_per_1k` / +`cache_read_per_1k` / `cache_creation_per_1k`). Semantics: + +- A **relative** path resolves against ``, so a bare filename lands + alongside the store. Empty config probes `/defaults_llm_pricing.yaml`. +- An **explicitly configured** path is *required to load*: a typo or malformed + file fails startup, because the operator believes those rates are live. The + conventional probe is optional — an absent file just serves compiled-in + defaults, and the path stays watched in case it appears later. +- File entries **replace** the compiled-in entry for the same (surface, model) + **whole** — they are not field-merged, so an entry must repeat the cache rates + it wants to keep. Everything the file doesn't mention keeps built-in rates. +- Unknown YAML fields are rejected (`KnownFields(true)`) and every rate must be + finite and non-negative — the same constraints the HTTP API enforces on + operator per-provider prices. +- Reload is an mtime poll (`ReloadInterval`, 1 min) and is **lenient at runtime**: + a parse error keeps the previous table, a deleted file reverts to compiled-in + defaults. A mid-edit save can never take pricing down. + +The live table feeds **both** consumers, which is what keeps them consistent: the +synthesizer (what proxies actually bill with) and `GET /api/agent-network/catalog` +via `applyDefaultPricing` (what the dashboard's model-row prices prefill with). +`defaults_llm_pricing.example.yaml` is generated from the compiled-in table +(`go generate ./management/internals/modules/agentnetwork/pricing`) and +golden-tested, so operators start from a file matching the built-in rates exactly. + +### Budget rule resolution (min-wins, group+user bound) + +```mermaid +flowchart TD + A[SelectPolicyForRequest in] --> B[checkAccountBudget — runs FIRST, independent of policies] + B --> C[GetAccountAgentNetworkBudgetRules] + C --> D{for each enabled rule} + D --> E{budgetRuleApplies?} + E -- no --> D + E -- yes --> F[attrGroup = lowestIntersect TargetGroups, in.GroupIDs] + F --> G{Token cap enabled?} + G -- yes --> H[evalTokenCap user dim + group dim] + H --> I{exhausted?} + I -- yes --> J[DENY: llm_account.token_cap_exceeded - STOP] + I -- no --> K{Budget cap enabled?} + G -- no --> K + K -- yes --> L[evalBudgetCap user dim + group dim] + L --> M{exhausted?} + M -- yes --> N[DENY: llm_account.budget_cap_exceeded - STOP] + M -- no --> D + K -- no --> D + D --> O[All rules passed -> fall through to per-policy selection] +``` + +Key invariant: **rules are checked sequentially and ANY exhausted rule denies (all-must-pass / min-wins).** Untargeted rules (`len(TargetGroups)==0 && len(TargetUsers)==0`) apply to every caller (`policyselect.go:393`). + +### Policy selection (per-peer, per-request) + +```mermaid +flowchart TD + A[Account-budget gate passed] --> B[GetAccountAgentNetworkPolicies] + B --> C[filterApplicablePolicies enabled + provider match + group intersect] + C --> D{candidates empty?} + D -- yes --> E[Allow, empty SelectedPolicyID] + D -- no --> F[scoreCandidates -> scoreOne per policy] + F --> G[scoreOne: attrGroup + window] + G --> H{any cap exhausted?} + H -- yes --> I[Drop policy; record last deny code] + H -- no --> K[Keep as live candidate] + F --> L{live candidates exist?} + L -- no --> M[Deny with last exhaustion code] + L -- yes --> N[Sort: uncapped wins -> larger group token -> group budget -> user token -> user budget -> oldest CreatedAt] + N --> O[winner = scored 0] + O --> P[Allow + SelectedPolicyID + AttributionGroupID + WindowSeconds] +``` + +End-to-end: a mutation calls `managerImpl.reconcile(ctx, accountID)` (`manager.go:205,239,...`). Reconcile defers an `accountManager.UpdateAccountPeers` so the network-map controller re-runs and `injectAllProxyPolicies` picks up the new access groups; with a `proxyController` wired, it re-synthesizes the service, diffs against `reconcileCache[accountID]` (guarded by `reconcileMu`), and emits proto mappings to the cluster derived from the mapping's domain (`reconcile.go:120`). Synthesis is stateless and idempotent. Sole persistent side effect: `backfillProviderSessionKeys` (`synthesizer.go:249`) mints ed25519 keys on legacy provider rows and writes them back. + +At request time the path is independent: the proxy calls `SelectPolicyForRequest` (`policyselect.go:56`); account-budget ceiling first, then per-policy scoring. Token + budget caps share `evalTokenCap` / `evalBudgetCap` — same primitive for account rules and policy limits, `label` differentiates the deny reason. After a served request, `RecordAccountBudgetUsage` (`policyselect.go:415`) fans deltas to every applicable rule's distinct `(dim_kind, dim_id, window)` tuple, deduplicating to prevent double-count when two rules share target+window. + +## Public contracts + +- **Manager interface** (`manager.go:48-80`): CRUD for `Providers/Policies/Guardrails/BudgetRules`; `GetSettings/UpdateSettings` (cluster + subdomain immutable, only the three toggles mutate); `ListConsumption/RecordConsumption(account, kind, dimID, windowSec, in, out, USD)`; `RecordAccountBudgetUsage(account, user, groups, in, out, USD)`; `SelectPolicyForRequest(ctx, PolicySelectionInput) → *PolicySelectionResult{Allow, SelectedPolicyID, AttributionGroupID, WindowSeconds, DenyCode, DenyReason}`. +- **`PolicySelectionInput`** (`manager.go:85-90`): `{AccountID, UserID, GroupIDs, ProviderID}` — populated by the proxy from CapturedData + `llm_router` resolution. +- **Synthesized middleware chain** (`synthesizer.go:576-657`), order load-bearing — response slot runs reverse-of-slice: + + | Slot | Idx | ID | ConfigJSON shape | CanMutate | + | --- | --- | --- | --- | --- | + | on_request | 0 | `llm_request_parser` | `{"capture_prompt": , "redact_pii"?: true}` | – | + | on_request | 1 | `llm_router` | `{"providers":[{id, models[], upstream_*, auth_header_*, allowed_group_ids[]}]}` | **true** | + | on_request | 2 | `llm_limit_check` | `{}` | – | + | on_request | 3 | `llm_identity_inject` | `{"providers":[{provider_id, header_pair?, json_metadata?, extra_headers?}]}` | **true** | + | on_request | 4 | `llm_guardrail` | `{"provider_allowlists"?: {providerID: []model}, "prompt_capture":{enabled,redact_pii}}` | – | + | on_response | 5 | `llm_limit_record` | `{}` (runs LAST at runtime) | – | + | on_response | 6 | `cost_meter` | `{"pricing":{"defaults":{surface:{model:rates}},"providers"?:{providerRecordID:{model:rates}}}}` — rates are `{input_per_1k, output_per_1k, cached_input_per_1k?, cache_read_per_1k?, cache_creation_per_1k?}` | – | + | on_response | 7 | `llm_response_parser` | `{"capture_completion": , "redact_pii"?: true}` | – | +- **Synthesized service shape** (`synthesizer.go:739`): `Mode=HTTP`, `Private=true`, `Domain=.`, `AccessGroups=unionSourceGroups(enabledPolicies)`, one `TargetTypeCluster` target with `Host=noop.invalid:443` (router rewrites per request), `Options.{DirectUpstream,AgentNetwork}=true`, `DisableAccessLog=!settings.EnableLogCollection`, `CaptureMax{Req,Resp}Bytes=1<<20`, `CaptureContentTypes=["application/json","text/event-stream"]`. + +## Invariants + +- **Min-wins / all-must-pass for account budget rules** (`checkAccountBudget`, `policyselect.go:353`): every applicable enabled rule is checked; first exhausted cap denies. Untargeted rules bind every caller. +- **Account toggle is the SOLE control for capture enablement.** `applyAccountCollectionControls` (`synthesizer.go:701`) sets `merged.PromptCapture.Enabled = settings.EnablePromptCollection` *unconditionally*. +- **Capture-pointer semantics on parser configs** — see "Things to scrutinize" below. +- **`EnableLogCollection` ↔ `DisableAccessLog` is the only access-log toggle** (`synthesizer.go:770`). Default off ⇒ access log suppressed. +- **`RedactPii` flows verbatim to BOTH parsers** (`synthesizer.go:584-585`) and is OR'd into the merged guardrail (`synthesizer.go:706`). +- **Cluster and Subdomain are immutable on Settings.** `UpdateSettings` reloads existing row and overlays only the three toggles (`manager.go:558-561`). +- **Orphan providers (no enabled policy authorises them) NEVER reach the router** (`synthesizer.go:351-357`); skipped from `identity_inject` for symmetry. +- **Provider creation refuses empty `api_key`** (`manager.go:175`); **deletion refuses while any policy still references it** (`manager.go:265-273`). +- **Session keypair stability across provider edits** (`manager.go:226-228`) — server-managed, copied through every `UpdateProvider`, never API-surfaced. +- **Management is the sole pricing authority.** The proxy has no embedded price list, so an account whose `cost_meter` config carries no `pricing` block bills **nothing** (`cost.skipped=unknown_model`, $0) rather than falling back to stale built-ins. The top-level `pricing` wrapper is also the feature-detection signal in both directions: an old proxy ignores it as an unknown field, and a new proxy reads its absence as "old management". +- **Per-provider prices are materialized at synth time, not merged on the proxy** (`synthesizer_pricing.go:114-131`). A per-record entry is always complete, so the proxy's lookup is per-record-then-defaults with no field-level fallback between tiers. +- **An explicit operator price of `0` prices the model as free** — it must not be treated as "unset" and reverted to list price (`synthesizer_pricing.go:49-54`). Only *cache*-rate fields distinguish unset from zero, via `*float64`. +- **Pricing model ids are normalized with the same functions the request parser uses** (`normalizePricingModelID`). If the two ever diverge, per-record prices silently stop matching and every request falls through to surface defaults. +- **The default table's coverage is structural, not curated.** It is derived from the catalog via each provider's `PricingSurfaces`; `TestDefaultTable_CoversEveryCatalogModel` fails on an unpriced catalog model and `TestDefaultTable_NoConflictingContributions` fails if two providers contribute the same (surface, model) at different rates. +- **A pricing-defaults file failure is fatal only at startup, and only for an explicitly configured path.** Runtime reload failures keep the previous table; a deleted file reverts to compiled-in defaults (`pricing/override.go:62-81, 113-148`). + +## Things to scrutinize + +### Correctness + +- **Capture-pointer semantics — `*bool` vs `bool`.** Three states, owned by separate sides: + - **Wire JSON this module emits:** `buildParserConfigJSON` (`synthesizer.go:678-693`) *always* stamps the capture field. Agent-network targets ship `"capture_prompt": false` or `"capture_prompt": true` — never absent. Same for `"capture_completion"`. The happy-path test pins `{"capture_prompt":false}` (`synthesizer_test.go:174`). + - **Proxy-side parser config (consumer):** parsers decode into `*bool`. Matrix: + - `nil` (field absent) → **legacy default = emit**. Preserved for non-agent-network callers and pre-existing tests (the backward-compat hook). + - `false` (field present, value false) → **suppress emission entirely**. The behaviour for opted-out agent-network accounts. Without this, `enable_log_collection=true` + `enable_prompt_collection=false` would leak raw user input AND raw model output to the access log. + - `true` → emit normally. + - **Why the synth always stamps a value:** an agent-network mapping omitting the field would hit legacy "always emit" and re-introduce the leak. The `json.Marshal` error fallback at `synthesizer.go:687` degrades to `{}` — comment-claimed unreachable, but if ever fired re-introduces the leak. Consider fail-closed (return literal `{"capture_prompt":false}`) instead. +- **`scoreCandidates` non-cumulative deny code.** Only the *last* exhausted policy's deny code survives (`policyselect.go:188-190`). Iteration order is store's natural order. Auth signal is `len(scored)==0`, so this is informational only — verify no UI depends on "first exhausted policy" semantics. +- **`effectiveWindowSeconds` token-wins tiebreak.** When both halves are enabled with different windows, token's window wins (`policyselect.go:482`). Verify `RecordLLMUsage` increments against the winning window only. +- **`RecordAccountBudgetUsage` dedup.** Two rules with the same `(kind, dim_id, window)` would double-count without the `tuples` map (`policyselect.go:434-449`). Key includes all three dimensions — correct. +- **Fail-closed on bad provider:** unknown catalog id (`synthesizer.go:794-796`) or empty API key (`synthesizer.go:801-803`) drops the **entire** account's synth, not just the bad provider. Confirm matches operator UX. + +### Security + +- **Redact OR-merge:** merged `RedactPii` = account OR guardrail (`synthesizer.go:706`). **Parser-side flag is `settings.RedactPii` only, NOT the OR** — a guardrail-only opt-in does not propagate to parsers. Correct because the account toggle gates capture, but worth noting on the proxy side. +- **Group resolution must not leak across accounts.** Every store call carries `accountID` (`policyselect.go:73, 286, 298, 322, 334, 354`); `lowestIntersect` uses caller's claimed groups only (`policyselect.go:494`). Risk surface is upstream (handler populates `in.GroupIDs`). +- **`UpdateSettings` preserves immutable Cluster + Subdomain** (`manager.go:558`). A client can't rebind the cluster. +- **Provider session keypair backfill writes through `SaveAgentNetworkProvider`** (`synthesizer.go:256`) from a read-shaped call. Idempotent → worst case is a wasted write under concurrent reconcile + snapshot. + +### Concurrency + +- **`reconcileMu`** guards `reconcileCache`. Lock window is narrow — compute diff inside, send outside (`reconcile.go:56-68`). +- **`labelRngMu`** guards `labelRng` because `math/rand.Source` is unsafe for concurrent use (`manager.go:638-640`). +- **Real-store tests** use `store.NewTestStoreFromSQL` with `t.TempDir()` per test — no shared state, no `t.Parallel()`. +- **`RecordAccountBudgetUsage` dedup `tuples` map is per-call;** concurrent calls fan out fully — correct (each request's tokens book once per applicable rule). +- **Deferred `UpdateAccountPeers` runs inline after the proxy push** (`reconcile.go:28-35`); a slow call stretches CRUD response time. + +### Backward compatibility + +- **Capture-pointer semantics (restated):** non-agent-network callers see no field → legacy nil-default emit, identical to pre-PR. Agent-network targets always carry an explicit `capture_*` value. +- **`TestSynthesizeServices_HappyPath` was updated:** request-parser config moved from `{}` to `{"capture_prompt":false}` (`synthesizer_test.go:174`). External snapshot tests against synth output need updating. +- **`MergedGuardrails` retains zeroed `TokenLimits`/`Budget`/`Retention`** even though `Policy.Limits` carries the real values now; `llm_limit_check` is the authoritative enforcement. Comment at `synthesizer.go:940-948` calls this out. +- **`cost_meter`'s `pricing` block is version-skew-safe in both directions.** A proxy predating config-delivered pricing ignores the field as unknown JSON (it previously priced from its own embedded table, so it keeps billing — at its own rates, which is the skew to be aware of during a rolling upgrade). A current proxy paired with old management sees no `pricing` block, logs one warning at chain-build time, and records `cost.skipped=unknown_model` — token counting and cap enforcement are unaffected, only the USD annotation goes to $0. + +### Performance + +- **`SynthesizeServices` runs on every controller tick / mutation reconcile.** Cost: 4 store reads + optional per-provider keypair backfill. Sort + index + merge are O(N log N) / O(P × G); dominant cost is JSON marshalling. No nested loops escape these dimensions. +- **The full default pricing table is marshalled into every account's `cost_meter` config on every synth** (~10 KB serialized). This is a deliberate trade: it keeps gateway-style providers priced for every catalog model, and it is the largest single contributor to the synth JSON. `DefaultTable()` itself is a pointer load (or a `sync.Once`-built map) — the cost is the marshal, not the build. +- **`reconcile.diffMappings` is O(N + M)** with N=M=1 per account today — effectively constant. +- **`SynthesizeServicesForCluster`** (`synthesizer.go:71`) walks every account on a cluster; per-account failures are **swallowed** (`synthesizer.go:91-93`) so a single misconfigured account doesn't drop the cluster. Runs per proxy reconnect. + +### Observability + +- **Activity codes:** `AgentNetwork{Provider,Policy,Guardrail,BudgetRule}{Created,Updated,Deleted}`; `AgentNetworkSettingsUpdated` with `log_collection/prompt_collection/redact_pii` payload (`manager.go:567-571`). **No activity code for `SelectPolicyForRequest` denies** — surfaced via proxy access log only (likely intentional given volume). +- **Deny codes** namespaced: `llm_policy.{token,budget}_cap_exceeded`, `llm_account.{token,budget}_cap_exceeded` (`policyselect.go:18-26`). +- **Reconcile failures are logged at warn and swallowed** (`reconcile.go:42-44`). Persistent synth failures (e.g. unknown catalog id) silently keep the proxy out of sync — consider a manager-level synth-health surface if this becomes a support burden. +- **Pricing-file lifecycle logs at info** (load, reload, revert-to-built-ins) and **at warn** for a runtime reload failure; the mtime check itself is `Debugf`. There is no metric on reload failures, so an operator who breaks the file mid-flight keeps billing at the previous table with only a log line to show it (`pricing/override.go:113-148`). + +## Test coverage + +| Test file | Locks down | +| --------- | ---------- | +| `synthesizer_test.go` | Mock-store: `HappyPath` (8-mw chain ordering, `{"capture_prompt":false}` baseline); `No{Settings,Providers}`; `Disabled{Provider,Policy}_NoService`; `RouterConfigOrdering`; `PolicyCheckConfig_UnionsSourceGroups`; `OrphanProvider_HasEmptyAllowedGroups`; identity-inject for LiteLLM / Bifrost (overrides + partial disable) / Cloudflare / Portkey / Vercel / OpenRouter / generic non-customizable; `GuardrailMerge_AllowlistUnion_LimitsRestrictive`; `BackfillsMissingSessionKeys`; `HTTPUpstream_KeepsExplicitPort`; `UpstreamURLPath_FlowsToRouter`; `UnknownProviderID_FailsClosed`; `EmptyAPIKey_FailsClosed`. | +| `synthesizer_realstore_test.go` | Real-sqlite: `SurvivesStatusToggle` reproduces the disable/re-enable 403 regression; `Reconcile_RealStore_PushesPrivateAfterStatusToggle` extends through reconcile push. | +| `synthesizer_guardrail_realstore_test.go` | `PromptCaptureAccountIsSoleControl`; `PromptCaptureFlowsWhenAccountOptsIn`; `AccountRedactWithoutGuardrailRedact`; `NoGuardrail_CaptureOff`. | +| `synthesizer_log_collection_realstore_test.go` | `LogCollection{Off_SuppressesAccessLog,On_PermitsAccessLog}` — verifies `DisableAccessLog` propagation through `ToProtoMapping`. | +| `synthesizer_parser_redact_realstore_test.go` | **Capture-pointer regression suite:** `ParserConfigsCarryRedactPii`; `ParserConfigsSuppressCaptureWhenLogCollectionOnly` (log=on/prompt=off ⇒ both capture flags false); `ParserConfigsOmitRedactPiiWhenOff`. | +| `synthesizer_pricing_test.go` | `BuildCostMeterConfig_{BedrockModelNormalization,CacheRateNilVsZero,OrphanAndGatewayProviders}` — the per-record tier's three load-bearing rules: keys normalized like the parser's, `nil` cache pointer inherits vs explicit `0` bills at input rate, and orphan / gateway (empty `Models`) providers ship no per-record entry. | +| `pricing/defaults_test.go` | `DefaultTable_{CoversEveryCatalogModel,NoConflictingContributions,AllRatesFiniteNonNegative,PinnedRates}`; `LookupDefault_SurfaceOrder`. Catalog-derived coverage + rate sanity are structural, not curated. | +| `pricing/override_test.go` | `LoadFile_{MergesOverCompiledDefaults,MissingPath,RejectsInvalid}`; `Reload_LifeCycle` (mtime detect, parse error keeps previous, delete reverts to built-ins); `ExampleYAML_InSyncWithBuiltins` golden. | +| `policyselect_test.go` | Mock-store: `NoApplicablePolicies`; `AllowWithLowestGroupAttribution`; `LargerPoolWinsAcrossUsageLevels`; `StaysOnLargerPoolAfterPartialDrain`; `FallsThroughToSmallerPoolWhenLargerExhausted`; `TiebreakBy{LargerGroupPool,CreatedAt}`; `DeniesWhenAllExhausted`; `UncappedPolicyAlwaysWinsAgainstCapped`; `DisabledPolicyIgnored`; `StoreErrorPropagates`; `RejectsEmptyAccount`; `SharesGroupCounterAcrossPolicies`; `AntiFallThroughOnLowestGroup`; `BudgetOnlyExhaustionDenies`; `BudgetTighterThanTokenWins`. | +| `policyselect_realstore_test.go` | Real-sqlite regression guard: `NoApplicablePolicies`; `AllowAndLowestGroupAttribution`; `LargerPoolWins_FallsThroughWhenExhausted`; `BudgetCapDenies`; `GroupCounterSharedAcrossPolicies`; `DisabledPolicyIgnored`. | +| `policyselect_account_realstore_test.go` | Account budget rules: `AccountCeilingBindsEvenWithUncappedPolicy` (min-wins); `AccountGroupCeiling`; `AccountTargetUsersBindsOnlyThatUser`; `AccountRuleRecordsToOwnWindow`. | +| `reconcile_test.go` | `FirstSynth_EmitsCreate`; `NoChange_EmitsNothingExtra` (re-push as Modified — verify desired); `PolicyRemoved_EmitsDelete`; `NilProxyController_NoOp`; `EmptyAccountID_NoOp`; `ClusterFromMapping`. | +| `wire_shape_test.go` | `TestSynthesizedService_WireShape` — proto-shape lockdown via `ToProtoMapping`. Catches "service not matching" (mapping reaches proxy but no SNI/HTTP route). Asserts ID, Domain, Mode, AuthToken, `Private`, `Auth.Oidc=false`, one path `/` + `https://noop.invalid/`, 8 middlewares with correct slot enums, router config `auth_header_value="Bearer sk-test-key"`. | +| `labelgen/labelgen_test.go` | `PickUnique_{DeterministicWithSeededRng,AvoidsTakenWordsWhenMostAreReserved,FallsBackWhenAllReserved}`; `UniqueWords_DropsDuplicates`. | +| `types/consumption_test.go` | `WindowStart_{AlignedToUnixEpoch,WithinWindowConverges,AcrossWindowsDiverges,DifferentWindowsHaveDifferentBuckets,SubMinuteAndMinuteAlignment,ZeroWindowReturnsInputUTC}`. Bucket alignment so multi-node reads converge. | +| `agentnetwork_realstack_test.go` | `ProviderCRUD_FansOutToProxyAndClientPeers` — no-mock end-to-end through real account manager + network-map + agentnetwork: provider create propagates the updated map to both proxy peer and client peer with the synth DNS surface. | +| `agentnetwork_budgetrule_realstack_test.go` | `BudgetRuleCRUD_RealManager`; `UpdateSettings_PreservesImmutableAndTogglesCollection`. | + +## Known limitations / explicit non-goals + +- **`MergedGuardrails.TokenLimits/Budget/Retention` emit at zero** (`synthesizer.go:940-948`); real enforcement is `Policy.Limits` via `llm_limit_check`. Future cleanup implied. +- **Session keys picked from first enabled provider by created_at** (`pickServiceSessionKeys`, `synthesizer.go:270`). Existing session cookies survive provider edits only while the first-by-CreatedAt provider stays in place. Document for operators. +- **Reconcile failures silently swallowed** (`reconcile.go:42-44`). Persistent failures keep the proxy out of sync until the next reconcile. +- **`scoreCandidates` exposes only the LAST exhaustion's deny code** when multiple policies are exhausted. +- **`bootstrapSettingsIfNeeded` failure is non-fatal to provider create** (`manager.go:200`): provider lands, synth is no-op until the next provider create retries the bootstrap. +- **Budget rules do not trigger a reconcile** (`manager.go:476-477`). Request-time evaluation only; new rules take effect on the next request without a proxy push. + +## Cross-references + +- **Upstream:** [shared/api](10-shared-api.md), [management/store](20-management-store.md), reverseproxy `service`/`proxy`/`sessionkey` packages, `management/server/permissions` + `activity`. +- **Downstream:** [management/handlers (HTTP wiring)](22-management-handlers-wiring.md), [proxy/middleware-builtin](31-proxy-middleware-builtin.md), network-map controller (`injectAllProxyPolicies` fan-out). +- **End-to-end flow:** [../01-end-to-end-flows.md](../01-end-to-end-flows.md) — "Provider create → reconcile → proxy push → peer map refresh" and "request → policy select → record" diagrams. +- **Top-level:** [../00-overview.md](../00-overview.md) diff --git a/docs/agent-networks/modules/22-management-handlers-wiring.md b/docs/agent-networks/modules/22-management-handlers-wiring.md new file mode 100644 index 000000000..9b8a47445 --- /dev/null +++ b/docs/agent-networks/modules/22-management-handlers-wiring.md @@ -0,0 +1,203 @@ +# management/handlers + wiring — HTTP API + gRPC delivery + +> **Risk level:** Medium — the surface is mostly additive, but two changes are load-bearing: `injectAllProxyPolicies` runs on every per-peer compute, and `shallowCloneMapping` must round-trip `Private` (a missed field silently breaks every MODIFIED). +> **Backward-compat impact:** Additive on the wire (new routes, new RPCs, new proto fields, new gorm column on `AccessLogEntry`). One management-internal break: `nbhttp.NewAPIHandler` gains a trailing `agentNetworkManager` parameter; `nil` is tolerated and silently skips route registration. + +## Module boundary + +This module is the seam between the public Agent Network HTTP API and the proxy fleet that serves agent traffic. North side: a `/api/agent-network/*` surface (providers, policies, guardrails, budget rules, settings, consumption) on the existing gorilla router, delegating to `agentnetwork.Manager`. Handlers are thin — they translate `api.*` ↔ `types.*`, validate shape, forward. RBAC and event emission stay inside the manager (`manager.go:680-682`). + +South side: `ProxyServiceServer` (`proxy.go`) learns to (a) ship synth services to a proxy on initial snapshot, (b) resolve agent-network domains in `getServiceByDomain` for OIDC/session/tunnel-peer flows, (c) gate LLM requests via `CheckLLMPolicyLimits` + `RecordLLMUsage`, (d) preserve `Private` through `shallowCloneMapping` so per-proxy live updates don't silently flip services public. The network_map controller prepends synth services to `account.Services` on every per-peer compute; `accesslogentry.go` gains an indexed `AgentNetwork` column so the dashboard can filter cheaply. + +## Files + +| Path | Role | +| ---- | ---- | +| `handlers/agentnetwork/providers_handler.go` | Catalog + provider CRUD + central `AddEndpoints` | +| `handlers/agentnetwork/policies_handler.go` | Policy CRUD + shared `validatePolicy*` | +| `handlers/agentnetwork/guardrails_handler.go` | Guardrail CRUD | +| `handlers/agentnetwork/budget_handler.go` | Account-level budget rule CRUD | +| `handlers/agentnetwork/settings_handler.go` | GET (200+`null` if unbootstrapped) + PUT toggles | +| `handlers/agentnetwork/consumption_handler.go` | Read-only consumption rows | +| `handlers/agentnetwork/handlers_test.go` | Real-store fixture; wire round-trip + validation | +| `handlers/agentnetwork/budget_handler_test.go` | Budget-rule + settings toggles | +| `server/http/handler.go` | New `agentNetworkManager` arg; conditional `AddEndpoints` | +| `server/permissions/modules/module.go` | New `AgentNetwork` module key | +| `internals/server/boot.go` | Wires synthesiser adapter + limits service into proxy server | +| `internals/server/modules.go` | `AgentNetworkManager()` lazy-create node | +| `internals/controllers/network_map/controller/controller.go` | `injectAllProxyPolicies` replaces 4 `InjectProxyPolicies` calls | +| `internals/controllers/network_map/controller/repository.go` | `SynthesizeAgentNetworkServices` repo method | +| `internals/modules/reverseproxy/service/service.go` | `MiddlewareConfig`, capture limits, `AgentNetwork`, `DisableAccessLog` + proto | +| `internals/modules/reverseproxy/accesslogs/accesslogentry.go` | Indexed `AgentNetwork bool` from proto | +| `internals/shared/grpc/proxy.go` | Synth wiring, 2 RPCs, domain fallback, `Private` in clone | +| `internals/shared/grpc/proxy_clone_test.go` | Locks every `ProxyMapping` field minus `AuthToken` | +| `server/activity/codes.go` | 13 new activity codes (125-137) | + +## HTTP routes added + +All routes inherit the platform's auth middleware. Perms enforced inside `agentnetwork.Manager.requirePermission` (`manager.go:680-682`) on `modules.AgentNetwork`. Permission column shows the `op` passed to `requirePermission` — read = `Read`, etc. + +| Method | Path | Perm | Handler | +| ------ | ---- | ---- | ------- | +| GET | `/agent-network/catalog/providers` | authn only | `providers_handler.go:43` | +| GET | `/agent-network/providers` | read | `providers_handler.go:57` | +| POST | `/agent-network/providers` | create | `providers_handler.go:97` | +| GET | `/agent-network/providers/{providerId}` | read | `providers_handler.go:77` | +| PUT | `/agent-network/providers/{providerId}` | update | `providers_handler.go:132` | +| DELETE | `/agent-network/providers/{providerId}` | delete | `providers_handler.go:172` | +| GET | `/agent-network/policies` | read | `policies_handler.go:32` | +| POST | `/agent-network/policies` | create | `policies_handler.go:72` | +| GET | `/agent-network/policies/{policyId}` | read | `policies_handler.go:52` | +| PUT | `/agent-network/policies/{policyId}` | update | `policies_handler.go:102` | +| DELETE | `/agent-network/policies/{policyId}` | delete | `policies_handler.go:142` | +| GET | `/agent-network/guardrails` | read | `guardrails_handler.go:25` | +| POST | `/agent-network/guardrails` | create | `guardrails_handler.go:65` | +| GET | `/agent-network/guardrails/{guardrailId}` | read | `guardrails_handler.go:45` | +| PUT | `/agent-network/guardrails/{guardrailId}` | update | `guardrails_handler.go:95` | +| DELETE | `/agent-network/guardrails/{guardrailId}` | delete | `guardrails_handler.go:135` | +| GET | `/agent-network/budget-rules` | read | `budget_handler.go:24` | +| POST | `/agent-network/budget-rules` | create | `budget_handler.go:64` | +| GET | `/agent-network/budget-rules/{ruleId}` | read | `budget_handler.go:44` | +| PUT | `/agent-network/budget-rules/{ruleId}` | update | `budget_handler.go:95` | +| DELETE | `/agent-network/budget-rules/{ruleId}` | delete | `budget_handler.go:135` | +| GET | `/agent-network/settings` | read | `settings_handler.go:53` (200+`null` if no row) | +| PUT | `/agent-network/settings` | update | `settings_handler.go:27` | +| GET | `/agent-network/consumption` | read | `consumption_handler.go:21` | + +## gRPC RPCs added (or modified) + +| RPC | Direction | Trigger | +| --- | --------- | ------- | +| `CheckLLMPolicyLimits` | proxy→mgmt unary | Pre-flight gate; returns allow/deny, selected policy, attribution group, window, deny code+reason (`proxy.go:259-301`). `Unimplemented` when limits service is nil. | +| `RecordLLMUsage` | proxy→mgmt unary | Post-flight write of tokens+cost against policy-window dimensions + every applicable account budget rule (`proxy.go:303-349`). `window_seconds==0` ⇒ no policy cap, only account fan-out runs. | +| `GetMappingUpdate`/`SendServiceUpdate` (stream) | mgmt→proxy | Snapshot (`proxy.go:752-780`) now appends `SynthesizeServicesForCluster`. Live updates use `SendServiceUpdateToCluster` + `shallowCloneMapping`. | + +## Architecture & flow + +### HTTP request lifecycle + +```mermaid +sequenceDiagram + participant DB as Dashboard + participant R as gorilla.Router (/api) + participant H as handler (agentnetwork) + participant M as agentnetwork.Manager + participant S as store.Store + participant AM as accountManager (StoreEvent) + + DB->>R: POST /api/agent-network/providers + R->>H: createProvider (auth mw sets UserAuth) + H->>H: GetUserAuthFromContext + validate(req) + H->>M: CreateProvider(userID, provider, bootstrapCluster) + M->>M: requirePermission(AgentNetwork, Create) + M->>S: SaveAgentNetworkProvider + M->>AM: StoreEvent(AgentNetworkProviderCreated) + M-->>H: created provider + H-->>DB: 200 + api.AgentNetworkProvider JSON +``` + +### Synth-service delivery via gRPC + +```mermaid +sequenceDiagram + participant P as Proxy + participant G as ProxyServiceServer + participant SM as service.Manager (persisted) + participant SA as synthesizerAdapter + participant AN as SynthesizeServicesForCluster + participant ST as store.Store + + Note over P,G: Initial snapshot + P->>G: GetMappingUpdate (stream open) + G->>SM: GetServicesForCluster(conn.address) + SM-->>G: persisted []*Service + G->>SA: SynthesizeServicesForCluster(conn.address) + SA->>AN: SynthesizeServicesForCluster(store, clusterAddr) + AN->>ST: walk every account; read providers/policies/settings + AN-->>SA: in-memory []*Service + SA-->>G: []*Service + G->>P: response (persisted + synth) + + Note over G,P: Per-request live update + G->>G: SendServiceUpdateToCluster(update, clusterAddr) + G->>G: shallowCloneMapping(update) %% Private MUST survive + G->>P: response with single mapping +``` + +End-to-end: HTTP write persists rows and emits an activity event; the manager then triggers `proxyController.SendServiceUpdate` so proxies re-render. **The snapshot path is the only one that calls into the synthesiser** — on stream open it pulls persisted services then appends synth services for the cluster. Synth services are never persisted. For OIDC/session/tunnel-peer flows, `getServiceByDomain` falls back to `SynthesizeServicesForCluster(clusterFromDomain(domain))` when persisted lookup misses (`proxy.go:1763-1793`). The network_map contribution is orthogonal: per-peer compute prepends the same synth services to `account.Services` before `InjectProxyPolicies`. + +## Permissions model added + +- `permissions/modules/module.go:22` adds `AgentNetwork Module = "agent_network"`, registered in `All` (`module.go:42`). Standard `operations.{Read,Create,Update,Delete}` matrix. +- Handlers don't call `permissionsManager` directly — they extract `UserAuth` and delegate to `agentnetwork.Manager`, which gates every mutation through `requirePermission` (`manager.go:168, 308, 549`, etc.). Confirm your role-set provider has `agent_network` rows for owner/admin/user/billing-admin before merging. +- `getCatalogProviders` (`providers_handler.go:43`) intentionally skips RBAC — catalog is global static data. + +## Activity codes added + +`activity/codes.go:244-274` adds Activities 125-137 + string/code mappings (`codes.go:428-444`), following `..` (e.g., `agent_network.provider.create`). Audit-log exporters / SIEM forwarders need to know the new codes. + +## Invariants + +- **Synth services are never persisted.** Snapshot appends after `serviceManager.GetServicesForCluster` (`proxy.go:761-770`); network_map prepends before `InjectProxyPolicies` (`controller.go:117-126`). +- **`shallowCloneMapping` must round-trip every `ProxyMapping` field except `AuthToken`** — `proxy_clone_test.go:50-58` enforces via `gproto.Equal`. The bug it guards: a missing `Private` made every MODIFIED arrive `private=false`, the proxy skipped `ValidateTunnelPeer`, `UserGroups` stayed empty, `llm_router` denied `no_authorised_provider`; a restart "fixed" it because the snapshot uses the original mapping. +- **Limit-window floor is 60s** (`policies_handler.go:189-220`); enabled cap with both per-group and per-user at zero is rejected. Budget rules reuse the same validator (`budget_handler.go:170`). +- **Manager is optional at boot.** `NewAPIHandler` registers routes only when non-nil (`handler.go:129`); `ProxyServiceServer` returns `Unimplemented` from both RPCs when limits service is unwired (`proxy.go:262-265, 306-309`). +- **Settings GET on an unbootstrapped account returns 200 + `null`** (`settings_handler.go:65-72`) — not 404. + +## Things to scrutinize + +### Correctness +- **`injectAllProxyPolicies` runs on every per-peer compute**: `controller.go:163, 309, 415, 681`. `sendUpdateAccountPeers` is the target of the buffered fan-out — synth runs once per debounced account-update tick **and** once per direct `UpdateAccountPeer`. Cost is O(providers + policies × users-per-group) per account under `LockingStrengthNone`. No per-account synth cache — verify it fits the buffer interval for your largest tenant. +- **`clusterFromDomain` strips at the first `.`** (`proxy.go:1784-1792`). A zero-dot domain returns `""` and the synth call walks every account. Confirm no path reaches this with a malformed/internal domain. +- **Account-budget `RecordConsumption` fans out even when `window_seconds == 0`** (`proxy.go:341-348`) — intentional. Verify the proxy never sends `RecordLLMUsage` for a request that wasn't actually allowed. + +### Security +- Every handler extracts `UserAuth` via `nbcontext.GetUserAuthFromContext` before any work. Routes live behind the standard `/api` mux; bypass list is not extended. +- `CheckLLMPolicyLimits` / `RecordLLMUsage` ride the existing **proxy → mgmt** gRPC connection auth. No additional token check inside the RPCs — they trust the connection. Confirm the proxy-side token-verification interceptor in this package gates both. +- `RecordLLMUsage` only validates `account_id != ""` (`proxy.go:317-319`). A compromised proxy can attribute cost to any account in its cluster — was already true for prior RPCs but is louder now that data drives denials. + +### Concurrency +- `SetAgentNetworkSynthesizer` / `SetAgentNetworkLimitsService` write under `s.mu.Lock`; read paths copy the interface under read lock (`proxy.go:236-247, 260-263, 304-307`). Same pattern as existing `serviceManager`/`proxyController` setters. +- Manager writes use `LockingStrengthUpdate`; synth reads use `LockingStrengthNone` — read-after-write via the proxy snapshot can observe a stale view by up to one fan-out tick. +- Network_map controller is single-threaded per account; cross-account is parallel. + +### Backward compatibility +- `proxy_clone_test.go` is the regression net; any new `ProxyMapping` field must be cloned or explicitly nulled in the test. +- `AccessLogEntry` adds indexed `AgentNetwork bool` — implicit AutoMigrate; deploy story must handle table-rewrite cost on high-volume access-log tables. +- `TargetOptions` gains seven `omitempty` JSON fields (`service.go:69-94`); on-wire shape stays compatible. `targetOptionsToProto` tests all fields when deciding nil (`service.go:551-556`). +- `NewAPIHandler` signature changes — every caller must pass `agentNetworkManager`; `nil` is supported. + +### Observability +- 13 new activity codes via `accountManager.StoreEvent` in the manager — confirm dashboard's audit-log UI maps them. +- `AccessLogEntry.AgentNetwork` is indexed for the dashboard's agent-network log filter. +- New RPCs log at error level on store/selector failures (`proxy.go:284, 327, 332, 348`). Snapshot synth failures degrade to warnings — stream is not aborted (`proxy.go:765`). + +## Test coverage + +| Test | Locks down | +| ---- | ---------- | +| `handlers_test.go::TestPolicyHandler_WindowSecondsRoundTrip` | GET carries `window_seconds`; legacy `window_hours`/`window_days` absent. | +| `handlers_test.go::TestPolicyHandler_RejectsSubMinuteWindow` | POST `<60s` returns 4xx. | +| `handlers_test.go::TestConsumptionHandler_EmptyAccountReturnsArray` | `/consumption` returns `[]` — never null. | +| `handlers_test.go::TestConsumptionHandler_PopulatedAccountListsRows` | RecordConsumption×2 surfaces both with correct tokens/cost/window. | +| `budget_handler_test.go::TestBudgetRuleHandler_RoundTrip` | Targets + PolicyLimits shape round-trip. | +| `budget_handler_test.go::TestBudgetRuleHandler_ListReturnsArray` | Empty-list shape. | +| `budget_handler_test.go::TestBudgetRuleHandler_{RejectsMissingName,RejectsSubMinuteWindow}` | Validation rejections are 4xx. | +| `budget_handler_test.go::TestSettingsHandler_GetExposesCollectionToggles` | All four toggles + computed `Endpoint`. | +| `proxy_clone_test.go::TestShallowCloneMapping_PreservesAllFieldsExceptAuthToken` | Future-proofs clone; every field round-trips, `AuthToken` dropped. | + +Handler tests use a real sqlite store + real manager + always-allow permissions mock (`handlers_test.go:53-75`). Create/update/delete success paths flow through `accountManager.StoreEvent` which the fixture doesn't wire — covered by manager-level no-mock tests outside this module. + +## Known limitations / explicit non-goals + +- No pagination on any list endpoint; no bulk endpoints. +- Synth result is not cached — every snapshot and every per-peer compute repeats the store walk. +- `getSettings` returning `200 + null` is a deliberate dashboard concession. +- No rate-limiting beyond the global `/api` rate limiter. + +## Cross-references + +- Upstream: [shared/api](10-shared-api.md), [management/agentnetwork](21-management-agentnetwork.md), [management/store](20-management-store.md) +- Downstream: [proxy/runtime](33-proxy-runtime.md) +- End-to-end flow: [../01-end-to-end-flows.md](../01-end-to-end-flows.md) +- Top-level: [../00-overview.md](../00-overview.md) diff --git a/docs/agent-networks/modules/30-proxy-middleware-framework.md b/docs/agent-networks/modules/30-proxy-middleware-framework.md new file mode 100644 index 000000000..39322fdce --- /dev/null +++ b/docs/agent-networks/modules/30-proxy-middleware-framework.md @@ -0,0 +1,215 @@ +# proxy/middleware-framework — generic plugin system + +> **Risk level:** **High** — every proxied request transits this chain. Budget exhaustion, panic recovery, or chain-close bugs hit the hot path for all targets, not just agent-network ones. +> **Backward-compat impact:** Additive at the proxy. The `middleware` and `bodytap` packages are new (`proxy/internal/middleware/middleware.go:1`, `proxy/internal/middleware/bodytap/request.go:13`); existing proxy targets keep working until a chain is bound to them via `Manager.Rebuild`. + +This module is the **framework only** — no LLM/agent-network domain knowledge is required, since every example built into it is generic. + +## Module boundary + +This module is the **framework only**: slots, chains, registry, dispatcher, accumulator, body-tap, output filters. No middleware *implementation* lives here — those land in `proxy/internal/middleware/builtin/*` (covered in module 31). The package contract is: + +1. The proxy hands a `Manager` to its config-apply path. The synth pushes per-path `PathTargetBinding` lists (`proxy/internal/middleware/manager.go:26`) into `Manager.Rebuild`, which resolves each spec via the `Registry`/`Resolver` (`proxy/internal/middleware/registry.go:81-121`) and produces an immutable `Chain` keyed by `serviceID|pathID` (`proxy/internal/middleware/manager.go:410-412`). +2. The reverse-proxy handler captures the request body via `bodytap.CaptureRequest`, calls `Chain.RunRequest`, applies returned mutations (already filtered by `chain.applyMutations`), forwards to the upstream behind a `bodytap.CapturingResponseWriter`, then calls `Chain.RunResponse` and `Chain.RunTerminal`. +3. Middlewares are inert plugins that receive a deep-cloned `Input` and return an `Output` whose decision/mutations are clamped by the dispatcher's `filterOutput` (`proxy/internal/middleware/dispatcher.go:149-172`). + +Everything that crosses the framework boundary in either direction is value-typed and deep-copied — middlewares cannot mutate the live request directly, and the framework cannot inadvertently leak middleware-owned slices into the request hot path. + +## Files + +| Path | Role | +| ---- | ---- | +| `proxy/internal/middleware/middleware.go` | `Middleware` + `Factory` interfaces. | +| `proxy/internal/middleware/types.go` | `Slot`, `FailMode`, `Decision`, all limit constants, `Input`/`Output`/`Mutations`/`UpstreamRewrite`/`AuthHeader` value types. | +| `proxy/internal/middleware/spec.go` | Apply-time `Spec` (validated wire shape + runtime-injected fields) and `Clone`. | +| `proxy/internal/middleware/registry.go` | `Registry` (factory map, RWMutex) and `Resolver` (Spec → bound `Middleware`). | +| `proxy/internal/middleware/manager.go` | `Manager`, `chainTable` reverse index, `Rebuild`/`Invalidate*`, async chain close. | +| `proxy/internal/middleware/chain.go` | `Chain.RunRequest`/`RunResponse`/`RunTerminal`, mutation gating, `cloneInputFor`. | +| `proxy/internal/middleware/chain_test.go` | Metadata threading, LIFO response order, rewrite gating, UserGroups propagation, terminal accumulation. | +| `proxy/internal/middleware/dispatcher.go` | Timeout/panic recovery, fail-mode, error classification, `filterOutput`. | +| `proxy/internal/middleware/decision.go` | `RenderDenyResponse`, deny-code regex, status clamp. | +| `proxy/internal/middleware/headerpolicy.go` | Compile-in header denylist + `FilterHeaderMutations`. | +| `proxy/internal/middleware/bodypolicy.go` | `ValidateBodyReplace` / `ApplyBodyReplace` smuggling guards. | +| `proxy/internal/middleware/keys.go` | Metadata key namespace constants. | +| `proxy/internal/middleware/metadata.go` | `Accumulator` — allowlist, per-mw/per-request byte caps, redaction. | +| `proxy/internal/middleware/metrics.go` | OTel instrument bundle (`proxy.middleware.*`). | +| `proxy/internal/middleware/redaction.go` | `Scan` — PEM/JWT/AWS/bearer/Luhn-validated CC patterns. | +| `proxy/internal/middleware/bodytap/request.go` | Capture + replay reader, `Budget` semaphore, bypass reason codes. | +| `proxy/internal/middleware/bodytap/response.go` | `CapturingResponseWriter` (tee with `PassthroughWriter` for Flusher/Hijacker preservation). | + +## Slot model + +Three slots, declared per-middleware exactly once (`proxy/internal/middleware/types.go:27-41`): + +- **`SlotOnRequest`** (`Slot=1`) — runs **before** the upstream call, in registration order. May `DecisionDeny`, may emit `Mutations` (header add/remove, body replace, `UpstreamRewrite`) when both `Spec.CanMutate` and `Middleware.MutationsSupported()` are true. May emit metadata. Each middleware in the slot sees metadata that earlier ones in the same slot just emitted (`proxy/internal/middleware/chain.go:144-178`) — this is how the framework gives middlewares an intra-slot side channel without a global bag. +- **`SlotOnResponse`** (`Slot=2`) — runs **after** the upstream returns, in **reverse** registration order. Cannot deny (clamped in `dispatcher.filterOutput`, `proxy/internal/middleware/dispatcher.go:153-157`). May still mutate response headers in principle, but the current chain only forwards `RewriteUpstream` from on_request, so on_response mutations are observe-only in practice. Threads the same per-slot metadata view as on_request. +- **`SlotTerminal`** (`Slot=3`) — runs **after** every on_response middleware has emitted, in registration order. Sees the full accumulated bag plus prior terminal emissions (`chain.go:221-245`). Cannot deny, cannot mutate (`dispatcher.go:168-170`). Designed for sinks (access log, metrics push, audit emitter). + +Splitting a feature across slots (e.g. "parse on the way out, ship on terminal") is the explicit architectural choice — `types.go:7-15` and `types.go:22-25` make it clear no middleware participates in more than one slot. + +## Architecture & flow + +### Chain dispatch + +```mermaid +sequenceDiagram + autonumber + participant H as proxy HTTP handler + participant BT as bodytap.CaptureRequest + participant CH as Chain + participant DI as Dispatcher + participant MW as Middleware (per slot) + participant US as Upstream + participant CW as CapturingResponseWriter + + H->>BT: CaptureRequest(r, cfg, budget) + BT-->>H: body[], truncated, release() + H->>CH: RunRequest(ctx, r, Input, Accumulator) + loop on_request, registration order + CH->>CH: cloneInputFor(in, OnRequest) + CH->>DI: Invoke(ctx, spec, mw, call) + DI->>MW: mw.Invoke(callCtx, in) + MW-->>DI: Output{decision, metadata, mutations?} + DI->>DI: filterOutput (clamp deny, gate mutations) + DI-->>CH: filtered Output + CH->>CH: Accumulator.Emit (allowlist + caps + redact) + alt DecisionDeny + CH-->>H: denied, merged, rewrite + else allow + CH->>CH: applyMutations(r, m) and capture rewrite + end + end + CH-->>H: nil, merged, rewrite + H->>US: ProxyRequest (with rewrite/mutations applied) + US-->>CW: bytes (streamed, tee'd into cap-bounded buf) + CW-->>H: passthrough complete + H->>CH: RunResponse(ctx, Input{RespBody:CW.Body(),...}, acc) + loop on_response, REVERSE order (LIFO) + CH->>DI: Invoke (same wrappers) + end + H->>CH: RunTerminal(ctx, Input{Metadata:full bag}, acc) + H->>BT: release() + CW.Release() +``` + +### Body-tap mechanics (request + response) + +```mermaid +flowchart LR + subgraph req[Request capture — bodytap.CaptureRequest] + R0[r.Body] --> R1{cfg.MaxRequestBytes > 0?\nUpgrade absent?\nContent-Type allowed?\nCL <= cap?} + R1 -- no --> R2[bypass = reason\nbody = nil\nr.Body untouched] + R1 -- yes --> R3[Budget.Acquire(cap)] + R3 -- denied --> R4[bypass=BypassBudget] + R3 -- ok --> R5[io.LimitReader(r.Body, cap+1)\nio.ReadAll] + R5 --> R6{len > cap?} + R6 -- truncated --> R7[viewable = buf[:cap]\nr.Body = replayReadCloser{buf, tail}] + R6 -- whole --> R8[r.Body = NopCloser(bytes.Reader(buf))\nclose original] + R7 --> R9[(release captured\nbudget on req end)] + R8 --> R9 + end + + subgraph resp[Response capture — CapturingResponseWriter] + W0[client] -.-> CW[Write(p)] + CW --> P1[PassthroughWriter.Write(p)\n— bytes leave to client first] + P1 --> P2{!stopped?} + P2 -- yes --> P3{remaining = cap - buf.Len()} + P3 --> P4[buf.Write(p[:take])\nset truncated if take P5[silent drop into the tee\n(client write already done)] + end +``` + +The body-tap is the highest-leak-risk surface in this module; three details matter: + +1. **Request capture is "read-and-replay", not "read-and-forward".** `CaptureRequest` always swaps `r.Body` for either a `bytes.Reader` (whole body fit) or a `replayReadCloser` that replays the captured prefix then drains the remaining stream from the original body (`bodytap/request.go:178-201`). This means the **upstream still sees the full body even when the tap truncates**. The original `r.Body` is **not** closed in the truncated branch — `replayReadCloser.Close()` only closes the tail (`bodytap/request.go:199-201`), which is the same reader, so close once on request end is correct, but reviewers should confirm the upstream proxy always reads to EOF (otherwise the tail is leaked). +2. **Response capture is a write-through tee.** `CapturingResponseWriter.Write` forwards to the underlying writer **first** (`bodytap/response.go:116-117`), then tees into `buf` under its own mutex. Client never blocks on the tee. `Flusher`/`Hijacker` are preserved via the embedded `responsewriter.PassthroughWriter`. SSE/chunked streams flow through untouched; middlewares only see the bounded prefix. +3. **Budget is a single shared semaphore.** `Manager` constructs one `bodytap.Budget` at startup (`manager.go:138-144`, default `256 MiB` from `bodytap/request.go:39`). Every capture pre-acquires its full `MaxRequestBytes` / `MaxResponseBytes` from the budget regardless of actual body size; that prevents a flood of small captures from collectively exceeding the cap, but it also means a misconfigured `MaxRequestBytes = 1 MiB` with 256 concurrent requests already exhausts the default budget. Reviewers should sanity-check the operator-facing defaults that ship with synth-service. + +The framework explicitly aborts capture (and increments `proxy.middleware.capture_bypass_total`) before reading the first byte when `Upgrade`/`Connection: upgrade` is set (`bodytap/request.go:120-125`), when the content-type isn't in the allowlist (`bodytap/request.go:126-128`), or when the advertised `Content-Length` already exceeds the cap (`bodytap/request.go:131-133`). This is the right place to make sure WebSocket upgrades and large file uploads never reach the buffer. + +## Public contracts + +- **`Middleware` interface** (`middleware.go:14-36`): `ID()`, `Version()`, `Slot()`, `AcceptedContentTypes()`, `MetadataKeys()`, `MutationsSupported()`, `Invoke(ctx, *Input) (*Output, error)`, `Close()`. `MetadataKeys()` is the **closed set** the middleware is allowed to emit — the accumulator drops anything outside it (`metadata.go:71-75`). `Close` must be idempotent (called even when `Invoke` was never reached). +- **`Factory` interface** (`middleware.go:44-47`): `ID()`, `New(rawConfig []byte) (Middleware, error)`. `RawConfig` is opaque JSON bytes on the wire (`spec.go:6-12`); each factory owns its own typed config. +- **`Decision` type** (`types.go:59-69`): `Allow=0`, `Deny=1`, `Passthrough=2`. Default-zero is permissive — important because every middleware that omits `Decision` gets `Allow`. Dispatcher clamps `Deny` to `Passthrough` outside `SlotOnRequest` (`dispatcher.go:153-157`). +- **`Mutations`** (`types.go:196-201`): `HeadersAdd`/`HeadersRemove` (filtered through `headerpolicy.go`), `BodyReplace` (gated through `bodypolicy.go`), and `RewriteUpstream`. `RewriteUpstream` is **last-write-wins** within the on_request slot (`chain.go:170-172`, locked down by `TestChain_RunRequest_LatestRewriteWins`). +- **Metadata propagation keys** (`keys.go`): all keys live in a single file and follow `^[a-z][a-z0-9_-]*(\.[a-z0-9_-]*)+$` (`metadata.go:8`). Framework-injected error tagging uses `mw..error_kind` (`keys.go:81`) so operators can distinguish framework-emitted entries from middleware-emitted ones. + +## Invariants + +- **Per-request context isolation.** `cloneInputFor` deep-copies every mutable field (`Headers`, `RespHeaders`, `Metadata`, `Body`, `RespBody`, `UserGroups`, `UserGroupNames`) before each invocation (`chain.go:286-308`). A misbehaving middleware that mutates `in.Headers` only corrupts its own copy. +- **Body-tap bounded by capture limit.** Request side uses `io.LimitReader(r.Body, limit+1)` (`bodytap/request.go:152`) — the `+1` is how the code detects truncation (`bodytap/request.go:160`); the surfaced buffer is sliced back down to `limit`. Response side stops teeing once `buf.Len() >= cap` (`bodytap/response.go:121-133`). Neither side can grow the buffer past the configured cap. +- **Headers/body redaction order.** Accumulator runs `Scan(value)` **before** counting cost (`metadata.go:81-82`), so the byte budgets are computed against post-redaction sizes. `Scan` order is PEM → JWT → AWS key → bearer → Luhn-validated CC (`redaction.go:25-51`) — the comment block in `redaction.go:8-13` is explicit that this is best-effort, not DLP. +- **No middleware can starve the chain.** Every invocation runs inside `context.WithTimeout(ctx, clampTimeout(spec.Timeout))` in a separate goroutine (`dispatcher.go:51-94`), with the deadline race-`select`ed against the result channel. A blocked middleware fires the timeout path, gets fail-mode'd, and `IncError(kind=timeout)`. Timeouts are clamped to `[10ms, 5s]` (`types.go:80-86`, `dispatcher.go:174-185`). +- **Panic recovery.** `recover()` captures the panic, logs only the type + a 4 KiB stack prefix (no panic value — avoids leaking secrets the middleware was processing), and produces a `panicError` that flows through fail-mode (`dispatcher.go:64-76`). +- **Chain immutability + atomic swap.** `chainTable` is cloned on every `Rebuild`/`Invalidate*` and swapped via `atomic.Pointer` (`manager.go:44-69`, `manager.go:221-300`). Readers (`ChainFor`) are lock-free; writers serialise on `writeMu`. The retired chain is `Close`-d in a background goroutine bounded by `chainCloseTimeout = 2 * MaxTimeout` (`manager.go:21-22`, `manager.go:326-346`), so in-flight invocations finish on the old chain after the swap. + +## Things to scrutinize + +### Correctness + +- **Chain ordering deterministic from synth output?** `Manager.buildChain` iterates `b.Specs` in slice order and appends to `bound` (`manager.go:366-391`); `NewChain` then partitions by slot but **preserves slice order within each slot** (`chain.go:50-60`). So order on the wire = order observed at runtime. Synth must therefore emit specs in the intended execution order — there is no per-spec `Priority` field. Worth flagging. +- **Decision short-circuit semantics.** `RunRequest` returns immediately on `DecisionDeny` (`chain.go:164-167`) **with the metadata accumulated so far** plus the `denied.Metadata`. Callers that ignore `merged` on deny will lose framework-injected `mw..error_kind` entries. The proxy runtime is the only caller; confirm it always feeds `merged` into the access log on the deny path as well. +- **`UpstreamRewrite` `AuthHeader` bypass** (`types.go:218-235`). The `AuthHeader`/`StripHeaders` fields *intentionally* bypass the header denylist on the basis that the proxy itself rewrites auth. The denylist still blocks middleware-emitted `HeadersAdd: Authorization=...`. This is a delicate carve-out — review the runtime consumer to confirm only the trusted upstream-build path unpacks `AuthHeader`, never the generic `applyMutations` loop. +- **`replayReadCloser.Close` only closes the tail** (`bodytap/request.go:199-201`). The replay buffer doesn't own a resource, so this is correct, but it conflates "replay finished" with "underlying body closed". If a caller `Close()`s without reading to EOF, the original body is closed but the captured prefix is lost; harmless for the proxy path (upstream always reads to EOF) but worth a doc-comment. + +### Security + +- **Body-tap memory bounds.** Discussed above — bounded by `MaxBodyCapBytes = 1 MiB` per direction (`types.go:77`) and the shared `Budget` (default 256 MiB). The concerning case is the **deep-copy in `cloneInputFor`** (`chain.go:300-306`): every middleware invocation gets its **own copy** of `Body` and `RespBody`. A chain of N middlewares with a 1 MiB body allocates N MiB of transient bytes per request. With `MaxMiddlewaresPerChain = 16` (`types.go:103`) that's up to 16 MiB extra per in-flight request. Worth pricing into the budget model. +- **Header redaction completeness.** `denyHeaders` (`headerpolicy.go:5-17`) covers the auth/forwarding family and framing (`Content-Length`, `Transfer-Encoding`, `Trailer`). `denyHeaderPrefixes` covers `X-Authenticated-*`, `X-Forwarded-*`, `X-Remote-*`, `X-NetBird-*`. Notably absent: `Range`, `If-Match`/`If-None-Match` (mutation could cause cache poisoning), `Origin`/`Referer`. Not necessarily wrong, but worth a deliberate decision. +- **Metadata key collisions across middlewares.** The accumulator has no cross-middleware uniqueness check; two middlewares with the same key in their allowlist can both emit it, and both copies land in `merged` (`metadata.go:51-99`). Downstream consumers must tolerate duplicates. Worth documenting. +- **Deny rendering.** `RenderDenyResponse` only allows codes matching `^[a-z][a-z0-9._-]{0,63}$` (`decision.go:9`), redacts/truncates message + detail values, caps `Details` at 8 entries (`decision.go:42-50`), clamps status to `[400,499]\{401}` (`decision.go:65-73`). The deny body type is fixed; middlewares cannot inject arbitrary JSON. + +### Concurrency + +- **Per-request state vs shared state in factories.** Each `Factory.New` is called once per chain build; the returned `Middleware` instance is **shared across all requests** for that chain. `Invoke` must be reentrant. The framework does not enforce this — a buggy middleware that holds per-call state on the struct will silently race. Suggest a `// Invoke must be safe for concurrent use` doc on the interface. +- **`chainTable` clone-on-write** is correct, but `addChain`/`removeChain` mutate the *cloned* table before the swap (`manager.go:71-108`), and they're called under `writeMu`. Readers only ever see the post-swap pointer. Good. +- **`Chain.inflight` WaitGroup**. `Run*` does `Add(1)`/`Done()` (`chain.go:142-143`, `chain.go:194-195`, `chain.go:225-226`); `Close` waits on it bounded by ctx (`chain.go:75-85`). One concern: a *new* `RunRequest` can `Add(1)` *after* `Close` started waiting if the caller still holds a stale chain pointer. `WaitGroup` does not panic on this if the count was already > 0 at `Wait` time, but it does panic if `Add` happens after `Wait` returns and another `Wait` runs. `Close` is documented one-shot, so single-`Wait` is fine, but callers must drop the chain reference before calling `Close`. Worth a code comment near `Close`. +- **Goroutine leaks.** `Dispatcher.Invoke` spawns one goroutine per call and *always* writes to a buffered (cap=1) channel (`dispatcher.go:62-76`), so even if the timeout fires the goroutine completes its send and exits. No leak. +- **`closeChainsAsync`** detaches retired chains into a goroutine (`manager.go:326-346`). If `Manager` is never GC'd this is fine, but there's no shutdown hook to wait on outstanding closes. Reviewers should confirm the proxy shutdown path explicitly drains in-flight requests before tearing down `Manager`, or accept that the last chain-close round may be cut short on exit. + +### Performance + +- **Allocations per request.** `cloneInputFor` allocates new slices for `Headers`, `RespHeaders`, `Metadata`, `Body`, `RespBody`, `UserGroups`, `UserGroupNames` — once per middleware per request. For a typical 5-middleware chain on a 1 KiB body that's ~10 small slice allocs plus one `Body` copy each. Not a hot-path crisis, but `sync.Pool` for the per-call `Input` would be a natural follow-up. +- **Accumulator allocates a fresh `allowSet` per `Emit` call** (`metadata.go:55-58`). One per middleware per slot pass = up to 48 per request. Cheap, but worth noting. +- **Regex cost.** `Scan` runs five regex passes on every accepted metadata value (`redaction.go:25-51`). Bounded by `MaxMetadataValueBytes = 4 KiB` so worst case is small. + +### Observability + +- **Per-middleware metrics.** `proxy.middleware.requests_total{middleware,target_id,outcome}` (`metrics.go:34-41`), `duration_ms`, `invocations_total`, `errors_total{kind}`, `metadata_rejected_total{reason}`, `header_mutation_blocked_total{header}`, `capture_bypass_total{reason}`. Comprehensive surface; operators can alert on `errors_total{kind=panic}` and `errors_total{kind=timeout}` separately. **Latency histogram is in milliseconds with default OTel buckets** — for a 10ms–5s timeout range default buckets cover OK, but a custom bucket set centred on 1–500ms would resolve the agent-network response-parser tail better. +- **Decision logs.** Panic logs (`dispatcher.go:69`) include `request_id`, type, and stack but not the panic value (safe). `Chain.Close` logs middleware-close errors at debug (`chain.go:91`). `applyMutations` logs body-replace rejections at warn (`chain.go:278`). No log on the deny path itself — by design, since the access-log terminal middleware is expected to record outcomes. + +## Test coverage + +| Test file | Locks down | +| --------- | ---------- | +| `proxy/internal/middleware/chain_test.go:77` | `RunRequest` threads metadata across on_request middlewares (regression for the "later mw can't see earlier mw's emissions" bug). | +| `chain_test.go:110` | `RunResponse` reverse-order threading. | +| `chain_test.go:142` | `cost_meter`-shaped scenario: response_parser registered after cost_meter still emits *before* cost_meter sees the bag (guards the `cost.skipped=missing_tokens` regression). | +| `chain_test.go:178` | `UpstreamRewrite` last-write-wins. | +| `chain_test.go:206` | No middleware emits → nil rewrite. | +| `chain_test.go:224` | Rewrite filtered when `CanMutate=false`. | +| `chain_test.go:245` | `Input.UserGroups` propagates verbatim through `cloneInputFor`. | +| `chain_test.go:304` | Terminal middlewares see the full accumulated bag + prior terminal emissions. | + +**Gaps** worth raising with the author: +- No direct test for `Dispatcher.Invoke` timeout / panic / fail-mode behaviour at the framework level (covered indirectly by built-in tests, but a unit test pinning `errors_total{kind=...}` labels would be cheap insurance). +- No test for `bodytap.CaptureRequest` truncated replay (the upstream-sees-full-body invariant is exactly the kind of thing a regression would silently break). +- No test for `Budget` exhaustion behaviour under concurrency. +- No test for `Manager.InvalidateMiddleware` + `LiveServiceCheck` race (the auth-revocation race the comment at `manager.go:33-38` calls out is the load-bearing reason for `LiveServiceCheck`). + +## Known limitations / explicit non-goals + +- **No middleware-to-middleware RPC.** Side-channel is metadata only. +- **No streaming body inspection.** Middlewares see a bounded prefix; SSE / chunked parsing happens against that prefix in the response middleware. +- **No per-spec priority.** Order is registration order in the spec slice. +- **No retry / circuit-breaker** on middleware errors. Fail-mode is binary (open/closed) and per-spec. +- **Mutations cannot rewrite the request URL path or query** — only `RewriteUpstream` can change scheme/host (+ optional path replacement, see `types.go:218-235`). +- **Redaction is best-effort.** Explicitly documented in `redaction.go:8-13`. Not a DLP solution. + +## Cross-references + +- Upstream wire shape: [../modules/10-shared-api.md](10-shared-api.md) (Spec/RawConfig encoding from management). +- Built-in middlewares using this framework: [../modules/31-proxy-middleware-builtin.md](31-proxy-middleware-builtin.md). +- Runtime wiring (where `Manager`, `Chain`, and `bodytap` are consumed by the HTTP handler): [../modules/33-proxy-runtime.md](33-proxy-runtime.md). +- End-to-end request flow including capture + chain dispatch: [../01-end-to-end-flows.md](../01-end-to-end-flows.md). +- Top-level architecture: [../00-overview.md](../00-overview.md). diff --git a/docs/agent-networks/modules/31-proxy-middleware-builtin.md b/docs/agent-networks/modules/31-proxy-middleware-builtin.md new file mode 100644 index 000000000..ad56feb77 --- /dev/null +++ b/docs/agent-networks/modules/31-proxy-middleware-builtin.md @@ -0,0 +1,402 @@ +# proxy/middleware-builtin — the LLM chain + +The registry-mounted middleware set the proxy executes on every agent-network +LLM request. The two highest-blast-radius areas are the **capture-pointer +semantics** and the **limit_check ⇒ limit_record** record-once invariant. + +Sibling module: [32-proxy-llm-parsers.md](./32-proxy-llm-parsers.md) — the SDK +adapters + pricing table and cost formula this chain delegates to. + +--- + +## Module boundary + +This module is the registry-mounted middleware set the proxy executes on +every agent-network LLM request. Each sub-package registers itself via +`init()` +([builtin.go:32–34](../../../proxy/internal/middleware/builtin/builtin.go)); +the proxy server anonymous-imports the set +([all_test.go:11–19](../../../proxy/internal/middleware/builtin/all_test.go)) +so the registry is populated at boot. The chain is wired by the management +synthesiser and executed by the framework +(`proxy/internal/middleware/{chain,dispatcher,accumulator}.go` — both out +of scope). Everything here reads from / writes to one envelope: the +`middleware.KV` metadata bag plus `middleware.Mutations` for header/body +rewrites. + +## The 8 middlewares + +| Name | Slot | Inputs (metadata read) | Outputs (metadata written) | Side effects | +|---|---|---|---|---| +| `llm_request_parser` | OnRequest | `Input.{URL,Body,BodyTruncated}` | `llm.{provider,model,stream,request_prompt_raw,capture_truncated}` | none | +| `llm_router` | OnRequest | `llm.model`, `Input.{URL,UserGroups}` | `llm.{resolved_provider_id,authorising_groups}`, `llm_policy.{decision,reason}` | upstream rewrite + auth strip/inject | +| `llm_limit_check` | OnRequest | `llm.{resolved_provider_id,model}`, `Input.{AccountID,UserID,UserGroups}` | `llm.{selected_policy_id,attribution_group_id,attribution_window_seconds}`, `llm_policy.{decision,reason}` | gRPC `CheckLLMPolicyLimits` | +| `llm_identity_inject` | OnRequest | `llm.{resolved_provider_id,authorising_groups}`, `Input.{UserEmail,UserID,UserGroups,UserGroupNames}` | none | header strip/inject + optional body rewrite | +| `llm_guardrail` | OnRequest | `llm.{model,request_prompt_raw}` | `llm_policy.{decision,reason}`, `llm.request_prompt` | none (model allowlist deny) | +| `llm_response_parser` | OnResponse | `llm.provider`, `Input.{RespHeaders,RespBody,Status}` | `llm.{input,output,total,cached_input,cache_creation}_tokens`, `llm.response_completion` | none | +| `cost_meter` | OnResponse | `llm.{provider,model,resolved_provider_id}`, token buckets | `cost.usd_{input,cached_input,cache_creation,output,total,cache}` or `cost.skipped` | none (in-memory pricing lookup) | +| `llm_limit_record` | OnResponse | `llm.{attribution_group_id,attribution_window_seconds,input_tokens,output_tokens}`, `cost.usd_total` | none | gRPC `RecordLLMUsage` | + +[all_test.go:26–40](../../../proxy/internal/middleware/builtin/all_test.go) +locks the ID set; adding or removing one is a conscious extension. + +## Files + +| File | LOC | Notes | +|---|---:|---| +| `builtin.go` | 90 | Registry + `FactoryContext` (ctx, meter, logger, mgmt client) | +| `all_test.go` | 41 | Locks the 8-ID registry surface | +| `agentnetwork_chain_integration_test.go` | 319 | Live sqlite + real gRPC bufconn; gate→recorder wire path | +| `llm_request_parser/*` | 162 / 66 / 356 | Provider detection, body parse, prompt extraction with capture-pointer gating | +| `llm_router/*` | 385 / 84 / 586 | Three-pass route selection (model → groups → path-prefix) | +| `llm_limit_check/*` | 196 / 38 / 182 | Pre-flight `CheckLLMPolicyLimits` (2s, fail-open) | +| `llm_identity_inject/*` | 440 / 108 / 666 | HeaderPair (LiteLLM) + JSONMetadata (Portkey) + ExtraHeaders | +| `llm_guardrail/*` | 176 / 82 / 75 / 219 / 217 | Model allowlist + optional prompt capture with PII redaction | +| `llm_response_parser/*` | 258 / 222 / 43 / 433 / 169 / 111 | Buffered + SSE accumulation; AWS event-stream accumulator (`streaming_bedrock.go`) for Bedrock; capture-pointer gates completion emit | +| `cost_meter/*` | 236 / 98 / 586 | Token → USD via `proxy/internal/llm/pricing`; both pricing tiers arrive in the middleware config | +| `llm_limit_record/*` | 144 / 35 / 191 | Post-flight `RecordLLMUsage` (5s, debug-on-error) | + +## Per-middleware + +### llm_request_parser + +Detects the LLM provider via `llm.DetectParser` (URL sniff) or by name via +`llm.ParserByName` when synthesiser stamps `provider_id` +([middleware.go:96–99](../../../proxy/internal/middleware/builtin/llm_request_parser/middleware.go)). +**Path-routed providers short-circuit first:** `parseVertexPath` and +`parseBedrockPath` ([middleware.go:85–94](../../../proxy/internal/middleware/builtin/llm_request_parser/middleware.go)) +pull the model + vendor out of the URL before parser selection runs — Vertex +from `/v1/projects/.../publishers/{pub}/models/{model}:{action}` (publisher → +vendor via `vertexPublisherVendor`), Bedrock from `/model/{id}/{action}` with +`normalizeBedrockModel` stripping the region prefix + version suffix. See +[50-path-routed-providers.md](./50-path-routed-providers.md) for the full path +grammar. For body-routed providers it decodes the body into `RequestFacts` +(model + stream) and extracts the prompt. On +`capture_prompt=true` (or absent — see capture-pointer semantics below) the +prompt is run through `llm_guardrail.RedactPII` when `redact_pii=true` and +truncated rune-safely to 3500 bytes +([middleware.go:109–122](../../../proxy/internal/middleware/builtin/llm_request_parser/middleware.go)). +**Key invariant:** redaction is parser-side, not guardrail-side — access-log +reads `llm.request_prompt_raw` directly. + +### llm_router + +Three-pass route selection in `matchRoute` +([middleware.go:241–300](../../../proxy/internal/middleware/builtin/llm_router/middleware.go)): +filter by `Models` claim → vendor-pin (a vendor-tagged request never crosses to +another vendor's route) → filter by `AllowedGroupIDs` intersection → model +precedence over path → tie-break by longest `UpstreamPath` prefix match. +Model-miss returns `llm_policy.model_not_routable`; known-but-unauthorised +returns `llm_policy.no_authorised_provider`. **Key invariant:** auth-header +strip+inject rides on `UpstreamRewrite.{StripHeaders,AuthHeader}` +([middleware.go:606–646](../../../proxy/internal/middleware/builtin/llm_router/middleware.go)) +— NOT `HeadersAdd/HeadersRemove` — because the framework's mutation gate +blocks `Authorization` on the generic header path. + +**Path-routed providers route before the model table.** `Invoke` checks +`isVertexPath` / `isBedrockPath` +([middleware.go:138–216](../../../proxy/internal/middleware/builtin/llm_router/middleware.go)) +ahead of the model lookup, so a path-carried model can't be claimed by a +same-vendor body-routed provider. `matchPathRoute` enforces the route's `Models` +allowlist (empty = catch-all) even though the model came from the URL. +Two path-only behaviours: +- **Vertex unmeterable publisher** — when `llm_request_parser` emits no + `llm.provider` (e.g. Gemini/`google`), the router denies with + `llm_policy.unmeterable_publisher` (403) rather than forward it uncounted. +- **GCP token minting** — when the route carries `GCPServiceAccountKeyB64` + (set from a `keyfile::` api_key), `gcpBearer` mints + caches a short-lived + OAuth2 token per request instead of injecting a static value; a bad key or + unreachable token endpoint denies with `llm_policy.upstream_auth_failed` + (502). Bedrock uses its static bearer token directly (no minting). +- **`/bedrock` prefix** — an optional `/bedrock` gateway-namespace prefix is + accepted and stripped via `RewriteUpstream.StripPathPrefix` so the native + `/model/...` path reaches the upstream. + +Full treatment in [50-path-routed-providers.md](./50-path-routed-providers.md). + +### llm_limit_check + +Pre-flight gate. Reads `llm.resolved_provider_id`, calls +`CheckLLMPolicyLimits` with a 2s context timeout +([middleware.go:24, 97–106](../../../proxy/internal/middleware/builtin/llm_limit_check/middleware.go)), +on allow stamps `llm.selected_policy_id`, `llm.attribution_group_id`, +`llm.attribution_window_seconds`. **Key invariant:** fail-open. Nil +`MgmtClient`, empty provider id, or RPC error returns `allowNoAttribution()` +— management outage doesn't take down every LLM request. Operators audit via +the access-log; a future flag may switch this to fail-closed. + +### llm_identity_inject + +Dispatches per-rule between LiteLLM-shaped `HeaderPair` +([middleware.go:169](../../../proxy/internal/middleware/builtin/llm_identity_inject/middleware.go)) +and Portkey-shaped `JSONMetadata` +([middleware.go:292](../../../proxy/internal/middleware/builtin/llm_identity_inject/middleware.go)). +Identity is the peer's email (or `UserID` fallback); tags are the +**authorising-groups intersection** emitted by `llm_router`, not the full +`UserGroups` — a peer in 5 groups authorised under 1 only tags as that 1. +**Anti-spoof:** every `HeadersAdd` is preceded by a `HeadersRemove` of the +same name; the framework runs `Remove` before `Add` so client-supplied +identity never reaches the upstream. Body-level inject (`tags_in_body`, +`end_user_id_in_body`) is skipped on empty / truncated / non-JSON bodies so +header attribution stays intact. + +### llm_guardrail + +Model allowlist deny + optional prompt-capture-with-redaction. Allowlist +match is case-insensitive via `normaliseModel`; empty allowlist disables the +check. Prompt capture reads `llm.request_prompt_raw` and emits +`llm.request_prompt` only when `prompt_capture.enabled` +([middleware.go:149–165](../../../proxy/internal/middleware/builtin/llm_guardrail/middleware.go)). +**Key invariant:** `RedactPII` is the exported function the parsers call — +single PII contract across all three keys. + +### llm_response_parser + +Buffered and SSE paths share one `Invoke` +([middleware.go:102–127](../../../proxy/internal/middleware/builtin/llm_response_parser/middleware.go)): +content-type sniffing dispatches to `invokeBuffered` (JSON, status<400) or +`invokeStreaming` (text/event-stream, partial bodies tolerated). Streaming +delegates to `accumulateStream` +([streaming.go:21–30](../../../proxy/internal/middleware/builtin/llm_response_parser/streaming.go)) +using `llm.NewScanner`. A third path, `accumulateBedrockStream` +([streaming_bedrock.go](../../../proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock.go)), +decodes the AWS binary event-stream (`application/vnd.amazon.eventstream`) +returned by Bedrock's `-stream` actions — InvokeModel `chunk` frames wrap a +base64 Anthropic event, Converse frames carry text + a trailing usage block. +Cached / cache-creation buckets emit only when non-zero, preserving the existing +token schema. + +### cost_meter + +Reads `llm.provider` + `llm.model` + token buckets, looks up the per-1k rates, +and emits the full `cost.usd_*` breakdown (four per-bucket values plus the +`_total` and `_cache` aggregates) or a closed-set `cost.skipped` reason +(`missing_provider/model/tokens`, `unparseable_tokens`, `zero_tokens`, +`unknown_model`). + +**Management owns pricing.** The proxy carries no embedded price list: the whole +table arrives in this middleware's `ConfigJSON` as +`{pricing: {defaults, providers}}`, synthesized by management from the catalog +plus the operator's stored per-provider prices +([factory.go:13–34](../../../proxy/internal/middleware/builtin/cost_meter/factory.go)). +Both tiers are validated by `pricing.NewTable` / `pricing.NewEntries` at +construction, so a non-finite or negative rate fails the chain build. A price +change is an ordinary mapping push — the chain rebuild yields a fresh instance +over a fresh immutable table, so there is no data dir, no pricing file, no +reload goroutine, and nothing to invalidate. + +**Two-tier lookup** +([middleware.go:165–183](../../../proxy/internal/middleware/builtin/cost_meter/middleware.go)): + +1. **Per-provider-record** — the operator's stored price for the route that + actually served the request, keyed by the `llm.resolved_provider_id` that + `llm_router` stamped on the allow path, then by normalized model id. Entries + arrive fully materialized (management folds default cache rates in at synth + time), so there is no merging here. Absent metadata — no router in the chain + — skips this tier. +2. **Surface defaults** — the catalog-derived table keyed by `llm.provider` + (`openai`/`anthropic`/`bedrock`). This is also what prices gateway-style + providers, which enumerate no models and therefore get no per-record entry. + +**Backward compatibility:** a config with no `pricing` block means management +predates config-delivered pricing. The factory logs one warning at build time +and the instance records `cost.skipped=unknown_model` ($0) for every request +rather than falling back to a stale built-in price list +([factory.go:55–60](../../../proxy/internal/middleware/builtin/cost_meter/factory.go)). + +**Key invariant:** the provider-shape switch lives in `pricing.EntryCosts` +(sibling doc) and is selected by the **surface**, not by which tier the entry +came from — `cost_meter` stays provider-agnostic, and a per-record override on +an Anthropic route still bills its cache buckets additively. + +### llm_limit_record + +Post-flight write. Always returns `DecisionAllow`; response has already been +served so RPC errors mustn't surface (logged at `Debugf`). Skip-on-no-signal +at line 81 (zero tokens + zero cost). **Key invariant:** the +skip-on-missing-attribution guard at line 98 is a safety net independent of +the framework's deny short-circuit — if the gate denied and the framework +still runs the recorder, the recorder skips on absent +`UserID`+`groupID`+`UserGroups` and no phantom counter materialises. + +## Full-chain diagram (canonical order) + +```mermaid +flowchart TD + A[HTTP request] --> B[llm_request_parser
                                                OnRequest] + B -->|llm.provider, llm.model,
                                                llm.stream, llm.request_prompt_raw| C[llm_router
                                                OnRequest] + C -->|llm.resolved_provider_id,
                                                llm.authorising_groups,
                                                upstream rewrite + auth| D[llm_limit_check
                                                OnRequest] + D -->|deny path| Z1[403 llm_policy.*] + D -->|allow + llm.selected_policy_id,
                                                llm.attribution_group_id,
                                                llm.attribution_window_seconds| E[llm_identity_inject
                                                OnRequest] + E -->|header strip+inject
                                                + optional body rewrite| F[llm_guardrail
                                                OnRequest] + F -->|deny: model_blocked| Z2[403 llm_policy.model_blocked] + F -->|allow + llm.request_prompt| G[upstream LLM call] + G --> H[llm_response_parser
                                                OnResponse] + H -->|llm.{input,output,total,cached_input,cache_creation}_tokens,
                                                llm.response_completion| I[cost_meter
                                                OnResponse] + I -->|cost.usd_total or cost.skipped| J[llm_limit_record
                                                OnResponse] + J --> K[response to client] +``` + +## limit_check ⇒ limit_record record-once invariant + +```mermaid +sequenceDiagram + participant LC as llm_limit_check + participant M as management gRPC + participant U as upstream LLM + participant LR as llm_limit_record + participant DB as sqlite consumption table + + LC->>M: CheckLLMPolicyLimits (2s) + alt allow + M-->>LC: selected_policy_id, attribution_group_id, window_s + LC->>U: stamps attribution metadata + U-->>LR: response + tokens (via llm_response_parser + cost_meter) + LR->>M: RecordLLMUsage (5s, debug-on-error) + M->>DB: increment (user, group, window) row + else deny + M-->>LC: llm_policy.token_cap_exceeded + Note over LR: framework short-circuits; even if invoked,
                                                recorder skips on absent UserID+groupID+UserGroups + else mgmt nil / rpc error + LC-->>LC: allowNoAttribution() — fail open + Note over LR: no window_s ⇒ recorder books only account-level
                                                budget rules (which run independently) + end +``` + +The integration test +[agentnetwork_chain_integration_test.go](../../../proxy/internal/middleware/builtin/agentnetwork_chain_integration_test.go) +exercises all three branches against a real sqlite store + bufconn gRPC — +no mocks. Tests: `TestChain_AllowPath_StampsAttributionAndRecordsCounter` +(line 130), `TestChain_DenyPath_GateRejectsAndNoConsumptionWritten` (line +207), `TestChain_CapExhaustTransition` (line 265). + +## Public contracts (per-middleware JSON config) + +| Middleware | Config shape | +|---|---| +| `llm_request_parser` | `{provider_id?, redact_pii?, capture_prompt?: *bool}` ([factory.go:19–37](../../../proxy/internal/middleware/builtin/llm_request_parser/factory.go)) | +| `llm_router` | `{providers: [{id, models, upstream_scheme, upstream_host, upstream_path?, auth_header_name, auth_header_value, allowed_group_ids}]}` | +| `llm_limit_check` | `{}` — pulls `MgmtClient` from `FactoryContext` | +| `llm_identity_inject` | `{providers: [{provider_id, header_pair?|json_metadata?, extra_headers?}]}` | +| `llm_guardrail` | `{provider_allowlists: {providerID: []string}, prompt_capture: {enabled, redact_pii}}` — allowlist keyed by resolved provider id; a provider absent from the map is unrestricted (fail-closed backstop; authoritative per-policy/group check is management's `CheckLLMPolicyLimits`) | +| `llm_response_parser` | `{redact_pii?, capture_completion?: *bool}` | +| `cost_meter` | `{pricing: {defaults: {surface: {model: rates}}, providers: {providerRecordID: {model: rates}}}}` — rates are `{input_per_1k, output_per_1k, cached_input_per_1k?, cache_read_per_1k?, cache_creation_per_1k?}`. A missing `pricing` key means "management predates config-delivered pricing": every request records `cost.skipped=unknown_model` | +| `llm_limit_record` | `{}` — same pattern as `llm_limit_check` | + +All factories accept empty / null / `{}` / whitespace as zero-value config; +only structurally invalid JSON is rejected so misconfig surfaces at chain +build time. `cost_meter` adds a semantic check on top of that: a `pricing` +block carrying a negative or non-finite rate fails the build too, rather than +mispricing live traffic. + +## Invariants + +1. **limit_check ↔ limit_record paired.** They MUST appear together. Gate + stamps attribution metadata on the request leg; recorder reads it on the + response leg. If a chain contains only the recorder, the + skip-on-missing-attribution guard at + [llm_limit_record/middleware.go:81–87, 98–103](../../../proxy/internal/middleware/builtin/llm_limit_record/middleware.go) + keeps counters consistent but no enforcement runs. Only-gate means + counters never tick and headroom appears infinite. + +2. **`capture_prompt` / `capture_completion` pointer semantics.** Both are + `*bool`. `nil` = "preserve legacy emit" (back-compat default for + non-agent-network callers and pre-toggle tests). `false` = suppress the + key entirely (access-log row carries zero prompt / completion content). + `true` = emit. The synthesiser sets the pointer explicitly to the + account's `EnablePromptCollection` toggle. The handling lives + in [llm_request_parser/factory.go:55–61](../../../proxy/internal/middleware/builtin/llm_request_parser/factory.go) + and the symmetric [llm_response_parser/middleware.go:62–68](../../../proxy/internal/middleware/builtin/llm_response_parser/middleware.go); + a missing pointer must not be treated as `false` (that would suppress + capture for legacy non-agent-network callers). + `redact_pii` is an orthogonal `bool` controlling **form** of emitted + content, not whether it's emitted. + +3. **`redact_pii` is parser-side.** Both parsers import + `llm_guardrail.RedactPII` and run it BEFORE stamping the metadata bag. + Load-bearing because the access-log sink reads `llm.request_prompt_raw` + and `llm.response_completion` directly — by the time `llm_guardrail` + runs its own pass on `llm.request_prompt`, the raw key has already been + stamped. Tests: `TestInvoke_RedactPii_RedactsBeforeEmittingRawPrompt`, + `TestInvoke_RedactPii_RedactsCompletionBeforeEmit`. + +4. **Metadata allowlist enforcement.** Every middleware declares + `MetadataKeys()`. The framework accumulator drops any KV outside that + allowlist. When adding a new key, also extend the docstring in + `middleware/keys.go`. + +5. **Closed deny-code set.** All deny paths emit one of: + `llm_policy.model_not_routable`, `llm_policy.no_authorised_provider`, + `llm_policy.model_blocked`, `llm_policy.token_cap_exceeded`, + `llm_policy.unmeterable_publisher` (path-routed Vertex publisher with no + parser → 403), `llm_policy.upstream_auth_failed` (GCP token mint failure → + 502), or the management-supplied code on `llm_limit_check`. These surface + verbatim; arbitrary middleware text never reaches the wire. + +## Things to scrutinise + +**Correctness.** `llm_router` model match treats an empty `Models` slice as +"claim every model" +([middleware.go:238–248](../../../proxy/internal/middleware/builtin/llm_router/middleware.go)) +for gateway-style providers — confirm no real provider record ships with an +empty `Models` by accident. Path-prefix tie-break falls back to declaration +order when no candidate prefix-matches, so the synthesiser must emit a +deterministic order. `llm_limit_record` discards `strconv.ParseInt` errors +([middleware.go:78–80](../../../proxy/internal/middleware/builtin/llm_limit_record/middleware.go)) +— relies on `llm_response_parser` always emitting parseable values; spot-check +the streaming partial path on truncated bodies. + +**Security.** Auth headers must NEVER appear on `Mutations.HeadersAdd/Remove` +for the router — a direct headers path would bypass the framework gate. The +capture-pointer handling is the kind of place a bug ships PII to logs +silently; every synthesiser config path must set the pointer explicitly. +`llm_identity_inject` body inject silently skips on a +non-object `metadata` field +([middleware.go:262–270](../../../proxy/internal/middleware/builtin/llm_identity_inject/middleware.go)) +— header path still attributes, but body-level tag-budget enforcement +doesn't run for that request. + +**Concurrency.** `cost_meter`'s two pricing tables are built once from the +middleware config and never mutated, so the lookup path needs no lock or atomic +swap — a price change replaces the whole instance. Every middleware is +otherwise a stateless value receiver. Integration test uses real bufconn gRPC — +race detector is the meaningful bar. + +**Perf.** Hot path is `lookupKV` linear scan over <10 KVs; `cost_meter.Cost` +is O(1); SSE accumulation is single-pass. No map allocation per call. + +**Observability.** Every deny stamps `llm_policy.decision=deny` and a +matching `llm_policy.reason` — access-log can pivot on either. +`llm_limit_record` only logs at `Debugf` on RPC failure +([middleware.go:125–130](../../../proxy/internal/middleware/builtin/llm_limit_record/middleware.go)); +operators need an alternate signal (metric on `RecordLLMUsage` failures) for +counter accuracy. + +## Test coverage + +| File | Tests | Notes | +|---|---:|---| +| `all_test.go` | 1 | Registry surface lock | +| `agentnetwork_chain_integration_test.go` | 3 | Allow/deny/cap-exhaust vs live sqlite + bufconn gRPC | +| `llm_request_parser/middleware_test.go` | 18 | `provider_id` bypass, redaction, capture-pointer, rune-safe truncation | +| `llm_router/middleware_test.go` | 19 | Three-pass match, deny codes, path-prefix tie-break, header strip+inject | +| `llm_limit_check/middleware_test.go` | 6 | Allow/deny, fail-open on nil mgmt / RPC error, attribution stamping | +| `llm_identity_inject/middleware_test.go` | 28 | HeaderPair, JSONMetadata, ExtraHeaders, body inject, anti-spoof | +| `llm_guardrail/middleware_test.go` | 15 | Allowlist case-insensitivity, prompt capture toggle, deny shape | +| `llm_guardrail/redact_test.go` | 15 | Email, SSN, phone (E.164 + NA), bearer, IPv4; fixture-driven | +| `llm_response_parser/middleware_test.go` | 18 | Buffered OAI+Anthro, capture-pointer, redact, truncation | +| `llm_response_parser/streaming_test.go` | 7 | OAI usage frame, Anthro message_delta, truncated body best-effort | +| `cost_meter/middleware_test.go` | 22 | Each skip reason, provider-shape formulas, config-delivered defaults, per-record-beats-defaults + miss-falls-back, per-record uses surface formula, nil-pricing skips everything, invalid-rate rejection | +| `llm_limit_record/middleware_test.go` | 7 | Skip-on-no-signal, skip-on-missing-attribution, RPC failure swallowed | + +## Cross-references + +- Sibling: [32-proxy-llm-parsers.md](./32-proxy-llm-parsers.md) — SDK adapters + + SSE framer + pricing table and cost formula. +- Path-routed providers (Vertex AI + Bedrock), `keyfile::` credential, GCP + token minting, `/bedrock` prefix: + [50-path-routed-providers.md](./50-path-routed-providers.md). +- Upstream config: `management/server/agentnetwork/synthesizer` (out of scope). +- Framework: `proxy/internal/middleware/{chain,dispatcher,accumulator,registry}.go`. +- Metadata key registry: `proxy/internal/middleware/keys.go`. +- gRPC surface: `proto.ProxyServiceClient.{CheckLLMPolicyLimits,RecordLLMUsage}`. diff --git a/docs/agent-networks/modules/32-proxy-llm-parsers.md b/docs/agent-networks/modules/32-proxy-llm-parsers.md new file mode 100644 index 000000000..52faeaac1 --- /dev/null +++ b/docs/agent-networks/modules/32-proxy-llm-parsers.md @@ -0,0 +1,434 @@ +# proxy/llm-parsers — SDK adapters + pricing + SSE + +The runtime-agnostic LLM library: the OpenAI Responses API (`/v1/responses`) +and the older Chat Completions API (`/v1/chat/completions`), the Anthropic +Messages API (`/v1/messages`), the SSE wire format (`event:` / `data:` lines, +`\n\n` framing, CRLF tolerance), and per-provider token accounting (OpenAI's +cached-prompt **subset** vs Anthropic's cache_read **additive** model). The +pricing table's per-provider cost formula is the highest-leverage place a +small bug would silently mis-bill operators. + +Sibling module: [31-proxy-middleware-builtin.md](./31-proxy-middleware-builtin.md) +— the 8 middlewares that consume this package's parsers + pricing table. + +--- + +## Module boundary + +`proxy/internal/llm` is the runtime-agnostic LLM library shared by every +middleware that needs to understand provider-specific shapes. Zero +proxy-framework dependencies: + +- `parser.go` — `Parser` interface, `Provider` enum, public factories + (`Parsers`, `DetectParser`, `ParserByName`). +- `openai.go` / `anthropic.go` / `bedrock.go` — per-provider `Parser` impls. +- `sse.go` — SSE scanner (`Scanner`, `Event`, `NewScanner`). +- `errors.go` — sentinels callers branch on with `errors.Is`. +- `pricing/` — immutable pricing table + the per-surface cost formula. The + rates themselves come from management inside `cost_meter`'s middleware + config; this package holds no price list and reads no files. +- `fixtures/` — captured request/response/stream bodies the tests replay. + +The package carries zero proxy-framework dependencies so the same parsers can +be reused later by a WASM adapter +([parser.go:1–6](../../../proxy/internal/llm/parser.go)). + +## Files + +| File | LOC | Notes | +|---|---:|---| +| `parser.go` | 104 | Interface + factories + `Provider{Unknown,OpenAI,Anthropic}` enum | +| `openai.go` | 347 | Chat Completions + Completions + Responses API; cached_tokens subset | +| `openai_test.go` | 222 | 11 tests; fixture replay + cached/Responses-API matrix | +| `anthropic.go` | 172 | Messages + legacy `/v1/complete`; cache_read + cache_creation additive | +| `anthropic_test.go` | 154 | 7 tests including streaming-extraction-skipped contract | +| `bedrock.go` | 190 | AWS Bedrock InvokeModel (snake_case) + Converse (camelCase) response shapes; model lives in URL path | +| `bedrock_test.go` | — | InvokeModel + Converse usage shapes; AWS event-stream content-type → `ErrStreamingUnsupported` on buffered `ParseResponse` | +| `sse.go` | 117 | `bufio`-backed scanner; CRLF normalised; trailing-event handling | +| `sse_test.go` | 175 | 12 tests; fixture replay + multiline + size limits | +| `parser_test.go` | 53 | `Parsers()`, `DetectParser`, provider enum values | +| `errors.go` | 31 | 6 sentinels: `Err{Unknown,Unsupported}Provider/Model`, `Err{NotLLM,Malformed}Response`, `ErrStreamingUnsupported`, `ErrMalformedRequest` | +| `pricing/pricing.go` | 234 | `Table`, `Entry`, `EntryJSON`, `Costs`; `NewTable`/`NewEntries` validation + `EntryCosts` formula. No I/O, no reload, no embedded rates | +| `pricing/pricing_test.go` | 177 | 10 tests — provider-shape formulas, cached clamp, rate fallback, nil-safety, rate validation | +| `fixtures/*` | 21–59 | OAI chat/responses/stream + Anthro messages/stream | + +## Request body → parser dispatch + +```mermaid +flowchart TD + A[HTTP request
                                                URL + JSON body] --> B{ParserByName?
                                                provider_id config set} + B -- yes --> P[matched Parser] + B -- no --> C[DetectParser] + C --> D{loop Parsers
                                                OpenAIParser, AnthropicParser} + D -- DetectFromURL match --> P + D -- no match --> X[ok=false
                                                middleware skips] + P --> E[ParseRequest body] + E -->|err: ErrMalformedRequest| Y[middleware emits provider only] + E --> F[RequestFacts
                                                model + stream] + P --> G[ExtractPrompt body] + G --> H[joinMessages
                                                extractContentParts
                                                decodeStringOrJoin] + H --> I[prompt text
                                                or empty] + F --> J[stamps llm.model + llm.stream] + I --> K[stamps llm.request_prompt_raw
                                                subject to capture_prompt gate] +``` + +OpenAI's URL hints +([openai.go:27–33](../../../proxy/internal/llm/openai.go)) include +both `/v1/chat/completions` and the bare `/chat/completions` — the latter +covers Cloudflare AI Gateway, which rewrites the canonical version segment. +Anthropic's hints are `/v1/messages` and `/v1/complete` +([anthropic.go:14–17](../../../proxy/internal/llm/anthropic.go)). +Both implementations use case-insensitive substring matching so a proxy prefix +strip / rewrite doesn't defeat detection. + +`ParserByName` ([parser.go:93–103](../../../proxy/internal/llm/parser.go)) +is the **agent-network bypass**: the synthesiser knows which parser to use +because it built the synth service from the catalog, so it stamps +`provider_id` on the parser config and the middleware skips URL sniffing +entirely. This is what makes the same parser set work whether the request +flows to OpenAI direct, to LiteLLM, to Portkey, or to any gateway with a +non-canonical URL shape. + +**Path-routed providers (Vertex AI, Bedrock) bypass both `ParserByName` and +`DetectParser`.** The model and the parser surface live in the URL path, so the +request middleware extracts them directly (`parseVertexPath` / +`parseBedrockPath`) before the parser-selection step. For Vertex the publisher +segment picks the parser (`anthropic` → Anthropic parser; `google`/Gemini → +none, request denied as unmeterable). For Bedrock the dedicated `BedrockParser` +handles the response. Full treatment in +[50-path-routed-providers.md](./50-path-routed-providers.md). + +## Streaming response → SSE chunker → response parser → completion + token count + +```mermaid +sequenceDiagram + participant U as upstream LLM + participant LR as llm_response_parser
                                                (OnResponse) + participant S as llm.NewScanner
                                                (SSE framer) + participant P as Parser-specific accumulator
                                                (accumulateOpenAIStream
                                                or accumulateAnthropicStream) + + U-->>LR: text/event-stream
                                                (buffered prefix in RespBody) + LR->>S: NewScanner(bytes.NewReader(body)) + loop until EOF or [DONE] + S-->>LR: Event{Type, Data} + LR->>P: dispatch per event.Type
                                                (OpenAI: data-only
                                                Anthropic: named events) + P-->>P: accumulate completion text
                                                track usage from final frame + end + P-->>LR: llm.Usage + completion string + LR->>LR: appendUsage stamps
                                                llm.{input,output,total,cached_input,cache_creation}_tokens + LR->>LR: truncateCompletion(3500 bytes, rune-safe) + LR->>LR: redactPII if redact_pii && captureCompletion +``` + +`Scanner.Next` +([sse.go:44–87](../../../proxy/internal/llm/sse.go)) returns one +event per `\n\n` boundary; multiple `data:` lines join with `\n`; comment lines +(starting with `:`) are skipped per the SSE spec; a trailing event without a +closing blank line is still returned before `io.EOF` so a server that closes +the connection cleanly doesn't lose the last frame +([sse.go:55–58](../../../proxy/internal/llm/sse.go)). CRLF is +normalised in `trimEOL` so fixtures captured from live servers replay +unchanged. + +## Per-provider + +### OpenAI + +[openai.go:54–67](../../../proxy/internal/llm/openai.go) defines +`openAIRequest` with three prompt fields: `messages` (Chat Completions), +`prompt` (legacy), `input` (Responses API). The decoder uses +`json.RawMessage` so each shape is parsed lazily. + +`ParseResponse` +([openai.go:117–146](../../../proxy/internal/llm/openai.go)) +accepts both naming conventions: Chat Completions returns +`prompt_tokens`/`completion_tokens`, Responses API returns +`input_tokens`/`output_tokens`. `pickInt64` prefers Responses-API names and +falls back — same parser handles both endpoints without per-route config. +`openAICachedTokens` mirrors the fallback for +`input_tokens_details.cached_tokens` vs `prompt_tokens_details.cached_tokens`. + +**Key invariant:** `CachedInputTokens` for OpenAI is a SUBSET of +`InputTokens`. The cost meter clamps to guard against malformed upstream +responses where `cached > total`. + +### Anthropic + +[anthropic.go:37–49](../../../proxy/internal/llm/anthropic.go) +defines `anthropicRequest` covering Messages API (`system` + `messages[]`) +and legacy `/v1/complete` (`prompt` string). `ExtractPrompt` emits +`system: ` first when present, then per-message `role: content`. + +`ParseResponse` +([anthropic.go:82–104](../../../proxy/internal/llm/anthropic.go)) +fills three independent token buckets: `InputTokens`, `CacheReadInputTokens`, +`CacheCreationInputTokens`. Latter two are **additive** (not subset). +`TotalTokens` sums all four so downstream dashboards render one "tokens" +number without double-counting. + +`ExtractCompletion` walks `content[]` `{type, text}` parts and concatenates +non-empty text with newlines, falling back to legacy `completion`. + +### Bedrock + +[bedrock.go](../../../proxy/internal/llm/bedrock.go) implements the +`Parser` interface for the AWS Bedrock runtime. Bedrock is **path-routed**: the +model lives in the URL (`/model/{id}/{action}`), so the request middleware +extracts it (see [50-path-routed-providers.md](./50-path-routed-providers.md)) +and `ParseRequest` is a deliberate no-op. The parser's real work is on the +response leg, covering both Bedrock body shapes: + +- **InvokeModel** — vendor-native. Anthropic-on-Bedrock returns snake_case usage + (`input_tokens`, `output_tokens`, `cache_read_input_tokens`, + `cache_creation_input_tokens`) with the same additive cache buckets as + first-party Anthropic. +- **Converse** — unified camelCase (`inputTokens`, `outputTokens`, + `totalTokens`). `firstNonZero` folds the two naming conventions into one + `Usage`; when Converse omits `totalTokens` the parser sums the buckets. + +`ProviderName()` returns `"bedrock"` — its own pricing surface in the table +management ships, keyed by the **normalised** model id (region prefix + version +suffix stripped by the request parser; management normalises its keys the same +way at synth time so the two compare equal). `ParseResponse` returns +`ErrStreamingUnsupported` for an +AWS binary event-stream content-type (`application/vnd.amazon.eventstream`, +`isAWSEventStream`) so the caller routes to the streaming accumulator instead. + +### SSE framing + +`Scanner` is `bufio`-backed, 64 KiB read buffer, 1 MiB max line so a +malicious upstream can't blow process memory +([sse.go:33–38, 97–100](../../../proxy/internal/llm/sse.go)). +`splitField` strips one space after the `:` per the SSE spec. Documented +`not safe for concurrent use`; every consumer creates a fresh scanner per +response body. Streaming accumulators live in the middleware package +([llm_response_parser/streaming.go](../../../proxy/internal/middleware/builtin/llm_response_parser/streaming.go)) +but use `llm.NewScanner` so the framing contract stays here. + +### Pricing table + +**Management is the sole pricing authority.** The proxy carries no embedded +price list and reads no pricing file: the whole table arrives inside +`cost_meter`'s `ConfigJSON` on the ordinary mapping push, and a price change +is just another push — the chain rebuild constructs a fresh `Table`, so there +is nothing to reload +([pricing.go:1–7](../../../proxy/internal/llm/pricing/pricing.go)). The +management side of the contract (catalog defaults, the operator's stored +per-provider prices, and `AgentNetwork.PricingDefaultsFile`) is covered in the +management-side module guide; `cost_meter`'s wire shape is in +[31-proxy-middleware-builtin.md](./31-proxy-middleware-builtin.md). + +`EntryJSON` +([pricing.go:36–45](../../../proxy/internal/llm/pricing/pricing.go)) is the +management→proxy contract — five USD-per-1k rates under `input_per_1k`, +`output_per_1k`, `cached_input_per_1k`, `cache_read_per_1k`, +`cache_creation_per_1k`. Management's `pricing.Entry` marshals the identical +names, and `EntryJSON`/`Entry` are field-identical so `NewEntries` converts by +direct struct conversion rather than field-by-field copying (a new rate can't +be silently dropped in transit). + +`EntryCosts` +([pricing.go:183–234](../../../proxy/internal/llm/pricing/pricing.go)) +is the cost formula — most security-relevant math in this module. The +**surface** (the `llm.provider` value the request parser stamped) selects the +formula, never the tier the entry came from: a per-provider-record override on +an Anthropic route still bills its cache buckets additively. + +| Provider | Formula | +|---|---| +| `openai` | `(inTokens − clamped) × InputPer1K + clamped × CachedInputPer1K + outTokens × OutputPer1K` where `clamped = min(cachedInput, inTokens)` | +| `anthropic`, `bedrock` | `inTokens × InputPer1K + cachedInput × CacheReadPer1K + cacheCreation × CacheCreationPer1K + outTokens × OutputPer1K` | +| default | `inTokens × InputPer1K + outTokens × OutputPer1K` | + +`bedrock` shares the Anthropic additive-cache formula +([pricing.go:214–229](../../../proxy/internal/llm/pricing/pricing.go)): +Anthropic-on-Bedrock reports the same additive cache buckets, while non-Anthropic +Bedrock models (Nova, Llama) simply report zero in those buckets so cost reduces +to `input + output`. + +Each per-bucket rate falls back to `InputPer1K` when zero — operators opt in +to discounts by setting the field. + +`Costs` +([pricing.go:143–163](../../../proxy/internal/llm/pricing/pricing.go)) is the +per-request split. The four per-bucket fields are the base; `TotalUSD` and +`CacheUSD` are **derived** in `newCosts` so the aggregates can never drift from +the breakdown. `InputUSD` is always the non-cached input bucket on both +provider shapes, so input and cached-input never double-count. + +## Public contracts + +**`Parser` interface** +([parser.go:50–66](../../../proxy/internal/llm/parser.go)): + +```go +type Parser interface { + Provider() Provider + ProviderName() string + DetectFromURL(path string) bool + ParseRequest(body []byte) (RequestFacts, error) + ParseResponse(status int, contentType string, body []byte) (Usage, error) + ExtractPrompt(body []byte) string + ExtractCompletion(status int, contentType string, body []byte) string +} +``` + +Adding a provider means implementing this interface and appending to the +slice returned by `Parsers()` ([parser.go:78–84](../../../proxy/internal/llm/parser.go)). +Order matters: `DetectFromURL` ties resolve by registration order. +`Parsers()` today returns `{OpenAIParser, AnthropicParser, BedrockParser}`. + +**`Provider` enum** +([parser.go:8–18](../../../proxy/internal/llm/parser.go)): +`ProviderUnknown = 0`, `ProviderOpenAI = 1`, `ProviderAnthropic = 2`, +`ProviderBedrock = 3`. Numeric values are persisted in nothing today but treat +them as wire-stable — new providers must take fresh numbers. + +**`Pricing` construction + lookup** +([pricing.go:60–130](../../../proxy/internal/llm/pricing/pricing.go)): + +```go +func NewEntries(raw map[string]map[string]EntryJSON) (map[string]map[string]Entry, error) +func NewTable(raw map[string]map[string]EntryJSON) (*Table, error) + +func (t *Table) Lookup(provider, model string) (Entry, bool) +func (t *Table) Cost(provider, model string, inTokens, outTokens, cachedInput, cacheCreation int64) (float64, bool) +func (t *Table) Costs(provider, model string, inTokens, outTokens, cachedInput, cacheCreation int64) (Costs, bool) +func EntryCosts(entry Entry, surface string, inTokens, outTokens, cachedInput, cacheCreation int64) Costs +``` + +`NewTable` is the surface-keyed defaults table; `NewEntries` returns the raw +two-level map `cost_meter` uses for the per-provider-record tier (it looks up an +`Entry` directly and calls `EntryCosts`, so it needs no `Table` wrapper). Both +reject any non-finite or negative rate, so a corrupt config fails the chain +build rather than mispricing silently. Nil input yields an empty, +never-matching table. + +Nil-safe: `t.Cost`/`t.Lookup` on a nil receiver returns `ok=false` +([pricing.go:96–99](../../../proxy/internal/llm/pricing/pricing.go)). +`ok=false` means the surface or model is absent from the table management sent; +the caller emits `cost.skipped=unknown_model`. + +## Invariants + +1. **The pricing package is pure and platform-independent.** No file I/O, no + `//go:embed`, no goroutines, no build tags — the rates arrive as config, so + there is nothing platform-specific left to port. Anything reintroducing a + read-from-disk path here re-splits pricing authority between management and + the proxy, which is exactly what this design removed. + +2. **SSE scanner handles partial chunks.** A buffered prefix that doesn't end + in `\n\n` still yields its accumulated event before `io.EOF` + ([sse.go:55–58](../../../proxy/internal/llm/sse.go)). Tests: + `TestSSEScanner_OpenAIFixture`, `TestSSEScanner_AnthropicFixture`, + `TestSSEScanner_MultilineData`, `TestSSEScanner_CRLF`. The streaming + accumulators ride on this: `accumulateAnthropicStream` and + `accumulateOpenAIStream` `break` on any scanner error to return partial + usage rather than aborting + ([streaming.go:68–73, 144–150](../../../proxy/internal/middleware/builtin/llm_response_parser/streaming.go)). + +3. **Management is the only source of rates.** `Table` has no constructor that + invents prices: the only way in is `NewTable`/`NewEntries` over the wire map + management sent. A missing or empty `pricing` block therefore means *no + prices at all* (`cost_meter` records `cost.skipped=unknown_model`, $0) — + never a stale built-in fallback that would silently bill list price. + +4. **Tables are immutable once built.** `Table.entries` is written only in + `NewEntries` and never mutated afterwards, and `cost_meter`'s `perRecord` + map is likewise build-time-only + ([pricing.go:47–52](../../../proxy/internal/llm/pricing/pricing.go)). This + is what makes the no-reload design safe: a price change arrives as a mapping + push that builds a new middleware instance over a new table, so concurrent + readers can't observe a half-updated price list and no atomic swap or lock + is needed on the hot path. + +5. **Rate validation happens at chain-build time, not per request.** + `NewEntries` rejects negative, NaN, and ±Inf rates field by field + ([pricing.go:60–83](../../../proxy/internal/llm/pricing/pricing.go)), naming + the offending surface/model/field in the error. Management enforces the same + constraints at its API boundary and in its YAML parser, so this is + defense-in-depth — but it means a corrupt push fails loudly at build instead + of producing negative costs on live traffic. Test: + `TestNewTable_ValidatesRates`. + +6. **New rates must be added to `Entry`, `EntryJSON`, *and* management's + `pricing.Entry` together.** `NewEntries` converts by direct struct + conversion `Entry(e)` + ([pricing.go:76–78](../../../proxy/internal/llm/pricing/pricing.go)), which + only compiles while the two structs stay field-identical — so the proxy half + is compiler-enforced. The management half is not: a rate added there but not + here unmarshals into nothing and prices that bucket at `InputPer1K`. + +## Things to scrutinise + +**Correctness.** Verify the OpenAI cached-prompt clamp at +[pricing.go:203–206](../../../proxy/internal/llm/pricing/pricing.go) +short-circuits before subtraction. Negative token counts are clamped to zero up +front ([pricing.go:186–197](../../../proxy/internal/llm/pricing/pricing.go)) so +no formula can yield a negative cost. `Anthropic.TotalTokens` sums all four +buckets (in + out + cache_read + cache_creation) — downstream dashboards +need to know this differs from `input + output`. +`OpenAIParser.ExtractPrompt` falls through `messages → input → prompt`; a +request sending all three reports only `messages` (uncommon but worth +noting). + +**Security.** `Scanner.maxLine = 1 MiB`; a 2 MiB single-line `data:` event +errors from `Scanner.Next` and both accumulators stop with partial usage. +Pricing is no longer file-backed, so the loader's path-traversal / symlink / +oversize surface is gone entirely — the config channel (an authenticated +mapping push from management) is now the only way rates enter the proxy, and +`NewEntries` is the validation boundary on it. A new rate added to management's +`pricing.Entry` but not to `EntryJSON` here is the remaining silent-mispricing +path (see invariant 6). + +**Concurrency.** Nothing in this package is shared mutable state: tables are +built once and never written again, so `cost_meter`'s hot path is lock-free by +construction rather than by atomic swap. Per-call `Scanner` instances mean no +shared state across concurrent response-parser calls. + +**Perf.** `Table.Cost` is two map lookups + multiplications, O(1); the +per-provider-record tier adds at most one more lookup. `Scanner.Next` is one +`ReadString('\n')` per line. No background goroutines and no per-request +allocation of pricing state. + +**Observability.** A config carrying no `pricing` block logs one warning at +chain-build time (`cost_meter` factory) and then records +`cost.skipped=unknown_model` per request, so an old-management deployment is +visible in both logs and the access log rather than quietly reporting $0. +Parser errors return sentinels — middleware uses `errors.Is` to map to the +right `cost.skipped` reason. + +## Test coverage + +| File | Tests | Coverage highlights | +|---|---:|---| +| `parser_test.go` | 3 | `Parsers()` shape lock, `DetectParser` URL matrix, provider enum stability | +| `openai_test.go` | 11 | Chat Completions + Responses API + legacy `prompt`; cached-tokens subset for both naming conventions; fixture replays | +| `anthropic_test.go` | 7 | Messages + legacy `/v1/complete`; streaming REJECTED on `ParseResponse` (must use scanner); fixture replays | +| `sse_test.go` | 12 | Fixture replay both providers; multiline `data:`; CRLF; comment skip; trailing-event-without-blank-line; oversize rejection | +| `pricing/pricing_test.go` | 10 | Provider-shape switch (surface selects the formula); cached-rate + cache-read/creation fallback to `InputPer1K`; cached-clamp; negative-token clamp; nil-receiver safety; rate validation (negative / NaN / Inf rejected); nil + empty table | + +**Fixtures** ([proxy/internal/llm/fixtures/](../../../proxy/internal/llm/fixtures/)): +`openai_chat_completion.json` (chat.completions with usage), +`openai_responses.json` (Responses API shape), +`openai_stream.txt` (3 deltas + usage + `[DONE]`), +`anthropic_messages.json` (Messages API non-streaming), +`anthropic_stream.txt` (full 7-event sequence: message_start → +content_block_{start,delta×2,stop} → message_delta (usage) → message_stop). +No pricing fixture: the table is config-delivered, so pricing tests construct +it in-process from a wire-shape map. + +## Cross-references + +- Sibling: [31-proxy-middleware-builtin.md](./31-proxy-middleware-builtin.md) + — the chain that calls `llm.Parsers()`, `llm.ParserByName`, + `llm.NewScanner`, `pricing.NewTable` / `pricing.NewEntries`. +- Path-routed providers (Vertex AI + Bedrock), credential syntax, and the + Bedrock AWS event-stream accumulator: + [50-path-routed-providers.md](./50-path-routed-providers.md). +- Direct callers: `llm_request_parser/middleware.go:82–94`, + `llm_response_parser/middleware.go:113–123`, + `llm_response_parser/streaming.go:65, 142`, `cost_meter/factory.go:49–57`. +- Related elsewhere: the agent-network synthesiser stamping `provider_id` + is covered in the management-side module guide; proxy server boot + + `FactoryContext` construction is covered in the proxy-framework guide. diff --git a/docs/agent-networks/modules/33-proxy-runtime.md b/docs/agent-networks/modules/33-proxy-runtime.md new file mode 100644 index 000000000..54046b614 --- /dev/null +++ b/docs/agent-networks/modules/33-proxy-runtime.md @@ -0,0 +1,193 @@ +# proxy/runtime — translate + serve + log + +> **Risk level:** High — every config push from management is translated here, and the chain runs on every HTTP request to a synth target. +> **Backward-compat impact:** Additive at the wire (`PathTargetOptions.middlewares`, `agent_network`, `disable_access_log`, capture caps) and on the proxy `Server` struct (`MiddlewareCaptureBudgetBytes`). Non-agent-network targets stay on the no-middleware fast path. Middleware config is entirely wire-delivered — no proxy-side data dir is involved, including for LLM pricing, which management ships inside `cost_meter`'s config. + +## Module boundary + +Turns the synth-service wire format from `ProxyService.SyncMappings`/`GetMappingUpdate` into in-process middleware chains and runs them on top of the existing `httputil.ReverseProxy`. Four concerns: (a) **translate** — `proto.MiddlewareConfig` → validated `middleware.Spec` (proxy/middleware_translate.go) + self-register the eight built-ins (proxy/middleware_register.go); (b) **boot + rebuild** — construct the `middleware.Manager`, share the OTel meter, install the live-service check, rebuild per-path chains on every `addMapping`/`modifyMapping` (proxy/server.go); (c) **serve** — resolve chain at request time, capture bodies under a global budget, invoke `RunRequest`/`RunResponse`/`RunTerminal`, render deny responses, apply `UpstreamRewrite` (proxy/internal/proxy/reverseproxy.go); (d) **log + tag** — emit access-log entries with the new `agent_network` flag, gate emission on `EnableLogCollection` via `DisableAccessLog` (proxy/internal/accesslog). + +**Inert for non-agent-network targets**: nil or empty chain → existing fast path (reverseproxy.go:127-139); `SuppressAccessLog` defaults false so the access-log middleware emits unchanged. + +## Files + +| Path | Role | +| ---- | ---- | +| proxy/middleware_translate.go | proto→Spec translation; slot/failmode/timeout mapping; caps | +| proxy/middleware_translate_test.go | translator unit tests | +| proxy/middleware_register.go | blank-imports the eight builtins for `init()` registration | +| proxy/server.go | `initMiddlewareManager`, `rebuildMiddlewareChains`, `isLiveService`, `buildMiddlewareBindings`, new Server fields, `protoToMapping` stamps AgentNetwork/DisableAccessLog/CaptureConfig/Middlewares | +| proxy/internal/proxy/reverseproxy.go | `WithMiddlewareManager`, chain dispatch, body capture, `applyUpstreamRewrite`/`Headers`, `buildRequestInput`, response-leg respInput identity fields | +| proxy/internal/proxy/reverseproxy_test.go | `TestBuildRequestInput_PropagatesIdentityAndGroups` | +| proxy/internal/proxy/context.go | `agentNetwork`, `suppressAccessLog`, `userGroupNames` on `CapturedData` | +| proxy/internal/proxy/servicemapping.go | new `PathTarget` fields | +| proxy/internal/proxy/agent_network_chain_realstack_test.go | end-to-end self-contained chain test | +| proxy/internal/accesslog/logger.go | `logEntry.AgentNetwork` → `proto.AccessLog` | +| proxy/internal/accesslog/middleware.go | reads `GetAgentNetwork()`; gates `l.log` on `!GetSuppressAccessLog()` | +| proxy/internal/accesslog/middleware_test.go | suppress/default/preserves-usage assertions | +| proxy/internal/auth/middleware_test.go | tunnel-peer group propagation contract | +| proxy/internal/metrics/metrics.go | `Meter()` getter for the middleware manager | + +## Architecture & flow + +### Synth-service ingestion → translate → register → serve + +```mermaid +flowchart TD + A[Management SyncMappings/GetMappingUpdate] --> B["processMappings\nserver.go:1492"] + B --> C{Mapping type} + C -->|CREATED| D["addMapping → setupHTTPMapping → updateMapping"] + C -->|MODIFIED| E["modifyMapping → cleanupMappingRoutes → setupHTTPMapping → updateMapping"] + C -->|REMOVED| F["removeMapping → cleanupMappingRoutes → invalidateMiddlewareChains"] + D --> G["protoToMapping\nserver.go:2181"] + E --> G + G --> H["translateMiddlewareConfigs\nmiddleware_translate.go:55"] + G --> I["translateMiddlewareCaptureConfig\nmiddleware_translate.go:18"] + H --> J["[]middleware.Spec on PathTarget"] + I --> K["*bodytap.Config on PathTarget"] + J --> L["proxy.AddMapping\nservicemapping.go:118"] + K --> L + L --> M["rebuildMiddlewareChains\nserver.go:2017 → Manager.Rebuild"] + F --> N["Manager.Invalidate(serviceID)"] +``` + +### Per-request lifecycle through the chain + accesslog + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant M as accesslog.Middleware + participant A as auth.Middleware (Protect) + participant RP as ReverseProxy.ServeHTTP + participant CH as middleware.Chain + participant U as Upstream + C->>M: HTTP request + M->>M: NewCapturedData(requestID), WithCapturedData(ctx) + M->>A: next.ServeHTTP + A->>A: Private → ValidateTunnelPeer → stamp UserID/Email/Groups/GroupNames/AuthMethod + A->>RP: next.ServeHTTP + RP->>RP: findTargetForRequest → targetResult + RP->>RP: stamp ServiceID/AccountID/AgentNetwork/SuppressAccessLog on CapturedData + RP->>RP: resolveChain via Manager.ChainFor + alt chain == nil or Empty + RP->>U: httputil.ReverseProxy.ServeHTTP (fast path) + else chain non-empty + RP->>RP: bodytap.CaptureRequest (global budget) + RP->>CH: RunRequest + CH-->>RP: denyOutput? requestMeta + upstreamRewrite + alt deny + RP->>C: RenderDenyResponse + else allow + RP->>RP: capturingWriter + applyUpstreamRewrite/Headers + RP->>U: httputil.ReverseProxy.ServeHTTP(respWriter) + U-->>RP: response + RP->>CH: RunResponse (respInput carries UserGroups) + RP->>CH: RunTerminal (merged request+response metadata) + end + end + RP-->>M: handler returns + M->>M: build logEntry incl. AgentNetwork + alt SuppressAccessLog == true + M->>M: skip l.log; still trackUsage + else default + M->>M: l.log → goroutine SendAccessLog + end +``` + +### EnableLogCollection suppression path + +```mermaid +flowchart LR + S["agentnetwork.Settings.EnableLogCollection"] --> B["synthesizer: target.DisableAccessLog = !EnableLogCollection"] + B --> P["proto PathTargetOptions.disable_access_log (field 13)"] + P --> T["protoToMapping reads GetDisableAccessLog()\nserver.go:2211"] + T --> M["PathTarget.DisableAccessLog\nservicemapping.go:47"] + M --> R["ServeHTTP: cd.SetSuppressAccessLog\nreverseproxy.go:106"] + R --> G["accesslog middleware: if !GetSuppressAccessLog l.log\nmiddleware.go:95"] + R --> U["trackUsage unconditional — bandwidth telemetry preserved"] +``` + +**Ingestion** lands as a `ProxyMapping` batch on `handleSyncMappingsStream`/`handleMappingStream`. `processMappings` dispatches to `addMapping`/`modifyMapping`/`removeMapping`; HTTP goes `setupHTTPMapping → updateMapping → protoToMapping`. `protoToMapping` (server.go:2181) is the single translation surface that materialises `[]middleware.Spec`, `*bodytap.Config`, `AgentNetwork`, `DisableAccessLog` onto each `PathTarget`; `updateMapping` finishes with `s.proxy.AddMapping(m)` (atomic swap under `mappingsMux`) and `s.rebuildMiddlewareChains(svcID, m)`. + +At **request time** the access-log middleware stamps `CapturedData`; the auth chain runs (Private services lift `peer_group_ids` from `ValidateTunnelPeer` — auth/middleware_test.go:322). `ReverseProxy.ServeHTTP` resolves the chain; nil or empty → original `httputil.ReverseProxy`, no body capture. When a chain matches, body is captured under the global budget, `RunRequest` produces an `UpstreamRewrite` (`llm_router` selects a provider, rewrites scheme/host/path, injects `Authorization`), and `RunResponse`+`RunTerminal` run after the upstream returns. The terminal slot sees the merged metadata bag — that's how `llm_limit_record` ships the consumption sample. The **access-log** addition: `logEntry.AgentNetwork` from `GetAgentNetwork()` onto `proto.AccessLog.AgentNetwork`; the gate at middleware.go:95 honors `EnableLogCollection`, skipping `l.log` but keeping `trackUsage` so bandwidth telemetry survives. + +## Public contracts touched + +- `proxy.Server.MiddlewareCaptureBudgetBytes` (int64) — process-wide capture cap; defaults to 256 MiB (server.go:249-253). There is no `MiddlewareDataDir`: no built-in middleware reads config from disk, so `builtin.FactoryContext` carries only the proxy-lifetime context, meter, logger, and management client. +- `proxy/internal/proxy.WithMiddlewareManager(*middleware.Manager) Option` — new option on `NewReverseProxy`; nil keeps the fast path (reverseproxy.go:48-56). +- `proxy/internal/proxy.PathTarget` adds `Middlewares`, `CaptureConfig`, `AgentNetwork`, `DisableAccessLog` (servicemapping.go:27-51), all zero-default. +- `proxy/internal/proxy.CapturedData` adds `agentNetwork`, `suppressAccessLog`, `userGroupNames` behind `sync.RWMutex`; slices deep-copied (context.go:47-66, 183-258). +- `accesslog.logEntry.AgentNetwork` + `proto.AccessLog.AgentNetwork` (logger.go:131, 268). +- `metrics.Metrics.Meter()` exposes the OTel meter for the middleware manager (metrics.go:53-58). + +## Invariants + +- **Synth-service updates are live (no proxy restart).** Every `MODIFIED` flows through `modifyMapping → cleanupMappingRoutes` (invalidates chains) `→ setupHTTPMapping → updateMapping → rebuildMiddlewareChains`. **ProxyMapping.Private preservation:** the relevant logic lives in `management/internals/shared/grpc/proxy.go:shallowCloneMapping`, not this module, but it surfaces here — if a `MODIFIED` synth service arrives `private=false`, auth skips `ValidateTunnelPeer`, `CapturedData.UserGroups` stays empty, and `llm_router` denies with `llm_policy.no_authorised_provider` until a management restart re-pushes the snapshot. This module assumes `mapping.GetPrivate()` is correct on every batch. +- **`EnableLogCollection=false` suppresses access-log writes but middleware still runs.** Gate is one `if !cd.GetSuppressAccessLog()` immediately around `l.log(entry)` (middleware.go:95); `trackUsage` runs below the gate. Locked by `TestMiddleware_SuppressAccessLog_PreservesUsageTracking` (middleware_test.go:139). +- **`agent_network` flag on access-log entries is set when the chain processed the request.** Source `target.AgentNetwork`, stamped at reverseproxy.go:105, read at accesslog/middleware.go:86. +- **auth → builtin group propagation.** `Protect` writes `UserGroups`/`UserGroupNames`; `buildRequestInput` (reverseproxy.go:333) copies them into `middleware.Input`. The response-leg `respInput` (reverseproxy.go:196-223) also carries `UserEmail`/`UserGroups`/`UserGroupNames` — `llm_limit_record` needs `UserGroups` to ship `group_ids` so management's group-targeted budget rules match (comment at reverseproxy.go:211-215). +- **Empty chains stay on the fast path.** `ServeHTTP` skips body capture and the run sequence when `chain == nil || chain.Empty()` (reverseproxy.go:127). +- **Self-registration is the only way a builtin reaches the registry.** `middleware_register.go` blank-imports each builtin; `init()` adds the factory to `mwbuiltin.DefaultRegistry()`. Missing it → translator drops the entry with a warn (translate.go:97). + +## Things to scrutinize + +### Correctness +- **Translate edge cases** — drops on nil cfg, empty ID, unknown ID, UNSPECIFIED slot; each logs one warn; volume bounded by `MaxMiddlewaresPerChain`. +- **Re-translate without dropping in-flight requests** — `Manager.Rebuild` is the only call from `rebuildMiddlewareChains`. Reverse proxy reads `ChainFor` once per request (reverseproxy.go:327) and runs the captured `*Chain` for the whole request. Verify in module 30 that `Rebuild` swaps atomically. +- **ProxyMapping.Private preservation** — enforced management-side in `shallowCloneMapping`. Proxy-side regression catches: `TestProtect_PrivateService_TunnelPeerGroupsPropagate` + the integration test. +- **Body-capture cleanup** — `defer releaseBudget()` (reverseproxy.go:145) and `defer capturingWriter.Release()` (reverseproxy.go:180) must run on every return; confirm no future `return` lands between acquisition and defer. +- **`applyUpstreamRewrite` clones the URL** — `cloned := *orig` value-copies `*url.URL`; safe because overwritten fields are strings, not slices/maps (reverseproxy.go:285-292). + +### Security +- **Translate validates every config** — registry membership rejects unknown IDs; UNSPECIFIED slot drops; ID-less drops; raw config copied (not aliased) at translate.go:109. +- **`AuthHeader`/`StripHeaders` only reachable via `UpstreamRewrite`** — regular mutation surface goes through the framework denylist (`Authorization`/`Cookie` blocked); only the router middleware can replace `Authorization` (reverseproxy.go:296-304). Confirm in module 30 nothing outside the proxy-trusted path populates `UpstreamRewrite.AuthHeader`. +- **`stampNetBirdIdentity` strips client-sent values first** (reverseproxy.go:742-743) — anti-spoof for `X-NetBird-User`/`X-NetBird-Groups`; control chars filtered; comma-bearing labels dropped (reverseproxy_test.go:1217/:1243/:1193). +- **Auth → group propagation** — `auth/middleware_test.go:322` and `:366` cover the contract. If auth ever stops calling `ValidateTunnelPeer` for Private services, every agent-network request silently denies. + +### Concurrency +- **Chain replacement under in-flight requests** — `findTargetForRequest` takes `mappingsMux.RLock`; `AddMapping` writes. `resolveChain` calls `ChainFor` once; even if `Rebuild` swaps mid-request, in-flight requests keep running on the captured pointer. +- **`CapturedData` mutation across slots** — accessors take `sync.RWMutex`; slices deep-copied on both Set and Get. Verify no caller mutates the returned slice expecting it to land back. +- **`Manager.Invalidate` race** — `removeMapping` invalidates after `cleanupMappingRoutes`; mapping read happens before chain resolution, so requests before invalidate run captured chains; later ones fail `findTargetForRequest`. +- **`Logger.log` goroutine** — `logSem` caps at `maxLogWorkers = 4096`; overflow → `dropped.Add(1)` + debug log. Middleware test uses a buffered channel and 150ms negative-assertion window — review whether 150ms holds on slow CI. + +### Backward compatibility +- **Non-agent-network services unaffected** — `protoToMapping` reads new fields only when `opts != nil`; defaults leave `Middlewares`/`CaptureConfig` nil → chain resolves nil → fast path. Existing `reverseproxy_test.go` (non-chain) still passes. +- **`disable_access_log` is proto field 13, default false** — every existing target unset; gate is no-op. Locked by `TestMiddleware_SuppressAccessLog_DefaultEmitsLog` (middleware_test.go:104). +- **`Server` additions optional** — 256 MiB default when `MiddlewareCaptureBudgetBytes ≤ 0` (server.go:1997-2000). + +### Performance +- **Translate cost per push** — O(n) with per-entry registry lookup and `config_json` copy; negligible vs. the upstream gRPC unmarshal. +- **Empty-chain hot path** — one `ChainFor` map lookup + one `chain.Empty()` check; no allocation delta vs. pre-PR. +- **Body capture buffer churn** — `bodytap.CaptureRequest` allocates `MaxRequestBytes` per chain-hitting request; `releaseBudget` ties allocation to the 256 MiB proxy-wide budget. Confirm in module 30 the budget is a hard cap. + +### Observability +- **Metrics** — `Metrics.Meter()` shared with `middleware.NewMetrics` (server.go:1990-1993) so middleware instruments land in the same prometheus exporter. No new metrics defined here. +- **Access-log accuracy** — every entry carries `AgentNetwork`; terminal-slot metadata merged into `CapturedData.Metadata` (reverseproxy.go:238-241). +- **Deny logs at `Infof`** (reverseproxy.go:170) — review whether `Info` is too noisy at high deny rates; consider Debug or rate-limit. + +## Test coverage + +| Test file | Locks down | +| --------- | ---------- | +| proxy/middleware_translate_test.go | Empty/nil → nil; field preservation; unknown ID skip; nil registry permissive; timeout clamping; fail-mode + slot incl. UNSPECIFIED-drop; empty-ID drop; truncation above + at `MaxMiddlewaresPerChain` | +| proxy/internal/proxy/reverseproxy_test.go | Rewrite host/headers/cookies/query; trusted proxy; path forwarding; classifyProxyError; X-NetBird-User/Groups anti-spoof + CSV-join + control-char/comma rejection + fallback-to-ID; `TestBuildRequestInput_PropagatesIdentityAndGroups` (UserGroups/Email/GroupNames/AgentNetwork reach `middleware.Input`) | +| proxy/internal/proxy/agent_network_chain_realstack_test.go | **The end-to-end integration test.** Drives a real agent-network request through `ReverseProxy.ServeHTTP` with the chain the synthesizer produces, against an in-process management gRPC (bufconn) backed by a real sqlite store + real `agentnetwork.Manager`, plus an `httptest` upstream — no external infrastructure or real LLM. Guarantees: (1) response-leg `respInput` carries `UserGroups` so `llm_limit_record` ships non-empty `group_ids` and the admin-group consumption row increments; (2) `RedactPii=true` redacts both prompt and completion on captured metadata; (3) the full chain runs against a real management stack. **Line 189-211 inlines the proto→Spec mapping** instead of calling the proxy's private `translateMiddlewareConfig` — keep that inline mirror in sync with `proxy/middleware_translate.go` or the test silently diverges from production. | +| proxy/internal/accesslog/middleware_test.go | `SuppressAccessLog=true` skips `SendAccessLog` (150ms negative wait); default emits one send (2s positive); usage tracking runs under suppression | +| proxy/internal/auth/middleware_test.go | `TestProtect_PrivateService_TunnelPeerGroupsPropagate` proves `peer_group_ids` reach `CapturedData.UserGroups`; `TestProtect_PrivateService_TunnelPeerDenied` proves rejected peers 403 without reaching the handler | + +The integration test runs in a few seconds with no external infrastructure — exercising the real synthesizer, `Manager.Rebuild`, `ServeHTTP` dispatch, and `llm_limit_record` writing a real consumption row through the real `agentnetwork.Manager` over real gRPC. + +## Known limitations / explicit non-goals + +- **Translator does not validate `RawConfig` JSON** — factory's job at `New([]byte)`. Confirm in module 30 that a per-binding factory failure doesn't poison the rest of the chain. +- **No throttle on management push rate** — every `MODIFIED` triggers `Manager.Rebuild`. Mitigation upstream. +- **Streaming responses (SSE)** — body capture is streaming-aware, but response-leg middleware runs only after the response completes; long SSE streams delay `llm_limit_record` until close. +- **OIDC-only path doesn't carry tunnel-peer groups** — agent-network synth services rely on the Private tunnel-peer path; JWT groups claim is the only carrier for non-Private OIDC. +- **`agent_network` flag on L4 entries** not added; HTTP-only. +- **`mw.capture.bypass_reason` metadata key** documented at reverseproxy.go:151,184; namespace this in module 30/31 to avoid collisions. + +## Cross-references +- Upstream: [shared/api](10-shared-api.md), [proxy/middleware-framework](30-proxy-middleware-framework.md), [proxy/middleware-builtin](31-proxy-middleware-builtin.md), [proxy/llm-parsers](32-proxy-llm-parsers.md) +- End-to-end flow: [../01-end-to-end-flows.md](../01-end-to-end-flows.md) +- Top-level: [../00-overview.md](../00-overview.md) diff --git a/docs/agent-networks/modules/40-dashboard.md b/docs/agent-networks/modules/40-dashboard.md new file mode 100644 index 000000000..4ed9021bb --- /dev/null +++ b/docs/agent-networks/modules/40-dashboard.md @@ -0,0 +1,228 @@ +# dashboard — UI for agent-networks + +This module documents code that lives in the **dashboard repo** (under +`src/modules/agent-network/` and `src/app/(dashboard)/agent-network/`), not +in this repo. It is co-located here so backend readers see the full picture. + +> **Risk level:** Medium. The new surface is isolated under `src/modules/agent-network/` and `src/app/(dashboard)/agent-network/`, but it also reshapes the sidebar, splits `/peers`, renames `reverse-proxy/clusters` → `self-hosted-proxies`, and overlays the Control Center graph. Regressions here would be cross-cutting. +> **Backward-compat impact:** Additive on the API side. Breaking on URL/navigation: `/peers` redirects to `/peers/devices` (src/app/(dashboard)/peers/page.tsx:7-15), `/reverse-proxy/clusters` was renamed to `/reverse-proxy/self-hosted-proxies`, the sidebar lost Access Control / Networks / Reverse Proxy / DNS / standalone Guardrails / Consumption / Activity (Navigation.tsx:165-171 — routes still resolve via URL), and the standalone `/agent-network/{access-log,consumption,global-controls}` routes are gone in favor of `/agent-network/observability`. + +## Module boundary + +The dashboard is the only place an operator interacts with agent-networks: provider catalog, configured providers, policies, guardrails, account-level budget rules, account settings (collection / redaction toggles), per-request access log, and consumption rollups all render, paginate, and edit here. Data flows in via SWR (`useFetchApi`) keyed by REST URL. One big context provider (`src/modules/agent-network/AIProvidersProvider.tsx`) aggregates five resources (providers, policies, guardrails, budget rules, settings) plus the proxy access-log stream filtered to `agent_network=true`, and exposes `add* / update* / toggle* / delete*` mutators that call through `useApiCall` and re-`mutate()` SWR. Pages mount the provider once at the top and compose presentational tables and modals beneath. The control-center page additionally fetches `/agent-network/{providers,policies}` directly (control-center/page.tsx:123-130) to overlay graph nodes. + +## What the UI delivers + +- **AI Observability** page with four tabs: Access Logs, Budget Dashboard, + Budget Settings, Log Settings (replaces the standalone access-log, + consumption, and global-controls routes). +- **Providers** page: provider catalog + connect/edit wizard with per-vendor + copy (LiteLLM, Portkey, Bifrost, Cloudflare, Vercel, OpenRouter, custom). +- **Policies** page: group → provider authorization with per-policy Limits + (minute-granular windows) + guardrail attach. +- **Guardrails** page: reusable model-allowlist + prompt-capture sets. +- **Account controls**: Log Collection / Prompt Collection / Redact PII toggles. +- **Budget rules**: account-level rules reusing the policy Limits UI. +- **Control Center overlay**: provider + agent-policy nodes on the graph. +- **Navigation + peers reshaping**: peers split into Devices / Agents, + `reverse-proxy/clusters` renamed to `self-hosted-proxies`, sidebar + repackaged for agent-network focus. + +## Surface added + +### New pages + +| Route | Purpose | Backing module(s) | +| ----- | ------- | ----------------- | +| `/agent-network` | Redirect to `/agent-network/providers` | page.tsx:7-15 | +| `/agent-network/providers` | List + connect providers; header surfaces per-account base URL | providers/page.tsx + AgentProvidersTable + AIProviderModal | +| `/agent-network/policies` | Group → Provider authorization with per-policy Limits + Guardrail attach | policies/page.tsx + AgentPoliciesTable + AgentPolicyModal | +| `/agent-network/guardrails` | Reusable guardrail sets (model allowlist + prompt capture) | guardrails/page.tsx + AgentGuardrailsTable + AgentGuardrailModal | +| `/agent-network/observability` | Tabs: Access Logs / Budget Dashboard / Budget Settings / Log Settings | observability/page.tsx | +| `/peers/devices`, `/peers/agents` | Split of `/peers`, shared via `PeersListView` keyed by `kind` | peers/{devices,agents}/page.tsx | +| `/reverse-proxy/self-hosted-proxies` | Renamed from `clusters` | self-hosted-proxies/page.tsx | + +Removed in favor of `/agent-network/observability`: `/agent-network/access-log`, `/agent-network/consumption`, `/agent-network/global-controls`. + +### New modules under src/modules/agent-network + +| File | Role | +| ---- | ---- | +| AIProvidersProvider.tsx (~1158 LOC) | Aggregates every agent-network resource via SWR; normalises snake↔camel; exposes mutators; holds wizard-open state | +| AIProviderModal.tsx (~1268 LOC) | Connect / edit provider wizard with per-vendor copy (Bifrost, Portkey, LiteLLM, Cloudflare, Vercel, OpenRouter, custom) | +| AIProviderLogo + useProviderCatalog | Catalog-driven brand swatch + SWR hook over `/agent-network/catalog/providers` | +| AgentPoliciesTable + AgentPolicyModal + AgentPolicyGuardrailsTab + AgentPolicyLimitsTab | Policies; modal has 3 tabs (Rule, Limits, Guardrails) | +| AgentGuardrailsTable + AgentGuardrailModal + AgentGuardrailBrowseModal + AgentGuardrailChecksCell | Guardrails CRUD + attach-from-policy | +| AgentBudgetRulesTable + AgentBudgetRuleModal | Account-level budget rules; modal reuses AgentPolicyLimitsTab verbatim | +| AgentAccountControlsCard | Three account-wide toggles (Log Collection / Prompt Collection / Redact PII) | +| AgentAccessLogTable + AgentAccessLogExpandedRow | Access log on `/events/proxy?agent_network=true` | +| AgentConsumptionPanel + AgentConsumptionTable | Token + cost panel: charts + counter table | +| table/AgentProvidersTable + AgentProviderActionCell | Providers table + per-row actions | +| data/mockData.ts | Domain types and a few residual `MOCK_*` constants (see scrutinize) | + +### Touched non-agent-network areas + +- **control-center**: agent-network overlay (provider + agent-policy nodes); removed the All Networks dropdown; hid the Networks tab in FlowSelector (FlowSelector.tsx:9-14 — enum value kept so `?tab=networks` still type-checks); wrapped `ControlCenterView` in `AIProvidersProvider` (page.tsx:73-83); `agentPolicyNode` clicks routed to a separate state slot (page.tsx:1871-1874). New node renderers: nodes/ProviderNode.tsx, nodes/AgentPolicyNode.tsx (registered at utils/nodes.ts:21-22). +- **peers**: Split into Devices and Agents sub-routes; shared via `PeersListView` keyed by `kind` (PeersListView.tsx:24-95). New compact-toolbar `UserFilterSelector` (users/UserFilterSelector.tsx). +- **reverse-proxy**: Folder rename `clusters/` → `self-hosted-proxies/`; deleted `ClustersFeaturesCell.tsx`, `ClusterTypeIndicator.tsx`; new ReverseProxyClusterTargetSelector for cluster target type; Private toggle on target modal; body-capture knobs removed; new ReverseProxyEventExpandedRow. +- **events**: `ReverseProxyEventsUserCell` rewritten with user + peer fallback (ReverseProxyEventsUserCell.tsx:14-21), shared with the access-log table. +- **navigation**: Full repackaging in Navigation.tsx — Agent Network items flattened (no collapsible parent), distinct icons per item; Access Control, Networks, Reverse Proxy, DNS, standalone Guardrails, Consumption, Activity removed (still URL-reachable, per lines 165-171). + +## Architecture & flow + +### Page → Provider → Table/Modal hierarchy + +```mermaid +graph TD + Nav[Navigation.tsx] + Nav --> ProvidersPage[/agent-network/providers/] + Nav --> PoliciesPage[/agent-network/policies/] + Nav --> GuardrailsPage[/agent-network/guardrails/] + Nav --> ObsPage[/agent-network/observability/] + + ProvidersPage --> AIPP1[AIProvidersProvider] + PoliciesPage --> AIPP2[AIProvidersProvider] + GuardrailsPage --> AIPP3[AIProvidersProvider] + ObsPage --> AIPP4[AIProvidersProvider] + ObsPage -.wraps.-> GroupsProvider + ObsPage -.wraps.-> PeersProvider + + AIPP1 --> ProvTable[AgentProvidersTable] + ProvTable --> ProvModal[AIProviderModal] + AIPP2 --> PolTable[AgentPoliciesTable] + PolTable --> PolModal[AgentPolicyModal] + PolModal --> PolGuardTab[AgentPolicyGuardrailsTab] + PolModal --> PolLimitsTab[AgentPolicyLimitsTab] + PolGuardTab --> GuardBrowse[AgentGuardrailBrowseModal] + PolGuardTab --> GuardModal[AgentGuardrailModal] + AIPP3 --> GuardTable[AgentGuardrailsTable] + GuardTable --> GuardModal + AIPP4 --> Tabs[Tabs] + Tabs --> AccessLog[AgentAccessLogTable] + Tabs --> Consumption[AgentConsumptionPanel] + Tabs --> BudgetRules[AgentBudgetRulesTable] + Tabs --> AccountCtl[AgentAccountControlsCard] + BudgetRules --> BudgetModal[AgentBudgetRuleModal] + BudgetModal -.reuses.-> PolLimitsTab +``` + +### AI Observability tab page + +```mermaid +graph LR + Page[AIObservabilityPage] --> RA[RestrictedAccess
                                                permission.services.read] + RA --> GP[GroupsProvider] + GP --> PP[PeersProvider] + PP --> AIP[AIProvidersProvider] + AIP --> Tabs[Tabs / TabsList] + Tabs --> T1[Access Logs
                                                AgentAccessLogTable] + Tabs --> T2[Budget Dashboard
                                                AgentConsumptionPanel] + Tabs --> T3[Budget Settings
                                                AgentBudgetRulesTable] + Tabs --> T4[Log Settings
                                                AgentAccountControlsCard] + T1 -.GET.-> EP[/events/proxy?agent_network=true/] + T2 -.GET poll 5s.-> CONS[/agent-network/consumption/] + T3 -.GET/PUT.-> BR[/agent-network/budget-rules/] + T4 -.GET/PUT.-> ST[/agent-network/settings/] +``` + +### Data fetch path + +```mermaid +graph TD + Page[Page component] --> Prov[AIProvidersProvider] + Prov -->|useFetchApi| SWR[(SWR cache
                                                key = URL)] + SWR -.GET.-> P[/agent-network/providers/] + SWR -.GET.-> POL[/agent-network/policies/] + SWR -.GET.-> G[/agent-network/guardrails/] + SWR -.GET.-> BR[/agent-network/budget-rules/] + SWR -.GET ignoreError.-> ST[/agent-network/settings/] + SWR -.GET.-> CAT[/agent-network/catalog/providers/] + SWR -.GET pageSize=100.-> EVT[/events/proxy agent_network=true/] + Prov --> Mut[useApiCall.post/put/del] + Mut -.on success.-> MutateSWR[SWR mutate keys] + Prov --> Children[Tables / Modals via useAIProviders] +``` + +Every list view reaches management through SWR over `/api/agent-network/*`. The provider context maps snake-case payloads to camelCase domain types (`fromAPI`, `policyFromAPI`, `guardrailFromAPI`, `budgetRuleFromAPI`, `settingsFromAPI`, `accessLogFromAPI` — AIProvidersProvider.tsx:138-562) and back via matching `*ToRequest` adaptors. The access log piggy-backs on `/events/proxy` with `agent_network=true&page_size=100` (line 707-709) and decodes LLM-specific fields from per-event `metadata`. Group IDs on events are resolved to current names through the surrounding GroupsProvider catalog (lines 515-521, 717-731) — no extra round trip. Mutators run `*ToRequest`, await `useApiCall.post/put/del`, call SWR `mutate()`, then `notify`. Errors caught and surfaced via `notify` — no exceptions escape into render. The Connect Provider modal's open state lives in the provider itself (`isWizardOpen` at lines 732-735) so the providers-page empty-state CTA and the table's + button share one modal. Control-center re-fetches `/agent-network/{providers,policies}` directly on top of `AIProvidersProvider` — SWR de-dupes but the code path is harder to reason about. + +## Public contracts consumed + +- `GET/POST /api/agent-network/providers`, `PUT/DELETE /:id` +- `GET/POST /api/agent-network/policies`, `PUT/DELETE /:id` +- `GET/POST /api/agent-network/guardrails`, `PUT/DELETE /:id` +- `GET/POST /api/agent-network/budget-rules`, `PUT/DELETE /:id` +- `GET/PUT /api/agent-network/settings` (ignoreError-tolerant; 404 = not yet bootstrapped — auto-bootstrap on first provider create via `bootstrap_cluster` field — AIProvidersProvider.tsx:737-760) +- `GET /api/agent-network/catalog/providers` (read-only declarative; backend owns vendor list, IDs, brand colors, models, extra_headers, identity_injection — useProviderCatalog.ts:6-95) +- `GET /api/agent-network/consumption` (polled every 5s on Budget Dashboard — ConsumptionPanel.tsx:53,65-71) +- `GET /api/events/proxy?agent_network=true&page_size=100` (shared with Proxy Events) +- `permission?.services?.read` gates every agent-network route via RestrictedAccess. + +`AIProviderId` is a closed union in dashboard types (data/mockData.ts:8-21) but the converter tolerates anything the backend ships — unknown ids fall through to `"custom"` (AIProvidersProvider.tsx:497-506). Catalog values are pure read-through: anything declared in `extra_headers` renders in the modal automatically, copy keyed by header name (`EXTRA_HEADER_UI` in AIProviderModal.tsx:61-89), labeled-fallback for unknown ones. + +## Invariants + +- Provider context wrap order on user-attribution pages: `GroupsProvider > PeersProvider > AIProvidersProvider` (observability/page.tsx:87-89). Reverse it and access-log group resolution silently drops names. +- Every agent-network route checks `permission?.services?.read` via `RestrictedAccess` (observability/page.tsx:85, providers/page.tsx:184, policies/page.tsx:53, guardrails/page.tsx:55). +- Modal `key={open ? 1 : 0}` pattern is used to force unmount/remount on close so internal `useState` resets between edits (AgentBudgetRuleModal.tsx:60, AgentPolicyModal.tsx:66). Removing this would leak prior-row state into a new-row session. +- `mockData.ts` is the canonical home for ALL agent-network domain types; `MOCK_*` constants must never reach a production code path. One leak remains (below). + +## Things to scrutinize + +### Correctness + +- **Tab-state URL hand-off is one-way.** observability/page.tsx:53-58 reads `?tab=` on mount (despite the file comment at line 28 saying URL hand-off is future) but `setTab` does NOT push back, so reload preserves the chosen tab only if it came in via the link. Inconsistent with control-center (page.tsx:1817-1831). +- **Provider overlay runs only in `applySingleGroupView` / `applyPeerView`** (control-center/page.tsx:557, 1159-1166). User view does NOT show providers — if agent-network is a primary lens, that's a gap. +- **Two useEffects race to invalidate the control-center layout.** page.tsx:1655-1657 drops `layoutInitialized` when `agentPolicies` / `agentProviders` arrive; the main effect (1786-1799) also lists them as deps. Functional but fragile — watch for flash-of-empty-graph. +- **`updateProvider` / `updatePolicy` / `updateBudgetRule` use `??` on `enabled`** (AIProvidersProvider.tsx:784, 859, 1018). Toggle paths are safe; any caller sending `enabled: false` thinking "leave it off" gets `existing.enabled` instead. Audit modal callers. +- **Form validation in modals is minimal.** Window-seconds picker — mockData.ts:209-215 documents "minimum 60 — one minute" but there is no matching UI guard in PolicyLimitsTab; the backend validator is the enforcement point. + +### Security + +- **No client-side enforcement claims** — every cap, allowlist, and toggle is display + edit; proxy is the source of truth for deny decisions (AccessLogTable.tsx:177-191 renders backend-emitted `denyReason` as-is). +- **Prompt display is gated by what the backend stamps.** When `enable_prompt_collection` is OFF the proxy must not put prompt/completion into event metadata; the dashboard renders whatever it gets verbatim (AccessLogTable lines 532-534, AccessLogExpandedRow.tsx:42-57). No UI filter on top of backend collection switches. +- Account Controls disables `Redact PII` when `Prompt Collection` is off (AgentAccountControlsCard.tsx:122) and clears it on off-transition (line 100), but relies on backend to enforce the same gate at write — confirm PUT handler rejects `redact_pii=true && enable_prompt_collection=false`. +- **Bifrost identity-header overrides**: empty-string vs nil semantics documented in AIProvidersProvider.tsx:772-781 ("omitted = preserve, empty = explicit clear"). Mishandling could leak group attribution to a header the operator thought disabled. Focused read of Bifrost code path in AIProviderModal.tsx recommended. + +### Accessibility + +- Observability TabsList (observability/page.tsx:96-113) uses the shared Tabs component — should inherit Radix roving-tabindex. All four TabsTriggers carry only icon + text, no `aria-label`; fine because text is visible. +- Modal focus traps are inherited from the shared Modal; agent-network modals don't override them. Quick keyboard pass recommended. +- `EndpointBadge` Copy button (providers/page.tsx:66-76) has an `aria-label`, good. + +### Performance + +- `AgentConsumptionPanel` polls `/agent-network/consumption` every 5s (ConsumptionPanel.tsx:53,70). Tab switches unmount the panel, so the poll stops — verify in network panel. +- `AgentAccessLogTable` is hard-capped at 100 rows via `page_size=100` (AIProvidersProvider.tsx:707-709). Server-side pagination is future work; high-traffic tenants miss everything past row 100 — known limitation. +- Observability page mounts providers ONCE at page level (observability/page.tsx:87-89); tab switches keep SWR cache hot. Moving the provider mount inside `TabsContent` would re-fetch the access log on every switch. + +### Visual consistency + +- The observability tab style mirrors peers/page.tsx. Outer Tabs `pt-4 pb-0 mb-0`, TabsList `px-8` (observability/page.tsx:94-96) — confirm chrome height matches so the page doesn't visually jump. +- Sidebar: `Boxes` for Providers, `AccessControlIcon` for Policies, `TelescopeIcon` for AI Observability (Navigation.tsx:113,120,133). Reusing `AccessControlIcon` makes Policies look identical to the (now hidden) Access Control item — if Access Control ever comes back, they collide. +- `AgentNetworkIcon` is used in breadcrumbs on every agent-network page but NOT in the sidebar (per-page icons instead). Deliberate departure — record so it doesn't get reverted. + +## Test coverage + +- **Cypress**: One file (`cypress/e2e/test.cy.ts`) covering only the install-page copy-to-clipboard flow. NOTHING covers agent-network UI. +- **Component / unit tests**: `src/utils/version.test.ts` is the only `.test.*` file in the repo. The agent-network modules ship without component tests. +- Data-cy hooks exist on key controls: `save-account-controls` (AgentAccountControlsCard.tsx:71), `enable-log-collection`, `enable-prompt-collection`, `redact-pii`, plus existing `data-cy={policy.name}` / `data-cy={provider.name}` on ActiveInactiveRow. Sufficient hooks for Cypress flows; none written yet. +- **Tooling gap (pre-existing):** `npm run lint` (`next lint`) is broken in Next 16 — the `lint` subcommand was removed from the Next CLI in 16.x, so the dashboard effectively has no working lint gate. The fix is to add either a flat-config `eslint .` script or wire ESLint via an explicit `eslint-config-next` invocation. + +## Known limitations / explicit non-goals + +- **`data/mockData.ts` still contains `MOCK_GROUPS`, `MOCK_PROVIDERS`, `MOCK_PEERS`.** Only `MOCK_GROUPS` is referenced from production — AgentPoliciesTable.tsx:45,76 uses it as a name-lookup fallback when a policy references a group ID the real GroupsProvider doesn't know about. `MOCK_PROVIDERS` / `MOCK_PEERS` are unreferenced; safe to delete. The file is `/* eslint-disable */` so dead-code warnings don't flag them. +- **Tab-state URL hand-off on observability page is one-way** (read-only). +- **Access log hard-capped at 100 rows**; no server-side pagination. +- **No optimistic updates.** All mutations are round-trip; failures rollback via SWR revalidation. +- **`FlowView.NETWORKS` retained but hidden** from FlowSelector (FlowSelector.tsx:9-14). Old `?tab=networks` links still route to the hidden view because `applyNetworksView` still runs. +- **Redirects are not query-preserving** — `router.replace("/peers/devices")` (peers/page.tsx:13) strips any incoming filter params. +- **Control-center cross-fetches** `/agent-network/{providers,policies}` directly on top of `AIProvidersProvider`. Could be collapsed. +- **Sidebar permanently hides Access Control, Networks, Reverse Proxy, standalone Guardrails, DNS, Activity, Consumption.** Routes still resolve via URL (Navigation.tsx:165-171); intentional. + +## Cross-references + +- Upstream API contracts: [shared/api](10-shared-api.md) +- Backend persistence: [management/store](20-management-store.md) +- Backend handler wiring: [management/handlers + wiring](22-management-handlers-wiring.md) +- End-to-end flow narrative: [../01-end-to-end-flows.md](../01-end-to-end-flows.md) +- Top-level overview: [../00-overview.md](../00-overview.md) diff --git a/docs/agent-networks/modules/50-path-routed-providers.md b/docs/agent-networks/modules/50-path-routed-providers.md new file mode 100644 index 000000000..08c976c5f --- /dev/null +++ b/docs/agent-networks/modules/50-path-routed-providers.md @@ -0,0 +1,257 @@ +# path-routed providers — Vertex AI + Bedrock + +This guide pulls the **path-routed** provider story together in one place +because it crosses the catalog, the synthesiser, the request parser, and the +router. The relevant building blocks are the `llm_router` / +`llm_request_parser` middlewares +([31-proxy-middleware-builtin.md](31-proxy-middleware-builtin.md)), the +per-provider parser surface ([32-proxy-llm-parsers.md](32-proxy-llm-parsers.md)), +and the synthesiser's catalog → `ProviderRoute` mapping +([21-management-agentnetwork.md](21-management-agentnetwork.md)). + +Sibling modules: [31-proxy-middleware-builtin.md](31-proxy-middleware-builtin.md) +(router + request parser) and [32-proxy-llm-parsers.md](32-proxy-llm-parsers.md) +(Bedrock parser + pricing). + +--- + +## What "path-routed" means + +Most catalog providers carry the model in the request **body** (`{"model": …}`), +so `llm_router` selects an upstream by matching the model name against each +provider's `Models` claim. Two providers instead carry the model in the **URL +path**, so they are routed by path before the model/vendor table is consulted: + +| Catalog id | Style flag | Request path shape | +|---|---|---| +| `vertex_ai_api` | `IsVertexPathStyle` → `ProviderRoute.Vertex` | `/v1/projects/{project}/locations/{region}/publishers/{publisher}/models/{model}:{action}` | +| `bedrock_api` | `IsBedrockPathStyle` → `ProviderRoute.Bedrock` | `/model/{modelId}/{action}` (optionally behind `/bedrock`) | + +The catalog declares the style with +[`catalog.IsVertexPathStyle` / `catalog.IsBedrockPathStyle`](../../../management/server/agentnetwork/catalog/catalog.go) +and the synthesiser copies the result onto the router route as the `Vertex` / +`Bedrock` booleans +([synthesizer.go:450-451](../../../management/server/agentnetwork/synthesizer.go)). +On the request leg `llm_router.Invoke` dispatches `isVertexPath` / `isBedrockPath` +**before** the model lookup +([llm_router/middleware.go:138-216](../../../proxy/internal/middleware/builtin/llm_router/middleware.go)) +so a model the parser extracted from the path can't be claimed by a same-vendor +*body-routed* provider (e.g. `claude-*` on `api.anthropic.com`). + +## Google Vertex AI (`vertex_ai_api`) + +### Catalog entry + +`KindProvider`, parser surface left unset on the catalog entry — the request +parser picks the parser from the URL **publisher** segment, not from +`ParserID`. Upstream host is `-aiplatform.googleapis.com` +(`https://aiplatform.googleapis.com` for the `global` location). The catalog +lists the Claude-on-Vertex lineup (`claude-opus-4-*`, `claude-sonnet-4-*`, +`claude-haiku-4-5`, `claude-fable-5`) at the same per-token rates as the +first-party Anthropic entry +([catalog.go:333-363](../../../management/server/agentnetwork/catalog/catalog.go)). + +### Credential — service-account OAuth (`keyfile::`) + +Vertex does **not** accept a static API key. The operator sets the provider +`api_key` to: + +``` +keyfile:: +``` + +The synthesiser recognises the `keyfile::` prefix in `providerAuthHeader` +([synthesizer.go:897-903](../../../management/server/agentnetwork/synthesizer.go)), +emits **no** static auth value, and carries the base64 key material on the +route as `GCPServiceAccountKeyB64` +([factory.go:56-61](../../../proxy/internal/middleware/builtin/llm_router/factory.go)). +At request time the router mints a short-lived OAuth2 access token from the key +(cloud-platform scope) and injects `Authorization: Bearer ` — +never the key itself +([llm_router/middleware.go:621-692](../../../proxy/internal/middleware/builtin/llm_router/middleware.go)): + +- One auto-refreshing `oauth2.TokenSource` is cached per key (keyed by a + SHA-256 of the base64 material), so token minting happens once and refreshes + amortise across requests. +- Mint / refresh is bounded by a 10s timeout HTTP client (`gcpTokenTimeout`) so + a slow Google token endpoint can't hang the request. +- A malformed key or an unreachable token endpoint fails the request with + `llm_policy.upstream_auth_failed` at HTTP **502** (an upstream problem, not a + policy denial) — see `denyUpstreamAuth`. + +### Metering — Anthropic-on-Vertex only + +The request parser extracts `{publisher, model, action}` from the path +(`parseVertexPath`, [llm_request_parser/middleware.go:237-263](../../../proxy/internal/middleware/builtin/llm_request_parser/middleware.go)), +strips the `@version` suffix from the model, and maps the publisher to a parser +surface via `vertexPublisherVendor`: + +- `anthropic` → `llm.provider="anthropic"` → metered through the Anthropic + parser, priced under the **`anthropic`** surface of the pricing table + management ships (the parser emits the standard Anthropic provider label, so + Vertex Claude reuses first-party Anthropic prices). +- `openai` → `llm.provider="openai"` (reserved; not in the catalog lineup + today). +- anything else (notably `google` / Gemini) → empty vendor → **no parser**. + +**Gemini is intentionally denied as unmeterable.** When the parser emits no +`llm.provider` for a Vertex publisher, `llm_router` returns +`llm_policy.unmeterable_publisher` (403) rather than forwarding the request +uncounted — serving it would bypass token / budget metering +([llm_router/middleware.go:144-162, 712-728](../../../proxy/internal/middleware/builtin/llm_router/middleware.go)). +A Gemini parser would lift this restriction; until then the `google` publisher +is omitted from the catalog. + +> Caveat: cross-region inference profiles in `eu` / `apac` carry a ~10% price +> premium that the base per-token rates do **not** model — cost annotations for +> those regions read low. Operators who need exact regional billing set the +> affected models' prices on the provider record, or replace the default entries +> via management's `AgentNetwork.PricingDefaultsFile`. + +## AWS Bedrock (`bedrock_api`) + +### Catalog entry + +`KindProvider`, upstream host `bedrock-runtime..amazonaws.com`. Metered +models are the Anthropic-on-Bedrock lineup (`anthropic.claude-*`) plus Amazon +Nova and Llama 3.3 entries +([catalog.go:300-332](../../../management/server/agentnetwork/catalog/catalog.go)). +Anthropic-on-Bedrock reuses the first-party Claude prices (with additive cache +buckets); Nova / Llama report no cache, so cost is `input + output`. + +### Credential — static bearer token + +Bedrock uses the **AWS Bedrock API key** as a static bearer. The operator sets +the provider `api_key` directly (no `keyfile::` prefix); the catalog template +is `Authorization: Bearer ${API_KEY}` +([catalog.go:306-307](../../../management/server/agentnetwork/catalog/catalog.go)). +No token minting — the synthesiser substitutes the key into the template and +the router injects the resulting `Authorization` header after stripping inbound +vendor auth (including client-supplied AWS SigV4 material: `X-Amz-Date`, +`X-Amz-Security-Token`, `X-Amz-Content-Sha256`, see `strippedAuthHeaders`). + +### Model id form — cross-region inference profiles + +Bedrock model ids in the request path must be the cross-region +**inference-profile** form, e.g. +`eu.anthropic.claude-sonnet-4-5-20250929-v1:0`. The bare +`anthropic.claude-…` id is rejected by AWS. `normalizeBedrockModel` +([llm_request_parser/middleware.go:398-414](../../../proxy/internal/middleware/builtin/llm_request_parser/middleware.go)) +strips the region prefix (`us.` / `eu.` / `apac.` / `global.`), an optional ARN +wrapper, and the `-YYYYMMDD-vN[:N]` version/throughput suffix so the normalised +id (`anthropic.claude-sonnet-4-5`) matches the catalog/pricing key. + +### Supported endpoints + actions + +`/model/{modelId}/{action}` where action ∈ `invoke`, +`invoke-with-response-stream`, `converse`, `converse-stream` +([llm_request_parser/middleware.go:363-390](../../../proxy/internal/middleware/builtin/llm_request_parser/middleware.go)). +`invoke` / `converse` are non-streaming; the `-stream` actions set the streaming +flag. + +- **InvokeModel** body uses the vendor-native shape — for Anthropic that means + `"anthropic_version":"bedrock-2023-05-31"` and snake_case usage with additive + cache buckets. +- **Converse** uses the unified camelCase shape with a precomputed `totalTokens`. +- The `BedrockParser` reads both shapes on the response leg + ([bedrock.go](../../../proxy/internal/llm/bedrock.go)); the request parser + doesn't need to distinguish them (`ParseRequest` is a no-op — model + stream + come from the path). + +### Streaming — AWS binary event-stream + +The `-stream` actions return `application/vnd.amazon.eventstream` (the AWS +binary event-stream framing), and streaming **is metered**. +`accumulateBedrockStream` +([llm_response_parser/streaming_bedrock.go](../../../proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock.go)) +decodes the frames with `aws-sdk-go-v2/aws/protocol/eventstream`: + +- InvokeModel `chunk` frames wrap a base64 `{"bytes":…}` payload carrying a + vendor-native (Anthropic) stream event — folded through the shared Anthropic + stream accumulator. +- Converse `contentBlockDelta` frames carry text; the trailing `metadata` frame + carries the final usage block. +- A truncated stream (cut at the body-tap capture cap) decodes best-effort: + frames up to the cut are applied and partial usage is returned. + +### Optional `/bedrock` gateway-namespace prefix + +Clients may place an optional `/bedrock` prefix before the native path +(`/bedrock/model/{modelId}/{action}`) to disambiguate Bedrock from other +providers that also use `/model/...`. Both the request parser +(`trimBedrockNamespace`) and the router (`splitBedrockNamespace`) accept it. +When the prefix is present, the router sets +`RewriteUpstream.StripPathPrefix = "/bedrock"` so the **native** path +(`/model/...`) is what reaches `bedrock-runtime..amazonaws.com` +([llm_router/middleware.go:168-184, 320-348](../../../proxy/internal/middleware/builtin/llm_router/middleware.go)). + +## Model allowlist on path-routed providers + +Because the model lives in the URL rather than the body, a path-routed provider +credential could otherwise be used for any model the upstream supports. The +router still enforces the route's `Models` allowlist via `matchPathRoute` +([llm_router/middleware.go:370-416](../../../proxy/internal/middleware/builtin/llm_router/middleware.go)): + +1. Filter to routes of the matching style (`Vertex` / `Bedrock`). +2. Filter to routes whose `AllowedGroupIDs` authorise the caller's groups + (else `no_authorised_provider`). +3. Filter to routes that **claim the requested model**. As with body-routed + providers, an **empty `Models` list = catch-all** (serve any model); + a non-empty list serves only the listed models (else `model_not_routable`). +4. Multiple survivors disambiguate by longest `UpstreamPath` prefix match. + +So an operator who lists explicit models on a Vertex/Bedrock provider gets a +hard allowlist; an operator who leaves `Models` empty accepts every model the +upstream serves (still subject to the unmeterable-publisher gate on Vertex). + +Model-less OpenAI endpoints (`GET /v1/models`) are **never** routed to a +Vertex/Bedrock provider — `matchModelless` skips path-routed routes +([llm_router/middleware.go:427-462](../../../proxy/internal/middleware/builtin/llm_router/middleware.go)) +so a model-listing call can't be rewritten onto an upstream that would 404 it. + +## Catalog ↔ pricing cross-check + +Catalog prices and context windows are cross-checked against LiteLLM's +`model_prices_and_context_window.json`. The **catalog is the source of default +prices**: management's `pricing.DefaultTable` folds every catalog provider's +models into the surfaces that provider declares (`PricingSurfaces`), so coverage +is structural rather than maintained in a parallel file +([pricing/defaults.go](../../../management/internals/modules/agentnetwork/pricing/defaults.go)). +`TestDefaultTable_CoversEveryCatalogModel` fails if a catalog model ends up +unpriced, and `TestDefaultTable_NoConflictingContributions` fails if two +providers contribute the same (surface, model) at different rates. Bedrock +entries are keyed by the **normalised** id the request parser emits (region +prefix + version suffix stripped) — management applies the same normalisation to +per-provider prices at synth time, so the two keys compare equal. Vertex Claude +carries no Bedrock-style prefix, so it prices straight off the `anthropic` +surface. + +## Things to scrutinise + +**Security.** The Vertex service-account key is never forwarded — only a minted +short-lived bearer. Confirm the key material stays out of access logs (it lives +on `ProviderRoute.GCPServiceAccountKeyB64`, not in any emitted metadata key). +The unmeterable-publisher deny is the only thing standing between an +operator-misconfigured Vertex provider and unmetered Gemini traffic; verify +`vertexPublisherVendor` stays conservative (deny by default for unknown +publishers). + +**Correctness.** `normalizeBedrockModel` is the join between the wire id and the +pricing key — a model that normalises to something absent from the shipped +pricing table meters at `cost.skipped=unknown_model` rather than failing the +request. The +`/bedrock` prefix strip must run on both the parser side (so the model is +extracted) and the router side (so the upstream path is native); a regression in +either silently breaks the other. + +**Metering caveats.** eu/apac cross-region Bedrock + Vertex profiles carry a +~10% premium not modelled by base pricing — flagged in the catalog comment. +Operators needing exact regional billing set per-provider prices on the model +rows (or replace the default entries via `AgentNetwork.PricingDefaultsFile`). + +## Cross-references + +- Router + request-parser detail: [31-proxy-middleware-builtin.md](31-proxy-middleware-builtin.md) +- Bedrock parser + pricing + SSE / event-stream: [32-proxy-llm-parsers.md](32-proxy-llm-parsers.md) +- Catalog → route synthesis + `keyfile::` handling: [21-management-agentnetwork.md](21-management-agentnetwork.md) +- Overview: [../00-overview.md](../00-overview.md) diff --git a/docs/io.netbird.client.plist b/docs/io.netbird.client.plist new file mode 100644 index 000000000..eec96d35b --- /dev/null +++ b/docs/io.netbird.client.plist @@ -0,0 +1,151 @@ + + + + + + + + managementURL + https://api.netbird.io:443 + + + + + + + allowServerSSH + + + + + + + + + + + + + + + + + + diff --git a/docs/netbird-macos.mobileconfig b/docs/netbird-macos.mobileconfig new file mode 100644 index 000000000..5d886c283 --- /dev/null +++ b/docs/netbird-macos.mobileconfig @@ -0,0 +1,176 @@ + + + + + + + PayloadType + Configuration + PayloadVersion + 1 + PayloadIdentifier + io.netbird.client.mdm + PayloadUUID + 11111111-1111-1111-1111-111111111111 + PayloadDisplayName + NetBird MDM Policy + PayloadDescription + Enforces NetBird client configuration. Values written here override any local user / CLI / on-disk setting and are re-applied at every daemon boot and on every 1-minute MDM reload tick. + PayloadOrganization + NetBird + PayloadScope + System + PayloadRemovalDisallowed + + + PayloadContent + + + + PayloadType + com.apple.ManagedClient.preferences + PayloadVersion + 1 + PayloadIdentifier + io.netbird.client.mdm.preferences + PayloadUUID + 22222222-2222-2222-2222-222222222222 + PayloadDisplayName + NetBird Managed Preferences + PayloadEnabled + + + PayloadContent + + io.netbird.client + + Forced + + + mcx_preference_settings + + + + managementURL + https://api.netbird.io:443 + + + + + + + allowServerSSH + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/netbird-macos.sh b/docs/netbird-macos.sh new file mode 100644 index 000000000..3d57c557c --- /dev/null +++ b/docs/netbird-macos.sh @@ -0,0 +1,236 @@ +#!/bin/bash +# +# SYNOPSIS +# Push the NetBird MDM policy to a macOS device via JumpCloud Commands. +# +# DESCRIPTION +# This is the macOS counterpart of docs/netbird-policy.reg.ps1. +# It writes the values declared in the "POLICY VALUES" block below to +# the managed-preferences plist that the NetBird daemon's +# client/mdm/policy_darwin.go loader reads on every 1-minute MDM +# reload tick: +# +# /Library/Managed Preferences/io.netbird.client.plist +# +# Once the plist lands, the daemon picks up the new values without +# restart (the ticker calls Config.apply() → applyMDMPolicy() and +# restarts the engine on diff). +# +# DEPLOYMENT (JumpCloud) +# 1. Admin Console -> Device Management -> Commands -> +. +# 2. Type: Mac, Shell, Run as: root. +# 3. Paste this file verbatim into the command body. +# 4. Bind to the target system group, save, run. +# +# IMPORTANT: PERSISTENCE +# macOS wipes /Library/Managed Preferences/ at every boot on devices +# that are NOT MDM-enrolled. For a persistent fleet rollout, push the +# companion docs/netbird-macos.mobileconfig as a Custom Configuration +# Profile (Admin Console -> MDM -> Mac Custom Configuration Profiles) +# instead of this script. Use this script when: +# - the device is MDM-enrolled (file survives reboots), or +# - you need a one-shot test push before reboot, or +# - you orchestrate via JumpCloud Commands and want the same +# variable-driven workflow as the Windows .ps1 sibling. +# +# IDEMPOTENCY: re-running with the same values is a no-op from the +# daemon's point of view (the 1-minute reload ticker diff returns empty). +# +# SECURITY: PreSharedKey (and any secret-bearing debugBundleUploadURL) is +# redacted in this script's log output, and the installed plist is 0600 +# root:wheel so its values are not readable by local non-root users. + +set -euo pipefail + +### POLICY VALUES — EDIT THIS BLOCK ########################################### +# +# Set each variable below to the desired value. Set to empty string "" +# or to NULL to omit a key entirely (the daemon treats an absent key +# as "no enforcement" for that field). Booleans use "true"/"false" +# (lowercase). Integers as decimal. +# +# Reference for key names + accepted values: +# client/mdm/policy.go (Key* constants) +# docs/netbird-macos.mobileconfig (sample profile) +# docs/netbird.admx + .adml (Windows ADMX schema) +# +NULL='__UNSET__' +managementURL='https://api.netbird.io:443' +preSharedKey="$NULL" # secret; redacted in log +allowServerSSH='true' +allowRemoteJobs="$NULL" +debugBundleUploadURL="$NULL" # HTTPS URL with a host; overrides management +blockInbound="$NULL" +disableAutoConnect="$NULL" +disableAutostart="$NULL" +disableClientRoutes="$NULL" +disableServerRoutes="$NULL" +disableMetricsCollection="$NULL" +disableUpdateSettings="$NULL" +disableProfiles="$NULL" +disableNetworks="$NULL" +disableAdvancedView="$NULL" # tristate at the daemon +rosenpassEnabled="$NULL" +rosenpassPermissive="$NULL" +wireguardPort='51820' +splitTunnelMode="$NULL" # "allow" or "disallow", Android-only at the daemon level +splitTunnelApps="$NULL" # comma-separated app IDs, Android-only +############################################################################## + +readonly PLIST_DIR='/Library/Managed Preferences' +readonly PLIST_PATH="$PLIST_DIR/io.netbird.client.plist" +readonly LOG_TAG='netbird-mdm' + +# log sends a message to the system logger using the configured tag and echoes the message to stdout prefixed by an ISO 8601 UTC timestamp and the tag. +log() { + /usr/bin/logger -t "$LOG_TAG" "$*" + printf '%s [%s] %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$LOG_TAG" "$*" +} + +# is_set returns success if the provided value is non-empty and is not equal to the special NULL marker. +is_set() { + local value="$1" + [[ -n "$value" && "$value" != "$NULL" ]] +} + +# start_plist creates the temporary plist file at "$PLIST_PATH.tmp" containing the XML plist header and opening `` for the policy plist. +start_plist() { + cat > "$PLIST_PATH.tmp" <<'EOF' + + + + +EOF +} + +# end_plist appends the closing `` and `` tags to the temporary plist file. +end_plist() { + cat >> "$PLIST_PATH.tmp" <<'EOF' + + +EOF +} + +# emit_string appends a plist ``/`` entry for the given key and value to "$PLIST_PATH.tmp", XML-escaping `&`, `<`, and `>`, and logs the assignment (masking the logged value as `********** (secret)` for secret keys — `preSharedKey` and `debugBundleUploadURL`, which can embed credentials or a signed query token). +emit_string() { + local key="$1" value="$2" log_value="$2" + # Escape XML entities in the value + local escaped + escaped="$(printf '%s' "$value" | sed -e 's/&/\&/g' -e 's//\>/g')" + printf ' %s\n %s\n' "$key" "$escaped" >> "$PLIST_PATH.tmp" + case "$key" in + preSharedKey|debugBundleUploadURL) log_value='********** (secret)' ;; + *) ;; + esac + log "set $key = $log_value" +} + +# is_bool returns success if the value is an accepted boolean token. +is_bool() { + local value="$1" + case "$value" in + true|True|TRUE|1|yes|false|False|FALSE|0|no) return 0 ;; + *) return 1 ;; + esac +} + +# emit_bool writes a boolean plist entry for a key when the provided value matches +# an accepted boolean token; logs an error and skips the key on invalid input. +# It returns success even on invalid input (like emit_int) so a single typo in one +# boolean does not abort the whole policy push under `set -euo pipefail`. Callers +# that must fail closed on an invalid value (e.g. allowRemoteJobs) validate with +# is_bool before calling and substitute a safe default themselves. +emit_bool() { + local key="$1" value="$2" + local xml_bool + case "$value" in + true|True|TRUE|1|yes) xml_bool='' ; value='true' ;; + false|False|FALSE|0|no) xml_bool='' ; value='false' ;; + *) log "invalid boolean for $key: $value (must be true/false); skipping"; return ;; + esac + printf ' %s\n %s\n' "$key" "$xml_bool" >> "$PLIST_PATH.tmp" + log "set $key = $value" +} + +# emit_int validates that VALUE contains only decimal digits and, if valid, appends an `` plist entry for KEY to the temporary plist (`$PLIST_PATH.tmp`) and logs the assignment; on invalid input it logs a skip and does not emit the key. +emit_int() { + local key="$1" value="$2" + if ! [[ "$value" =~ ^[0-9]+$ ]]; then + log "invalid integer for $key: $value (must be decimal); skipping" + return + fi + printf ' %s\n %s\n' "$key" "$value" >> "$PLIST_PATH.tmp" + log "set $key = $value" +} + +# main builds the NetBird MDM plist from configured policy variables, validates and installs it to /Library/Managed Preferences/io.netbird.client.plist (root:wheel, 600 — the daemon reads it directly as root, so it need not be world-readable) and optionally triggers the NetBird daemon to reload. +main() { + log "applying NetBird MDM policy to $PLIST_PATH" + # Restrict the temp plist while it is being built: it carries the same + # secret-bearing values as the final file, which is installed 0600 below. + umask 077 + /bin/mkdir -p "$PLIST_DIR" + start_plist + # Force 0600 on the temp file explicitly: start_plist writes it with a + # truncating redirect, which keeps an existing file's mode, so a leftover + # 0644 tmp from an interrupted run would not be tightened by umask alone. + # start_plist only wrote the header so far — the secret-bearing values are + # appended after this point. + /bin/chmod 600 "$PLIST_PATH.tmp" + + is_set "$managementURL" && emit_string managementURL "$managementURL" + is_set "$preSharedKey" && emit_string preSharedKey "$preSharedKey" + is_set "$allowServerSSH" && emit_bool allowServerSSH "$allowServerSSH" + # Fail closed: an invalid allowRemoteJobs value must not drop the key and + # leave a conflicting local opt-in active — enforce the safe default (false). + if is_set "$allowRemoteJobs"; then + if is_bool "$allowRemoteJobs"; then + emit_bool allowRemoteJobs "$allowRemoteJobs" + else + log "invalid boolean for allowRemoteJobs: $allowRemoteJobs; enforcing safe default (false)" + emit_bool allowRemoteJobs false + fi + fi + is_set "$debugBundleUploadURL" && emit_string debugBundleUploadURL "$debugBundleUploadURL" + is_set "$blockInbound" && emit_bool blockInbound "$blockInbound" + is_set "$disableAutoConnect" && emit_bool disableAutoConnect "$disableAutoConnect" + is_set "$disableAutostart" && emit_bool disableAutostart "$disableAutostart" + is_set "$disableClientRoutes" && emit_bool disableClientRoutes "$disableClientRoutes" + is_set "$disableServerRoutes" && emit_bool disableServerRoutes "$disableServerRoutes" + is_set "$disableMetricsCollection" && emit_bool disableMetricsCollection "$disableMetricsCollection" + is_set "$disableUpdateSettings" && emit_bool disableUpdateSettings "$disableUpdateSettings" + is_set "$disableProfiles" && emit_bool disableProfiles "$disableProfiles" + is_set "$disableNetworks" && emit_bool disableNetworks "$disableNetworks" + is_set "$disableAdvancedView" && emit_bool disableAdvancedView "$disableAdvancedView" + is_set "$rosenpassEnabled" && emit_bool rosenpassEnabled "$rosenpassEnabled" + is_set "$rosenpassPermissive" && emit_bool rosenpassPermissive "$rosenpassPermissive" + is_set "$wireguardPort" && emit_int wireguardPort "$wireguardPort" + is_set "$splitTunnelMode" && emit_string splitTunnelMode "$splitTunnelMode" + is_set "$splitTunnelApps" && emit_string splitTunnelApps "$splitTunnelApps" + + end_plist + + if ! /usr/bin/plutil -lint "$PLIST_PATH.tmp" >/dev/null 2>&1; then + log "ERROR: generated plist failed plutil lint; not installing" + /usr/bin/plutil -lint "$PLIST_PATH.tmp" >&2 || true + /bin/rm -f "$PLIST_PATH.tmp" + exit 1 + fi + + /bin/mv -f "$PLIST_PATH.tmp" "$PLIST_PATH" + /usr/sbin/chown root:wheel "$PLIST_PATH" + # 0600, not 0644: the daemon's loader (client/mdm/policy_darwin.go) opens the + # plist directly as root, so it does not need to be world-readable. Restricting + # it keeps secret-bearing values (preSharedKey, a signed debugBundleUploadURL) + # from any local non-root user. The loader's only mode check refuses a + # world-writable file, which 0600 satisfies. + /bin/chmod 600 "$PLIST_PATH" + + log "policy installed; NetBird daemon will pick it up within the next 1-minute reload tick" + + # Optional: kick the daemon for an immediate apply. Safe — does + # nothing on a host where NetBird is not yet installed. + /bin/launchctl kickstart -k system/io.netbird.client 2>/dev/null || true +} + +main "$@" diff --git a/docs/netbird-policy.reg b/docs/netbird-policy.reg new file mode 100644 index 000000000..d116e4c77 Binary files /dev/null and b/docs/netbird-policy.reg differ diff --git a/docs/netbird-policy.reg.ps1 b/docs/netbird-policy.reg.ps1 new file mode 100644 index 000000000..011d706dc --- /dev/null +++ b/docs/netbird-policy.reg.ps1 @@ -0,0 +1,94 @@ +#requires -Version 5.1 +<# +.SYNOPSIS + Push the NetBird MDM policy to a Windows device via JumpCloud Commands + by importing a sidecar netbird-policy.reg file. + +.DESCRIPTION + Windows counterpart of docs/netbird-macos.sh. Outcome: + HKLM\Software\Policies\NetBird populated from the attached + netbird-policy.reg file, daemon picks up the change via the + 1-minute MDM reload ticker. + + Deployment: + 1. Admin Console -> Device Management -> Commands -> +. + 2. Type: Windows PowerShell. Run as: SYSTEM. + 3. Paste this file verbatim into the command body. + 4. In the same command, attach `netbird-policy.reg` as a file. + JumpCloud copies attached files into the command's working + directory before invoking the script, so `$PSScriptRoot` or + Get-Location resolves to where the .reg lives. + 5. Bind to the target system group, save, run. + + Producing the .reg file: + On a reference machine, after configuring the policy values either + via gpedit (GPO) or manual `reg add`, export with: + + reg export "HKLM\Software\Policies\NetBird" netbird-policy.reg /y + + Then attach the resulting file to the JumpCloud command. + + Semantics: + - The script nukes the existing HKLM\Software\Policies\NetBird key + before importing the .reg, so the .reg is the SINGLE SOURCE OF + TRUTH. Any value present in the registry but absent from the .reg + is removed. This is what an MDM admin almost always wants. + - Setting the .reg to an empty (header-only) file effectively unsets + the policy. + + Idempotency: re-running the script with the same .reg is a no-op from + the daemon's perspective (values identical → 1-min ticker sees no + diff → engine not restarted). + + Exit codes: 0 = success; 1 = .reg missing or reg.exe error. +#> + +$ErrorActionPreference = "Stop" + +$RegFileName = "netbird-policy.reg" +$RegKey = "HKLM\Software\Policies\NetBird" + +# Resolve the attached .reg file: JumpCloud copies command attachments +# into C:\Windows\Temp\ before invoking the script. Cwd / $PSScriptRoot +# fallbacks cover the local-dev case where you might dot-source this +# from elsewhere. +$candidates = @( + (Join-Path "$env:WINDIR\Temp" $RegFileName) + (Join-Path (Get-Location) $RegFileName) + (Join-Path $PSScriptRoot $RegFileName) +) | Where-Object { Test-Path $_ } + +if ($candidates.Count -eq 0) { + Write-Error "[netbird-mdm] $RegFileName not found in working directory or `$PSScriptRoot. Attach the file to the JumpCloud command." + exit 1 +} +$regFile = $candidates[0] +Write-Host "[netbird-mdm] using $regFile" + +# Wipe the existing policy key so the .reg is authoritative. +$existed = Test-Path "Registry::HKEY_LOCAL_MACHINE\Software\Policies\NetBird" +if ($existed) { + & reg.exe delete $RegKey /f | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Error "[netbird-mdm] failed to clear $RegKey before import (exit $LASTEXITCODE)" + exit 1 + } + Write-Host "[netbird-mdm] cleared previous values under $RegKey" +} + +# Import. reg.exe writes both data and (re-)creates the key if needed. +& reg.exe import $regFile +if ($LASTEXITCODE -ne 0) { + Write-Error "[netbird-mdm] reg import failed (exit $LASTEXITCODE)" + exit 1 +} + +# Audit dump so the JumpCloud per-execution log captures the applied state. +Write-Host "[netbird-mdm] final policy state under $RegKey :" +& reg.exe query $RegKey /s + +# Daemon's 1-min reload ticker picks up the change automatically. +# Uncomment to force immediate convergence (skips the ticker wait): +# Restart-Service netbird -Force -ErrorAction SilentlyContinue + +exit 0 diff --git a/docs/netbird.adml b/docs/netbird.adml new file mode 100644 index 000000000..05cfb71ad --- /dev/null +++ b/docs/netbird.adml @@ -0,0 +1,113 @@ + + + NetBird Client Policies + Group Policy template for NetBird client MDM-managed settings. Values are written under HKLM\Software\Policies\NetBird and consumed by the netbird daemon at startup and every 1-minute reload tick. + + + + + NetBird + NetBird Client 0.40+ + + + Management URL + URL of the NetBird management server. Format: https://host[:port]. When set, users cannot override this value via UI or CLI. + + Pre-shared key + WireGuard pre-shared key used as an additional symmetric secret on every peer-to-peer tunnel. Secret value. + + + Disable auto-connect + When enabled, the NetBird tunnel does not auto-connect at daemon startup. Equivalent to --disable-auto-connect. + + Disable autostart + When enabled, the NetBird GUI is prevented from registering itself as an OS autostart entry on fresh installs, and any existing OS autostart entry registration is removed on the next GUI launch (Windows Registry Run key, macOS Login Item, Linux .desktop). Once the admin lifts the policy, the setting stays off until the user re-enables it in Settings. + + Disable client routes + When enabled, this client will not consume routes advertised by routing peers. Equivalent to --disable-client-routes. + + Disable server routes + When enabled, this client will not act as a routing peer for other clients. Equivalent to --disable-server-routes. + + Block inbound + When enabled, the client firewall blocks all inbound peer traffic on the WireGuard interface. Equivalent to --block-inbound. + + Allow server SSH + When enabled, this client accepts incoming SSH sessions via NetBird SSH. Equivalent to --allow-server-ssh. + + Allow remote jobs + When enabled, this client accepts management-requested remote jobs (e.g. debug bundles). Off by default. Equivalent to --allow-remote-jobs. + + Debug bundle upload URL + Overrides the upload service used for debug bundles produced by remote jobs, taking precedence over the value requested by management. Must be an https URL with a host. + + Enable Rosenpass + Enables Rosenpass post-quantum key exchange on WireGuard tunnels. Both peers must support it. + + Rosenpass permissive + When enabled, the client falls back to plain WireGuard if a peer does not support Rosenpass; otherwise it refuses the connection. + + WireGuard port + UDP port used by the local WireGuard interface. Allowed range: 1-65535. + + Split tunnel + Restrict the NetBird tunnel to or from a chosen list of application package names. Choose either the allow mode (only the listed apps route through NetBird) or the disallow mode (the listed apps bypass NetBird; everything else routes through). The mode is mutually exclusive — only one can be active at a time. Android-only at the daemon level; Windows/macOS/iOS clients ignore this policy. + Allow only listed apps (everything else bypasses) + Disallow listed apps (everything else routes) + + + Disable update settings + When enabled, blocks every configuration change from the client UI and from the CLI (netbird up / login / setconfig). The Settings view stays viewable but read-only. Equivalent to --disable-update-settings. + + Disable profiles + When enabled, the client UI/CLI cannot list, create, switch or remove NetBird connection profiles. Equivalent to --disable-profiles. + + Disable networks + When enabled, the client UI/CLI cannot list, select or deselect NetBird networks (the corresponding daemon RPCs return Unavailable). Equivalent to --disable-networks. + + Disable advanced view + When enabled, the client UI hides the advanced-view section of the new UI revision. Tristate at the daemon: 1 (enabled) hides the section; 0 (disabled) explicitly shows it; not configured leaves the UI's default behavior in place. MDM is the sole source — no equivalent CLI flag exists. + + Disable metrics collection + When enabled, the client does not collect or report local usage metrics. + + + + + + + + https://api.netbird.io:443 + + + + + + + + + + + + + + + + + WireGuard UDP port: + + + + Mode: + + + + + + + + diff --git a/docs/netbird.admx b/docs/netbird.admx new file mode 100644 index 000000000..6a188cff1 --- /dev/null +++ b/docs/netbird.admx @@ -0,0 +1,272 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + allow + disallow + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/testing-privileged.md b/docs/testing-privileged.md new file mode 100644 index 000000000..72e8a0f8f --- /dev/null +++ b/docs/testing-privileged.md @@ -0,0 +1,78 @@ +# Privileged tests + +Some tests in this repo need `root` or mutate host network state: they create +TUN/WireGuard interfaces, open netlink/raw sockets, run eBPF programs, or shell +out to `ip`/`iptables`/`nft`/`ifconfig`/`route`. Running them on a developer +machine would require `sudo` and could leave stray interfaces or routes behind. + +These tests are gated behind the **`privileged` build tag** so the default test +run is host-safe. + +## Running tests + +```bash +# Host-safe: excludes privileged tests. Runs as a normal user, no sudo. +make test-unit +# equivalently: +go test -tags devcert ./... + +# Privileged suite: runs the privileged-tagged tests inside a +# --privileged --cap-add=NET_ADMIN container (requires Docker). +make test-privileged + +# Narrow the container run to a single test / package: +PRIV_RUN=TestNftablesManager PRIV_PKGS=./client/firewall/nftables/... make test-privileged +``` + +`PRIV_RUN` adds a `-run` test-name filter and `PRIV_PKGS` overrides the package +list; both are optional and default to the full privileged suite. + +`make test-privileged` invokes the `ory/dockertest` harness in +`client/testutil/privileged/`. The harness: + +1. Skips immediately when it detects it is already inside the container + (`DOCKER_CI=true`), so the privileged tests run in place instead of recursing. +2. Otherwise spins up a `golang:1.26.7-alpine` container (matching CI), + bind-mounts the repo and the host Go build/module caches, installs the + required packages, and runs `go test -tags 'devcert privileged'` over the + client packages. +3. Streams the container's output to the test log and fails if the suite fails. + +## Adding a privileged test + +A test is privileged if it does any of: + +- creates a real interface via `iface.NewWGIFace(...).Create()`, +- opens a netlink or raw socket that hard-fails without `CAP_NET_ADMIN`, +- runs an eBPF program (`ebpf.*.Listen()`), +- shells out to `ip`, `iptables`, `nft`, `ifconfig`, or `route` to change state. + +Add the tag to the **top** of the file, combined with any existing platform +constraint: + +```go +//go:build privileged && linux + +package foo +``` + +If a file mixes privileged and pure-logic tests, **split it**: keep the pure +tests (and any shared data — type/var declarations, table-driven `testCases`, +helper interfaces) in an untagged file, and move the privileged tests into a +`*_privileged_test.go` file with the tag. Shared declarations must stay untagged, +otherwise the unprivileged files in the package will not compile. + +Always verify both build modes compile on every target platform: + +```bash +go vet -tags devcert ./... +go vet -tags 'devcert privileged' ./... +``` + +## CI + +- The `Client / Unit` job runs `go test -tags devcert` with **no** `sudo` — only + host-safe tests. +- The `Client (Docker) / Unit` job runs `go test -tags 'devcert privileged'` + inside a `--privileged --cap-add=NET_ADMIN` container, which is where the + privileged tests actually execute. diff --git a/e2e/agentnetwork/agent_config_test.go b/e2e/agentnetwork/agent_config_test.go new file mode 100644 index 000000000..58bfddab3 --- /dev/null +++ b/e2e/agentnetwork/agent_config_test.go @@ -0,0 +1,143 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// joinGroup places the PAT's own user into the group so caller-scoped answers +// (GET /api/agent-network/agent-config) see the policies sourced from it, and +// restores the previous auto-groups on cleanup. Self-service updates of one's +// own auto_groups are permitted for every role, so this needs no second user. +func joinGroup(t *testing.T, ctx context.Context, groupID string) { + t.Helper() + me, err := srv.API().Users.Current(ctx) + require.NoError(t, err, "read current user") + before := append([]string(nil), me.AutoGroups...) + _, err = srv.API().Users.Update(ctx, me.Id, api.PutApiUsersUserIdJSONRequestBody{ + Role: me.Role, + IsBlocked: me.IsBlocked, + AutoGroups: append(append([]string(nil), before...), groupID), + }) + require.NoError(t, err, "add the caller to the policy source group") + t.Cleanup(func() { + _, _ = srv.API().Users.Update(context.Background(), me.Id, api.PutApiUsersUserIdJSONRequestBody{ + Role: me.Role, + IsBlocked: me.IsBlocked, + AutoGroups: before, + }) + }) +} + +// configProvider returns the agent-config entry for the named provider, nil +// when the answer does not offer it. The suite shares one account, so other +// tests' fixtures may add unrelated providers to the caller's answer. +func configProvider(cfg api.AgentNetworkAgentConfig, name string) *api.AgentNetworkAgentConfigProvider { + for i := range cfg.Providers { + if cfg.Providers[i].Name == name { + return &cfg.Providers[i] + } + } + return nil +} + +// TestAgentConfigAllowlistOfDeclaredModels reproduces the post-#7221 field +// report: a provider carrying a declared model set plus a policy guardrail +// whose allowlist holds those same declared ids must advertise the models on +// GET /api/agent-network/agent-config — the guardrail was built FROM the +// provider's model list (the dashboard's allowlist picker persists the +// declared ids verbatim), so nothing about the setup excludes them. +// +// The plain case passes today. The path-style case (Bedrock; Vertex has the +// same shape) fails: the declared id is compared through the proxy parser's +// canonical form (region prefix and version suffix stripped) while the +// allowlist entry is not, so the raw-vs-raw pair never intersects and the +// caller sees an empty model list. The same one-sided normalization sits in +// policyPermitsModel, so the proxy also denies the model at request time — +// the guardrail meant to allow exactly this model turns it off end to end. +func TestAgentConfigAllowlistOfDeclaredModels(t *testing.T) { + ctx := context.Background() + + cases := []struct { + name string + catalogID string + upstream string + declared string + }{ + { + name: "plain-declared-id", + catalogID: "openai_api", + upstream: "https://api.openai.com", + declared: "gpt-4o-mini", + }, + { + // The operator declares the id AWS issues — region-prefixed + // inference profile with a version suffix — and the allowlist + // picker copies it as-is. + name: "bedrock-declared-id", + catalogID: "bedrock_api", + upstream: "https://bedrock-runtime.eu-central-1.amazonaws.com", + declared: "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-agentcfg-" + tc.name}) + require.NoError(t, err, "create source group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + joinGroup(t, ctx, grp.Id) + + providerName := "e2e-agentcfg-" + tc.name + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: providerName, + ProviderId: tc.catalogID, + UpstreamUrl: tc.upstream, + ApiKey: ptr("sk-dummy-e2e-key"), + Enabled: ptr(true), + Models: &[]api.AgentNetworkProviderModel{{Id: tc.declared, InputPer1k: 0.001, OutputPer1k: 0.002}}, + }) + require.NoError(t, err, "create provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + // Allowlist exactly the declared model, the way the dashboard + // builds a guardrail from the provider's model list. + var gr api.AgentNetworkGuardrailRequest + gr.Name = "e2e-agentcfg-" + tc.name + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = []string{tc.declared} + guard, err := srv.CreateGuardrail(ctx, gr) + require.NoError(t, err, "create guardrail") + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), guard.Id) }) + + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-agentcfg-" + tc.name, + Enabled: ptr(true), + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{guard.Id}, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + cfg, err := srv.GetAgentConfig(ctx) + require.NoError(t, err, "read the caller-scoped agent config") + require.True(t, cfg.Configured, "the account endpoint is bootstrapped by TestMain") + + entry := configProvider(cfg, providerName) + require.NotNil(t, entry, "the policy authorizes the caller for the provider, so it must be offered") + assert.False(t, entry.AllModelsAllowed, "an allowlist guardrail restricts the provider") + assert.Equal(t, []string{tc.declared}, entry.Models, + "the allowlist holds the provider's own declared id, so that model must be advertised") + }) + } +} diff --git a/e2e/agentnetwork/bootstrap_test.go b/e2e/agentnetwork/bootstrap_test.go new file mode 100644 index 000000000..a73d55117 --- /dev/null +++ b/e2e/agentnetwork/bootstrap_test.go @@ -0,0 +1,30 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCombinedBootstrap proves Pillar 1: the shared combined server came up and +// the /api/setup-minted PAT authenticates a real management API call through +// the typed REST client (the bootstrap itself ran in TestMain). +func TestCombinedBootstrap(t *testing.T) { + ctx := context.Background() + + require.NotEmpty(t, srv.PAT, "TestMain must have minted an admin PAT") + + users, err := srv.API().Users.List(ctx) + require.NoError(t, err, "authenticated Users.List must round-trip") + require.NotEmpty(t, users, "the bootstrapped account must have at least one user") + + var emails []string + for _, u := range users { + emails = append(emails, u.Email) + } + assert.Contains(t, emails, "admin@netbird.test", "the bootstrapped owner should appear in the users list") +} diff --git a/e2e/agentnetwork/chat_test.go b/e2e/agentnetwork/chat_test.go new file mode 100644 index 000000000..792f67c89 --- /dev/null +++ b/e2e/agentnetwork/chat_test.go @@ -0,0 +1,582 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// per1k is a model's published USD rates per 1k tokens. read is the prompt-cache read rate +// (OpenAI: the cached-input discount rate); write is the cache-creation rate where one exists. +type per1k struct{ in, out, read, write float64 } + +// publishedPer1k hardcodes the vendors' PUBLISHED rates for the models the live matrix can drive, +// keyed by the normalized model id the proxy stamps. Deliberately independent of NetBird's own +// default pricing table so a wrong default rate or a broken normalization fails the run. +// +// These rates are also what providerRequest registers as the operator's per-model prices. Since +// management now ships operator prices to the cost meter as a per-provider-record table that is +// consulted BEFORE the surface defaults, registering the published rate is what keeps this matrix +// asserting vendor rates — and exercises the per-record path at the same time. +var publishedPer1k = map[string]per1k{ + "gpt-4o-mini": {0.00015, 0.0006, 0.000075, 0}, + "gpt-4o": {0.0025, 0.01, 0.00125, 0}, + "claude-haiku-4-5": {0.001, 0.005, 0.0001, 0.00125}, + "claude-sonnet-4-5": {0.003, 0.015, 0.0003, 0.00375}, + "claude-sonnet-4-6": {0.003, 0.015, 0.0003, 0.00375}, + "kimi-k3": {0.003, 0.015, 0.0003, 0.003}, // no published write rate: bills at the input rate + "anthropic.claude-haiku-4-5": {0.001, 0.005, 0.0001, 0.00125}, + "anthropic.claude-sonnet-4-5": {0.003, 0.015, 0.0003, 0.00375}, + "anthropic.claude-sonnet-4-6": {0.003, 0.015, 0.0003, 0.00375}, + // Gateway-prefixed ids (Vercel AI Gateway, OpenRouter). A gateway model is not in + // NetBird's default table, so before operator pricing it could only be recorded at + // cost 0. The operator names it and prices it — at the underlying vendor's published + // rate, which is what the gateway charges through — so these rows are now priced. + "openai/gpt-4o-mini": {0.00015, 0.0006, 0.000075, 0}, + "openai/gpt-4o": {0.0025, 0.01, 0.00125, 0}, +} + +// rawCostVerificationSQL is the operator-facing double-check, run straight against the management +// sqlite store: recompute each usage row's expected total and cache cost from its own persisted +// token buckets and hardcoded published rates. OpenAI counts cached tokens as a subset of input; +// Anthropic-shape providers count cache buckets additively. +// +// The rate rows must stay in sync with publishedPer1k — they are the same vendor rates the matrix +// registers as operator prices. The join is on model, so rows written by other tests in this +// package (which price their own made-up model ids) are simply not covered here. +const rawCostVerificationSQL = ` +WITH rates(model, in_rate, out_rate, read_rate, write_rate) AS ( + VALUES + ('gpt-4o-mini', 0.00015, 0.0006, 0.000075, 0.0), + ('gpt-4o', 0.0025, 0.01, 0.00125, 0.0), + ('claude-haiku-4-5', 0.001, 0.005, 0.0001, 0.00125), + ('claude-sonnet-4-5', 0.003, 0.015, 0.0003, 0.00375), + ('claude-sonnet-4-6', 0.003, 0.015, 0.0003, 0.00375), + ('kimi-k3', 0.003, 0.015, 0.0003, 0.003), + ('anthropic.claude-haiku-4-5', 0.001, 0.005, 0.0001, 0.00125), + ('anthropic.claude-sonnet-4-5', 0.003, 0.015, 0.0003, 0.00375), + ('anthropic.claude-sonnet-4-6', 0.003, 0.015, 0.0003, 0.00375), + ('openai/gpt-4o-mini', 0.00015, 0.0006, 0.000075, 0.0), + ('openai/gpt-4o', 0.0025, 0.01, 0.00125, 0.0) +) +SELECT + u.provider, + u.model, + u.input_tokens, + u.output_tokens, + u.cached_input_tokens, + u.cache_creation_tokens, + u.input_cost_usd, + u.cached_input_cost_usd, + u.cache_creation_cost_usd, + u.output_cost_usd, + -- No cost_usd / cache_cost_usd columns are stored: both are derived from the + -- four per-bucket columns above, exactly as the API renders them. + (u.input_cost_usd + u.cached_input_cost_usd + u.cache_creation_cost_usd + u.output_cost_usd) AS cost_usd, + (u.cached_input_cost_usd + u.cache_creation_cost_usd) AS cache_cost_usd, + CASE WHEN u.provider = 'openai' THEN + (u.input_tokens - MIN(u.cached_input_tokens, u.input_tokens))*r.in_rate/1000.0 + ELSE + u.input_tokens*r.in_rate/1000.0 + END AS expected_input, + CASE WHEN u.provider = 'openai' THEN + MIN(u.cached_input_tokens, u.input_tokens)*r.read_rate/1000.0 + ELSE + u.cached_input_tokens*r.read_rate/1000.0 + END AS expected_cached_input, + CASE WHEN u.provider = 'openai' THEN + 0.0 + ELSE + u.cache_creation_tokens*r.write_rate/1000.0 + END AS expected_cache_creation, + u.output_tokens*r.out_rate/1000.0 AS expected_output, + CASE WHEN u.provider = 'openai' THEN + (u.input_tokens - MIN(u.cached_input_tokens, u.input_tokens))*r.in_rate/1000.0 + + MIN(u.cached_input_tokens, u.input_tokens)*r.read_rate/1000.0 + + u.output_tokens*r.out_rate/1000.0 + ELSE + u.input_tokens*r.in_rate/1000.0 + u.cached_input_tokens*r.read_rate/1000.0 + + u.cache_creation_tokens*r.write_rate/1000.0 + u.output_tokens*r.out_rate/1000.0 + END AS expected_total, + CASE WHEN u.provider = 'openai' THEN + MIN(u.cached_input_tokens, u.input_tokens)*r.read_rate/1000.0 + ELSE + u.cached_input_tokens*r.read_rate/1000.0 + u.cache_creation_tokens*r.write_rate/1000.0 + END AS expected_cache +FROM agent_network_request_usage u +JOIN rates r ON r.model = u.model +ORDER BY u.timestamp` + +// verifyUsageRowsSQL re-checks every persisted usage row directly in the management sqlite store, +// bypassing the API path — the same audit an operator can run on a production store.db. +func verifyUsageRowsSQL(t *testing.T, srv *harness.Combined) { + t.Helper() + + dbPath, err := srv.SnapshotStoreDB(t.TempDir()) + require.NoError(t, err, "snapshot management sqlite store") + + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + require.NoError(t, err, "open store snapshot") + sqlDB, err := db.DB() + require.NoError(t, err) + defer func() { _ = sqlDB.Close() }() + + rows, err := db.Raw(rawCostVerificationSQL).Rows() + require.NoError(t, err, "run raw cost verification query") + defer func() { _ = rows.Close() }() + + verified := 0 + for rows.Next() { + var provider, model string + var inTok, outTok, readTok, writeTok int64 + var inCost, cachedInCost, cacheCreateCost, outCost, cost, cacheCost float64 + var wantInput, wantCachedInput, wantCacheCreation, wantOutput, wantTotal, wantCache float64 + require.NoError(t, rows.Scan(&provider, &model, &inTok, &outTok, &readTok, &writeTok, + &inCost, &cachedInCost, &cacheCreateCost, &outCost, &cost, &cacheCost, + &wantInput, &wantCachedInput, &wantCacheCreation, &wantOutput, &wantTotal, &wantCache), "scan usage row") + t.Logf("[sql] %s/%s: in=%d out=%d cache_read=%d cache_write=%d stored in/cached/create/out=$%.6f/$%.6f/$%.6f/$%.6f total=$%.6f cache=$%.6f expected total=$%.6f cache=$%.6f", + provider, model, inTok, outTok, readTok, writeTok, + inCost, cachedInCost, cacheCreateCost, outCost, cost, cacheCost, wantTotal, wantCache) + assert.InDeltaf(t, wantInput, inCost, 1e-6, "stored input_cost_usd for %s/%s must match the published-rate recompute", provider, model) + assert.InDeltaf(t, wantCachedInput, cachedInCost, 1e-6, "stored cached_input_cost_usd for %s/%s must match the published-rate recompute", provider, model) + assert.InDeltaf(t, wantCacheCreation, cacheCreateCost, 1e-6, "stored cache_creation_cost_usd for %s/%s must match the published-rate recompute", provider, model) + assert.InDeltaf(t, wantOutput, outCost, 1e-6, "stored output_cost_usd for %s/%s must match the published-rate recompute", provider, model) + assert.InDeltaf(t, wantTotal, cost, 1e-6, "derived cost_usd for %s/%s must match the published-rate recompute", provider, model) + assert.InDeltaf(t, wantCache, cacheCost, 1e-6, "derived cache_cost_usd for %s/%s must match the published-rate recompute", provider, model) + assert.InDeltaf(t, inCost+cachedInCost+cacheCreateCost+outCost, cost, 1e-9, + "stored buckets must sum to the derived cost_usd for %s/%s", provider, model) + verified++ + } + require.NoError(t, rows.Err(), "iterate usage rows") + require.Positive(t, verified, "raw SQL check must cover at least one usage row") + t.Logf("[sql] verified %d usage rows in store.db against published rates", verified) + + // Gateway-prefixed model ids are absent from NetBird's default pricing table, so they are + // priced only because the operator registered and priced them on the provider record. Assert + // they are priced (not silently 0) — the join above already checked the exact figures for the + // ones this matrix drives. A gateway row at cost 0 means the per-record table never reached + // the cost meter, which is the regression this guards. + gwRows, err := db.Raw(`SELECT model, + (input_cost_usd + cached_input_cost_usd + cache_creation_cost_usd + output_cost_usd) AS cost_usd + FROM agent_network_request_usage WHERE model LIKE '%/%'`).Rows() + require.NoError(t, err, "query gateway-prefixed usage rows") + defer func() { _ = gwRows.Close() }() + for gwRows.Next() { + var model string + var cost float64 + require.NoError(t, gwRows.Scan(&model, &cost), "scan gateway usage row") + t.Logf("[sql] gateway %s: stored=$%.6f (priced from the operator's per-record rate)", model, cost) + assert.Positivef(t, cost, "gateway-prefixed model %q is priced on the provider record, so its cost must be > 0", model) + } + require.NoError(t, gwRows.Err(), "iterate gateway usage rows") +} + +// validateAccessLogCost recomputes a live access-log row's expected total and cache cost from the +// published per-1k rates and the row's persisted token buckets, and asserts both stored values. +// Gateway-prefixed model ids the proxy deliberately does not price must store cost 0. +func validateAccessLogCost(t *testing.T, pc providerCase, row api.AgentNetworkAccessLog) { + t.Helper() + model := catalogModel(pc) + provider := "" + if row.Provider != nil { + provider = *row.Provider + } + t.Logf("[cost] %s: provider=%s model=%s in=%d out=%d total=%d cache_read=%d cache_write=%d cost=$%.6f cache_cost=$%.6f", + pc.name, provider, model, row.InputTokens, row.OutputTokens, row.TotalTokens, + row.CachedInputTokens, row.CacheCreationTokens, row.CostUsd, row.CacheCostUsd) + + rates, known := publishedPer1k[model] + if !known { + t.Logf("[cost] %s: no published rate on file for model %q (env-overridden?); skipping cost validation", pc.name, model) + return + } + + // input_tokens may legitimately be 0: Moonshot/Kimi reports fully cached prompts under the cache + // buckets only. Output and total must always be present on a priced row. + require.Positive(t, row.OutputTokens, "priced row must carry output tokens") + require.Positive(t, row.TotalTokens, "priced row must carry total tokens") + + var wantInput, wantCachedInput, wantCacheCreation float64 + if provider == "openai" { + cached := min(row.CachedInputTokens, row.InputTokens) // cached is a subset of input + wantInput = float64(row.InputTokens-cached) / 1000 * rates.in + wantCachedInput = float64(cached) / 1000 * rates.read + // OpenAI has no cache-write bucket; wantCacheCreation stays 0. + } else { + // Anthropic / Bedrock shape: cache buckets are additive to input_tokens. + wantInput = float64(row.InputTokens) / 1000 * rates.in + wantCachedInput = float64(row.CachedInputTokens) / 1000 * rates.read + wantCacheCreation = float64(row.CacheCreationTokens) / 1000 * rates.write + } + wantOutput := float64(row.OutputTokens) / 1000 * rates.out + wantCache := wantCachedInput + wantCacheCreation + wantTotal := wantInput + wantCache + wantOutput + + t.Logf("[cost] %s: expecting input=$%.6f cached_input=$%.6f cache_creation=$%.6f output=$%.6f total=$%.6f cache=$%.6f from published rates", + pc.name, wantInput, wantCachedInput, wantCacheCreation, wantOutput, wantTotal, wantCache) + assert.InDeltaf(t, wantInput, row.InputCostUsd, 1e-6, "stored input_cost_usd for %s (%s)", pc.name, model) + assert.InDeltaf(t, wantCachedInput, row.CachedInputCostUsd, 1e-6, "stored cached_input_cost_usd for %s (%s)", pc.name, model) + assert.InDeltaf(t, wantCacheCreation, row.CacheCreationCostUsd, 1e-6, "stored cache_creation_cost_usd for %s (%s)", pc.name, model) + assert.InDeltaf(t, wantOutput, row.OutputCostUsd, 1e-6, "stored output_cost_usd for %s (%s)", pc.name, model) + assert.InDeltaf(t, wantTotal, row.CostUsd, 1e-6, "derived cost_usd for %s (%s)", pc.name, model) + assert.InDeltaf(t, wantCache, row.CacheCostUsd, 1e-6, "derived cache_cost_usd for %s (%s)", pc.name, model) + + // The aggregates must be exactly the sum of the stored components, not an + // independently-computed figure that could drift from the breakdown. + assert.InDeltaf(t, row.InputCostUsd+row.CachedInputCostUsd+row.CacheCreationCostUsd+row.OutputCostUsd, + row.CostUsd, 1e-9, "stored buckets must sum to the derived cost_usd for %s (%s)", pc.name, model) + assert.InDeltaf(t, row.CachedInputCostUsd+row.CacheCreationCostUsd, + row.CacheCostUsd, 1e-9, "stored cache buckets must sum to the derived cache_cost_usd for %s (%s)", pc.name, model) +} + +// providerCase is one entry in the live provider matrix. The same scenario runs +// for every available provider; availability is keyed off env vars so the suite +// covers whatever credentials are present (source ~/.llm-keys locally / set the +// Actions secrets in CI). +type providerCase struct { + name string + catalogID string + upstream string + apiKey string + model string // body model (chat/messages) or path model@version (vertex) + kind string // harness.WireChat, harness.WireMessages, or harness.WireVertex + project string // vertex only: GCP project for the rawPredict path + region string // vertex only: GCP region for the rawPredict path + pathPrefix string // base-URL path prefix the agent carries (e.g. "/anthropic" for Kimi) +} + +// availableProviders builds the matrix from the provider env vars that are set. +func availableProviders() []providerCase { + var ps []providerCase + if k := os.Getenv("OPENAI_TOKEN"); k != "" { + ps = append(ps, providerCase{name: "openai", catalogID: "openai_api", upstream: "https://api.openai.com", apiKey: k, model: "gpt-4o-mini", kind: harness.WireChat}) + } + if k := os.Getenv("ANTHROPIC_TOKEN"); k != "" { + ps = append(ps, providerCase{name: "anthropic", catalogID: "anthropic_api", upstream: "https://api.anthropic.com", apiKey: k, model: "claude-haiku-4-5", kind: harness.WireMessages}) + } + if k := os.Getenv("KIMI_TOKEN"); k != "" { + // Kimi (Moonshot AI) serves two body shapes from the same key: OpenAI + // Chat Completions on the bare host (/v1/...) and the Anthropic + // Messages API under the /anthropic path prefix (the endpoint + // Moonshot's Claude Code guide uses). The provider keeps the bare + // default upstream and the AGENT carries the /anthropic prefix in + // its base URL — exactly the documented Claude Code / Kimi CLI + // setup (ANTHROPIC_BASE_URL=https:///anthropic) — so one + // provider serves both shapes and the prefix rides through to + // Moonshot. Run the Anthropic shape, the flagship Claude Code path; + // the OpenAI wire shape is covered live by the other chat-shaped + // matrix providers, and Kimi-over-chat passed with kimi-k3 before + // the single-model constraint surfaced (run #73 on the kimi feature + // branch). The platform serves this account exactly ONE model — + // kimi-k3 (kimi-k2-thinking and even kimi-latest return + // resource_not_found_error on both surfaces). + ps = append(ps, providerCase{name: "kimi", catalogID: "kimi_api", upstream: "https://api.moonshot.ai", apiKey: k, model: "kimi-k3", kind: harness.WireMessages, pathPrefix: "/anthropic"}) + } + if k, u := os.Getenv("VERCEL_TOKEN"), os.Getenv("VERCEL_URL"); k != "" && u != "" { + ps = append(ps, providerCase{name: "vercel", catalogID: "vercel_ai_gateway", upstream: u, apiKey: k, model: "openai/gpt-4o-mini", kind: harness.WireChat}) + } + if k, u := os.Getenv("OPENROUTER_TOKEN"), os.Getenv("OPENROUTER_URL"); k != "" && u != "" { + // Distinct model string from Vercel so each provider routes unambiguously + // while all are enabled together. + ps = append(ps, providerCase{name: "openrouter", catalogID: "openrouter", upstream: u, apiKey: k, model: "openai/gpt-4o", kind: harness.WireChat}) + } + if k, u := os.Getenv("CLOUDFLARE_TOKEN"), os.Getenv("CLOUDFLARE_URL"); k != "" && u != "" { + // Cloudflare AI Gateway routes by a provider segment in the URL path; + // append the openai provider unless the gateway URL already carries one. + if !strings.Contains(u, "/openai") { + u = strings.TrimRight(u, "/") + "/openai" + } + // Raw model (distinct string from OpenAI's gpt-4o-mini). + ps = append(ps, providerCase{name: "cloudflare", catalogID: "cloudflare_ai_gateway", upstream: u, apiKey: k, model: "gpt-4o", kind: harness.WireChat}) + } + // Vertex (vertex_ai_api): Anthropic-on-Vertex, path-routed, SA-OAuth + // (api_key = keyfile::). The model travels in the rawPredict path rather + // than the body, so the provider is created without a models array. Region + // defaults to "global" (host aiplatform.googleapis.com); a real region uses + // -aiplatform.googleapis.com. + if sa := os.Getenv("GOOGLE_VERTEX_SA_BASE64"); sa != "" { + project := os.Getenv("GOOGLE_VERTEX_PROJECT") + if project != "" { + region := os.Getenv("GOOGLE_VERTEX_REGION") + if region == "" { + region = "global" + } + host := "aiplatform.googleapis.com" + if region != "global" { + host = region + "-aiplatform.googleapis.com" + } + model := os.Getenv("GOOGLE_VERTEX_MODEL") + if model == "" { + model = "claude-sonnet-4-5@20250929" + } + ps = append(ps, providerCase{ + name: "vertex", catalogID: "vertex_ai_api", upstream: "https://" + host, + apiKey: "keyfile::" + sa, model: model, kind: harness.WireVertex, + project: project, region: region, + }) + } + } + + // Bedrock: path-routed, bearer auth. Model is the FULL cross-region + // inference-profile id exactly as AWS issues it — region-family prefix + // plus the date/version suffix. A bare or wrong-region id makes Bedrock + // reject the request with "The provided model identifier is invalid" + // before any inference runs. The proxy normalizes this id to the catalog + // key (anthropic.claude-haiku-4-5) for routing/pricing/allowlists. + // Defaults pair eu-central-1 with the eu.* profile; AWS_REGION overrides + // the region and the prefix follows its family. + if k := os.Getenv("AWS_BEARER_TOKEN_BEDROCK"); k != "" { + region := os.Getenv("AWS_REGION") + if region == "" { + region = "eu-central-1" + } + // A valid Bedrock inference-profile id, overridable per account (AWS_BEDROCK_MODEL, also the + // workflow's bedrock_model dispatch input). `global.` profiles work from any region. Defaults to + // Sonnet 4.6, whose id convention dropped the -YYYYMMDD-v1:0 suffix that Haiku 4.5 still carries. + model := os.Getenv("AWS_BEDROCK_MODEL") + if model == "" { + model = "global.anthropic.claude-sonnet-4-6" + } + ps = append(ps, providerCase{name: "bedrock", catalogID: "bedrock_api", upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, model: model, kind: harness.WireBedrock}) + } + return ps +} + +// providerRequest builds a create request for a matrix provider: enabled, with +// its model registered at the vendor's published rates for body-routed +// providers, and no models for the path-routed Vertex (whose model lives in the +// request path, so it prices from the defaults table management ships). +// +// The registered rates matter: management synthesizes them into the cost +// meter's per-provider-record table, which is consulted before the surface +// defaults, so these are the rates the proxy actually bills with. Registering +// the published rate keeps the cost assertions vendor-anchored while covering +// the operator-pricing path. A model with no published rate on file (an +// env-overridden Bedrock profile) falls back to a nominal rate, and +// validateAccessLogCost skips its cost check. +func providerRequest(pc providerCase) api.AgentNetworkProviderRequest { + req := api.AgentNetworkProviderRequest{ + Name: pc.name, + ProviderId: pc.catalogID, + UpstreamUrl: pc.upstream, + ApiKey: &pc.apiKey, + Enabled: ptr(true), + } + if pc.kind != harness.WireVertex { + // The router matches the normalized catalog id. Bedrock's request model + // travels as a region-prefixed inference-profile id in the URL path + // (us.anthropic...), which the router strips before matching, so register + // the normalized form here or routing fails as model_not_routable. + modelID := pc.model + if pc.kind == harness.WireBedrock { + modelID = catalogModel(pc) + } + model := api.AgentNetworkProviderModel{Id: modelID, InputPer1k: 0.001, OutputPer1k: 0.002} + if rates, known := publishedPer1k[catalogModel(pc)]; known { + model.InputPer1k = rates.in + model.OutputPer1k = rates.out + // Pin the cache rates too, rather than letting them inherit from the + // defaults table: a gateway-prefixed id has no default entry to + // inherit from, and an unset rate bills that bucket at the input + // rate, which would not match the published-rate recompute. + if rates.read > 0 { + model.CachedInputPer1k = ptr(rates.read) // OpenAI shape + model.CacheReadPer1k = ptr(rates.read) // Anthropic / Bedrock shape + } + if rates.write > 0 { + model.CacheCreationPer1k = ptr(rates.write) + } + } + req.Models = &[]api.AgentNetworkProviderModel{model} + } + return req +} + +// TestProvidersMatrix is Pillar 3: it provisions every available provider (all +// enabled, each with a unique model so routing stays unambiguous), runs proxy + +// client once, and drives the same live chat-completion scenario through each +// provider over the WireGuard tunnel. Each provider must return 200 and produce +// an ingested access-log row. +func TestProvidersMatrix(t *testing.T) { + matrix := availableProviders() + if len(matrix) == 0 { + t.Skip("no provider keys set; source ~/.llm-keys to run the provider matrix") + } + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + + // Group + setup key the client joins into; the policy authorizes it. + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-agents"}) + require.NoError(t, err, "create agents group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + // Create every provider, all enabled, each with a unique model string so the + // proxy's connect-time snapshot carries them all and model→provider routing + // is unambiguous (provider toggles after connect don't reconcile to the + // proxy, so we enable everything up front). + ids := make([]string, 0, len(matrix)) + for _, pc := range matrix { + req := providerRequest(pc) + prov, perr := srv.CreateProvider(ctx, req) + require.NoError(t, perr, "create provider %s", pc.name) + ids = append(ids, prov.Id) + id := prov.Id + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), id) }) + } + + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-allow", + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: ids, + // Token limit at the 60s window floor with caps far above the few hundred + // tokens this suite drives, so it never blocks traffic but switches on + // usage metering, which is what makes consumption rows get recorded. + Limits: &api.AgentNetworkPolicyLimits{ + TokenLimit: api.AgentNetworkPolicyTokenLimit{ + Enabled: true, + GroupCap: 10_000_000, + UserCap: 10_000_000, + WindowSeconds: 60, + }, + }, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings for endpoint") + require.NotEmpty(t, settings.Endpoint, "agent-network endpoint must be assigned") + + // Proxy (global CLI token) + client, brought up once. + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-proxy") + require.NoError(t, err, "mint proxy token via CLI") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + // Probe first: the GET resolves the endpoint (DNS error fails) and its first packet wakes the lazy proxy peer, so WaitProxyPeer sees it connected; any HTTP status counts. + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve agent-network endpoint to proxy IP") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + + for _, pc := range matrix { + pc := pc + t.Run(pc.name, func(t *testing.T) { + before, _ := srv.ListAccessLogs(ctx) + + // Unique per provider so we can find this provider's row by its + // session id and confirm the marker propagated end-to-end. + sessionID := "e2e-session-" + pc.name + + // A long-form prompt so completions carry realistic token counts for cost validation; + // max_tokens in the harness bodies (2048) lets the full answer through. + const matrixPrompt = "explain GitHub workflow in 1000 words" + + // Retry briefly to absorb tunnel/DNS jitter on the first call. + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + var c int + var b string + var cerr error + switch pc.kind { + case harness.WireVertex: + c, b, cerr = cl.Vertex(ctx, settings.Endpoint, proxyIP, pc.project, pc.region, pc.model, matrixPrompt, sessionID) + case harness.WireBedrock: + c, b, cerr = cl.Bedrock(ctx, settings.Endpoint, proxyIP, pc.model, matrixPrompt, sessionID) + default: + c, b, cerr = cl.ChatPrefixed(ctx, settings.Endpoint, proxyIP, pc.pathPrefix, pc.kind, pc.model, matrixPrompt, sessionID) + } + if cerr == nil { + code, body = c, b + if code == 200 { + break + } + } + time.Sleep(5 * time.Second) + } + require.Equal(t, 200, code, "chat through %s (%s %s) should return 200; body: %s", pc.name, pc.kind, pc.model, body) + + require.Eventually(t, func() bool { + logs, lerr := srv.ListAccessLogs(ctx) + return lerr == nil && logs.TotalRecords > before.TotalRecords + }, 30*time.Second, 2*time.Second, "an access-log row should be ingested for %s", pc.name) + + // The session id sent as x-session-id must round-trip into the + // access-log row for this provider. + var row api.AgentNetworkAccessLog + require.Eventually(t, func() bool { + logs, lerr := srv.ListAccessLogs(ctx) + if lerr != nil { + return false + } + for _, r := range logs.Data { + if r.SessionId != nil && *r.SessionId == sessionID { + row = r + return true + } + } + return false + }, 30*time.Second, 2*time.Second, "session id %q must be recorded in an access-log row for %s", sessionID, pc.name) + + // Stored total and cache cost must match the published rates applied to the row's buckets. + validateAccessLogCost(t, pc, row) + }) + } + + // Metering: the policy's uncapped token limit switches on usage recording, + // so the live traffic just driven must surface as consumption rows with + // positive token counts. Consumption is account-scoped (keyed by source + // group / user and time window, not per provider), and ingest is async, so + // poll for any row that has booked tokens. + require.Eventually(t, func() bool { + rows, lerr := srv.ListConsumption(ctx) + if lerr != nil { + return false + } + for _, r := range rows { + if r.TokensInput > 0 && r.TokensOutput > 0 { + return true + } + } + return false + }, 60*time.Second, 3*time.Second, "consumption must be recorded with positive token counts after live traffic") + + // Final raw-SQL audit: bypass the API and re-verify every persisted usage row in the store. + verifyUsageRowsSQL(t, srv) +} diff --git a/e2e/agentnetwork/credential_check_live_test.go b/e2e/agentnetwork/credential_check_live_test.go new file mode 100644 index 000000000..f83a18129 --- /dev/null +++ b/e2e/agentnetwork/credential_check_live_test.go @@ -0,0 +1,235 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "net/http" + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/shared/management/client/rest" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// credentialCase is one vendor to try the save-time check against. The key is +// the real one the suite already sources; corrupting it is what produces the +// refusal, so the pair of cases differ only in the credential. +type credentialCase struct { + name string + catalogID string + upstream string + apiKey string +} + +// liveCredentialCases mirrors the discovery matrix's env gating so a partial +// key set still yields partial coverage. Vertex is left out: its credential is +// a service-account keyfile, and mangling one produces a client-side parse +// failure rather than the vendor refusal this is about. +func liveCredentialCases() []credentialCase { + var cases []credentialCase + + if k := os.Getenv("OPENAI_TOKEN"); k != "" { + cases = append(cases, credentialCase{ + name: "openai", catalogID: "openai_api", + upstream: "https://api.openai.com", apiKey: k, + }) + } + if k := os.Getenv("ANTHROPIC_TOKEN"); k != "" { + cases = append(cases, credentialCase{ + name: "anthropic", catalogID: "anthropic_api", + upstream: "https://api.anthropic.com", apiKey: k, + }) + } + if k := os.Getenv("AWS_BEARER_TOKEN_BEDROCK"); k != "" { + region := os.Getenv("AWS_REGION") + if region == "" { + region = "eu-central-1" + } + cases = append(cases, credentialCase{ + name: "bedrock", catalogID: "bedrock_api", + upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, + }) + } + + return cases +} + +// TestLiveProviderCredentialCheck drives the save-time check against the real +// vendors. A unit test can only assert that a mocked refusal is classified; +// what it cannot show is that these vendors refuse a bad key on their listing +// endpoint at all, which is the assumption the whole feature rests on. +// +// The good-key case matters just as much as the bad one: a check that refused +// everything would pass a test asserting only the refusal, and would make the +// product unusable. +// +// The suite asserts on the vendors themselves, so it inherits their +// availability: the check blocks on 5xx and 429 by design, and a vendor outage +// or a rate limit during a run fails "a good credential saves" with a +// perfectly valid key. There is no retry here on purpose — a retry loop would +// also mask the outage classification these tests exist to prove. Re-run the +// job. +func TestLiveProviderCredentialCheck(t *testing.T) { + cases := liveCredentialCases() + if len(cases) == 0 { + t.Skip("no live provider credentials in the environment; source ~/.llm-keys to run") + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Run("a good credential saves", func(t *testing.T) { + prov, err := srv.CreateProvider(ctx, credentialProviderRequest(tc, "e2e-cred-ok-"+tc.name, tc.apiKey)) + require.NoError(t, err, "the suite's own credential must pass its check") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + require.NotEmpty(t, prov.Id) + }) + + t.Run("a rejected credential is refused", func(t *testing.T) { + _, err := srv.CreateProvider(ctx, credentialProviderRequest(tc, "e2e-cred-bad-"+tc.name, corrupt(tc.apiKey))) + require.Error(t, err, "a key the vendor rejects must not save") + + var apiErr *rest.APIError + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusUnprocessableEntity, apiErr.StatusCode, + "a refused credential is the caller's problem to fix, not a server fault") + require.Contains(t, strings.ToLower(apiErr.Message), "rejected the credential", + "the message must name the credential rather than the url") + + // The record must be absent, not merely unusable: a provider + // saved despite its check is the state this prevents. + all, listErr := srv.ListProviders(ctx) + require.NoError(t, listErr) + for _, p := range all { + require.NotEqual(t, "e2e-cred-bad-"+tc.name, p.Name, "a refused provider must not be stored") + } + }) + }) + } +} + +// TestLiveProviderUrlCheck points a real credential at a host that is not the +// vendor's API. It is the half of the split a wrong key cannot exercise: the +// operator has to be told the URL is at fault while their key is fine. +// +// One vendor, deliberately. The transport classification under test happens +// before any vendor is reached, so running it per configured vendor would +// repeat the same code path and multiply the wall-clock of a suite that +// already creates real records. cases[0] is whichever vendor the environment +// supplies first. +func TestLiveProviderUrlCheck(t *testing.T) { + cases := liveCredentialCases() + if len(cases) == 0 { + t.Skip("no live provider credentials in the environment; source ~/.llm-keys to run") + } + tc := cases[0] + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + // A name that resolves nowhere. The check has to reach a verdict without + // the vendor's help, which is the transport half of the classification. + req := credentialProviderRequest(tc, "e2e-cred-badurl", tc.apiKey) + req.UpstreamUrl = "https://not-a-real-vendor-host.netbird-e2e.invalid" + + _, err := srv.CreateProvider(ctx, req) + require.Error(t, err, "an upstream that does not resolve must not save") + + var apiErr *rest.APIError + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusUnprocessableEntity, apiErr.StatusCode) + require.Contains(t, strings.ToLower(apiErr.Message), "could not be reached", + "the message must name the url rather than the credential") + + // An error is not the same fact as an absent record: a handler that saved + // first and reported afterwards would satisfy everything above. + all, listErr := srv.ListProviders(ctx) + require.NoError(t, listErr) + for _, p := range all { + require.NotEqual(t, "e2e-cred-badurl", p.Name, "a refused provider must not be stored") + } +} + +// TestLiveProviderUpdateKeepsTheWorkingKey is the state the check exists to +// prevent on the update path: a rejected rotation that has already replaced +// the credential would take a working provider down. +// +// Also one vendor: the behaviour is in the manager's merge, not in any +// vendor's response, and each run creates and mutates a real provider record. +func TestLiveProviderUpdateKeepsTheWorkingKey(t *testing.T) { + cases := liveCredentialCases() + if len(cases) == 0 { + t.Skip("no live provider credentials in the environment; source ~/.llm-keys to run") + } + tc := cases[0] + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + prov, err := srv.CreateProvider(ctx, credentialProviderRequest(tc, "e2e-cred-rotate", tc.apiKey)) + require.NoError(t, err) + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + rotation := credentialProviderRequest(tc, "e2e-cred-rotate", corrupt(tc.apiKey)) + _, err = srv.UpdateProvider(ctx, prov.Id, rotation) + require.Error(t, err, "a rotation the vendor rejects must not be stored") + + var apiErr *rest.APIError + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusUnprocessableEntity, apiErr.StatusCode) + + // The stored key is never returned by the API, so the proof that it + // survived is that an edit which reuses it still passes its check. A + // replaced key would fail here exactly as the rotation just did. + // + // The trailing slash is what makes that an actual check: an edit touching + // neither the url, the key nor the catalog entry is stored without asking + // the vendor anything, so a rename alone would pass whatever is on the + // record. Only the host is read out of the upstream, so the same vendor is + // reached — but the string differs, and the check runs. + recheck := credentialProviderRequest(tc, "e2e-cred-rotate-renamed", "") + recheck.UpstreamUrl = tc.upstream + "/" + updated, err := srv.UpdateProvider(ctx, prov.Id, recheck) + require.NoError(t, err, "the working key must still be the stored one") + require.Equal(t, "e2e-cred-rotate-renamed", updated.Name) +} + +// credentialProviderRequest builds a create/update body for a case. An empty apiKey is +// omitted rather than sent blank, which is how the form asks to keep whatever +// is already stored. +func credentialProviderRequest(tc credentialCase, name, apiKey string) api.AgentNetworkProviderRequest { + req := api.AgentNetworkProviderRequest{ + Name: name, + ProviderId: tc.catalogID, + UpstreamUrl: tc.upstream, + Enabled: ptr(true), + } + if apiKey != "" { + req.ApiKey = &apiKey + } + return req +} + +// corrupt returns a key the vendor will reject while keeping the shape of the +// original. Replacing the last character rather than appending keeps any +// length or prefix validation satisfied, so the refusal comes from the vendor +// checking the secret rather than from it rejecting an obviously malformed +// one. +func corrupt(key string) string { + if key == "" { + return key + } + last := key[len(key)-1] + replacement := byte('A') + if last == 'A' { + replacement = 'B' + } + return key[:len(key)-1] + string(replacement) +} diff --git a/e2e/agentnetwork/custom_pricing_test.go b/e2e/agentnetwork/custom_pricing_test.go new file mode 100644 index 000000000..90e198d3d --- /dev/null +++ b/e2e/agentnetwork/custom_pricing_test.go @@ -0,0 +1,776 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "fmt" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// The mock vLLM upstream (harness/vllm.go) always answers with this fixed usage +// block, so every request drives deterministic token counts regardless of the +// model the client asks for. The proxy prices off the REQUEST model, not the +// upstream response model, so a made-up model id billed at operator rates lets +// these tests assert exact costs without a real vendor key. +// Sourced from the harness so the counts can't drift from the mock's config. +const ( + vllmPromptTokens = harness.VLLMChatInputTokens + vllmCompletionTokens = harness.VLLMChatOutputTokens +) + +// pricedEnv is a connected single-provider agent-network deployment pointed at +// the mock vLLM upstream, with the proxy and client up and the endpoint resolved +// — ready to drive chat. All containers are torn down via t.Cleanup. +type pricedEnv struct { + providerID string + groupID string // source group of the policy; the client peer's auto-group + policyID string // policy that authorises (and meters) the requests + upstream string // provider upstream URL, needed to re-send on a PUT update + endpoint string + proxyIP string + client *harness.Client + proxy *harness.Proxy +} + +// provisionPricedProvider brings up the full path for a cost test: a mock vLLM +// upstream, a group + reusable setup key, one openai_api provider pointed at the +// mock enumerating exactly the given models (with the operator's per-1k prices), +// a policy whose token limit switches on usage metering, and a connected proxy + +// client. The provider is created with the given models so the router dispatches +// them to this provider and the cost meter bills at these rates. +// +// Passing nil models makes it a gateway-style catch-all: the router claims every +// model, and since the synthesizer ships no per-provider-record pricing entry +// for a provider that enumerates nothing, the shipped defaults table is the only +// thing that can price the request. The policy sets no model guardrail, so the +// proxy's per-provider allowlist backstop stays empty and any model routes. +func provisionPricedProvider(t *testing.T, ctx context.Context, name string, models []api.AgentNetworkProviderModel) pricedEnv { + t.Helper() + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock vLLM upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-price-" + name}) + require.NoError(t, err, "create group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-price-" + name + "-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + // The mock ignores auth, so a dummy key satisfies the "Bearer ${API_KEY}" + // template. openai_api is a known catalog provider; the enumerated model id + // need NOT be in the catalog — the operator names it and prices it here. + dummyKey := "sk-price-e2e" + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: name, + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &dummyKey, + Enabled: ptr(true), + Models: &models, + }) + require.NoError(t, err, "create provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + // Uncapped token limit: never blocks the handful of tokens driven here, but + // switches on usage metering — the switch that makes consumption rows record. + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-price-" + name, + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + Limits: &api.AgentNetworkPolicyLimits{ + TokenLimit: api.AgentNetworkPolicyTokenLimit{ + Enabled: true, + GroupCap: 10_000_000, + UserCap: 10_000_000, + WindowSeconds: 60, + }, + }, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-price-"+name+"-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + // Probe first: the GET resolves the endpoint and its first packet wakes the + // lazy proxy peer, so WaitProxyPeer then observes it connected. + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve endpoint to proxy IP") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + + return pricedEnv{ + providerID: prov.Id, + groupID: grp.Id, + policyID: pol.Id, + upstream: vllm.URL, + endpoint: settings.Endpoint, + proxyIP: proxyIP, + client: cl, + proxy: px, + } +} + +// chatOnce drives one OpenAI-shaped chat for model through the tunnel, retrying +// to absorb first-call tunnel/DNS jitter, and returns the response body. +func chatOnce(t *testing.T, ctx context.Context, env pricedEnv, model, sessionID string) string { + t.Helper() + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + c, b, cerr := env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireChat, model, "Reply with exactly: pong", sessionID) + if cerr == nil { + code, body = c, b + if code == 200 { + break + } + } + if !waitBeforeRetry(ctx, 5*time.Second) { + break + } + } + require.Equal(t, 200, code, + "chat for %s must return 200; body: %s\n=== proxy logs ===\n%s", model, body, env.proxy.Logs(context.Background())) + return body +} + +// accessLogIngestWindow is how long a single request's access-log row is given +// to appear before the caller gives up on it. +// accessLogIngestWindow bounds how long a row may take to appear after its +// request returned. The proxy streams each entry to management with a 10s send +// timeout of its own, so a request whose send hits one full timeout and is +// retried has not yet missed anything real — 30s left barely three send +// attempts of headroom and lost the race on a loaded runner. +const accessLogIngestWindow = 60 * time.Second + +// accessLogPollInterval is how long the lookup waits between pages. Ingest is +// asynchronous, so the row lands somewhere inside the window rather than on +// any particular poll. +const accessLogPollInterval = 2 * time.Second + +// lookupAccessLogBySession polls the access-log page for the row carrying +// sessionID and reports whether it arrived within the window. It never fails +// the test: callers that can recover — by firing a fresh request under a new +// session — need to see the miss rather than die on it. +func lookupAccessLogBySession(ctx context.Context, sessionID string, within time.Duration) (api.AgentNetworkAccessLog, bool) { + deadline := time.Now().Add(within) + for { + // Each poll is bounded by what is left of the window rather than by the + // caller's context: a single stalled request would otherwise hold the + // loop open long past the ingest window it is meant to enforce, and the + // caller would read the delay as a missing row. + if logs, lerr := listAccessLogsBy(ctx, deadline); lerr == nil { + for _, r := range logs.Data { + if r.SessionId != nil && *r.SessionId == sessionID { + return r, true + } + } + } + // The wait is bounded by the window as well, so the answer arrives when + // the caller's budget runs out rather than a poll interval later: a + // full interval slept past the deadline reports "no row" up to two + // seconds late, which reads as a slower lookup than the one asked for. + wait := time.Until(deadline) + if wait > accessLogPollInterval { + wait = accessLogPollInterval + } + if wait <= 0 { + return api.AgentNetworkAccessLog{}, false + } + timer := time.NewTimer(wait) + select { + case <-ctx.Done(): + timer.Stop() + return api.AgentNetworkAccessLog{}, false + case <-timer.C: + } + // Checked after the wait rather than before the request: a poll issued + // past the deadline carries no budget and would fail on arrival. + if !time.Now().Before(deadline) { + return api.AgentNetworkAccessLog{}, false + } + } +} + +// listAccessLogsBy fetches one access-log page under a context that expires at +// deadline, so no single call can outlive the window its caller is polling +// within. The parent's cancellation still applies: the child inherits it. +func listAccessLogsBy(ctx context.Context, deadline time.Time) (api.AgentNetworkAccessLogsResponse, error) { + reqCtx, cancel := context.WithDeadline(ctx, deadline) + defer cancel() + return srv.ListAccessLogs(reqCtx) +} + +// findAccessLogBySession polls the access-log page for the row carrying +// sessionID, failing the test if it never lands. Use it for a request whose row +// must exist; where a missing row is a recoverable race, use +// lookupAccessLogBySession and retry. +func findAccessLogBySession(t *testing.T, ctx context.Context, sessionID string) api.AgentNetworkAccessLog { + t.Helper() + row, ok := lookupAccessLogBySession(ctx, sessionID, accessLogIngestWindow) + require.True(t, ok, "session id %q must be recorded in an access-log row", sessionID) + return row +} + +// assertOpenAICostAtRates asserts an access-log row's token counts and every cost +// bucket match the mock's fixed usage priced at the given operator rates. The +// openai surface has no cache-write bucket and the mock reports no cache tokens, +// so the whole cost is input + output; cache costs must be exactly zero. +func assertOpenAICostAtRates(t *testing.T, row api.AgentNetworkAccessLog, inRate, outRate float64) { + t.Helper() + wantInput := float64(vllmPromptTokens) / 1000 * inRate + wantOutput := float64(vllmCompletionTokens) / 1000 * outRate + wantTotal := wantInput + wantOutput + + model := "" + if row.Model != nil { + model = *row.Model + } + t.Logf("[cost] model=%s in=%d out=%d rates in/out=%.4f/%.4f stored input/output/total=$%.6f/$%.6f/$%.6f expected input/output/total=$%.6f/$%.6f/$%.6f", + model, row.InputTokens, row.OutputTokens, inRate, outRate, + row.InputCostUsd, row.OutputCostUsd, row.CostUsd, wantInput, wantOutput, wantTotal) + + assert.EqualValues(t, vllmPromptTokens, row.InputTokens, "prompt tokens from the mock usage block") + assert.EqualValues(t, vllmCompletionTokens, row.OutputTokens, "completion tokens from the mock usage block") + assert.InDeltaf(t, wantInput, row.InputCostUsd, 1e-6, "input_cost_usd must be prompt tokens at the operator input rate") + assert.InDeltaf(t, wantOutput, row.OutputCostUsd, 1e-6, "output_cost_usd must be completion tokens at the operator output rate") + assert.InDeltaf(t, wantTotal, row.CostUsd, 1e-6, "cost_usd must be the sum of the priced buckets") + assert.Zerof(t, row.CachedInputCostUsd, "no cache-read tokens, so cached_input_cost_usd must be 0") + assert.Zerof(t, row.CacheCreationCostUsd, "openai surface has no cache-write bucket, so cache_creation_cost_usd must be 0") + assert.Zerof(t, row.CacheCostUsd, "no cache usage, so cache_cost_usd must be 0") + assert.InDeltaf(t, row.InputCostUsd+row.OutputCostUsd, row.CostUsd, 1e-9, "stored buckets must sum to cost_usd") +} + +// verifyUsageRowForSession re-checks the persisted usage row for a session +// directly in the management sqlite store — the same audit an operator runs on a +// production store.db — asserting its cost buckets match the operator rates. +func verifyUsageRowForSession(t *testing.T, sessionID string, inRate, outRate float64) { + t.Helper() + dbPath, err := srv.SnapshotStoreDB(t.TempDir()) + require.NoError(t, err, "snapshot management sqlite store") + + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + require.NoError(t, err, "open store snapshot") + sqlDB, err := db.DB() + require.NoError(t, err) + defer func() { _ = sqlDB.Close() }() + + var provider, model string + var inTok, outTok, cachedTok, cacheCreateTok int64 + var inCost, cachedInCost, cacheCreateCost, outCost float64 + row := db.Raw(`SELECT provider, model, input_tokens, output_tokens, cached_input_tokens, cache_creation_tokens, + input_cost_usd, cached_input_cost_usd, cache_creation_cost_usd, output_cost_usd + FROM agent_network_request_usage WHERE session_id = ? ORDER BY timestamp DESC LIMIT 1`, sessionID).Row() + require.NoError(t, row.Scan(&provider, &model, &inTok, &outTok, &cachedTok, &cacheCreateTok, + &inCost, &cachedInCost, &cacheCreateCost, &outCost), + "a usage row must exist for session %q", sessionID) + + wantInput := float64(inTok) / 1000 * inRate + wantOutput := float64(outTok) / 1000 * outRate + t.Logf("[sql] session=%s %s/%s in=%d out=%d stored input/cached/create/output=$%.6f/$%.6f/$%.6f/$%.6f", + sessionID, provider, model, inTok, outTok, inCost, cachedInCost, cacheCreateCost, outCost) + assert.EqualValues(t, vllmPromptTokens, inTok, "usage row prompt tokens") + assert.EqualValues(t, vllmCompletionTokens, outTok, "usage row completion tokens") + assert.InDeltaf(t, wantInput, inCost, 1e-6, "usage input_cost_usd must be prompt tokens at the operator input rate") + assert.InDeltaf(t, wantOutput, outCost, 1e-6, "usage output_cost_usd must be completion tokens at the operator output rate") + assert.Zerof(t, cachedInCost, "usage cached_input_cost_usd must be 0 (no cache usage)") + assert.Zerof(t, cacheCreateCost, "usage cache_creation_cost_usd must be 0 (no cache usage)") +} + +// TestCustomModelPricing proves an operator can serve a model that is NOT in +// NetBird's compiled catalog, at prices they type themselves, and that those +// operator prices drive the recorded cost end to end — access log AND usage +// ledger. The provider enumerates one made-up model id at deliberately odd rates +// (no default entry could supply them), the client requests it, and every cost +// bucket must equal the mock's fixed token counts multiplied by those rates. +func TestCustomModelPricing(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + const ( + customModel = "e2e-custom-model" // absent from the compiled catalog + inRate = 0.037 // odd rates so a stray default can't match + outRate = 0.089 + ) + + env := provisionPricedProvider(t, ctx, "custommodel", []api.AgentNetworkProviderModel{ + {Id: customModel, InputPer1k: inRate, OutputPer1k: outRate}, + }) + + sessionID := "e2e-session-custommodel" + body := chatOnce(t, ctx, env, customModel, sessionID) + require.Contains(t, body, "chat.completion", "body should be an OpenAI-compatible completion; got: %s", body) + + row := findAccessLogBySession(t, ctx, sessionID) + require.NotNil(t, row.Model, "access-log row must carry the requested model") + assert.Equal(t, customModel, *row.Model, "the row must be stamped with the requested (custom) model, not the mock's response model") + assertOpenAICostAtRates(t, row, inRate, outRate) + + // Metering: the uncapped token limit switches on usage recording, so the + // request must surface as a consumption row with positive tokens and cost. + require.Eventually(t, func() bool { + rows, lerr := srv.ListConsumption(ctx) + if lerr != nil { + return false + } + for _, r := range rows { + if r.TokensInput > 0 && r.TokensOutput > 0 && r.CostUsd > 0 { + return true + } + } + return false + }, 60*time.Second, 3*time.Second, "custom-model usage must be metered into a consumption row with positive cost") + + // Final raw-SQL audit: bypass the API and re-verify the persisted usage row. + verifyUsageRowForSession(t, sessionID, inRate, outRate) +} + +// TestPriceChangeUpdatesRecordedCost proves that changing a provider's model +// price is reflected in the cost recorded for subsequent requests — in both the +// access log and the usage ledger — while requests already priced at the old +// rate keep their original cost. The update propagates to the connected proxy +// live (a mapping push rebuilds the cost_meter chain with the new table), so no +// reconnect or restart is needed; the test polls a fresh request until the new +// rate lands. +func TestPriceChangeUpdatesRecordedCost(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + const ( + customModel = "e2e-repriced-model" + inRateA = 0.010 + outRateA = 0.020 + inRateB = 0.050 // 5x / 4x the original, so a repriced row is unmistakable + outRateB = 0.080 + // Per-attempt ingest wait, shorter than the default so a request that + // produces no row costs one retry rather than most of the budget, and an + // overall deadline long enough to hold several attempts. + repriceIngestWindow = 20 * time.Second + repriceDeadline = 180 * time.Second + ) + + env := provisionPricedProvider(t, ctx, "reprice", []api.AgentNetworkProviderModel{ + {Id: customModel, InputPer1k: inRateA, OutputPer1k: outRateA}, + }) + + // Phase 1 — request priced at the original rate A. + sessionA := "e2e-session-reprice-a" + chatOnce(t, ctx, env, customModel, sessionA) + rowA := findAccessLogBySession(t, ctx, sessionA) + assertOpenAICostAtRates(t, rowA, inRateA, outRateA) + verifyUsageRowForSession(t, sessionA, inRateA, outRateA) + + // Change the model's price. The API key is omitted so the stored one is kept; + // the models array is re-sent with the new rates (PUT replaces the list). + // This reconciles synchronously and pushes a fresh cost_meter table to the + // already-connected proxy — no reconnect. + _, err := srv.UpdateProvider(ctx, env.providerID, api.AgentNetworkProviderRequest{ + Name: "reprice", + ProviderId: "openai_api", + UpstreamUrl: env.upstream, + Enabled: ptr(true), + Models: &[]api.AgentNetworkProviderModel{ + {Id: customModel, InputPer1k: inRateB, OutputPer1k: outRateB}, + }, + }) + require.NoError(t, err, "update provider price") + + // Phase 2 — the push + chain rebuild is async, so drive fresh requests (each + // under its own session) until one is priced at the new rate B. Each iteration + // fires one request and waits for that session's row to be ingested before + // reading its cost, so an un-ingested row is never mistaken for "still rate A". + // The expected new input cost is unmistakably higher than rate A, so a + // lingering old-rate row can't satisfy the check. + // + // Every way an iteration can come up short — the request failing, its row not + // landing, or the row still carrying rate A — is a symptom of the same + // in-flight rebuild, so each one retries under a fresh session rather than + // ending the test. Only the outer deadline is fatal. + wantInputB := float64(vllmPromptTokens) / 1000 * inRateB + var repriced api.AgentNetworkAccessLog + var lastSession string + // The cost last read, kept separately: repriced is the zero value on every + // path that gives up, so reporting its cost would say "$0.000000" whether + // the rows were still at rate A or no row was ever read. + var lastCost float64 + var sawRow bool + deadline := time.Now().Add(repriceDeadline) + // Everything inside the loop runs under the deadline rather than the + // test's own context. An attempt started just before it would otherwise + // run well past it: the chat container is capped at 90s of its own and the + // row lookup at another 20s, so the loop could report a repricing failure + // nearly two minutes after the window it was given had closed. + repriceCtx, cancelReprice := context.WithDeadline(ctx, deadline) + defer cancelReprice() + for time.Now().Before(deadline) { + lastSession = fmt.Sprintf("e2e-session-reprice-b-%d", time.Now().UnixNano()) + code, _, cerr := env.client.Chat(repriceCtx, env.endpoint, env.proxyIP, harness.WireChat, customModel, "Reply with exactly: pong", lastSession) + if cerr != nil || code != 200 { + if !waitBeforeRetry(repriceCtx, 5*time.Second) { + break + } + continue + } + row, ok := lookupAccessLogBySession(repriceCtx, lastSession, repriceIngestWindow) + if !ok { + // No row for this request. The proxy now publishes a rebuilt chain + // before the route that reaches it, so a request can no longer be + // served unattributed mid-update; this retry covers the ingest + // window alone. Fire another one under a fresh session. + t.Logf("no access-log row for session %q within %s; retrying under a fresh session", lastSession, repriceIngestWindow) + continue + } + if inDelta(row.InputCostUsd, wantInputB, 1e-6) { + repriced = row + break + } + // Still priced at the old rate — the push hasn't landed yet; retry. + lastCost, sawRow = row.InputCostUsd, true + if !waitBeforeRetry(repriceCtx, 5*time.Second) { + break + } + } + lastSeen := "no row was ever read" + if sawRow { + lastSeen = fmt.Sprintf("last input_cost_usd=$%.6f", lastCost) + } + require.NotEmpty(t, repriced.Id, "a request after the price change must be priced at the new rate B; %s, wanted $%.6f\n=== proxy logs ===\n%s", + lastSeen, wantInputB, env.proxy.Logs(context.Background())) + + assertOpenAICostAtRates(t, repriced, inRateB, outRateB) + verifyUsageRowForSession(t, lastSession, inRateB, outRateB) + + // The original request keeps its original cost: repricing is not retroactive. + rowAStill := findAccessLogBySession(t, ctx, sessionA) + assertOpenAICostAtRates(t, rowAStill, inRateA, outRateA) + verifyUsageRowForSession(t, sessionA, inRateA, outRateA) +} + +// TestPricingDefaultsFileDrivesCost proves the operator-supplied pricing +// defaults file is what the proxy bills with. The harness configures +// server.agentNetwork.pricingDefaultsFile as a BARE FILENAME and writes that +// file into the bind-mounted datadir (see harness.PricingDefaultsFileName), so a +// pass exercises the whole chain: combined yaml → ToManagementConfig → +// pricing.LoadFile (relative path resolved against datadir) → DefaultTable → +// the synthesizer's cost_meter defaults payload → the proxy's lookup. +// +// The provider enumerates NO models, so it is a catch-all route with no +// per-provider-record pricing entry at all — the only rates that can price the +// request are the shipped defaults. The model is a real catalog model whose +// built-in rates the file replaces with deliberately odd values, so billing at +// the compiled-in rates (i.e. the file never loaded) fails the assertions. +func TestPricingDefaultsFileDrivesCost(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + // nil models: a gateway-style provider claiming every model. The synthesizer + // ships no per-record entry for it, so the defaults table is its price list. + env := provisionPricedProvider(t, ctx, "defaultsfile", nil) + + sessionID := "e2e-session-defaultsfile" + body := chatOnce(t, ctx, env, harness.PricedDefaultModel, sessionID) + require.Contains(t, body, "chat.completion", "body should be an OpenAI-compatible completion; got: %s", body) + + row := findAccessLogBySession(t, ctx, sessionID) + require.NotNil(t, row.Model, "access-log row must carry the requested model") + assert.Equal(t, harness.PricedDefaultModel, *row.Model, "the row must be stamped with the requested model") + + // The file's rates, not the compiled-in catalog rates for this model. + assertOpenAICostAtRates(t, row, harness.PricedDefaultInputPer1k, harness.PricedDefaultOutputPer1k) + verifyUsageRowForSession(t, sessionID, harness.PricedDefaultInputPer1k, harness.PricedDefaultOutputPer1k) +} + +// TestPricingDefaultsFileLeavesOtherModelsAlone proves the defaults file merges +// per entry rather than replacing the whole table: the file names exactly one +// model, so a DIFFERENT catalog model must still bill at its compiled-in rates. +// Without this, a file that shipped as a wholesale replacement would silently +// zero-cost every model the operator didn't list, and TestPricingDefaultsFile- +// DrivesCost alone would not notice. +func TestPricingDefaultsFileLeavesOtherModelsAlone(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + // gpt-4o-mini is a catalog model the pricing file does NOT mention, so it must + // keep its built-in rates. Pinned here independently of the catalog source so + // a rate change in either place surfaces as a failure to reconcile rather + // than passing silently. + const ( + untouchedModel = "gpt-4o-mini" + builtinInRate = 0.00015 + builtinOutRate = 0.0006 + ) + + env := provisionPricedProvider(t, ctx, "defaultsfileother", nil) + + sessionID := "e2e-session-defaultsfile-other" + chatOnce(t, ctx, env, untouchedModel, sessionID) + + row := findAccessLogBySession(t, ctx, sessionID) + assertOpenAICostAtRates(t, row, builtinInRate, builtinOutRate) + verifyUsageRowForSession(t, sessionID, builtinInRate, builtinOutRate) +} + +// TestCustomModelAccessLogAttribution proves a custom (non-catalog) model is +// handled correctly in the ACCESS LOG, not just in the cost columns. The other +// tests here assert money; this one asserts the row's identity and attribution +// dimensions — the columns the dashboard filters, groups and drills down on. +// +// A custom model id is the interesting case precisely because nothing in +// NetBird's catalog describes it. Its provider vendor, parser surface, cost +// buckets, and dashboard filterability all have to come from the operator's +// provider record rather than from a compiled-in entry. So this checks: +// +// - the row is stamped with the REQUESTED model id verbatim, not the mock +// upstream's response model (Qwen/Qwen2.5-0.5B-Instruct) and not a +// normalized or catalog-substituted id; +// - provider is the vendor SURFACE ("openai", from the catalog entry's +// ParserID) — a custom model does not change which wire shape was spoken; +// - resolved_provider_id / selected_policy_id / group_ids attribute the row to +// the operator's provider record, the authorising policy, and the caller's +// group, so spend on a custom model is attributable; +// - decision is "allow" with no deny reason, and the request dimensions +// (status 200, POST, the OpenAI chat path, non-stream, source IP, duration) +// are recorded; +// - management's SERVER-SIDE model filter finds the row by its custom id, so +// the model column is genuinely indexed and queryable rather than merely +// stored; +// - prompt/completion capture stays empty, since prompt collection is off by +// default and a custom model must not bypass that gate. +func TestCustomModelAccessLogAttribution(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + // A model id no catalog entry carries, at odd rates so its cost cannot come + // from anywhere but the provider record. + const ( + customModel = "e2e-attribution-model-v9" + inRate = 0.0271 + outRate = 0.0913 + ) + + env := provisionPricedProvider(t, ctx, "attribution", []api.AgentNetworkProviderModel{ + {Id: customModel, InputPer1k: inRate, OutputPer1k: outRate}, + }) + + sessionID := "e2e-session-attribution" + body := chatOnce(t, ctx, env, customModel, sessionID) + require.Contains(t, body, "chat.completion", "body should be an OpenAI-compatible completion; got: %s", body) + + row := findAccessLogBySession(t, ctx, sessionID) + + // Identity: the requested model verbatim. The mock answers with its own + // served model id, so a row carrying that instead means the log is sourced + // from the response body rather than the parsed request. + require.NotNil(t, row.Model, "access-log row must carry the requested model") + assert.Equal(t, customModel, *row.Model, + "the row must be stamped with the requested custom model id verbatim, not the mock upstream's response model (%s)", harness.VLLMModel) + + // Surface: a custom model id does not change the wire shape that was spoken. + // provider is the vendor surface from the catalog entry's parser, which is + // also the key the cost meter's cache formula switches on. + require.NotNil(t, row.Provider, "access-log row must carry the vendor surface") + assert.Equal(t, "openai", *row.Provider, + "openai_api's parser surface is openai, regardless of how exotic the model id is") + + // Attribution: which provider record served it, which policy authorised it, + // and which group the authorisation came through. Without these, spend on a + // custom model can be seen but not attributed. + require.NotNil(t, row.ResolvedProviderId, "row must name the provider record that served the request") + assert.Equal(t, env.providerID, *row.ResolvedProviderId, + "the router stamps the operator's provider record id; a custom model must attribute to the record that enumerated it") + require.NotNil(t, row.SelectedPolicyId, "row must name the policy that authorised the request") + assert.Equal(t, env.policyID, *row.SelectedPolicyId, + "the policy carrying the token limit is the one that paid for the request") + require.NotNil(t, row.GroupIds, "row must carry the authorising group ids") + assert.Contains(t, *row.GroupIds, env.groupID, + "the caller's group is the policy's source group, so it must be the authorising group") + + // Decision + request dimensions. + require.NotNil(t, row.Decision, "row must carry the policy decision") + assert.Equal(t, "allow", *row.Decision, "the uncapped policy allows this request") + if row.DenyReason != nil { + assert.Empty(t, *row.DenyReason, "an allowed request must carry no deny reason") + } + assert.Equal(t, 200, row.StatusCode, "the mock upstream answers 200") + if row.Method != nil { + assert.Equal(t, "POST", *row.Method, "a chat completion is a POST") + } + require.NotNil(t, row.Path, "row must record the request path") + assert.Equal(t, "/v1/chat/completions", *row.Path, + "the OpenAI chat path the client called, as seen by the proxy") + require.NotNil(t, row.Host, "row must record the host the client addressed") + assert.Equal(t, env.endpoint, *row.Host, "the agent-network endpoint the client resolved") + if row.Stream != nil { + assert.False(t, *row.Stream, "the harness sends a non-streaming request") + } + require.NotNil(t, row.SourceIp, "row must record the caller's tunnel IP") + assert.NotEmpty(t, *row.SourceIp, "the request arrived over the tunnel, so a source IP is known") + + // Tokens and cost, so the attribution above is anchored to a real priced row + // rather than an empty shell that happens to carry the right ids. + assertOpenAICostAtRates(t, row, inRate, outRate) + assert.EqualValues(t, vllmPromptTokens+vllmCompletionTokens, row.TotalTokens, + "total_tokens is the mock's reported total") + + // Prompt capture is off by default (account master switch), and a custom + // model must not bypass that gate. + if row.RequestPrompt != nil { + assert.Empty(t, *row.RequestPrompt, "prompt collection is off by default, so no prompt may be stored") + } + if row.ResponseCompletion != nil { + assert.Empty(t, *row.ResponseCompletion, "prompt collection is off by default, so no completion may be stored") + } + + // Queryability: management's SERVER-SIDE model filter must find the row by + // its custom id. findAccessLogBySession above scans a page client-side, so + // this is the check that the model column is actually indexed and filterable + // — the dashboard's per-model drill-down on a custom model depends on it. + filtered, err := srv.ListAccessLogsFiltered(ctx, url.Values{"model": []string{customModel}}) + require.NoError(t, err, "filter access logs by the custom model id") + require.Positive(t, filtered.TotalRecords, "the custom model must be findable via the server-side model filter") + foundSession := false + for _, r := range filtered.Data { + require.NotNil(t, r.Model, "filtered row must carry a model") + assert.Equal(t, customModel, *r.Model, "the model filter must not return rows for other models") + if r.SessionId != nil && *r.SessionId == sessionID { + foundSession = true + } + } + assert.True(t, foundSession, "the filtered page must include this test's request") + + // Final raw-SQL audit of the parallel usage row: the ledger must carry the + // same custom model, surface, and provider-record attribution as the log. + verifyUsageAttributionForSession(t, sessionID, customModel, "openai", env.providerID, env.groupID) +} + +// verifyUsageAttributionForSession checks the usage ledger's attribution columns +// for a session directly in the management sqlite store — including the group +// child row, which the API renders but which only exists if the proxy's +// authorising-group CSV was parsed into normalised rows. The usage table is +// written unconditionally (independent of the log-collection toggle), so this is +// the record that must attribute spend even for accounts with logs off. +func verifyUsageAttributionForSession(t *testing.T, sessionID, wantModel, wantProvider, wantProviderID, wantGroupID string) { + t.Helper() + dbPath, err := srv.SnapshotStoreDB(t.TempDir()) + require.NoError(t, err, "snapshot management sqlite store") + + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + require.NoError(t, err, "open store snapshot") + sqlDB, err := db.DB() + require.NoError(t, err) + defer func() { _ = sqlDB.Close() }() + + var id, provider, model, resolvedProviderID, userID string + require.NoError(t, db.Raw( + `SELECT id, provider, model, resolved_provider_id, user_id + FROM agent_network_request_usage WHERE session_id = ? ORDER BY timestamp DESC LIMIT 1`, sessionID). + Row().Scan(&id, &provider, &model, &resolvedProviderID, &userID), + "a usage row must exist for session %q", sessionID) + + t.Logf("[sql] usage attribution session=%s id=%s provider=%s model=%s resolved_provider_id=%s user_id=%s", + sessionID, id, provider, model, resolvedProviderID, userID) + assert.Equal(t, wantModel, model, "usage row must carry the requested custom model") + assert.Equal(t, wantProvider, provider, "usage row must carry the vendor surface") + assert.Equal(t, wantProviderID, resolvedProviderID, "usage row must attribute to the operator's provider record") + assert.NotEmpty(t, userID, "the tunnel peer resolves to a principal, so the usage row must be attributable to it") + + // The authorising group lands in the normalised child table, which is what + // the usage overview joins on to break spend down by group. + var groupIDs []string + require.NoError(t, db.Raw( + `SELECT group_id FROM agent_network_request_usage_group WHERE usage_id = ?`, id). + Scan(&groupIDs).Error, "read usage group child rows") + assert.Contains(t, groupIDs, wantGroupID, + "the authorising group must be normalised into a usage_group row so spend can be grouped by it") +} + +// inDelta reports whether a and b are within tol of each other. +func inDelta(a, b, tol float64) bool { + d := a - b + if d < 0 { + d = -d + } + return d <= tol +} + +// TestCustomDatedModelKeepsItsOwnPrice covers the review fix that anchored the +// release-date fallback to Claude ids. Pricing looks every model up through +// that helper, so while it matched a bare trailing date any operator id ending +// in eight digits inherited the rate of its undated sibling — a silent +// mis-bill on models NetBird knows nothing about. +func TestCustomDatedModelKeepsItsOwnPrice(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + const ( + baseModel = "internal-llm" + datedModel = "internal-llm-20250101" + baseIn = 0.010 + baseOut = 0.020 + // An order of magnitude apart, so a row billed at the wrong entry is + // unmistakable rather than a rounding argument. + datedIn = 0.100 + datedOut = 0.200 + ) + + env := provisionPricedProvider(t, ctx, "customdated", []api.AgentNetworkProviderModel{ + {Id: baseModel, InputPer1k: baseIn, OutputPer1k: baseOut}, + {Id: datedModel, InputPer1k: datedIn, OutputPer1k: datedOut}, + }) + + t.Run("the undated id bills at its own rate", func(t *testing.T) { + session := fmt.Sprintf("e2e-session-customdated-base-%d", time.Now().UnixNano()) + chatOnce(t, ctx, env, baseModel, session) + assertOpenAICostAtRates(t, findAccessLogBySession(t, ctx, session), baseIn, baseOut) + }) + + t.Run("the dated id keeps its own rate", func(t *testing.T) { + session := fmt.Sprintf("e2e-session-customdated-dated-%d", time.Now().UnixNano()) + chatOnce(t, ctx, env, datedModel, session) + row := findAccessLogBySession(t, ctx, session) + assertOpenAICostAtRates(t, row, datedIn, datedOut) + + // Spelled out because it is the regression: inheriting the sibling's + // rate would bill this request at a tenth of its price. + assert.Greater(t, row.InputCostUsd, float64(vllmPromptTokens)/1000*baseIn*2, + "a custom dated id must not inherit the undated entry's rate") + }) +} diff --git a/e2e/agentnetwork/discovery_live_test.go b/e2e/agentnetwork/discovery_live_test.go new file mode 100644 index 000000000..22c9f31c2 --- /dev/null +++ b/e2e/agentnetwork/discovery_live_test.go @@ -0,0 +1,447 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "encoding/json" + "os" + "sort" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + sharedllm "github.com/netbirdio/netbird/shared/llm" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// TestLiveModelDiscovery drives model discovery against the REAL vendor +// endpoints — OpenAI, Anthropic, Bedrock and Vertex — rather than the mock. +// +// The mock upstream proves the filter's mechanics: it advertises ids we chose, +// so a listing narrowing to the ones we authorised is arithmetic we already +// controlled both sides of. What it cannot prove is that the filter survives +// contact with a real catalogue — ids we never enumerated, dated builds whose +// suffix the vendor picks, surfaces that answer a listing request with +// something other than a listing. That is what this covers, and it is the part +// a QA engineer would otherwise have to walk through by hand. +// +// One proxy serves every case. Each provider gets its own group, policy and +// client, because a model-less request matches exactly ONE route +// (matchModelless): with two providers authorised for the same caller, the +// listing would go to whichever won the tiebreak and the other would go +// untested. Group-scoping the caller makes each provider the only candidate +// for its own client. +func TestLiveModelDiscovery(t *testing.T) { + cases := liveDiscoveryCases() + if len(cases) == 0 { + t.Skip("no provider keys set; source ~/.llm-keys to run live model discovery") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + + t.Logf("[discovery] live matrix: %s", strings.Join(caseNames(cases), ", ")) + + // Provision every provider, group and policy before the proxy starts: the + // proxy takes a configuration snapshot at connect time and does not + // reconcile provider changes made afterwards. + keys := make(map[string]string, len(cases)) + for i := range cases { + keys[cases[i].name] = provisionLiveDiscovery(t, ctx, &cases[i]) + } + + endpoint, firstIP, firstClient, px := connectClient(t, ctx, "disc-live", keys[cases[0].name]) + clients := map[string]*harness.Client{cases[0].name: firstClient} + ips := map[string]string{cases[0].name: firstIP} + for _, tc := range cases[1:] { + cl := joinClient(t, ctx, px, endpoint, keys[tc.name]) + ip, err := cl.ResolveProxyIP(ctx, endpoint) + require.NoError(t, err, "resolve endpoint from the %s client", tc.name) + clients[tc.name] = cl + ips[tc.name] = ip + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + runLiveDiscoveryCase(t, ctx, tc, clients[tc.name], endpoint, ips[tc.name]) + }) + } +} + +// discoveryOutcome is what a discovery request must produce end to end. The +// three are genuinely different contracts, not degrees of success: only the +// first puts a bounded listing in front of the caller. +type discoveryOutcome int + +const ( + // outcomeFiltered: the proxy routes the request and bounds the response to + // what the caller may use. + outcomeFiltered discoveryOutcome = iota + // outcomeDenied: no provider of this shape can serve the surface, so the + // proxy refuses rather than rewriting the request onto an upstream that + // would 404 it. The caller gets a NetBird error, not a vendor one. + outcomeDenied + // outcomeUpstreamNoListing: the proxy routes the request to the configured + // upstream, and the vendor does not implement the endpoint there. Proxy + // side correct, product side a dead end — see the Bedrock case. + outcomeUpstreamNoListing +) + +// liveDiscoveryCase is one provider's discovery surface and what the proxy +// must make of it. +type liveDiscoveryCase struct { + name string + catalogID string + upstream string + apiKey string + + // path is the discovery endpoint the client calls. Not every surface uses + // /v1/models: Bedrock lists inference profiles instead. + path string + // headers the vendor requires on a bare GET (Anthropic versions its API + // through a header, and rejects a request without one). + headers []string + + // models the provider record enumerates. Empty models a gateway record, + // which enumerates nothing and claims everything. + models []string + // allowlist, when non-empty, is a guardrail narrowing the policy below the + // provider's own enumeration — the second of the two bounds discovery + // applies, and the only one a provider record alone cannot demonstrate. + allowlist []string + + // outcome is what this surface must produce end to end. + outcome discoveryOutcome + + // permitted is every id allowed to survive filtering, in the form the + // provider record registers it. A surviving id counts as permitted when it + // matches one of these outright or after Anthropic date-normalisation. + permitted []string + // wantHidden are ids the upstream is known to advertise and the bound must + // remove. Only set where we enumerate the model ourselves, so the + // expectation cannot rot when a vendor changes its catalogue. + wantHidden []string +} + +// liveDiscoveryCases builds the matrix from whichever provider credentials are +// present, mirroring availableProviders' env-var gating so a partial key set +// still yields partial coverage. +func liveDiscoveryCases() []liveDiscoveryCase { + var cases []liveDiscoveryCase + + // OpenAI enumerates TWO real models and the policy permits one. That is + // the only case here where both bounds are observable at once: the + // upstream advertises dozens of ids, the provider record cuts them to two, + // and the guardrail cuts those to one. + if k := os.Getenv("OPENAI_TOKEN"); k != "" { + cases = append(cases, liveDiscoveryCase{ + name: "openai", catalogID: "openai_api", upstream: "https://api.openai.com", apiKey: k, + path: "/v1/models", + models: []string{"gpt-4o-mini", "gpt-4o"}, + allowlist: []string{"gpt-4o-mini"}, + outcome: outcomeFiltered, + permitted: []string{"gpt-4o-mini"}, + wantHidden: []string{"gpt-4o"}, + }) + } + + // Anthropic is the surface Claude Code actually calls. Its listing returns + // DATED build ids (claude-haiku-4-5-20251001) while the provider record + // registers the undated id, so this is the case that proves the filter's + // date-normalisation against ids the vendor chose rather than ids we wrote. + if k := os.Getenv("ANTHROPIC_TOKEN"); k != "" { + cases = append(cases, liveDiscoveryCase{ + name: "anthropic", catalogID: "anthropic_api", upstream: "https://api.anthropic.com", apiKey: k, + path: "/v1/models", + headers: []string{"anthropic-version: 2023-06-01"}, + models: []string{"claude-haiku-4-5"}, + outcome: outcomeFiltered, + permitted: []string{"claude-haiku-4-5"}, + }) + } + + // Bedrock lists inference profiles, not models: matchModelless routes + // /inference-profiles to a Bedrock route and refuses /v1/models for one. + // + // The listing is served by the CONTROL PLANE (bedrock.), not the + // runtime host a provider record must point at for InvokeModel — the + // runtime host answers . The router now sends + // the listing, and only the listing, to the control plane, so this case + // asserts a real filtered listing rather than the 404 it used to get. + // + // The mock upstream cannot show any of this: it answers + // /inference-profiles on the same listener as everything else, so a + // mock-based test passes whichever host the request went to. + if k := os.Getenv("AWS_BEARER_TOKEN_BEDROCK"); k != "" { + region := os.Getenv("AWS_REGION") + if region == "" { + region = "eu-central-1" + } + model := os.Getenv("AWS_BEDROCK_MODEL") + if model == "" { + model = "global.anthropic.claude-sonnet-4-6" + } + cases = append(cases, liveDiscoveryCase{ + name: "bedrock", catalogID: "bedrock_api", + upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, + path: "/inference-profiles", + // Registered verbatim, as an operator would copy it from AWS: the + // region prefix is what makes the id invocable, and the listing + // returns ids in exactly this form. + models: []string{model}, + outcome: outcomeFiltered, + permitted: []string{model}, + }) + } + + // Vertex carries the model in the rawPredict path and serves no listing + // endpoint at all, so the proxy must refuse discovery rather than rewrite + // it onto an upstream that would 404. + if sa := os.Getenv("GOOGLE_VERTEX_SA_BASE64"); sa != "" { + if project := os.Getenv("GOOGLE_VERTEX_PROJECT"); project != "" { + region := os.Getenv("GOOGLE_VERTEX_REGION") + if region == "" { + region = "global" + } + host := "aiplatform.googleapis.com" + if region != "global" { + host = region + "-aiplatform.googleapis.com" + } + cases = append(cases, liveDiscoveryCase{ + name: "vertex", catalogID: "vertex_ai_api", upstream: "https://" + host, + apiKey: "keyfile::" + sa, + path: "/v1/models", + outcome: outcomeDenied, + }) + } + } + + return cases +} + +// provisionLiveDiscovery creates the group, provider, optional guardrail and +// policy for one case, and returns the setup key a client joins that group +// with. Scoping each provider to its own group is what keeps it the only +// candidate for its own client's model-less request. +func provisionLiveDiscovery(t *testing.T, ctx context.Context, tc *liveDiscoveryCase) string { + t.Helper() + + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-disc-live-" + tc.name}) + require.NoError(t, err, "create group for %s", tc.name) + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-disc-live-" + tc.name, + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key for %s", tc.name) + require.NotEmpty(t, sk.Key, "setup key plaintext for %s", tc.name) + + req := api.AgentNetworkProviderRequest{ + Name: "e2e-disc-live-" + tc.name, + ProviderId: tc.catalogID, + UpstreamUrl: tc.upstream, + ApiKey: &tc.apiKey, + Enabled: ptr(true), + } + if len(tc.models) > 0 { + models := make([]api.AgentNetworkProviderModel, 0, len(tc.models)) + for _, id := range tc.models { + models = append(models, api.AgentNetworkProviderModel{Id: id, InputPer1k: 0.001, OutputPer1k: 0.002}) + } + req.Models = &models + } + prov, err := srv.CreateProvider(ctx, req) + require.NoError(t, err, "create provider %s", tc.name) + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + polReq := api.AgentNetworkPolicyRequest{ + Name: "e2e-disc-live-" + tc.name, + Enabled: ptr(true), + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + } + if len(tc.allowlist) > 0 { + var gr api.AgentNetworkGuardrailRequest + gr.Name = "e2e-disc-live-" + tc.name + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = tc.allowlist + g, gerr := srv.CreateGuardrail(ctx, gr) + require.NoError(t, gerr, "create guardrail for %s", tc.name) + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), g.Id) }) + polReq.GuardrailIds = &[]string{g.Id} + } + pol, err := srv.CreatePolicy(ctx, polReq) + require.NoError(t, err, "create policy for %s", tc.name) + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + return sk.Key +} + +// runLiveDiscoveryCase issues the discovery request and reports everything the +// vendor said before asserting on any of it. The log is the point on the first +// run: a live catalogue is the one input we do not control, so a failure has to +// arrive with the response that caused it rather than just a count. +func runLiveDiscoveryCase(t *testing.T, ctx context.Context, tc liveDiscoveryCase, cl *harness.Client, endpoint, proxyIP string) { + t.Helper() + + // A single request is enough for the two non-listing outcomes, and retrying + // them would burn the retry window waiting for a status that is never + // coming. + if tc.outcome != outcomeFiltered { + code, body, err := cl.Get(ctx, endpoint, proxyIP, tc.path, tc.headers) + require.NoError(t, err, "request must reach the proxy") + t.Logf("[discovery] %s GET %s -> %d; body: %s", tc.name, tc.path, code, truncate(body, 2000)) + assert.NotEqual(t, 200, code, + "%s serves no bounded listing, so a 200 here would mean the caller was handed a picker nothing narrows; body: %s", + tc.name, truncate(body, 2000)) + + // Which side refused is the whole distinction between these two + // outcomes, and a NetBird error is the thing that tells them apart: the + // middleware chain stamps its own name on anything it generates. + if tc.outcome == outcomeDenied { + assert.True(t, isProxyError(body), + "%s serves no listing endpoint at all, so the proxy must refuse the request itself rather than forward it to an upstream that would answer for us; body: %s", + tc.name, truncate(body, 2000)) + return + } + assert.False(t, isProxyError(body), + "%s discovery must be routed to the configured upstream and refused by the vendor, not blocked by the proxy; body: %s", + tc.name, truncate(body, 2000)) + return + } + + code, body := callUntil(t, func() (int, string, error) { + return cl.Get(ctx, endpoint, proxyIP, tc.path, tc.headers) + }, 200) + // Status only, not the body. A Bedrock listing embeds inference-profile + // ARNs carrying the 12-digit AWS account id, and these job logs are + // readable by anyone who can see the run. The ids line below is the finding + // anyway. The failure paths below are the same log: a listing that fails to + // arrive is an AWS refusal naming the resource it refused, and that name is + // an ARN carrying the same account id. + t.Logf("[discovery] %s GET %s -> %d", tc.name, tc.path, code) + require.Equal(t, 200, code, "%s discovery must be served; response was %s", tc.name, bodyShape(body)) + + ids, ok := listingIDs(body) + require.Truef(t, ok, + "%s answered discovery with something other than a {\"data\":[{\"id\":…}]} listing, which the filter forwards untouched — the caller would get an unbounded picker; response was %s", + tc.name, bodyShape(body)) + sort.Strings(ids) + t.Logf("[discovery] %s: %d ids after filtering: %s", tc.name, len(ids), strings.Join(ids, ", ")) + + require.NotEmpty(t, ids, "%s filtered the listing down to nothing; the caller would see an empty picker", tc.name) + + permitted := make(map[string]struct{}, len(tc.permitted)*2) + for _, id := range tc.permitted { + permitted[id] = struct{}{} + permitted[sharedllm.NormalizeAnthropicModel(id)] = struct{}{} + } + for _, id := range ids { + _, direct := permitted[id] + _, dated := permitted[sharedllm.NormalizeAnthropicModel(id)] + // Bedrock ids carry a region prefix and version suffix the record may + // not repeat; the proxy's filter tries the same forms. + _, bedrock := permitted[sharedllm.NormalizeBedrockModel(id)] + assert.Truef(t, direct || dated || bedrock, + "%s offered %q, which no policy on this route permits — every entry the picker shows must be a request the guardrail would allow", tc.name, id) + } + for _, hidden := range tc.wantHidden { + assert.NotContainsf(t, ids, hidden, + "%s offered %q, which the provider enumerates but the policy does not permit", tc.name, hidden) + } +} + +// isProxyError reports whether a response body was generated by the middleware +// chain rather than forwarded from a vendor. Every chain-generated error names +// the middleware that raised it, which no upstream's error body does — so this +// separates "the proxy refused" from "the proxy routed it and the vendor +// refused", the two failures that otherwise look alike from the client side. +func isProxyError(body string) bool { + return strings.Contains(body, `"middleware":`) +} + +// listingIDs pulls the model ids out of a listing response. ok is false when +// the body is neither envelope the proxy's filter recognises — the two must +// stay in step, or this test reports "not a listing" for a response the proxy +// filtered perfectly well. +func listingIDs(body string) ([]string, bool) { + var doc struct { + // OpenAI's shape, which Anthropic adopted. + Data []struct { + ID string `json:"id"` + } `json:"data"` + // Bedrock returns inference-profile summaries under a key of its own, + // with the id under a field of its own. + Summaries []struct { + ID string `json:"inferenceProfileId"` + } `json:"inferenceProfileSummaries"` + } + if err := json.Unmarshal([]byte(body), &doc); err != nil { + return nil, false + } + switch { + case doc.Data != nil: + ids := make([]string, 0, len(doc.Data)) + for _, entry := range doc.Data { + ids = append(ids, entry.ID) + } + return ids, true + case doc.Summaries != nil: + ids := make([]string, 0, len(doc.Summaries)) + for _, entry := range doc.Summaries { + ids = append(ids, entry.ID) + } + return ids, true + } + return nil, false +} + +func caseNames(cases []liveDiscoveryCase) []string { + names := make([]string, 0, len(cases)) + for _, c := range cases { + names = append(names, c.name) + } + return names +} + +// bodyShape describes a response without quoting any of it: its size and the +// top-level keys it arrived under. That is what a discovery failure is +// diagnosed from — which envelope the vendor answered with — and it is all +// that may go in a message rendered into a public job log, because the values +// underneath can carry an ARN and its account id. +func bodyShape(body string) string { + var doc map[string]json.RawMessage + if err := json.Unmarshal([]byte(body), &doc); err != nil { + return strconv.Itoa(len(body)) + " bytes, not a JSON object" + } + keys := make([]string, 0, len(doc)) + for key := range doc { + keys = append(keys, key) + } + sort.Strings(keys) + if len(keys) == 0 { + return strconv.Itoa(len(body)) + " bytes, an empty JSON object" + } + return strconv.Itoa(len(body)) + " bytes, keyed by: " + strings.Join(keys, ", ") +} + +// truncate bounds a logged response body. A live catalogue can run to tens of +// kilobytes, and the useful part is the front. +func truncate(s string, limit int) string { + if len(s) <= limit { + return s + } + return s[:limit] + "… (" + strconv.Itoa(len(s)-limit) + " more bytes)" +} diff --git a/e2e/agentnetwork/discovery_multipolicy_test.go b/e2e/agentnetwork/discovery_multipolicy_test.go new file mode 100644 index 000000000..447c1314c --- /dev/null +++ b/e2e/agentnetwork/discovery_multipolicy_test.go @@ -0,0 +1,170 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// TestDiscoveryBoundToCallersPolicies covers a model listing on a provider two +// teams reach under different allowlists. +// +// Bounding the listing by the provider's enumerated models alone is not enough +// once more than one policy is in play: the caller would be offered every model +// any team may use, and each one outside their own policy is a request the +// guardrail refuses a moment later — the empty-or-wrong picker this endpoint +// exists to avoid, just moved one level up. +// +// The client joins the main group only. Both models are enumerated by the same +// provider and both are advertised by the upstream, so a listing that leaked +// the other team's model would visibly contain it. +func TestDiscoveryBoundToCallersPolicies(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + grpMain, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-disc-mp-main"}) + require.NoError(t, err, "create main group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpMain.Id) }) + + grpOther, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-disc-mp-other"}) + require.NoError(t, err, "create other group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpOther.Id) }) + + ephemeral := false + mkKey := func(name, groupID string) string { + sk, kerr := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: name, + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{groupID}, + Ephemeral: &ephemeral, + }) + require.NoError(t, kerr, "mint setup key %s", name) + require.NotEmpty(t, sk.Key, "setup key plaintext") + return sk.Key + } + // One client per group. The second is what makes the first assertion mean + // something: without a client that DOES see the other team's model, its + // absence from the main client's listing could equally be a policy that + // never propagated. + keyMain := mkKey("e2e-disc-mp-main-client", grpMain.Id) + keyOther := mkKey("e2e-disc-mp-other-client", grpOther.Id) + + // One provider enumerating both models the upstream advertises, so the + // listing is narrowed by policy rather than by what the provider serves. + staticKey := "static-e2e-token" + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "e2e-disc-mp", + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &staticKey, + Enabled: ptr(true), + Models: &[]api.AgentNetworkProviderModel{ + {Id: harness.VLLMModel, InputPer1k: 0.001, OutputPer1k: 0.001}, + {Id: harness.VLLMUnlistedModel, InputPer1k: 0.001, OutputPer1k: 0.001}, + }, + }) + require.NoError(t, err, "create provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + mkGuardrail := func(name, model string) api.AgentNetworkGuardrail { + var gr api.AgentNetworkGuardrailRequest + gr.Name = name + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = []string{model} + g, gerr := srv.CreateGuardrail(ctx, gr) + require.NoError(t, gerr, "create guardrail %s", name) + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), g.Id) }) + return g + } + gMain := mkGuardrail("e2e-disc-mp-main", harness.VLLMModel) + gOther := mkGuardrail("e2e-disc-mp-other", harness.VLLMUnlistedModel) + + enabled := true + polMain, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-disc-mp-main", + Enabled: &enabled, + SourceGroups: []string{grpMain.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{gMain.Id}, + }) + require.NoError(t, err, "create main policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polMain.Id) }) + + // The other team's policy, on the same provider, permitting the model the + // client must never be offered. + polOther, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-disc-mp-other", + Enabled: &enabled, + SourceGroups: []string{grpOther.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{gOther.Id}, + }) + require.NoError(t, err, "create other policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polOther.Id) }) + + endpoint, proxyIP, clMain, px := connectClient(t, ctx, "disc-mp", keyMain) + clOther := joinClient(t, ctx, px, endpoint, keyOther) + + listing := func(t *testing.T, cl *harness.Client, ip string) string { + t.Helper() + code, body := callUntil(t, func() (int, string, error) { + return cl.Get(ctx, endpoint, ip, "/v1/models?limit=1000", nil) + }, 200) + require.Equal(t, 200, code, "discovery must be served; body: %s", body) + return body + } + + otherIP, err := clOther.ResolveProxyIP(ctx, endpoint) + require.NoError(t, err, "resolve endpoint from the other client") + + // The other team's client first: seeing its own model proves polOther is + // live, so the main client's listing is narrowed by policy scoping rather + // than by the other policy having failed to apply at all. + otherBody := listing(t, clOther, otherIP) + assert.Contains(t, otherBody, harness.VLLMUnlistedModel, + "the other group's policy must be in force, or this test proves nothing") + assert.NotContains(t, otherBody, harness.VLLMModel, + "and it must not be offered the main group's model either — isolation runs both ways") + + mainBody := listing(t, clMain, proxyIP) + assert.Contains(t, mainBody, harness.VLLMModel, + "the model the caller's own policy permits must reach the picker") + assert.NotContains(t, mainBody, harness.VLLMUnlistedModel, + "a model only another group's policy permits must not be offered to this caller") +} + +// joinClient starts a second tunnel client against an already-running proxy, so +// a test can drive the same endpoint as two different group memberships without +// paying for a second proxy. +func joinClient(t *testing.T, ctx context.Context, px *harness.Proxy, endpoint, setupKey string) *harness.Client { + t.Helper() + + cl, err := harness.StartClient(ctx, srv, setupKey) + require.NoError(t, err, "start second client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "second client must connect to management") + _, err = cl.ResolveProxyIP(ctx, endpoint) + require.NoError(t, err, "second client could not resolve the endpoint") + // Guarded rather than passed straight to require: px.Logs pulls the whole + // proxy container log, which is only worth fetching when the wait failed. + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + require.NoError(t, err, "second client did not see the proxy peer\n=== proxy logs ===\n%s", + px.Logs(context.Background())) + } + return cl +} diff --git a/e2e/agentnetwork/gateway_protocol_test.go b/e2e/agentnetwork/gateway_protocol_test.go new file mode 100644 index 000000000..c21a4fc53 --- /dev/null +++ b/e2e/agentnetwork/gateway_protocol_test.go @@ -0,0 +1,455 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// Models each catalog surface is registered with in the matrix below. They +// differ per provider so the router's choice is unambiguous: a request that +// lands on the wrong provider record fails the surface assertion instead of +// passing by coincidence. +const ( + matrixAnthropicModel = "claude-sonnet-5" + matrixBedrockModel = "anthropic.claude-sonnet-5" + // matrixBedrockPathModel is what a Bedrock SDK client puts in the URL: a + // cross-region inference profile with a release date and version suffix. + // The proxy must normalise it back to matrixBedrockModel to route and price. + matrixBedrockPathModel = "us.anthropic.claude-sonnet-5-20250101-v1:0" + // matrixVertexModel differs from the Anthropic record's model on purpose: + // a shared id would leave two routes claiming it and make which one serves + // /v1/messages depend on declaration order. + matrixVertexModel = "claude-haiku-4-5" + matrixVertexProject = "e2e-project" + matrixVertexRegion = "us-east5" +) + +// gatewayEnv is a connected client plus a set of provider records, all pointed +// at one mock upstream, so several wire shapes can be driven over a single +// tunnel. +type gatewayEnv struct { + endpoint string + proxyIP string + client *harness.Client + proxy *harness.Proxy + vllm *harness.VLLM + // providerIDs maps the catalog id to the created provider record id. + providerIDs map[string]string +} + +// provisionGatewayMatrix brings up one mock upstream and one provider record +// per catalog surface, all authorised for the same group by a single policy. +// Sharing one proxy and client keeps the wire-shape cases to one tunnel setup; +// each case still creates its own session id so its access-log row is findable. +func provisionGatewayMatrix(t *testing.T, ctx context.Context) gatewayEnv { + t.Helper() + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-gw-matrix"}) + require.NoError(t, err, "create group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-gw-matrix-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + // The mock ignores auth, so a dummy credential satisfies each catalog + // entry's auth template. Vertex is the exception: its api_key is a GCP + // service-account keyfile the proxy mints an OAuth token from, and a dummy + // one cannot mint. That is deliberate — the Vertex case below asserts on + // routing, which happens before the token mint. + dummyKey := "sk-gw-e2e" + dummyKeyfile := "keyfile::" + "e2e-not-a-real-service-account-key" + + specs := []struct { + name string + catalogID string + apiKey string + models []api.AgentNetworkProviderModel + }{ + { + name: "openai", catalogID: "openai_api", apiKey: dummyKey, + models: []api.AgentNetworkProviderModel{{Id: harness.VLLMModel, InputPer1k: 0.001, OutputPer1k: 0.002}}, + }, + { + name: "anthropic", catalogID: "anthropic_api", apiKey: dummyKey, + models: []api.AgentNetworkProviderModel{{Id: matrixAnthropicModel, InputPer1k: 0.003, OutputPer1k: 0.015}}, + }, + { + name: "bedrock", catalogID: "bedrock_api", apiKey: dummyKey, + models: []api.AgentNetworkProviderModel{{Id: matrixBedrockModel, InputPer1k: 0.003, OutputPer1k: 0.015}}, + }, + { + name: "vertex", catalogID: "vertex_ai_api", apiKey: dummyKeyfile, + models: []api.AgentNetworkProviderModel{{Id: matrixVertexModel, InputPer1k: 0.001, OutputPer1k: 0.005}}, + }, + } + + providerIDs := make(map[string]string, len(specs)) + ids := make([]string, 0, len(specs)) + for _, spec := range specs { + key := spec.apiKey + models := spec.models + prov, perr := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "e2e-gw-" + spec.name, + ProviderId: spec.catalogID, + UpstreamUrl: vllm.URL, + ApiKey: &key, + Enabled: ptr(true), + Models: &models, + }) + require.NoError(t, perr, "create %s provider", spec.name) + id := prov.Id + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), id) }) + providerIDs[spec.catalogID] = id + ids = append(ids, id) + } + + // Uncapped token limit: never blocks the handful of tokens driven here, but + // switches on usage metering so consumption and cost land in the row. + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-gw-matrix", + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: ids, + Limits: &api.AgentNetworkPolicyLimits{ + TokenLimit: api.AgentNetworkPolicyTokenLimit{ + Enabled: true, + GroupCap: 10_000_000, + UserCap: 10_000_000, + WindowSeconds: 60, + }, + }, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + endpoint, proxyIP, cl, px := connectClient(t, ctx, "gw-matrix", sk.Key) + return gatewayEnv{ + endpoint: endpoint, + proxyIP: proxyIP, + client: cl, + proxy: px, + vllm: vllm, + providerIDs: providerIDs, + } +} + +// connectClient starts a proxy and a tunnel client for the shared account and +// waits until the client can reach the proxy peer, returning the endpoint and +// the proxy's tunnel IP to pin requests to. +func connectClient(t *testing.T, ctx context.Context, name, setupKey string) (string, string, *harness.Client, *harness.Proxy) { + t.Helper() + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-"+name+"-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, setupKey) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + // The probe resolves the endpoint and its first packet wakes the lazy proxy + // peer, so WaitProxyPeer then observes it connected. + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve endpoint to proxy IP") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + return settings.Endpoint, proxyIP, cl, px +} + +// callUntil retries an HTTP call through the tunnel until it returns one of the +// wanted statuses or the deadline passes, absorbing the DNS and tunnel jitter +// the first call through a fresh tunnel can hit. The last status and body are +// returned either way so the caller can assert with real detail. +func callUntil(t *testing.T, call func() (int, string, error), want ...int) (int, string) { + t.Helper() + wanted := make(map[int]struct{}, len(want)) + for _, w := range want { + wanted[w] = struct{}{} + } + + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + c, b, err := call() + if err == nil { + code, body = c, b + if _, ok := wanted[code]; ok { + return code, body + } + } + time.Sleep(5 * time.Second) + } + return code, body +} + +// TestGatewayProtocolProviderMatrix drives one request per wire shape over a +// single tunnel, with a provider record per catalog surface behind it. It is +// the regression net for the routing and parser-selection changes: each case +// asserts the surface the request was metered under and the token counts that +// surface's own usage block carries, so a request parsed by the wrong provider's +// parser meters zero and fails rather than passing on a coincidence. +func TestGatewayProtocolProviderMatrix(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + + env := provisionGatewayMatrix(t, ctx) + diag := func() string { + return fmt.Sprintf("\n=== upstream logs ===\n%s\n=== proxy logs ===\n%s", + env.vllm.Logs(context.Background()), env.proxy.Logs(context.Background())) + } + + t.Run("openai chat completions", func(t *testing.T) { + session := "e2e-gw-openai" + code, body := callUntil(t, func() (int, string, error) { + return env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireChat, harness.VLLMModel, "ping", session) + }, 200) + require.Equal(t, 200, code, "openai chat must succeed; body: %s%s", body, diag()) + require.Contains(t, body, "chat.completion", "body must be an OpenAI completion; got: %s", body) + + row := findAccessLogBySession(t, ctx, session) + require.NotNil(t, row.Provider) + assert.Equal(t, "openai", *row.Provider, "the OpenAI chat path must meter under the openai surface") + assert.Equal(t, int64(harness.VLLMChatInputTokens), row.InputTokens, "OpenAI usage block must be read") + assert.Equal(t, int64(harness.VLLMChatOutputTokens), row.OutputTokens) + }) + + t.Run("anthropic messages", func(t *testing.T) { + session := "e2e-gw-anthropic" + code, body := callUntil(t, func() (int, string, error) { + return env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireMessages, matrixAnthropicModel, "ping", session) + }, 200) + require.Equal(t, 200, code, "anthropic messages must succeed; body: %s%s", body, diag()) + + row := findAccessLogBySession(t, ctx, session) + require.NotNil(t, row.Provider) + assert.Equal(t, "anthropic", *row.Provider, "the /v1/messages path must meter under the anthropic surface") + // These counts only appear if the Anthropic parser read the response: + // its usage fields are named differently from the OpenAI block. + assert.Equal(t, int64(harness.VLLMMessagesInputTokens), row.InputTokens, + "Anthropic input_tokens must be read; zero here means the wrong parser ran") + assert.Equal(t, int64(harness.VLLMMessagesOutputTokens), row.OutputTokens) + assert.Positive(t, row.CachedInputTokens, "the Anthropic cache-read bucket must be recorded") + assert.Positive(t, row.CostUsd, "a metered request must carry a cost") + require.NotNil(t, row.ResolvedProviderId) + assert.Equal(t, env.providerIDs["anthropic_api"], *row.ResolvedProviderId, + "a vendor-tagged request must not cross to another provider's record") + }) + + t.Run("bedrock invoke normalises the path model", func(t *testing.T) { + session := "e2e-gw-bedrock" + code, body := callUntil(t, func() (int, string, error) { + return env.client.Bedrock(ctx, env.endpoint, env.proxyIP, matrixBedrockPathModel, "ping", session) + }, 200) + require.Equal(t, 200, code, "bedrock invoke must succeed; body: %s%s", body, diag()) + + row := findAccessLogBySession(t, ctx, session) + require.NotNil(t, row.Provider) + assert.Equal(t, "bedrock", *row.Provider, "a native Bedrock path must meter under the bedrock surface") + require.NotNil(t, row.Model) + assert.Equal(t, matrixBedrockModel, *row.Model, + "the inference-profile prefix, release date and version suffix must be normalised away") + assert.Equal(t, int64(harness.VLLMMessagesInputTokens), row.InputTokens) + }) + + t.Run("anthropic token counting", func(t *testing.T) { + code, body := callUntil(t, func() (int, string, error) { + return env.client.PostJSON(ctx, env.endpoint, env.proxyIP, "/v1/messages/count_tokens", + fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"ping"}]}`, matrixAnthropicModel), + []string{"anthropic-version: 2023-06-01"}) + }, 200) + assert.Equal(t, 200, code, "token counting must route rather than deny; body: %s%s", body, diag()) + }) + + t.Run("bedrock token counting", func(t *testing.T) { + code, body := callUntil(t, func() (int, string, error) { + return env.client.PostJSON(ctx, env.endpoint, env.proxyIP, + "/model/"+matrixBedrockPathModel+"/count-tokens", + `{"input":{"converse":{"messages":[{"role":"user","content":[{"text":"ping"}]}]}}}`, nil) + }, 200) + assert.Equal(t, 200, code, + "the Bedrock count-tokens action must route; denying it pushes counting onto the billable inference path; body: %s%s", + body, diag()) + }) + + t.Run("vertex token counting reaches its provider", func(t *testing.T) { + // The dummy service-account key cannot mint an OAuth token, so the + // request stops at the upstream credential. Both outcomes render as + // 403, so the deny code is what distinguishes them: upstream_auth_failed + // means the path resolved to the Vertex route and only the credential + // failed, while model_not_routable would mean the method segment was + // swallowed into the model id and no route ever claimed it. + path := fmt.Sprintf("/v1/projects/%s/locations/%s/publishers/anthropic/models/%s/count-tokens:rawPredict", + matrixVertexProject, matrixVertexRegion, matrixVertexModel) + _, body := callUntil(t, func() (int, string, error) { + return env.client.PostJSON(ctx, env.endpoint, env.proxyIP, path, + `{"anthropic_version":"vertex-2023-10-16","messages":[{"role":"user","content":"ping"}]}`, nil) + }, 403) + assert.NotContains(t, body, "model_not_routable", + "the count-tokens method segment must not be parsed as part of the model id; body: %s%s", body, diag()) + assert.Contains(t, body, "llm_policy.upstream_auth_failed", + "the request must reach the Vertex route and fail only at the credential; body: %s%s", body, diag()) + }) + + t.Run("connection warming probe", func(t *testing.T) { + code, body := callUntil(t, func() (int, string, error) { + return env.client.Get(ctx, env.endpoint, env.proxyIP, "/api/hello", nil) + }, 200) + assert.NotEqual(t, 403, code, + "the warm-up probe carries no model and must not be refused as unroutable; body: %s%s", body, diag()) + }) + + t.Run("unknown model denies in the caller's error shape", func(t *testing.T) { + code, body := callUntil(t, func() (int, string, error) { + return env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireMessages, + "claude-not-a-real-model-9", "ping", "e2e-gw-unknown") + }, 403) + require.Equal(t, 403, code, "a model no provider claims must still be refused; body: %s%s", body, diag()) + + // The NetBird fields stay where they were for existing consumers. + assert.Contains(t, body, "llm_policy.model_not_routable", "the deny code must be preserved") + // And the vendor's own envelope rides alongside, so the client can show + // the reason instead of an unexplained API error. + assert.Contains(t, body, `"type":"error"`, "an Anthropic caller must get the Anthropic error envelope") + assert.Contains(t, body, "permission_error", "403 must map to the vendor's permission error type") + }) +} + +// TestModelDiscoveryWithModelAllowlist covers gateway model discovery on an +// account that restricts models, which is the configuration that broke: the +// listing carries no model, and the per-model allowlist fails closed on an +// undetermined one, so discovery denied for exactly the accounts using the +// feature. It also asserts the allowlist still refuses a model outside it, so +// the exemption cannot be read as a way around the gate. +func TestModelDiscoveryWithModelAllowlist(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-gw-discovery"}) + require.NoError(t, err, "create group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-gw-discovery-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + // One provider enumerating a single model, while the upstream's own listing + // advertises two. The proxy must serve the shorter list. + dummyKey := "sk-discovery-e2e" + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "e2e-gw-discovery", + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &dummyKey, + Enabled: ptr(true), + Models: &[]api.AgentNetworkProviderModel{ + {Id: harness.VLLMModel, InputPer1k: 0.001, OutputPer1k: 0.002}, + }, + }) + require.NoError(t, err, "create provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + // The model allowlist is what makes this a regression test: without a + // guardrail enabled, discovery was never gated in the first place. + var gr api.AgentNetworkGuardrailRequest + gr.Name = "e2e-gw-discovery-allowlist" + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = []string{harness.VLLMModel} + guard, err := srv.CreateGuardrail(ctx, gr) + require.NoError(t, err, "create guardrail") + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), guard.Id) }) + + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-gw-discovery", + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{guard.Id}, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + endpoint, proxyIP, cl, px := connectClient(t, ctx, "gw-discovery", sk.Key) + diag := func() string { + return fmt.Sprintf("\n=== upstream logs ===\n%s\n=== proxy logs ===\n%s", + vllm.Logs(context.Background()), px.Logs(context.Background())) + } + + t.Run("listing is served and bounded by policy", func(t *testing.T) { + code, body := callUntil(t, func() (int, string, error) { + return cl.Get(ctx, endpoint, proxyIP, "/v1/models?limit=1000", nil) + }, 200) + require.Equal(t, 200, code, + "discovery must not be refused because the request carries no model; body: %s%s", body, diag()) + + assert.Contains(t, body, harness.VLLMModel, "the authorised model must reach the picker") + assert.NotContains(t, body, harness.VLLMUnlistedModel, + "a model the policy does not authorise must not be offered; body: %s", body) + }) + + t.Run("allowlist still refuses a model outside it", func(t *testing.T) { + code, body := callUntil(t, func() (int, string, error) { + return cl.Chat(ctx, endpoint, proxyIP, harness.WireChat, + harness.VLLMUnlistedModel, "ping", "e2e-gw-discovery-blocked") + }, 403) + require.Equal(t, 403, code, + "exempting model-less endpoints must not exempt inference; body: %s%s", body, diag()) + assert.True(t, + strings.Contains(body, "llm_policy.model_blocked") || strings.Contains(body, "llm_policy.model_not_routable"), + "the refusal must name a model policy code; body: %s", body) + }) + + t.Run("allowlisted model still routes", func(t *testing.T) { + code, body := callUntil(t, func() (int, string, error) { + return cl.Chat(ctx, endpoint, proxyIP, harness.WireChat, + harness.VLLMModel, "ping", "e2e-gw-discovery-allowed") + }, 200) + require.Equal(t, 200, code, "the allowlisted model must still be served; body: %s%s", body, diag()) + }) +} diff --git a/e2e/agentnetwork/gateway_review_test.go b/e2e/agentnetwork/gateway_review_test.go new file mode 100644 index 000000000..556bc4a53 --- /dev/null +++ b/e2e/agentnetwork/gateway_review_test.go @@ -0,0 +1,242 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// The cases in this file cover behaviour that arrived from code review, after +// the gateway-protocol end-to-end tests were written. Each had unit coverage +// only; none needed a new harness capability, which is why they belong here +// rather than on a manual checklist. + +// TestNonInferenceEndpointsAreAuthorised covers the two review findings on the +// endpoints that carry no body: the per-model lookup must be authorised +// against the same allowlist that bounds the listing beside it, and only a read +// method may claim the non-inference exemption that skips the token pre-flight. +func TestNonInferenceEndpointsAreAuthorised(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + + env := provisionDiscoveryProvider(t, ctx) + + t.Run("lookup of an authorised model succeeds", func(t *testing.T) { + code, body := callUntil(t, func() (int, string, error) { + return env.client.Get(ctx, env.endpoint, env.proxyIP, "/v1/models/"+harness.VLLMModel, nil) + }, 200) + assert.Equal(t, 200, code, "an allowlisted model must remain reachable; body: %s", body) + }) + + t.Run("lookup of an unauthorised model is refused", func(t *testing.T) { + code, body, err := env.client.Get(ctx, env.endpoint, env.proxyIP, "/v1/models/"+harness.VLLMUnlistedModel, nil) + require.NoError(t, err, "request must reach the proxy") + assert.Equal(t, 403, code, + "a model the policy does not authorise must not be confirmed by the detail lookup; body: %s", body) + }) + + // A write must not claim the exemption that lets the listing skip the token + // pre-flight. The body names no model on purpose: that is what a request + // probing for the exemption looks like, and it is the case the method gate + // exists to refuse. (A POST that does name a model is a different thing — + // it routes and meters as the inference request it is.) + for _, path := range []string{"/v1/models", "/v1/models/" + harness.VLLMModel, "/api/hello"} { + t.Run("write to "+path+" is refused", func(t *testing.T) { + code, body, err := env.client.PostJSON(ctx, env.endpoint, env.proxyIP, path, + `{"messages":[{"role":"user","content":"hi"}]}`, nil) + require.NoError(t, err, "request must reach the proxy") + assert.NotEqual(t, 200, code, + "a write to a non-inference path must not be served unmetered; body: %s", body) + }) + } + + // A request carrying the sub-agent attribution headers must still be served + // and metered normally. Asserting the ids themselves is not possible yet: + // the parser lifts them onto the request's metadata, but nothing persists + // them, so they have no queryable surface to check against. + t.Run("sub-agent headers do not disturb the request", func(t *testing.T) { + sessionID := fmt.Sprintf("e2e-session-agentid-%d", time.Now().UnixNano()) + code, body, err := env.client.PostJSON(ctx, env.endpoint, env.proxyIP, "/v1/chat/completions", + fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"Reply with exactly: pong"}]}`, harness.VLLMModel), + []string{ + "x-session-id: " + sessionID, + "x-claude-code-agent-id: agent-child-7", + "x-claude-code-parent-agent-id: agent-root-1", + }) + require.NoError(t, err, "request must reach the proxy") + require.Equal(t, 200, code, "the request must succeed; body: %s", body) + + row := findAccessLogBySession(t, ctx, sessionID) + assert.Positive(t, row.InputTokens, "the request must still be metered normally") + }) +} + +// TestDatedModelIdRouting covers both halves of the dated-id rule that review +// tightened: a dated id still reaches an undated registration, but a route +// pinned to one dated build must never serve a different one. +func TestDatedModelIdRouting(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + + const ( + undated = "claude-sonnet-9" + datedA = "claude-sonnet-9-20250101" + datedB = "claude-sonnet-9-20250202" + ) + + t.Run("a dated id reaches its undated registration", func(t *testing.T) { + env := provisionModelProvider(t, ctx, "dated-undated", "anthropic_api", undated) + + sessionID := fmt.Sprintf("e2e-session-dated-%d", time.Now().UnixNano()) + code, body := callUntil(t, func() (int, string, error) { + return env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireMessages, datedA, "Reply with exactly: pong", sessionID) + }, 200) + require.Equal(t, 200, code, "a pinned release of a registered family must route; body: %s", body) + + row := findAccessLogBySession(t, ctx, sessionID) + assert.Positive(t, row.InputTokens, "the dated request must price at the registered rate, not zero") + }) + + t.Run("a route pinned to one dated build refuses another", func(t *testing.T) { + env := provisionModelProvider(t, ctx, "dated-pinned", "anthropic_api", datedA) + + code, body := callUntil(t, func() (int, string, error) { + return env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireMessages, datedA, "Reply with exactly: pong", "") + }, 200) + require.Equal(t, 200, code, "the exact dated id must still route; body: %s", body) + + code, body, err := env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireMessages, datedB, "Reply with exactly: pong", "") + require.NoError(t, err, "request must reach the proxy") + assert.Equal(t, 403, code, + "a provider pinned to one dated build must not serve another; body: %s", body) + }) +} + +// TestBedrockInferenceProfilesReachTheUpstream covers the startup lookup a +// Bedrock client makes. The proxy forwards it to the configured upstream rather +// than denying it, so what comes back is the upstream's answer — never a +// NetBird policy rejection. +func TestBedrockInferenceProfilesReachTheUpstream(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + + env := provisionModelProvider(t, ctx, "infprofiles", "bedrock_api", "anthropic.claude-sonnet-5") + + code, body := callUntil(t, func() (int, string, error) { + return env.client.Get(ctx, env.endpoint, env.proxyIP, "/inference-profiles", nil) + }, 200) + + assert.Equal(t, 200, code, "the lookup must reach the upstream; body: %s", body) + assert.NotContains(t, body, "llm_policy.", + "the proxy must not answer a control-plane lookup with a policy denial") + assert.Contains(t, body, "inferenceProfileSummaries", + "the upstream's own answer must come back untouched") +} + +// provisionDiscoveryProvider brings up one mock-backed provider enumerating a +// single model, with an allowlist guardrail in effect, plus a connected client. +func provisionDiscoveryProvider(t *testing.T, ctx context.Context) pricedEnv { + t.Helper() + env := provisionModelProvider(t, ctx, "noninference", "openai_api", harness.VLLMModel) + + var gr api.AgentNetworkGuardrailRequest + gr.Name = "e2e-noninference-allowlist-" + fmt.Sprint(time.Now().UnixNano()) + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = []string{harness.VLLMModel} + guard, err := srv.CreateGuardrail(ctx, gr) + require.NoError(t, err, "create guardrail") + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), guard.Id) }) + + enabled := true + _, err = srv.UpdatePolicy(ctx, env.policyID, api.AgentNetworkPolicyRequest{ + Name: "e2e-noninference", + Enabled: &enabled, + SourceGroups: []string{env.groupID}, + DestinationProviderIds: []string{env.providerID}, + GuardrailIds: &[]string{guard.Id}, + }) + require.NoError(t, err, "attach guardrail to policy") + return env +} + +// provisionModelProvider brings up the mock, one provider under the given +// catalog id enumerating exactly one model, an authorising policy, and a +// connected proxy + client. +func provisionModelProvider(t *testing.T, ctx context.Context, name, catalogID, model string) pricedEnv { + t.Helper() + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + suffix := strings.ToLower(name) + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-gwr-" + suffix}) + require.NoError(t, err, "create group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-gwr-" + suffix + "-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + dummyKey := "sk-gwr-e2e" + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "e2e-gwr-" + suffix, + ProviderId: catalogID, + UpstreamUrl: vllm.URL, + ApiKey: &dummyKey, + Enabled: ptr(true), + Models: &[]api.AgentNetworkProviderModel{ + {Id: model, InputPer1k: 0.001, OutputPer1k: 0.002}, + }, + }) + require.NoError(t, err, "create provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-gwr-" + suffix, + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + Limits: &api.AgentNetworkPolicyLimits{ + TokenLimit: api.AgentNetworkPolicyTokenLimit{ + Enabled: true, + GroupCap: 10_000_000, + UserCap: 10_000_000, + WindowSeconds: 60, + }, + }, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + endpoint, proxyIP, cl, px := connectClient(t, ctx, "gwr-"+suffix, sk.Key) + return pricedEnv{ + providerID: prov.Id, + groupID: grp.Id, + policyID: pol.Id, + upstream: vllm.URL, + endpoint: endpoint, + proxyIP: proxyIP, + client: cl, + proxy: px, + } +} diff --git a/e2e/agentnetwork/guardrail_block_test.go b/e2e/agentnetwork/guardrail_block_test.go new file mode 100644 index 000000000..302b5107e --- /dev/null +++ b/e2e/agentnetwork/guardrail_block_test.go @@ -0,0 +1,208 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// pathRoutedGuardrailCase is one provider's self-contained scenario: its own +// provider, its own guardrail whose allowlist holds ONLY that provider's +// allowed model, and its own policy. Each case runs in isolation (its own +// proxy + client), so the guardrail the proxy enforces contains exactly this +// provider's model — never a mixed cross-provider list. +type pathRoutedGuardrailCase struct { + name string + catalogID string // agent-network catalog provider id + wire string // harness.WireVertex | harness.WireBedrock + allowEntry string // the single model id put on the guardrail allowlist + allowModel string // model id sent that MUST be served (200) + blockModel string // model id sent that MUST be denied (403 model_blocked) +} + +// TestGuardrailBlocksUnselectedModel_PathRouted is the end-to-end regression +// guard for the customer report that a model-allowlist guardrail attached to a +// policy has no effect for PATH-ROUTED providers — where the model travels in +// the URL, not the JSON body: Google Vertex (…/models/{model}:rawPredict) and +// AWS Bedrock (/model/{id}/invoke). +// +// Each provider is tested in isolation with a guardrail allowlisting a single +// model of its own: the allowed model (in the URL path) is served (200) and an +// unselected model (in the URL path) is denied 403 by the guardrail +// (llm_policy.model_blocked) before the upstream. The Vertex case mirrors the +// customer verbatim — allow Sonnet, and the unselected model is the exact +// claude-opus-4-6 they reported reaching the model unblocked. The Bedrock case +// sends a region-prefixed, versioned inference-profile id so URL-path model +// normalization is exercised too. +// +// The provider is catch-all (no models), so the router forwards any model and a +// 403 can only come from the guardrail, never model_not_routable. Only the +// upstream LLM is mocked (the vLLM nginx answers any path with 200); management +// synth/reconcile, the proxy middleware chain (URL-path model extraction, +// router, guardrail) and the tunnel are all real, and the guardrail denies +// before the upstream is dialed so the mock cannot influence the block. A +// static bearer api key is used so the router injects a static Authorization +// header instead of minting a GCP token — the only reason path-routed providers +// normally need live credentials — so the test runs with none and is always on. +func TestGuardrailBlocksUnselectedModel_PathRouted(t *testing.T) { + cases := []pathRoutedGuardrailCase{ + { + name: "vertex", + catalogID: "vertex_ai_api", + wire: harness.WireVertex, + allowEntry: "claude-sonnet-4-5", + allowModel: "claude-sonnet-4-5", + blockModel: "claude-opus-4-6", // the customer-reported model + }, + { + name: "bedrock", + catalogID: "bedrock_api", + wire: harness.WireBedrock, + allowEntry: "anthropic.claude-sonnet-4-5", // normalized catalog id + allowModel: "us.anthropic.claude-sonnet-4-5-v1:0", + blockModel: "us.anthropic.claude-opus-4-8-v1:0", + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + runPathRoutedGuardrailCase(t, tc) + }) + } +} + +func runPathRoutedGuardrailCase(t *testing.T, tc pathRoutedGuardrailCase) { + t.Helper() + + const ( + vertexProject = "e2e-project" + vertexRegion = "global" + ) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-guardrail-" + tc.name}) + require.NoError(t, err, "create group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-guardrail-" + tc.name + "-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + // Catch-all provider (no models) so the router forwards any model; a static + // bearer key means the router injects a static auth header instead of minting + // a GCP token. + staticKey := "static-e2e-token" + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: tc.name, + ProviderId: tc.catalogID, + UpstreamUrl: vllm.URL, + ApiKey: &staticKey, + Enabled: ptr(true), + }) + require.NoError(t, err, "create %s provider", tc.name) + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + // Guardrail allowlisting ONLY this provider's allowed model. + var gr api.AgentNetworkGuardrailRequest + gr.Name = "e2e-guardrail-" + tc.name + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = []string{tc.allowEntry} + guard, err := srv.CreateGuardrail(ctx, gr) + require.NoError(t, err, "create guardrail") + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), guard.Id) }) + + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-guardrail-" + tc.name, + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{guard.Id}, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-guardrail-"+tc.name+"-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + // Probe first: the GET resolves the endpoint and its first packet wakes the + // lazy proxy peer, so WaitProxyPeer then observes it connected. + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve endpoint to proxy IP") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + + send := func(model string) (int, string) { + var code int + var body string + var cerr error + switch tc.wire { + case harness.WireVertex: + code, body, cerr = cl.Vertex(ctx, settings.Endpoint, proxyIP, vertexProject, vertexRegion, model, "Reply with exactly: pong", "") + case harness.WireBedrock: + code, body, cerr = cl.Bedrock(ctx, settings.Endpoint, proxyIP, model, "Reply with exactly: pong", "") + default: + t.Fatalf("unsupported wire %q", tc.wire) + } + require.NoError(t, cerr, "request must reach the proxy for %s", tc.name) + return code, body + } + + // Allowed model (in the URL path) is served. Retry to absorb tunnel/DNS + // jitter on the first call over the freshly warmed tunnel. + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + code, body = send(tc.allowModel) + if code == 200 { + break + } + time.Sleep(5 * time.Second) + } + assert.Equal(t, 200, code, + "allowed %s model (URL path) must be served; body: %s\n=== proxy logs ===\n%s", tc.name, body, px.Logs(context.Background())) + + // Unselected model (in the URL path) must be blocked by the guardrail. + code, body = send(tc.blockModel) + assert.Equal(t, 403, code, + "unselected %s model (URL path) must be denied, not served; body: %s\n=== proxy logs ===\n%s", tc.name, body, px.Logs(context.Background())) + assert.Contains(t, body, "llm_policy.model_blocked", + "%s denial must come from the guardrail allowlist, not routing; body: %s", tc.name, body) +} diff --git a/e2e/agentnetwork/guardrail_declared_ids_test.go b/e2e/agentnetwork/guardrail_declared_ids_test.go new file mode 100644 index 000000000..a34d545be --- /dev/null +++ b/e2e/agentnetwork/guardrail_declared_ids_test.go @@ -0,0 +1,132 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// TestModelAllowlistOfDeclaredIDsServed drives the setup an operator actually +// builds for a path-routed provider: the models are declared in the form the +// vendor issues (Bedrock's region-prefixed, versioned inference-profile id; +// Vertex's model@version), and the guardrail allowlist is built from that +// declared list — the dashboard's allowlist picker persists the declared ids +// verbatim. A request for the declared model must be served end to end, and a +// model outside the allowlist must still be denied. +// +// TestModelAllowlistEnforced never caught this because it registers and +// allowlists the pre-normalized catalog form (see the catalogModel comment +// there and the one in providerRequest: "register the normalized form here or +// routing fails as model_not_routable") — the harness encoded the +// canonicalization workaround instead of the shape operators configure. +func TestModelAllowlistOfDeclaredIDsServed(t *testing.T) { + var providers []providerCase + for _, pc := range availableProviders() { + if pc.kind == harness.WireBedrock || pc.kind == harness.WireVertex { + providers = append(providers, pc) + } + } + if len(providers) == 0 { + t.Skip("no path-routed provider keys set (AWS_BEARER_TOKEN_BEDROCK / GOOGLE_VERTEX_*); source ~/.llm-keys") + } + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-declared-allowlist"}) + require.NoError(t, err, "create group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-declared-allowlist-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + t.Cleanup(func() { _ = srv.API().SetupKeys.Delete(context.Background(), sk.Id) }) + + // Providers declaring the raw vendor-issued model id — NOT the normalized + // catalog form providerRequest would register. + ids := make([]string, 0, len(providers)) + declared := make([]string, 0, len(providers)) + for _, pc := range providers { + req := providerRequest(pc) + req.Models = &[]api.AgentNetworkProviderModel{{Id: pc.model, InputPer1k: 0.001, OutputPer1k: 0.002}} + prov, perr := srv.CreateProvider(ctx, req) + require.NoError(t, perr, "create provider %s", pc.name) + id := prov.Id + ids = append(ids, id) + declared = append(declared, pc.model) + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), id) }) + } + + // Guardrail allowlisting the declared ids verbatim, the way the dashboard + // builds an allowlist from the providers' model lists. + var gr api.AgentNetworkGuardrailRequest + gr.Name = "e2e-declared-allowlist" + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = declared + guard, err := srv.CreateGuardrail(ctx, gr) + require.NoError(t, err, "create guardrail") + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), guard.Id) }) + + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-declared-allowlist", + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: ids, + GuardrailIds: &[]string{guard.Id}, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings for endpoint") + require.NotEmpty(t, settings.Endpoint, "agent-network endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-proxy-declared-allowlist") + require.NoError(t, err, "mint proxy token via CLI") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + // Probe first: the GET resolves the endpoint (DNS error fails) and its first packet wakes the lazy proxy peer, so WaitProxyPeer sees it connected; any HTTP status counts. + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve agent-network endpoint to proxy IP") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + + for _, pc := range providers { + pc := pc + t.Run(pc.name, func(t *testing.T) { + // The model the operator declared and allowlisted is served end to + // end: the route must claim it and the guardrail must permit it, + // both through the canonicalization the parser applies at request + // time — whatever id form the operator configured. + assert.Equal(t, 200, sendModel(ctx, t, cl, settings.Endpoint, proxyIP, pc, pc.model), + "the declared and allowlisted model must be served for %s", pc.name) + // A model outside the allowlist stays denied. + assert.Equal(t, 403, sendModel(ctx, t, cl, settings.Endpoint, proxyIP, pc, disallowedModel(pc)), + "model outside the allowlist must be denied for %s", pc.name) + }) + } +} diff --git a/e2e/agentnetwork/guardrail_groupswitch_test.go b/e2e/agentnetwork/guardrail_groupswitch_test.go new file mode 100644 index 000000000..7108bb587 --- /dev/null +++ b/e2e/agentnetwork/guardrail_groupswitch_test.go @@ -0,0 +1,204 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// TestGuardrailGroupSwitchTakesEffectAfterTTL proves that moving a peer between +// groups flips its model-allowlist decision once the proxy's tunnel-peer cache +// expires. The peer's groups reach the guardrail via ValidateTunnelPeer, which +// the proxy caches; the switch is invisible until that cache expires. The proxy +// runs with a short NB_PROXY_TUNNEL_CACHE_TTL so the flip happens in seconds +// instead of the 5-minute default. +// +// Setup: one catch-all provider declaring modelA + modelB; polA (grpA -> allow +// modelA) and polB (grpB -> allow modelB). The client starts in grpA. modelA is +// served and modelB denied; after switching the client grpA -> grpB, modelB is +// served and modelA denied. The cross-group deny comes from management's +// per-policy/group CheckLLMPolicyLimits (the proxy backstop carries the union). +func TestGuardrailGroupSwitchTakesEffectAfterTTL(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + const ( + modelA = "e2e-model-a" + modelB = "e2e-model-b" + // Short tunnel-cache TTL so a group switch propagates in seconds. + // Exercises the NB_PROXY_TUNNEL_CACHE_TTL override. + cacheTTL = 3 * time.Second + ) + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + grpA, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-gswitch-a"}) + require.NoError(t, err, "create group A") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpA.Id) }) + + grpB, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-gswitch-b"}) + require.NoError(t, err, "create group B") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpB.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-gswitch-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grpA.Id}, // client starts in group A + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + staticKey := "static-e2e-token" + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "gswitch", + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &staticKey, + Enabled: ptr(true), + Models: &[]api.AgentNetworkProviderModel{ + {Id: modelA, InputPer1k: 0.001, OutputPer1k: 0.001}, + {Id: modelB, InputPer1k: 0.001, OutputPer1k: 0.001}, + }, + }) + require.NoError(t, err, "create provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + mkGuard := func(name, model string) api.AgentNetworkGuardrail { + var gr api.AgentNetworkGuardrailRequest + gr.Name = name + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = []string{model} + g, gerr := srv.CreateGuardrail(ctx, gr) + require.NoError(t, gerr, "create guardrail %s", name) + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), g.Id) }) + return g + } + gA := mkGuard("e2e-gswitch-a", modelA) + gB := mkGuard("e2e-gswitch-b", modelB) + + enabled := true + polA, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-gswitch-a", + Enabled: &enabled, + SourceGroups: []string{grpA.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{gA.Id}, + }) + require.NoError(t, err, "create policy A") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polA.Id) }) + + polB, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-gswitch-b", + Enabled: &enabled, + SourceGroups: []string{grpB.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{gB.Id}, + }) + require.NoError(t, err, "create policy B") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polB.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-gswitch-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken, map[string]string{ + "NB_PROXY_TUNNEL_CACHE_TTL": cacheTTL.String(), + }) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve endpoint to proxy IP") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + + send := func(model string) (int, string) { + code, body, cerr := cl.Chat(ctx, settings.Endpoint, proxyIP, harness.WireChat, model, "Reply with exactly: pong", "") + require.NoError(t, cerr, "request must reach the proxy") + return code, body + } + sendUntil := func(model string, want int, timeout time.Duration) (int, string) { + var code int + var body string + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + code, body = send(model) + if code == want { + return code, body + } + time.Sleep(2 * time.Second) + } + return code, body + } + + // Phase 1 — client is in group A: modelA served, modelB denied. + code, body := sendUntil(modelA, 200, 90*time.Second) + assert.Equal(t, 200, code, + "group-A model must be served while the client is in group A; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background())) + code, body = send(modelB) + assert.Equal(t, 403, code, + "group-B model must be denied while the client is in group A; body: %s", body) + assert.Contains(t, body, "llm_policy.model_blocked") + + // Switch the client peer from group A to group B. + peerID := clientPeerInGroup(t, ctx, grpA.Id) + _, err = srv.API().Groups.Update(ctx, grpB.Id, api.PutApiGroupsGroupIdJSONRequestBody{ + Name: grpB.Name, + Peers: &[]string{peerID}, + }) + require.NoError(t, err, "add peer to group B") + _, err = srv.API().Groups.Update(ctx, grpA.Id, api.PutApiGroupsGroupIdJSONRequestBody{ + Name: grpA.Name, + Peers: &[]string{}, + }) + require.NoError(t, err, "remove peer from group A") + + // Phase 2 — after the short TTL expires the proxy re-validates the peer, + // sees group B, and the decision flips. Poll to absorb TTL + re-validation. + code, body = sendUntil(modelB, 200, 60*time.Second) + assert.Equal(t, 200, code, + "after the group switch + TTL, the group-B model must be served; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background())) + code, body = send(modelA) + assert.Equal(t, 403, code, + "after the switch, the old group-A model must be denied; body: %s", body) + assert.Contains(t, body, "llm_policy.model_blocked") +} + +// clientPeerInGroup returns the id of the single peer that is a member of the +// given group — the test client. The proxy peer is never added to test groups. +func clientPeerInGroup(t *testing.T, ctx context.Context, groupID string) string { + t.Helper() + peers, err := srv.API().Peers.List(ctx) + require.NoError(t, err, "list peers") + for _, p := range peers { + for _, g := range p.Groups { + if g.Id == groupID { + return p.Id + } + } + } + t.Fatalf("no peer found in group %s", groupID) + return "" +} diff --git a/e2e/agentnetwork/guardrail_multipolicy_test.go b/e2e/agentnetwork/guardrail_multipolicy_test.go new file mode 100644 index 000000000..2bb56b3bc --- /dev/null +++ b/e2e/agentnetwork/guardrail_multipolicy_test.go @@ -0,0 +1,200 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// TestGuardrailMultiPolicyModelAllowlist: modelSelected served (200), grpOther's +// modelOther denied for the grpMain client (403 model_blocked, no cross-group +// leak), and openModel on the un-guardrailed policy's provider served (200). +func TestGuardrailMultiPolicyModelAllowlist(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + const ( + modelSelected = "e2e-selected" + modelOther = "e2e-other" + openModel = "e2e-open" + ) + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + grpMain, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-guardrail-mp-main"}) + require.NoError(t, err, "create main group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpMain.Id) }) + + grpOther, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-guardrail-mp-other"}) + require.NoError(t, err, "create other group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpOther.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-guardrail-mp-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grpMain.Id}, // client joins grpMain only + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + staticKey := "static-e2e-token" + models := func(ids ...string) *[]api.AgentNetworkProviderModel { + out := make([]api.AgentNetworkProviderModel, 0, len(ids)) + for _, id := range ids { + out = append(out, api.AgentNetworkProviderModel{Id: id, InputPer1k: 0.001, OutputPer1k: 0.001}) + } + return &out + } + + // pRestricted declares the two guardrailed models so routing is deterministic + // (model -> provider). + pRestricted, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "restricted", + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &staticKey, + Enabled: ptr(true), + Models: models(modelSelected, modelOther), + }) + require.NoError(t, err, "create restricted provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), pRestricted.Id) }) + + pOpen, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "open", + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &staticKey, + Enabled: ptr(true), + Models: models(openModel), + }) + require.NoError(t, err, "create open provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), pOpen.Id) }) + + mkGuardrail := func(name, model string) api.AgentNetworkGuardrail { + var gr api.AgentNetworkGuardrailRequest + gr.Name = name + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = []string{model} + g, gerr := srv.CreateGuardrail(ctx, gr) + require.NoError(t, gerr, "create guardrail %s", name) + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), g.Id) }) + return g + } + gMain := mkGuardrail("e2e-guardrail-mp-main", modelSelected) + gOther := mkGuardrail("e2e-guardrail-mp-other", modelOther) + + enabled := true + // polMain: grpMain restricted to modelSelected on pRestricted. + polMain, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-guardrail-mp-main", + Enabled: &enabled, + SourceGroups: []string{grpMain.Id}, + DestinationProviderIds: []string{pRestricted.Id}, + GuardrailIds: &[]string{gMain.Id}, + }) + require.NoError(t, err, "create main policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polMain.Id) }) + + // polOther: grpOther restricted to modelOther on the SAME provider. The + // client is not in grpOther, so modelOther must never be usable by it. + polOther, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-guardrail-mp-other", + Enabled: &enabled, + SourceGroups: []string{grpOther.Id}, + DestinationProviderIds: []string{pRestricted.Id}, + GuardrailIds: &[]string{gOther.Id}, + }) + require.NoError(t, err, "create other policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polOther.Id) }) + + // polOpen: grpMain on pOpen with NO guardrail — unrestricted. + polOpen, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-guardrail-mp-open", + Enabled: &enabled, + SourceGroups: []string{grpMain.Id}, + DestinationProviderIds: []string{pOpen.Id}, + }) + require.NoError(t, err, "create open policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polOpen.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-guardrail-mp-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve endpoint to proxy IP") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + + send := func(model string) (int, string) { + code, body, cerr := cl.Chat(ctx, settings.Endpoint, proxyIP, harness.WireChat, model, "Reply with exactly: pong", "") + require.NoError(t, cerr, "request must reach the proxy") + return code, body + } + // sendUntil200 absorbs first-call tunnel/DNS jitter on the freshly warmed tunnel. + sendUntil200 := func(model string) (int, string) { + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + code, body = send(model) + if code == 200 { + break + } + time.Sleep(5 * time.Second) + } + return code, body + } + + t.Run("selected model allowed for its group", func(t *testing.T) { + code, body := sendUntil200(modelSelected) + assert.Equal(t, 200, code, + "grpMain's allowlisted model must be served; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background())) + }) + + t.Run("other group's model does not leak", func(t *testing.T) { + // modelOther is allowlisted only for grpOther. The grpMain client must be + // denied by management's per-policy/group check — not waved through by an + // account-wide union. This is the security-critical wrong-ALLOW guard. + code, body := send(modelOther) + assert.Equal(t, 403, code, + "another group's allowlisted model must be denied for this caller; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background())) + assert.Contains(t, body, "llm_policy.model_blocked", + "denial must be a model-allowlist decision; body: %s", body) + }) + + t.Run("unguarded policy leaves its provider unrestricted", func(t *testing.T) { + // polOpen carries no guardrail, so pOpen is unrestricted for grpMain. The + // old account-wide union would have blocked openModel (it is on no + // allowlist); it must now be served — the false-DENY guard. + code, body := sendUntil200(openModel) + assert.Equal(t, 200, code, + "an un-guardrailed policy's provider must not be blocked by another policy's allowlist; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background())) + }) +} diff --git a/e2e/agentnetwork/guardrail_pergroup_providers_test.go b/e2e/agentnetwork/guardrail_pergroup_providers_test.go new file mode 100644 index 000000000..0e98330c4 --- /dev/null +++ b/e2e/agentnetwork/guardrail_pergroup_providers_test.go @@ -0,0 +1,418 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// pergroupCase describes one provider surface for the per-group allowlist matrix. +// selectedReq/otherReq are the model identifiers as they travel in the request +// (URL path for Bedrock/Vertex, body "model" for chat/messages). selectedAllow/ +// otherAllow are the (normalized) forms the guardrail allowlist holds — for +// Bedrock these differ from the request form so path normalization is exercised. +type pergroupCase struct { + name string + catalogID string + wire string // "chat", "messages", "vertex", "bedrock" + models *[]api.AgentNetworkProviderModel + selectedReq string + selectedAllow string + otherReq string + otherAllow string + + providerID string // filled during setup +} + +// TestGuardrailPerGroupAllowlist_AllProviders proves the per-policy/group model +// allowlist end to end across every always-on provider surface, including the +// path-routed ones (Vertex, Bedrock) where the model travels in the URL. +// +// For each provider two policies target it: grpMain (the client) is allowed only +// selectedReq; grpOther (which the client is NOT in) is allowed only otherReq. +// The client must get selectedReq served (200) and otherReq denied (403, +// llm_policy.model_blocked) — the cross-group no-leak property. The deny is the +// authoritative per-policy/group decision from management (the proxy per-provider +// backstop carries the union of both models), so this also confirms management +// receives the correct normalized model for path-routed providers. +func TestGuardrailPerGroupAllowlist_AllProviders(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + + const ( + vertexProject = "e2e-project" + vertexRegion = "global" + ) + + priced := func(ids ...string) *[]api.AgentNetworkProviderModel { + out := make([]api.AgentNetworkProviderModel, 0, len(ids)) + for _, id := range ids { + out = append(out, api.AgentNetworkProviderModel{Id: id, InputPer1k: 0.001, OutputPer1k: 0.001}) + } + return &out + } + + cases := []*pergroupCase{ + { + name: "openai", catalogID: "openai_api", wire: harness.WireChat, + models: priced("oai-model-a", "oai-model-b"), + selectedReq: "oai-model-a", selectedAllow: "oai-model-a", + otherReq: "oai-model-b", otherAllow: "oai-model-b", + }, + { + name: "anthropic", catalogID: "anthropic_api", wire: harness.WireMessages, + models: priced("ant-model-a", "ant-model-b"), + selectedReq: "ant-model-a", selectedAllow: "ant-model-a", + otherReq: "ant-model-b", otherAllow: "ant-model-b", + }, + { + // Vertex catalog ids travel bare in the rawPredict path. + name: "vertex", catalogID: "vertex_ai_api", wire: "vertex", + selectedReq: "claude-sonnet-4-5", selectedAllow: "claude-sonnet-4-5", + otherReq: "claude-opus-4-6", otherAllow: "claude-opus-4-6", + }, + { + // Bedrock request ids are region-prefixed/versioned; the parser + // normalizes them to the catalog key the allowlist holds. + name: "bedrock", catalogID: "bedrock_api", wire: "bedrock", + selectedReq: "us.anthropic.claude-sonnet-4-5-v1:0", selectedAllow: "anthropic.claude-sonnet-4-5", + otherReq: "us.anthropic.claude-opus-4-8-v1:0", otherAllow: "anthropic.claude-opus-4-8", + }, + } + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + grpMain, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-pergroup-main"}) + require.NoError(t, err, "create main group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpMain.Id) }) + + grpOther, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-pergroup-other"}) + require.NoError(t, err, "create other group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpOther.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-pergroup-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grpMain.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + staticKey := "static-e2e-token" + enabled := true + + for _, c := range cases { + req := api.AgentNetworkProviderRequest{ + Name: "e2e-pergroup-" + c.name, + ProviderId: c.catalogID, + UpstreamUrl: vllm.URL, + ApiKey: &staticKey, + Enabled: ptr(true), + Models: c.models, + } + prov, perr := srv.CreateProvider(ctx, req) + require.NoError(t, perr, "create provider %s", c.name) + c.providerID = prov.Id + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + gSel := mkAllowGuardrail(t, ctx, "e2e-pergroup-"+c.name+"-sel", c.selectedAllow) + gOth := mkAllowGuardrail(t, ctx, "e2e-pergroup-"+c.name+"-oth", c.otherAllow) + + polMain, merr := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-pergroup-" + c.name + "-main", + Enabled: &enabled, + SourceGroups: []string{grpMain.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{gSel.Id}, + }) + require.NoError(t, merr, "create main policy %s", c.name) + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polMain.Id) }) + + polOther, oerr := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-pergroup-" + c.name + "-other", + Enabled: &enabled, + SourceGroups: []string{grpOther.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{gOth.Id}, + }) + require.NoError(t, oerr, "create other policy %s", c.name) + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polOther.Id) }) + } + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-pergroup-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve endpoint to proxy IP") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + + send := func(c *pergroupCase, model string) (int, string) { + var code int + var body string + var cerr error + switch c.wire { + case "vertex": + code, body, cerr = cl.Vertex(ctx, settings.Endpoint, proxyIP, vertexProject, vertexRegion, model, "Reply with exactly: pong", "") + case "bedrock": + code, body, cerr = cl.Bedrock(ctx, settings.Endpoint, proxyIP, model, "Reply with exactly: pong", "") + default: + code, body, cerr = cl.Chat(ctx, settings.Endpoint, proxyIP, c.wire, model, "Reply with exactly: pong", "") + } + require.NoError(t, cerr, "request must reach the proxy for %s", c.name) + return code, body + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + // grpMain's own model is served. Retry to absorb tunnel/DNS jitter on + // the first call over the freshly warmed tunnel. + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + code, body = send(c, c.selectedReq) + if code == 200 { + break + } + time.Sleep(5 * time.Second) + } + assert.Equal(t, 200, code, + "%s: grpMain's allowlisted model must be served; body: %s\n=== proxy logs ===\n%s", c.name, body, px.Logs(context.Background())) + + // grpOther's model must NOT leak to the grpMain client. + code, body = send(c, c.otherReq) + assert.Equal(t, 403, code, + "%s: another group's allowlisted model must be denied for this caller; body: %s\n=== proxy logs ===\n%s", c.name, body, px.Logs(context.Background())) + assert.Contains(t, body, "llm_policy.model_blocked", + "%s: denial must be a model-allowlist decision, not routing; body: %s", c.name, body) + }) + } +} + +// TestGuardrailMultiGroupUser proves the per-policy/group decision for a caller +// that belongs to MULTIPLE groups at once. Two scenarios, one shared stack: +// +// - union across the user's groups: the client is in gUX and gUY, each with +// its own policy+guardrail on provider P1 (gUX->union-a, gUY->union-b). The +// client may use BOTH models (the union of its groups' allowlists) while a +// third, un-allowlisted model is denied. +// - an un-guardrailed group lifts the restriction: the client is in gMP and +// gMQ on provider P2, where gMP restricts to mix-a but gMQ's policy carries +// NO guardrail. Because one applicable policy is unrestricted, the client may +// use a model on no allowlist (mix-z) as well as mix-a. +func TestGuardrailMultiGroupUser(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + const ( + unionA = "mg-union-a" + unionB = "mg-union-b" + unionC = "mg-union-c" // allowlisted by neither group + mixA = "mg-mix-a" + mixZ = "mg-mix-z" // on no allowlist; reachable only via the un-guardrailed policy + ) + + priced := func(ids ...string) *[]api.AgentNetworkProviderModel { + out := make([]api.AgentNetworkProviderModel, 0, len(ids)) + for _, id := range ids { + out = append(out, api.AgentNetworkProviderModel{Id: id, InputPer1k: 0.001, OutputPer1k: 0.001}) + } + return &out + } + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + mkGroup := func(name string) *api.Group { + g, gerr := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: name}) + require.NoError(t, gerr, "create group %s", name) + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), g.Id) }) + return g + } + gUX := mkGroup("e2e-mg-union-x") + gUY := mkGroup("e2e-mg-union-y") + gMP := mkGroup("e2e-mg-mix-p") + gMQ := mkGroup("e2e-mg-mix-q") + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-mg-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{gUX.Id, gUY.Id, gMP.Id, gMQ.Id}, // client in all four groups + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + staticKey := "static-e2e-token" + enabled := true + + // P1 — union scenario: two restricting policies, one per group. + p1, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "e2e-mg-union", + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &staticKey, + Enabled: ptr(true), + Models: priced(unionA, unionB, unionC), + }) + require.NoError(t, err, "create union provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), p1.Id) }) + + polUX, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-mg-union-x", + Enabled: &enabled, + SourceGroups: []string{gUX.Id}, + DestinationProviderIds: []string{p1.Id}, + GuardrailIds: &[]string{mkAllowGuardrail(t, ctx, "e2e-mg-union-x", unionA).Id}, + }) + require.NoError(t, err, "create union policy X") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polUX.Id) }) + + polUY, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-mg-union-y", + Enabled: &enabled, + SourceGroups: []string{gUY.Id}, + DestinationProviderIds: []string{p1.Id}, + GuardrailIds: &[]string{mkAllowGuardrail(t, ctx, "e2e-mg-union-y", unionB).Id}, + }) + require.NoError(t, err, "create union policy Y") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polUY.Id) }) + + // P2 — mixed scenario: one restricting policy + one un-guardrailed policy. + p2, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "e2e-mg-mix", + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &staticKey, + Enabled: ptr(true), + Models: priced(mixA, mixZ), + }) + require.NoError(t, err, "create mix provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), p2.Id) }) + + polMP, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-mg-mix-p", + Enabled: &enabled, + SourceGroups: []string{gMP.Id}, + DestinationProviderIds: []string{p2.Id}, + GuardrailIds: &[]string{mkAllowGuardrail(t, ctx, "e2e-mg-mix-p", mixA).Id}, + }) + require.NoError(t, err, "create mix policy P") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polMP.Id) }) + + polMQ, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-mg-mix-q", + Enabled: &enabled, + SourceGroups: []string{gMQ.Id}, + DestinationProviderIds: []string{p2.Id}, // NO guardrail -> unrestricted + }) + require.NoError(t, err, "create mix policy Q") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polMQ.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-mg-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve endpoint to proxy IP") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + + send := func(model string) (int, string) { + code, body, cerr := cl.Chat(ctx, settings.Endpoint, proxyIP, harness.WireChat, model, "Reply with exactly: pong", "") + require.NoError(t, cerr, "request must reach the proxy") + return code, body + } + sendUntil200 := func(model string) (int, string) { + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + code, body = send(model) + if code == 200 { + break + } + time.Sleep(5 * time.Second) + } + return code, body + } + + t.Run("union across the user's groups", func(t *testing.T) { + code, body := sendUntil200(unionA) + assert.Equal(t, 200, code, "model allowed by group X must be served; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background())) + + code, body = sendUntil200(unionB) + assert.Equal(t, 200, code, "model allowed by group Y must also be served (union across the user's groups); body: %s", body) + + code, body = send(unionC) + assert.Equal(t, 403, code, "a model on neither group's allowlist must be denied; body: %s", body) + assert.Contains(t, body, "llm_policy.model_blocked") + }) + + t.Run("an un-guardrailed group lifts the restriction", func(t *testing.T) { + code, body := sendUntil200(mixA) + assert.Equal(t, 200, code, "the restricted group's model must be served; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background())) + + code, body = sendUntil200(mixZ) + assert.Equal(t, 200, code, + "a non-allowlisted model must be served because the user is also in a group whose policy has no guardrail; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background())) + }) +} + +// mkAllowGuardrail creates a guardrail whose model allowlist is enabled and holds +// exactly the given model, registering cleanup. +func mkAllowGuardrail(t *testing.T, ctx context.Context, name, model string) api.AgentNetworkGuardrail { + t.Helper() + var gr api.AgentNetworkGuardrailRequest + gr.Name = name + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = []string{model} + g, err := srv.CreateGuardrail(ctx, gr) + require.NoError(t, err, "create guardrail %s", name) + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), g.Id) }) + return g +} diff --git a/e2e/agentnetwork/guardrail_test.go b/e2e/agentnetwork/guardrail_test.go new file mode 100644 index 000000000..ddc95d62d --- /dev/null +++ b/e2e/agentnetwork/guardrail_test.go @@ -0,0 +1,185 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "regexp" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// bedrockRegionPrefixes and bedrockVersionSuffix mirror the proxy's Bedrock +// model normalization (region/inference-profile prefix + version suffix) so the +// provider is registered under the same catalog key the router matches against. +var ( + bedrockRegionPrefixes = []string{"us.", "eu.", "apac.", "global."} + bedrockVersionSuffix = regexp.MustCompile(`-(\d{8}-)?v\d+(:\d+)?$`) +) + +// catalogModel returns the normalized catalog id the proxy stamps for a +// path-routed provider's configured model — the form the router and guardrail +// allowlist compare against (Bedrock region prefix + version stripped, Vertex +// @version stripped). +func catalogModel(pc providerCase) string { + switch pc.kind { + case harness.WireBedrock: + m := pc.model + for _, p := range bedrockRegionPrefixes { + if strings.HasPrefix(m, p) { + m = m[len(p):] + break + } + } + return bedrockVersionSuffix.ReplaceAllString(m, "") + case harness.WireVertex: + return strings.SplitN(pc.model, "@", 2)[0] + default: + return pc.model + } +} + +// disallowedModel returns a valid-shaped model id for the provider that is NOT +// the configured/allowed one, so the guardrail must reject it before the +// request ever reaches the upstream. +func disallowedModel(pc providerCase) string { + switch pc.kind { + case harness.WireBedrock: + // Same profile prefix as the allowed model so only the model name + // differs; the guardrail must deny it before it reaches AWS. + return strings.SplitN(pc.model, ".", 2)[0] + ".anthropic.claude-opus-4-8" + case harness.WireVertex: + return "claude-opus-4-8@20250101" + default: + return "unlisted-model" + } +} + +// sendModel drives one request for the given model through the provider's native +// wire shape and returns the HTTP status. +func sendModel(ctx context.Context, t *testing.T, cl *harness.Client, endpoint, proxyIP string, pc providerCase, model string) int { + t.Helper() + var code int + var err error + switch pc.kind { + case harness.WireBedrock: + code, _, err = cl.Bedrock(ctx, endpoint, proxyIP, model, "Reply with exactly: pong", "") + case harness.WireVertex: + code, _, err = cl.Vertex(ctx, endpoint, proxyIP, pc.project, pc.region, model, "Reply with exactly: pong", "") + default: + code, _, err = cl.ChatPrefixed(ctx, endpoint, proxyIP, pc.pathPrefix, pc.kind, model, "Reply with exactly: pong", "") + } + require.NoError(t, err, "request must reach the proxy for %s", pc.name) + return code +} + +// TestModelAllowlistEnforced provisions a Model Allowlist guardrail limiting each +// path-routed provider (Bedrock, Vertex) to its configured model, then drives +// requests over the tunnel: the allowed model returns 200 while a model outside +// the allowlist is denied 403 by the guardrail before it reaches the upstream. +// This is the coverage missing for #6751 — the model for these providers travels +// in the URL path, and the allowlist must be enforced there. +func TestModelAllowlistEnforced(t *testing.T) { + var providers []providerCase + for _, pc := range availableProviders() { + if pc.kind == harness.WireBedrock || pc.kind == harness.WireVertex { + providers = append(providers, pc) + } + } + if len(providers) == 0 { + t.Skip("no path-routed provider keys set (AWS_BEARER_TOKEN_BEDROCK / GOOGLE_VERTEX_*); source ~/.llm-keys") + } + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-allowlist"}) + require.NoError(t, err, "create group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-allowlist-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + + // Providers with their configured (allowed) models + ids := make([]string, 0, len(providers)) + allowed := make([]string, 0, len(providers)) + for _, pc := range providers { + req := providerRequest(pc) + prov, perr := srv.CreateProvider(ctx, req) + require.NoError(t, perr, "create provider %s", pc.name) + id := prov.Id + ids = append(ids, id) + allowed = append(allowed, catalogModel(pc)) + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), id) }) + } + + // Guardrail allowlisting exactly the configured models. + var gr api.AgentNetworkGuardrailRequest + gr.Name = "e2e-allowlist" + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = allowed + guard, err := srv.CreateGuardrail(ctx, gr) + require.NoError(t, err, "create guardrail") + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), guard.Id) }) + + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-allowlist", + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: ids, + GuardrailIds: &[]string{guard.Id}, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings for endpoint") + require.NotEmpty(t, settings.Endpoint, "agent-network endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-proxy-allowlist") + require.NoError(t, err, "mint proxy token via CLI") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + // Probe first: the GET resolves the endpoint (DNS error fails) and its first packet wakes the lazy proxy peer, so WaitProxyPeer sees it connected; any HTTP status counts. + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve agent-network endpoint to proxy IP") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + + for _, pc := range providers { + pc := pc + t.Run(pc.name, func(t *testing.T) { + // The admin's allowlisted model is served end to end. + assert.Equal(t, 200, sendModel(ctx, t, cl, settings.Endpoint, proxyIP, pc, pc.model), + "allowlisted model must be permitted for %s", pc.name) + // A model outside the allowlist is rejected by the guardrail (before + // the upstream), regardless of whether it is a real catalog model. + assert.Equal(t, 403, sendModel(ctx, t, cl, settings.Endpoint, proxyIP, pc, disallowedModel(pc)), + "model outside the allowlist must be denied for %s", pc.name) + }) + } +} diff --git a/e2e/agentnetwork/main_test.go b/e2e/agentnetwork/main_test.go new file mode 100644 index 000000000..687af1d4d --- /dev/null +++ b/e2e/agentnetwork/main_test.go @@ -0,0 +1,72 @@ +//go:build e2e + +// Package agentnetwork holds the container-based agent-network e2e suite. A +// single combined server is built and bootstrapped once per package run +// (TestMain) and shared across tests via srv; each test creates and cleans up +// its own resources so order doesn't matter. +package agentnetwork + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// srv is the shared combined server for the package, ready (PAT-authenticated) +// by the time any Test runs. +var srv *harness.Combined + +func TestMain(m *testing.M) { + os.Exit(run(m)) +} + +func run(m *testing.M) int { + // Generous timeout to cover a cold image build on first run. + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + var err error + srv, err = harness.StartCombined(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "e2e: start combined server: %v\n", err) + return 1 + } + defer func() { _ = srv.Terminate(context.Background()) }() + + if _, err := srv.Bootstrap(ctx); err != nil { + fmt.Fprintf(os.Stderr, "e2e: bootstrap admin PAT: %v\n", err) + return 1 + } + + // Bootstrap the account's agent-network endpoint once for the package: + // providers no longer have settings side effects, and every data-plane + // test expects the shared account pinned to the combined proxy cluster. + cluster := harness.AgentNetworkCluster + if _, err := srv.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{ProxyAddress: &cluster}); err != nil { + fmt.Fprintf(os.Stderr, "e2e: bootstrap agent-network endpoint: %v\n", err) + return 1 + } + + return m.Run() +} + +// waitBeforeRetry pauses between attempts of a polling loop and reports +// whether the caller should keep going. A cancelled context ends the loop +// where a plain sleep would keep retrying against it: every call fails +// instantly once ctx is done, so the loop would spend its whole remaining +// window sleeping between failures nobody is waiting for any more. +func waitBeforeRetry(ctx context.Context, d time.Duration) bool { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} diff --git a/e2e/agentnetwork/management_test.go b/e2e/agentnetwork/management_test.go new file mode 100644 index 000000000..6868a9d4d --- /dev/null +++ b/e2e/agentnetwork/management_test.go @@ -0,0 +1,251 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/client/rest" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +func ptr[T any](v T) *T { return &v } + +// newProvider creates an OpenAI-catalog provider these tests can hang a policy +// off, and registers cleanup. Nothing here calls the upstream. +func newProvider(t *testing.T, ctx context.Context, name string) api.AgentNetworkProvider { + t.Helper() + // A provider save is credential-checked against the vendor, and every + // caller here wants a provider row to hang a policy off rather than a + // working upstream. A private address is left unchecked — the proxy would + // reach it through the tunnel, management cannot reach it at all — which + // keeps this fixture independent of whether the run has vendor keys, and + // covers the unchecked-provider-still-saves path while it is at it. + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: name, + ProviderId: "openai_api", + UpstreamUrl: "https://10.255.255.1", + ApiKey: ptr("sk-dummy-e2e-key"), + }) + require.NoError(t, err, "create provider %q", name) + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + return prov +} + +// requireClientError asserts err is a REST APIError with a 4xx status. +func requireClientError(t *testing.T, err error) { + t.Helper() + var apiErr *rest.APIError + require.ErrorAs(t, err, &apiErr, "expected a REST APIError") + assert.GreaterOrEqual(t, apiErr.StatusCode, 400, "expected a 4xx status") + assert.Less(t, apiErr.StatusCode, 500, "expected a 4xx status") +} + +// TestProviderLifecycle covers create → get → list → delete → 404 for every +// available real provider catalog (and a synthetic OpenAI provider when no +// provider keys are set), so each catalog's create and field round-trip is +// exercised. Create is offline — no upstream call — so this stays fast and +// burns no provider quota. +func TestProviderLifecycle(t *testing.T) { + ctx := context.Background() + + cases := availableProviders() + if len(cases) == 0 { + cases = []providerCase{{ + name: "openai", catalogID: "openai_api", upstream: "https://api.openai.com", + apiKey: "sk-dummy-e2e-key", model: "gpt-4o-mini", kind: harness.WireChat, + }} + } + + for _, pc := range cases { + pc := pc + t.Run(pc.name, func(t *testing.T) { + req := providerRequest(pc) + req.Name = "lc-" + pc.name + prov, err := srv.CreateProvider(ctx, req) + require.NoError(t, err, "create %s provider", pc.name) + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + assert.NotEmpty(t, prov.Id, "created provider must have an id") + assert.Equal(t, pc.catalogID, prov.ProviderId, "catalog id must round-trip") + assert.Equal(t, req.Name, prov.Name, "name must round-trip") + assert.Equal(t, pc.upstream, prov.UpstreamUrl, "upstream must round-trip") + + got, err := srv.GetProvider(ctx, prov.Id) + require.NoError(t, err, "get provider") + assert.Equal(t, prov.Id, got.Id) + + list, err := srv.ListProviders(ctx) + require.NoError(t, err, "list providers") + var ids []string + for _, p := range list { + ids = append(ids, p.Id) + } + assert.Contains(t, ids, prov.Id, "created provider must appear in the list") + + require.NoError(t, srv.DeleteProvider(ctx, prov.Id), "delete provider") + _, err = srv.GetProvider(ctx, prov.Id) + requireClientError(t, err) + }) + } +} + +// TestProviderValidation exercises the create-time validation rules. These are +// uniform across catalogs (no per-provider required-field rules exist: a +// catalog-specific malformed value such as a Vertex key without the keyfile:: +// prefix is accepted at create and only fails at the proxy), so the cases here +// are catalog-agnostic: missing API key, unknown catalog id, an invalid upstream +// URL, and a blank name. +func TestProviderValidation(t *testing.T) { + ctx := context.Background() + + _, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "No Key", + ProviderId: "openai_api", + UpstreamUrl: "https://api.openai.com", + }) + requireClientError(t, err) + + _, err = srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "Unknown Catalog", + ProviderId: "totally_unknown_provider", + UpstreamUrl: "https://example.com", + ApiKey: ptr("sk-dummy"), + }) + requireClientError(t, err) + + _, err = srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "Bad Upstream", + ProviderId: "openai_api", + UpstreamUrl: "not-a-url", + ApiKey: ptr("sk-dummy"), + }) + requireClientError(t, err) + + _, err = srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: " ", + ProviderId: "openai_api", + UpstreamUrl: "https://api.openai.com", + ApiKey: ptr("sk-dummy"), + }) + requireClientError(t, err) +} + +// TestSettingsRoundTrip flips the collection toggles and confirms the +// endpoint and proxy address stay immutable, then restores the original +// state. A second bootstrap attempt must be rejected as a conflict. +func TestSettingsRoundTrip(t *testing.T) { + ctx := context.Background() + + // The package's TestMain bootstrapped the shared account's endpoint. + before, err := srv.GetSettings(ctx) + require.NoError(t, err, "get settings") + require.NotEmpty(t, before.Endpoint, "settings must carry the bootstrapped endpoint") + require.NotEmpty(t, before.ProxyAddress, "settings must carry the bootstrapped proxy address") + + require.NotNil(t, before.AccessLogRetentionDays, "bootstrapped settings must carry a retention") + beforeRetention := *before.AccessLogRetentionDays + + flipped, err := srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{ + Endpoint: before.Endpoint, + ProxyAddress: before.ProxyAddress, + EnableLogCollection: !before.EnableLogCollection, + EnablePromptCollection: !before.EnablePromptCollection, + RedactPii: !before.RedactPii, + AccessLogRetentionDays: beforeRetention, + }) + require.NoError(t, err, "update settings") + assert.Equal(t, !before.EnableLogCollection, flipped.EnableLogCollection, "log collection toggle must flip") + assert.Equal(t, !before.EnablePromptCollection, flipped.EnablePromptCollection, "prompt collection toggle must flip") + require.NotNil(t, flipped.AccessLogRetentionDays) + assert.Equal(t, beforeRetention, *flipped.AccessLogRetentionDays, + "retention sent unchanged must round-trip, not reset to the zero value") + assert.Equal(t, before.Endpoint, flipped.Endpoint, "endpoint must be immutable across updates") + assert.Equal(t, before.ProxyAddress, flipped.ProxyAddress, "proxy address must be immutable across updates") + + // The account is already bootstrapped: a second bootstrap is a conflict, + // whatever shape it asks for. + _, err = srv.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{ + Endpoint: ptr("attacker.cluster.invalid"), + }) + requireClientError(t, err) + + // The identity fields ride along on the PUT as a required echo: a request + // carrying a different endpoint is rejected without applying anything. + _, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{ + Endpoint: "other.cluster.invalid", + ProxyAddress: before.ProxyAddress, + EnableLogCollection: before.EnableLogCollection, + EnablePromptCollection: before.EnablePromptCollection, + RedactPii: before.RedactPii, + AccessLogRetentionDays: beforeRetention, + }) + requireClientError(t, err) + + // Restore the original toggles. + _, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{ + Endpoint: before.Endpoint, + ProxyAddress: before.ProxyAddress, + EnableLogCollection: before.EnableLogCollection, + EnablePromptCollection: before.EnablePromptCollection, + RedactPii: before.RedactPii, + AccessLogRetentionDays: beforeRetention, + }) + require.NoError(t, err, "restore settings") +} + +// TestPolicyWindowFloor rejects an enabled limit below the 60s window floor and +// accepts one at the floor. +func TestPolicyWindowFloor(t *testing.T) { + ctx := context.Background() + + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-policy-grp"}) + require.NoError(t, err, "create source group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + prov := newProvider(t, ctx, "Policy Provider") + + limits := func(window int64) *api.AgentNetworkPolicyLimits { + return &api.AgentNetworkPolicyLimits{ + TokenLimit: api.AgentNetworkPolicyTokenLimit{ + Enabled: true, + GroupCap: 1000, + UserCap: 1000, + WindowSeconds: window, + }, + } + } + + _, err = srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-below-floor", + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + Limits: limits(30), + }) + requireClientError(t, err) + + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-at-floor", + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + Limits: limits(60), + }) + require.NoError(t, err, "policy at the 60s floor must be accepted") + assert.NotEmpty(t, pol.Id, "created policy must have an id") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) +} + +// TestConsumptionList confirms the read endpoint always returns an array, never +// a 404/500. +func TestConsumptionList(t *testing.T) { + ctx := context.Background() + + rows, err := srv.ListConsumption(ctx) + require.NoError(t, err, "consumption list must not error") + assert.NotNil(t, rows, "consumption must be a JSON array (possibly empty)") +} diff --git a/e2e/agentnetwork/settings_bootstrap_test.go b/e2e/agentnetwork/settings_bootstrap_test.go new file mode 100644 index 000000000..806ce82f4 --- /dev/null +++ b/e2e/agentnetwork/settings_bootstrap_test.go @@ -0,0 +1,179 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// harnessStartFresh boots a dedicated combined server with its own fresh +// account and registers its teardown on t. Unlike the shared srv, the fresh +// account has NOT had its agent-network endpoint bootstrapped. +func harnessStartFresh(ctx context.Context, t *testing.T) (*harness.Combined, error) { + t.Helper() + fresh, err := harness.StartCombined(ctx) + if err != nil { + return nil, err + } + t.Cleanup(func() { _ = fresh.Terminate(context.Background()) }) + if _, err := fresh.Bootstrap(ctx); err != nil { + return nil, err + } + return fresh, nil +} + +// TestSettingsBootstrapViaPost covers the explicit bootstrap contract on an +// account that has never been bootstrapped: the GET reads as the defaults +// with an empty endpoint/proxy_address, a PUT has no row to update and fails, +// and a POST creates the row and assigns the immutable endpoint — labeled +// beneath a proxy address here, with the toggle overrides from the same +// request applied. The shared srv cannot provide that starting state +// (TestMain bootstraps it), so this boots a dedicated combined server — the +// image is already built and cached by TestMain's StartCombined, so the extra +// cost is one container start. +func TestSettingsBootstrapViaPost(t *testing.T) { + ctx := context.Background() + + fresh, err := harnessStartFresh(ctx, t) + require.NoError(t, err, "start dedicated combined server") + + // Before agent-network bootstrap the settings read as the defaults, not + // as an error and not as a null body. + before, err := fresh.GetSettings(ctx) + require.NoError(t, err, "get settings on a fresh account must succeed") + assert.Empty(t, before.Endpoint, "endpoint must be empty before bootstrap") + assert.Empty(t, before.ProxyAddress, "proxy address must be empty before bootstrap") + assert.False(t, before.Dedicated, "an unbootstrapped account has no serving shape") + assert.True(t, before.EnableLogCollection, "defaults must show log collection on, matching bootstrap") + assert.False(t, before.EnablePromptCollection, "defaults must show prompt collection off") + + // A PUT has no row to update yet — bootstrap is the explicit POST. + _, err = fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{ + EnableLogCollection: true, + AccessLogRetentionDays: 30, + }) + requireClientError(t, err) + + // A POST with a proxy address bootstraps a labeled endpoint and applies + // the toggles from the same request. Every toggle is set away from its + // bootstrap default so each assertion can actually fail. + const cluster = "e2e.bootstrap.netbird.selfhosted" + bootstrapped, err := fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{ + ProxyAddress: ptr(cluster), + EnableLogCollection: ptr(false), + EnablePromptCollection: ptr(true), + RedactPii: ptr(true), + }) + require.NoError(t, err, "bootstrap settings via POST must succeed") + assert.Equal(t, cluster, bootstrapped.ProxyAddress, "proxy address must be pinned from the request") + require.NotEmpty(t, bootstrapped.Endpoint, "endpoint must be assigned at bootstrap") + assert.True(t, strings.HasSuffix(bootstrapped.Endpoint, "."+cluster), + "labeled endpoint must hang one label beneath the proxy address: %s", bootstrapped.Endpoint) + assert.False(t, bootstrapped.Dedicated, "a labeled pin is not dedicated") + assert.False(t, bootstrapped.EnableLogCollection, "log collection from the bootstrap request must override the default") + assert.True(t, bootstrapped.EnablePromptCollection, "prompt collection from the bootstrap request must apply") + assert.True(t, bootstrapped.RedactPii, "redact toggle from the bootstrap request must apply") + + // The row is persisted: an independent read agrees on every field. + after, err := fresh.GetSettings(ctx) + require.NoError(t, err, "get settings after bootstrap must succeed") + assert.Equal(t, bootstrapped.Endpoint, after.Endpoint, "bootstrap must persist across reads") + assert.Equal(t, bootstrapped.EnableLogCollection, after.EnableLogCollection, "log collection must persist") + assert.Equal(t, bootstrapped.EnablePromptCollection, after.EnablePromptCollection, "prompt collection must persist") + assert.Equal(t, bootstrapped.RedactPii, after.RedactPii, "redact toggle must persist") + + // Once bootstrapped, PUT updates the toggles. The identity fields ride + // along as a required echo of the assigned values; a matching echo is + // accepted and never written. + persisted, err := fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{ + Endpoint: bootstrapped.Endpoint, + ProxyAddress: bootstrapped.ProxyAddress, + EnableLogCollection: true, + EnablePromptCollection: false, + RedactPii: true, + AccessLogRetentionDays: 21, + }) + require.NoError(t, err, "post-bootstrap update must succeed") + require.NotNil(t, persisted.AccessLogRetentionDays) + assert.Equal(t, 21, *persisted.AccessLogRetentionDays, "retention from the update must apply") + assert.Equal(t, bootstrapped.Endpoint, persisted.Endpoint, "endpoint must survive updates untouched") + assert.Equal(t, cluster, persisted.ProxyAddress, "proxy address must survive updates untouched") + assert.True(t, persisted.EnableLogCollection, "post-bootstrap toggle must apply") + assert.False(t, persisted.EnablePromptCollection, "post-bootstrap toggle must apply") + + // The endpoint is immutable: a PUT carrying a different endpoint is + // rejected, and a second bootstrap is rejected as a conflict. Neither + // rejected write may disturb anything. + _, err = fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{ + Endpoint: "other.cluster.invalid", + ProxyAddress: persisted.ProxyAddress, + EnableLogCollection: persisted.EnableLogCollection, + EnablePromptCollection: persisted.EnablePromptCollection, + RedactPii: persisted.RedactPii, + AccessLogRetentionDays: 21, + }) + requireClientError(t, err) + + _, err = fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{ + Endpoint: ptr("other.cluster.invalid"), + }) + requireClientError(t, err) + + final, err := fresh.GetSettings(ctx) + require.NoError(t, err, "get settings after the rejected bootstrap must succeed") + assert.Equal(t, persisted.Endpoint, final.Endpoint, "rejected bootstrap must not change the endpoint") + assert.Equal(t, persisted.ProxyAddress, final.ProxyAddress, "rejected bootstrap must not change the proxy address") + assert.Equal(t, persisted.EnableLogCollection, final.EnableLogCollection, "rejected bootstrap must not apply its toggles") + assert.Equal(t, persisted.EnablePromptCollection, final.EnablePromptCollection, "rejected bootstrap must not apply its toggles") + assert.Equal(t, persisted.RedactPii, final.RedactPii, "rejected bootstrap must not apply its toggles") +} + +// TestSettingsBootstrapSelfAddressed covers the dedicated shape end to end: +// a POST carrying an endpoint claims the hostname verbatim, the proxy address +// equals it, and the pin reads as dedicated — the address-first flow a +// self-hosted operator uses before deploying the proxy that will declare it. +// The tail covers the recovery path the guarded DELETE exists for: with no +// providers and no proxy at the address, the claim can be released and a +// fresh bootstrap succeeds — the fix for a typo'd immutable endpoint. +func TestSettingsBootstrapSelfAddressed(t *testing.T) { + ctx := context.Background() + + fresh, err := harnessStartFresh(ctx, t) + require.NoError(t, err, "start dedicated combined server") + + created, err := fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{ + Endpoint: ptr("gw.e2e.netbird.selfhosted"), + }) + require.NoError(t, err, "self-addressed bootstrap must succeed") + assert.Equal(t, "gw.e2e.netbird.selfhosted", created.Endpoint, "endpoint must be claimed verbatim") + assert.Equal(t, created.Endpoint, created.ProxyAddress, "self-addressed: proxy address is the endpoint") + assert.True(t, created.Dedicated, "a self-addressed pin is dedicated") + + // No providers exist and no proxy declares the address, so both delete + // guards are clear: the delete releases the claim and the account reads + // as unbootstrapped defaults again. + require.NoError(t, fresh.DeleteSettings(ctx), "guarded delete with both guards clear must succeed") + + after, err := fresh.GetSettings(ctx) + require.NoError(t, err, "get settings after delete must succeed") + assert.Empty(t, after.Endpoint, "a deleted account must read as unbootstrapped") + + // A second delete has nothing to remove. + requireClientError(t, fresh.DeleteSettings(ctx)) + + // Re-creating is a fresh bootstrap — the released hostname is free to be + // claimed again, or a different one chosen. + recreated, err := fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{ + Endpoint: ptr("gw2.e2e.netbird.selfhosted"), + }) + require.NoError(t, err, "bootstrap after delete must succeed") + assert.Equal(t, "gw2.e2e.netbird.selfhosted", recreated.Endpoint, "the fresh bootstrap claims the new hostname") +} diff --git a/e2e/agentnetwork/skiptls_test.go b/e2e/agentnetwork/skiptls_test.go new file mode 100644 index 000000000..85af8c604 --- /dev/null +++ b/e2e/agentnetwork/skiptls_test.go @@ -0,0 +1,139 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// TestProviderSkipTLSVerification proves skip_tls_verification is per-provider: +// two providers share one self-signed upstream, one skipping TLS verification +// and one not. The skip=true provider's chat reaches the upstream and returns +// 200; the skip=false provider's chat fails at the TLS handshake — same +// upstream, opposite outcome. This is the behaviour a target-level flag could +// not give, since all of an account's providers share one synthesised target. +func TestProviderSkipTLSVerification(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + up, err := harness.StartFakeUpstream(ctx, srv) + require.NoError(t, err, "start self-signed upstream") + t.Cleanup(func() { _ = up.Terminate(context.Background()) }) + + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-skiptls"}) + require.NoError(t, err, "create group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-skiptls-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + const ( + insecureModel = "insecure-model" + secureModel = "secure-model" + ) + + // Two providers on the SAME self-signed upstream, distinguished only by their + // skip_tls_verification and a unique model string so the router picks each + // unambiguously. + newReq := func(name, model string, skip bool) api.AgentNetworkProviderRequest { + key := "sk-dummy-e2e" + return api.AgentNetworkProviderRequest{ + Name: name, + ProviderId: "openai_api", + UpstreamUrl: up.URL, + ApiKey: &key, + Enabled: ptr(true), + SkipTlsVerification: ptr(skip), + Models: &[]api.AgentNetworkProviderModel{ + {Id: model, InputPer1k: 0.001, OutputPer1k: 0.002}, + }, + } + } + + insecureReq := newReq("skip-tls", insecureModel, true) + insecureProv, err := srv.CreateProvider(ctx, insecureReq) + require.NoError(t, err, "create skip-tls provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), insecureProv.Id) }) + require.True(t, insecureProv.SkipTlsVerification, "response must echo skip_tls_verification=true") + + secureProv, err := srv.CreateProvider(ctx, newReq("verify-tls", secureModel, false)) + require.NoError(t, err, "create verify-tls provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), secureProv.Id) }) + require.False(t, secureProv.SkipTlsVerification, "response must echo skip_tls_verification=false") + + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-skiptls-allow", + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{insecureProv.Id, secureProv.Id}, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-skiptls-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + // Probe first: the GET resolves the endpoint (DNS error fails) and its first packet wakes the lazy proxy peer, so WaitProxyPeer sees it connected; any HTTP status counts. + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve endpoint to proxy IP") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + + // Positive: skip=true reaches the self-signed upstream. Retry to absorb + // tunnel/DNS jitter on the first call; success also proves the path works. + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + c, b, cerr := cl.Chat(ctx, settings.Endpoint, proxyIP, harness.WireChat, insecureModel, "Reply with exactly: pong", "e2e-skiptls-insecure") + if cerr == nil { + code, body = c, b + if code == 200 { + break + } + } + time.Sleep(5 * time.Second) + } + require.Equal(t, 200, code, + "skip_tls_verification=true must reach the self-signed upstream; body: %s\n=== upstream logs ===\n%s\n=== proxy logs ===\n%s", + body, up.Logs(context.Background()), px.Logs(context.Background())) + + // Negative: skip=false must fail the TLS handshake to the SAME upstream. The + // path is already proven working, so a non-200 here is the cert rejection. + secureCode, secureBody, cerr := cl.Chat(ctx, settings.Endpoint, proxyIP, harness.WireChat, secureModel, "Reply with exactly: pong", "e2e-skiptls-secure") + require.NoError(t, cerr, "the chat call itself must complete (proxy returns an error status, not a transport error)") + require.NotEqual(t, 200, secureCode, + "skip_tls_verification=false must NOT reach the self-signed upstream; got %d, body: %s", secureCode, secureBody) + require.GreaterOrEqual(t, secureCode, 500, + "a TLS verification failure should surface as a 5xx from the proxy; got %d, body: %s", secureCode, secureBody) +} diff --git a/e2e/agentnetwork/streaming_test.go b/e2e/agentnetwork/streaming_test.go new file mode 100644 index 000000000..a5fa8df3f --- /dev/null +++ b/e2e/agentnetwork/streaming_test.go @@ -0,0 +1,209 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// streamedModel is priced high enough that a mis-metered request is obvious in +// the recorded cost, and named so it cannot collide with another test's route. +const streamedModel = "e2e-streamed-model" + +const ( + streamInRate = 0.010 + streamOutRate = 0.020 + // The cache-read bucket is priced separately from input, so a run that + // folded the two together fails the per-bucket assertions below. + streamCacheReadRate = 0.001 +) + +// TestStreamingResponseMetersInputTokens is the end-to-end guard for the +// metering bug this endpoint's gateway-protocol work fixed. +// +// On a streamed answer the input-token count exists only in the opening +// message_start event; every later frame reports output. A response read with +// the wrong vendor's parser — the shape a gateway record produces when it names +// one API surface and serves another — never looks at that event, so input +// metered as zero and the bulk of the bill silently vanished. Nothing in the +// suite sent stream: true before this test, so the whole branch went unrun. +// +// The provider points at the mock's streaming listener, which answers every +// request as SSE with token counts that differ from the buffered surface. That +// difference is the point: passing these assertions is only possible if the +// stream accumulator ran. +func TestStreamingResponseMetersInputTokens(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + env := provisionStreamingProvider(t, ctx, "anthropic_api") + + sessionID := fmt.Sprintf("e2e-session-stream-%d", time.Now().UnixNano()) + code, body := chatStreamUntil(t, ctx, env, harness.WireMessages, streamedModel, sessionID) + require.Equal(t, 200, code, "streamed chat must succeed; body: %s", body) + assert.Contains(t, body, "message_start", + "the client must receive the event stream itself, not a buffered rewrite of it") + + row := findAccessLogBySession(t, ctx, sessionID) + + assert.Equal(t, harness.VLLMStreamInputTokens, int(row.InputTokens), + "input tokens live in message_start; zero here is the bug this test exists for") + assert.Equal(t, harness.VLLMStreamOutputTokens, int(row.OutputTokens), + "output tokens ride message_delta and supersede the message_start seed") + assert.Equal(t, harness.VLLMStreamCacheReadTokens, int(row.CachedInputTokens), + "the Anthropic cache bucket rides message_start too, and only its own parser reads it") + + // The Anthropic surface bills cache reads additively, so the input bucket + // prices the full input count rather than a remainder. + wantInput := float64(harness.VLLMStreamInputTokens) / 1000 * streamInRate + wantOutput := float64(harness.VLLMStreamOutputTokens) / 1000 * streamOutRate + wantCacheRead := float64(harness.VLLMStreamCacheReadTokens) / 1000 * streamCacheReadRate + assert.InDelta(t, wantInput, row.InputCostUsd, 1e-6, "input cost must price the streamed input tokens") + assert.InDelta(t, wantOutput, row.OutputCostUsd, 1e-6, "output cost must price the streamed output tokens") + // The total, not merely a positive number: input and output alone are + // positive, so a cache bucket parsed and then never billed would pass any + // weaker assertion. The gap is 7e-6, well outside the delta. + assert.InDelta(t, wantInput+wantOutput+wantCacheRead, row.CostUsd, 1e-6, + "the recorded cost must be every bucket the surface bills, cache reads included") +} + +// TestStreamingOnGatewayTypedProvider drives the same streamed Anthropic call +// through a provider record whose catalog id names the OpenAI surface — the +// exact misconfiguration that hid the bug, since gateway records commonly pin +// one parser while the upstream serves another shape entirely. +// +// The router must choose the parser from the request path rather than the +// record's provider id, or the Anthropic usage block goes unread and input +// meters at zero all over again. +func TestStreamingOnGatewayTypedProvider(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + env := provisionStreamingProvider(t, ctx, "openai_api") + + sessionID := fmt.Sprintf("e2e-session-stream-gw-%d", time.Now().UnixNano()) + code, body := chatStreamUntil(t, ctx, env, harness.WireMessages, streamedModel, sessionID) + require.Equal(t, 200, code, "streamed chat through a gateway record must succeed; body: %s", body) + + row := findAccessLogBySession(t, ctx, sessionID) + + assert.Equal(t, harness.VLLMStreamInputTokens, int(row.InputTokens), + "a record typed openai_api must still read the Anthropic usage block it is actually serving") + assert.Equal(t, harness.VLLMStreamOutputTokens, int(row.OutputTokens), + "output tokens must survive the surface mismatch too") + assert.InDelta(t, float64(harness.VLLMStreamInputTokens)/1000*streamInRate, row.InputCostUsd, 1e-6, + "the request must be priced on the surface it spoke, not the one the record names") +} + +// provisionStreamingProvider brings up the mock, one provider pointed at its +// streaming listener under the given catalog id, a policy authorising it, and a +// connected proxy + client. +func provisionStreamingProvider(t *testing.T, ctx context.Context, catalogID string) pricedEnv { + t.Helper() + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + name := "stream-" + catalogID + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-" + name}) + require.NoError(t, err, "create group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-" + name + "-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + // Deleting the group does not delete the key it auto-joins, so the key + // needs a cleanup of its own. + t.Cleanup(func() { _ = srv.API().SetupKeys.Delete(context.Background(), sk.Id) }) + require.NotEmpty(t, sk.Key, "setup key plaintext") + + dummyKey := "sk-stream-e2e" + cacheRead := streamCacheReadRate + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: name, + ProviderId: catalogID, + UpstreamUrl: vllm.StreamURL, + ApiKey: &dummyKey, + Enabled: ptr(true), + Models: &[]api.AgentNetworkProviderModel{{ + Id: streamedModel, + InputPer1k: streamInRate, + OutputPer1k: streamOutRate, + CacheReadPer1k: &cacheRead, + }}, + }) + require.NoError(t, err, "create provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-" + name, + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + Limits: &api.AgentNetworkPolicyLimits{ + TokenLimit: api.AgentNetworkPolicyTokenLimit{ + Enabled: true, + GroupCap: 10_000_000, + UserCap: 10_000_000, + WindowSeconds: 60, + }, + }, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + endpoint, proxyIP, cl, px := connectClient(t, ctx, name, sk.Key) + return pricedEnv{ + providerID: prov.Id, + groupID: grp.Id, + policyID: pol.Id, + upstream: vllm.StreamURL, + endpoint: endpoint, + proxyIP: proxyIP, + client: cl, + proxy: px, + } +} + +// chatStreamUntil drives one streamed chat, retrying to absorb the tunnel and +// DNS jitter a first call through a fresh peer can hit. +func chatStreamUntil(t *testing.T, ctx context.Context, env pricedEnv, kind, model, sessionID string) (int, string) { + t.Helper() + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + c, b, cerr := env.client.ChatStream(ctx, env.endpoint, env.proxyIP, kind, model, "Reply with exactly: pong", sessionID) + if cerr == nil { + code, body = c, b + if code == 200 { + break + } + } + if !waitBeforeRetry(ctx, 5*time.Second) { + break + } + } + if code != 200 { + t.Logf("=== proxy logs ===\n%s", env.proxy.Logs(context.Background())) + } + return code, body +} diff --git a/e2e/agentnetwork/vllm_test.go b/e2e/agentnetwork/vllm_test.go new file mode 100644 index 000000000..6f5117b2f --- /dev/null +++ b/e2e/agentnetwork/vllm_test.go @@ -0,0 +1,171 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// TestVLLMProvider proves the proxy supports a self-hosted vLLM backend. vLLM is +// OpenAI-compatible, so it uses the "vllm" catalog entry (KindCustom) and is +// reached over plain HTTP — no TLS anywhere on the path: +// +// client --tunnel--> netbird proxy --http--> vllm (:8000, OpenAI-compatible) +// +// The mock vLLM server answers /v1/chat/completions with an OpenAI-shaped +// completion carrying a non-zero usage block. The test asserts the chat returns +// 200 with the completion, that the request is recorded in the access log by its +// session id, and that vLLM's usage block is metered into a consumption row — +// which together prove request routing, response parsing, and token accounting +// all work for a self-hosted OpenAI-compatible provider. +// +// It needs no external credentials (the mock ignores auth), so it always runs. +func TestVLLMProvider(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock vLLM server") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-vllm"}) + require.NoError(t, err, "create group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-vllm-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + // vLLM provider pointed at the mock over plain HTTP. The mock ignores auth, + // so a dummy key satisfies the "Bearer ${API_KEY}" template. The served model + // is enumerated so the router dispatches this model string to this provider. + dummyKey := "sk-vllm-e2e" + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "vllm", + ProviderId: "vllm", + UpstreamUrl: vllm.URL, + ApiKey: &dummyKey, + Enabled: ptr(true), + Models: &[]api.AgentNetworkProviderModel{ + {Id: harness.VLLMModel, InputPer1k: 0.001, OutputPer1k: 0.002}, + }, + }) + require.NoError(t, err, "create vllm provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + // Token limit far above the handful of tokens this test drives, so it never + // blocks but switches on usage metering — the switch that makes consumption + // rows get recorded. + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-vllm-allow", + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + Limits: &api.AgentNetworkPolicyLimits{ + TokenLimit: api.AgentNetworkPolicyTokenLimit{ + Enabled: true, + GroupCap: 10_000_000, + UserCap: 10_000_000, + WindowSeconds: 60, + }, + }, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-vllm-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + // Probe first: the GET resolves the endpoint (DNS error fails) and its first packet wakes the lazy proxy peer, so WaitProxyPeer sees it connected; any HTTP status counts. + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve endpoint to proxy IP") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + + before, _ := srv.ListAccessLogs(ctx) + sessionID := "e2e-session-vllm" + + // Retry to absorb tunnel/DNS jitter on the first call. + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + c, b, cerr := cl.Chat(ctx, settings.Endpoint, proxyIP, harness.WireChat, harness.VLLMModel, "Reply with exactly: pong", sessionID) + if cerr == nil { + code, body = c, b + if code == 200 { + break + } + } + time.Sleep(5 * time.Second) + } + require.Equal(t, 200, code, + "chat through the vLLM provider must return 200; body: %s\n=== vllm logs ===\n%s\n=== proxy logs ===\n%s", + body, vllm.Logs(context.Background()), px.Logs(context.Background())) + require.True(t, strings.Contains(body, "chat.completion"), + "body should be an OpenAI-compatible chat completion; got: %s", body) + + // The request must surface as an access-log row carrying our session id. + require.Eventually(t, func() bool { + logs, lerr := srv.ListAccessLogs(ctx) + return lerr == nil && logs.TotalRecords > before.TotalRecords + }, 30*time.Second, 2*time.Second, "an access-log row should be ingested for the vLLM provider") + + require.Eventually(t, func() bool { + logs, lerr := srv.ListAccessLogs(ctx) + if lerr != nil { + return false + } + for _, r := range logs.Data { + if r.SessionId != nil && *r.SessionId == sessionID { + return true + } + } + return false + }, 30*time.Second, 2*time.Second, "session id %q must be recorded in an access-log row", sessionID) + + // vLLM's usage block (prompt_tokens=11, completion_tokens=2) must be parsed + // and metered into a consumption row with positive token counts. + require.Eventually(t, func() bool { + rows, lerr := srv.ListConsumption(ctx) + if lerr != nil { + return false + } + for _, r := range rows { + if r.TokensInput > 0 && r.TokensOutput > 0 { + return true + } + } + return false + }, 60*time.Second, 3*time.Second, "vLLM usage must be metered into a consumption row") +} diff --git a/e2e/harness/Dockerfile.client b/e2e/harness/Dockerfile.client new file mode 100644 index 000000000..4c76b95c6 --- /dev/null +++ b/e2e/harness/Dockerfile.client @@ -0,0 +1,28 @@ +# Multistage build for the NetBird client used in e2e tests. The repo has no +# source-building client Dockerfile (client/Dockerfile packages a goreleaser +# artifact), so this mirrors its alpine runtime + entrypoint while compiling the +# CGO-free client inline. BuildKit cache mounts keep rebuilds incremental. + +FROM golang:1.26.7-bookworm AS builder +WORKDIR /src +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod go mod download +COPY . . +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=linux go build -o /out/netbird ./client + +FROM alpine:3.24 +RUN apk add --no-cache bash ca-certificates ip6tables iproute2 iptables +ENV NETBIRD_BIN="/usr/local/bin/netbird" \ + NB_LOG_FILE="console,/var/log/netbird/client.log" \ + NB_DAEMON_ADDR="unix:///var/run/netbird.sock" \ + NB_ENABLE_CAPTURE="false" \ + NB_ENTRYPOINT_SERVICE_TIMEOUT="30" +ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ] +# --chmod because the build context is not always a git checkout. A suite in +# another module builds from this module's extracted copy in the module cache, +# where every file is 0444 — the cache drops the executable bit git records — and +# a bare COPY then produces an entrypoint the runtime cannot exec. +COPY --chmod=0755 client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh +COPY --from=builder /out/netbird /usr/local/bin/netbird diff --git a/e2e/harness/agentnetwork.go b/e2e/harness/agentnetwork.go new file mode 100644 index 000000000..e51f2dd7a --- /dev/null +++ b/e2e/harness/agentnetwork.go @@ -0,0 +1,187 @@ +//go:build e2e + +package harness + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// The shared REST client doesn't (yet) expose typed agent-network methods, so +// these helpers drive the /api/agent-network/* endpoints through the client's +// NewRequest primitive — reusing its auth, error handling (rest.APIError on +// non-2xx), and transport — while still speaking the generated api types. + +// anRequest issues an agent-network API call and decodes the JSON response into +// T. A non-2xx response surfaces as a *rest.APIError from the client, which +// tests inspect for negative-path status assertions. +func anRequest[T any](ctx context.Context, c *Combined, method, path string, body any) (T, error) { + var out T + var reader io.Reader + if body != nil { + bs, err := json.Marshal(body) + if err != nil { + return out, fmt.Errorf("marshal %s %s: %w", method, path, err) + } + reader = bytes.NewReader(bs) + } + + resp, err := c.api.NewRequest(ctx, method, path, reader, nil) + if err != nil { + return out, err + } + defer resp.Body.Close() + + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return out, fmt.Errorf("decode %s %s response: %w", method, path, err) + } + return out, nil +} + +// anDelete issues a DELETE and discards the (empty-object) body. +func anDelete(ctx context.Context, c *Combined, path string) error { + resp, err := c.api.NewRequest(ctx, http.MethodDelete, path, nil, nil) + if err != nil { + return err + } + resp.Body.Close() + return nil +} + +// CreateProvider creates an agent-network provider. +func (c *Combined) CreateProvider(ctx context.Context, req api.AgentNetworkProviderRequest) (api.AgentNetworkProvider, error) { + return anRequest[api.AgentNetworkProvider](ctx, c, http.MethodPost, "/api/agent-network/providers", req) +} + +// GetProvider fetches a provider by id. +func (c *Combined) GetProvider(ctx context.Context, id string) (api.AgentNetworkProvider, error) { + return anRequest[api.AgentNetworkProvider](ctx, c, http.MethodGet, "/api/agent-network/providers/"+id, nil) +} + +// ListProviders returns all providers for the account. +func (c *Combined) ListProviders(ctx context.Context) ([]api.AgentNetworkProvider, error) { + return anRequest[[]api.AgentNetworkProvider](ctx, c, http.MethodGet, "/api/agent-network/providers", nil) +} + +// DeleteProvider removes a provider by id. +func (c *Combined) DeleteProvider(ctx context.Context, id string) error { + return anDelete(ctx, c, "/api/agent-network/providers/"+id) +} + +// UpdateProvider replaces a provider by id (PUT). The API key may be omitted on +// the request to keep the stored one; Models replaces the enumerated list, so +// this is the path a test uses to change a model's price mid-run. +func (c *Combined) UpdateProvider(ctx context.Context, id string, req api.AgentNetworkProviderRequest) (api.AgentNetworkProvider, error) { + return anRequest[api.AgentNetworkProvider](ctx, c, http.MethodPut, "/api/agent-network/providers/"+id, req) +} + +// SetProviderEnabled toggles a provider's enabled flag, preserving its other +// fields (the API key is omitted, which keeps the stored one). Used to run one +// provider at a time so model→provider routing is unambiguous. +func (c *Combined) SetProviderEnabled(ctx context.Context, id string, enabled bool) error { + p, err := c.GetProvider(ctx, id) + if err != nil { + return err + } + _, err = anRequest[api.AgentNetworkProvider](ctx, c, http.MethodPut, "/api/agent-network/providers/"+id, api.AgentNetworkProviderRequest{ + Name: p.Name, + ProviderId: p.ProviderId, + UpstreamUrl: p.UpstreamUrl, + Enabled: &enabled, + Models: &p.Models, + }) + return err +} + +// GetAgentConfig returns the caller-scoped self-service connection config — +// the answer the dashboard's "Connect Agent" view renders for the PAT's user. +// Providers appear only when the caller's own groups intersect an enabled +// policy's source groups, so tests must place the PAT user into the policy's +// source group first (via the Users API auto-groups). +func (c *Combined) GetAgentConfig(ctx context.Context) (api.AgentNetworkAgentConfig, error) { + return anRequest[api.AgentNetworkAgentConfig](ctx, c, http.MethodGet, "/api/agent-network/agent-config", nil) +} + +// CreatePolicy creates an agent-network policy. +func (c *Combined) CreatePolicy(ctx context.Context, req api.AgentNetworkPolicyRequest) (api.AgentNetworkPolicy, error) { + return anRequest[api.AgentNetworkPolicy](ctx, c, http.MethodPost, "/api/agent-network/policies", req) +} + +// UpdatePolicy replaces a policy by id. +func (c *Combined) UpdatePolicy(ctx context.Context, id string, req api.AgentNetworkPolicyRequest) (api.AgentNetworkPolicy, error) { + return anRequest[api.AgentNetworkPolicy](ctx, c, http.MethodPut, "/api/agent-network/policies/"+id, req) +} + +// DeletePolicy removes a policy by id. +func (c *Combined) DeletePolicy(ctx context.Context, id string) error { + return anDelete(ctx, c, "/api/agent-network/policies/"+id) +} + +// CreateGuardrail creates an agent-network guardrail (e.g. a model allowlist) +// that can then be attached to a policy via its GuardrailIds. +func (c *Combined) CreateGuardrail(ctx context.Context, req api.AgentNetworkGuardrailRequest) (api.AgentNetworkGuardrail, error) { + return anRequest[api.AgentNetworkGuardrail](ctx, c, http.MethodPost, "/api/agent-network/guardrails", req) +} + +// DeleteGuardrail removes a guardrail by id. +func (c *Combined) DeleteGuardrail(ctx context.Context, id string) error { + return anDelete(ctx, c, "/api/agent-network/guardrails/"+id) +} + +// CreateSettings bootstraps the account's agent-network settings row, +// assigning the immutable endpoint. Exactly one of req.ProxyAddress (labeled +// endpoint beneath that cluster) and req.Endpoint (self-addressed dedicated +// endpoint) must be set; a second bootstrap returns a conflict. +func (c *Combined) CreateSettings(ctx context.Context, req api.AgentNetworkSettingsCreateRequest) (api.AgentNetworkSettings, error) { + return anRequest[api.AgentNetworkSettings](ctx, c, http.MethodPost, "/api/agent-network/settings", req) +} + +// GetSettings returns the account's agent-network settings row. Before the +// CreateSettings bootstrap it reads as the defaults with an empty endpoint. +func (c *Combined) GetSettings(ctx context.Context) (api.AgentNetworkSettings, error) { + return anRequest[api.AgentNetworkSettings](ctx, c, http.MethodGet, "/api/agent-network/settings", nil) +} + +// UpdateSettings applies the mutable collection toggles. The request must +// echo the assigned endpoint and proxy address unchanged — the server rejects +// a PUT that tries to change them. +func (c *Combined) UpdateSettings(ctx context.Context, req api.AgentNetworkSettingsRequest) (api.AgentNetworkSettings, error) { + return anRequest[api.AgentNetworkSettings](ctx, c, http.MethodPut, "/api/agent-network/settings", req) +} + +// DeleteSettings removes the account's settings row, releasing the endpoint. +// Refused while providers exist or a proxy is actively serving the endpoint. +func (c *Combined) DeleteSettings(ctx context.Context) error { + return anDelete(ctx, c, "/api/agent-network/settings") +} + +// ListConsumption returns the account's consumption rows (possibly empty). +func (c *Combined) ListConsumption(ctx context.Context) ([]api.AgentNetworkConsumption, error) { + return anRequest[[]api.AgentNetworkConsumption](ctx, c, http.MethodGet, "/api/agent-network/consumption", nil) +} + +// ListAccessLogs returns the account's agent-network access-log page (the +// flattened per-request rows the proxy ships and management ingests). +func (c *Combined) ListAccessLogs(ctx context.Context) (api.AgentNetworkAccessLogsResponse, error) { + return anRequest[api.AgentNetworkAccessLogsResponse](ctx, c, http.MethodGet, "/api/agent-network/access-logs", nil) +} + +// ListAccessLogsFiltered returns the access-log page narrowed by the given +// query parameters (e.g. model=..., session_id=..., provider_id=...). This +// exercises management's server-side filtering rather than filtering client +// side, so a row that is ingested but not indexed under the filtered column +// surfaces as an empty page. +func (c *Combined) ListAccessLogsFiltered(ctx context.Context, query url.Values) (api.AgentNetworkAccessLogsResponse, error) { + path := "/api/agent-network/access-logs" + if encoded := query.Encode(); encoded != "" { + path += "?" + encoded + } + return anRequest[api.AgentNetworkAccessLogsResponse](ctx, c, http.MethodGet, path, nil) +} diff --git a/e2e/harness/bootstrap.go b/e2e/harness/bootstrap.go new file mode 100644 index 000000000..defa03c14 --- /dev/null +++ b/e2e/harness/bootstrap.go @@ -0,0 +1,47 @@ +//go:build e2e + +package harness + +import ( + "context" + "fmt" + + "github.com/netbirdio/netbird/shared/management/client/rest" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// Bootstrap creates the initial admin owner through the unauthenticated +// /api/setup endpoint and returns the plaintext admin PAT. It also wires an +// authenticated REST client on the Combined (see API). create_pat requires the +// server to run with NB_SETUP_PAT_ENABLED=true, which the harness sets. A +// second call returns an error (the server reports setup already completed). +func (c *Combined) Bootstrap(ctx context.Context) (string, error) { + // The setup endpoint is unauthenticated; use a tokenless client. + setupClient := rest.NewWithOptions(rest.WithManagementURL(c.BaseURL)) + + createPAT := true + expireDays := 1 + resp, err := setupClient.Instance.Setup(ctx, api.PostApiSetupJSONRequestBody{ //nolint:gosec // static throwaway test credentials + Email: "admin@netbird.test", + Password: "Netbird-e2e-Passw0rd!", + Name: "E2E Admin", + CreatePat: &createPAT, + PatExpireIn: &expireDays, + }) + if err != nil { + return "", fmt.Errorf("instance setup: %w", err) + } + if resp.PersonalAccessToken == nil || *resp.PersonalAccessToken == "" { + return "", fmt.Errorf("setup succeeded but no PAT returned (is NB_SETUP_PAT_ENABLED set?)") + } + + c.PAT = *resp.PersonalAccessToken + c.api = rest.New(c.BaseURL, c.PAT) + return c.PAT, nil +} + +// API returns the PAT-authenticated management REST client. It is nil until +// Bootstrap runs. +func (c *Combined) API() *rest.Client { + return c.api +} diff --git a/e2e/harness/cert.go b/e2e/harness/cert.go new file mode 100644 index 000000000..c8a28e470 --- /dev/null +++ b/e2e/harness/cert.go @@ -0,0 +1,66 @@ +//go:build e2e + +package harness + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "os" + "path/filepath" + "time" +) + +// writeSelfSignedCert generates a self-signed TLS cert/key pair covering the +// given DNS names and writes them as tls.crt / tls.key in dir. The proxy serves +// this for the agent-network endpoint; the client curls with -k, so validity +// chains don't matter — the proxy just needs a usable cert to present. +func writeSelfSignedCert(dir string, dnsNames []string) error { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return fmt.Errorf("generate key: %w", err) + } + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return fmt.Errorf("generate serial: %w", err) + } + + tmpl := x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: dnsNames[0]}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: dnsNames, + BasicConstraintsValid: true, + } + + der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv) + if err != nil { + return fmt.Errorf("create certificate: %w", err) + } + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + if err := os.WriteFile(filepath.Join(dir, "tls.crt"), certPEM, 0o644); err != nil { //nolint:gosec // public cert, bind-mounted and read by the proxy container + return fmt.Errorf("write cert: %w", err) + } + + keyDER, err := x509.MarshalECPrivateKey(priv) + if err != nil { + return fmt.Errorf("marshal key: %w", err) + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + // World-readable so the (non-root) proxy container can read the bind-mounted + // key on Linux CI runners; this is a throwaway self-signed e2e key. + if err := os.WriteFile(filepath.Join(dir, "tls.key"), keyPEM, 0o644); err != nil { //nolint:gosec // throwaway self-signed e2e key, must be readable by the proxy container uid + return fmt.Errorf("write key: %w", err) + } + return nil +} diff --git a/e2e/harness/client.go b/e2e/harness/client.go new file mode 100644 index 000000000..9843c25b2 --- /dev/null +++ b/e2e/harness/client.go @@ -0,0 +1,458 @@ +//go:build e2e + +package harness + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "os/exec" + "strconv" + "strings" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/testcontainers/testcontainers-go" + tcexec "github.com/testcontainers/testcontainers-go/exec" +) + +const ( + clientDockerfile = "e2e/harness/Dockerfile.client" + // defaultClientImage is the local tag the client is built under from + // clientDockerfile. Override with NB_E2E_CLIENT_IMAGE: a value with a "/" is + // pulled as a published image; a bare tag is built under that name. + defaultClientImage = "netbird-client:e2e" + clientAlias = "client" + curlImage = "curlimages/curl:latest" +) + +// Client is a running NetBird client container joined to the combined server. +type Client struct { + container testcontainers.Container + // name is the container hostname the agent reports to management at + // registration — the name the peer appears under in the peers API. + name string +} + +// clientOptions is what the ClientOption values assemble. +type clientOptions struct { + name string +} + +// ClientOption adjusts how StartClient runs the agent. +type ClientOption func(*clientOptions) + +// WithClientName names the agent, which sets both its network alias and its +// container hostname. The hostname matters beyond addressing: the agent reports +// it to management at registration, so it is the name the peer appears under in +// the API. +// +// Required to run more than one agent against the same server — the default name +// is shared, and two containers cannot hold the same alias on one network. +func WithClientName(name string) ClientOption { + return func(o *clientOptions) { o.name = name } +} + +// StartClient builds the client image and runs it on the combined server's +// network, joining via the given setup key. The image entrypoint brings the +// daemon up automatically; callers wait for connectivity with WaitConnected / +// WaitProxyPeer. +func StartClient(ctx context.Context, c *Combined, setupKey string, opts ...ClientOption) (*Client, error) { + o := clientOptions{name: clientAlias} + for _, opt := range opts { + opt(&o) + } + + root, err := repoRoot(ctx) + if err != nil { + return nil, err + } + clientImage, err := resolveImage(ctx, root, "NB_E2E_CLIENT_IMAGE", defaultClientImage, clientDockerfile) + if err != nil { + return nil, err + } + + req := testcontainers.ContainerRequest{ + Image: clientImage, + // The agent reports the container's hostname to management, so this is + // the name the peer is addressable by in the API as well as on the + // network. The entrypoint takes no hostname flag of its own. + Hostname: o.name, + Networks: []string{c.network.Name}, + NetworkAliases: map[string][]string{c.network.Name: {o.name}}, + Env: map[string]string{ + "NB_MANAGEMENT_URL": combinedExposedURL, + "NB_SETUP_KEY": setupKey, + "NB_LOG_LEVEL": "info", + // Match the proxy: the combined relay is WebSocket-only, so the + // client must use WS transport to keep a stable relay link to it. + "NB_RELAY_TRANSPORT": "ws", + }, + HostConfigModifier: func(hc *container.HostConfig) { + hc.CapAdd = append(hc.CapAdd, "NET_ADMIN", "SYS_ADMIN", "SYS_RESOURCE") + }, + } + + ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + return nil, fmt.Errorf("start client container: %w", err) + } + return &Client{container: ctr, name: o.name}, nil +} + +// Hostname returns the container hostname the agent reports to management — +// the name the registered peer appears under in the peers API. +func (cl *Client) Hostname() string { + return cl.name +} + +// Restart bounces the client connection (netbird down/up) so it pulls a fresh +// network map — the documented workaround for a freshly-joined client not yet +// seeing a synthesized agent-network service. +func (cl *Client) Restart(ctx context.Context) error { + return cl.Up(ctx) +} + +// Up re-runs `netbird up` inside the client with the given extra flags (e.g. +// "--allow-remote-jobs"), bouncing the connection first so the new config is +// picked up and re-synced to management. Used to toggle peer options that ride +// on the login/sync request without recreating the container. +func (cl *Client) Up(ctx context.Context, extraArgs ...string) error { + if _, _, err := cl.container.Exec(ctx, []string{"netbird", "down"}, tcexec.Multiplexed()); err != nil { + return fmt.Errorf("netbird down: %w", err) + } + time.Sleep(2 * time.Second) + code, reader, err := cl.container.Exec(ctx, append([]string{"netbird", "up"}, extraArgs...), tcexec.Multiplexed()) + if err != nil { + return fmt.Errorf("netbird up: %w", err) + } + if code != 0 { + out, _ := io.ReadAll(reader) + return fmt.Errorf("netbird up %v exited %d: %s", extraArgs, code, string(out)) + } + return nil +} + +// Status returns `netbird status` output from inside the client. +func (cl *Client) Status(ctx context.Context) (string, error) { + code, reader, err := cl.container.Exec(ctx, []string{"netbird", "status"}, tcexec.Multiplexed()) + if err != nil { + return "", err + } + out, _ := io.ReadAll(reader) + if code != 0 { + return string(out), fmt.Errorf("netbird status exited %d", code) + } + return string(out), nil +} + +// WaitConnected polls until the client reports Management: Connected. +func (cl *Client) WaitConnected(ctx context.Context, timeout time.Duration) error { + return cl.pollStatus(ctx, timeout, "Management: Connected") +} + +// WaitProxyPeer polls until the client sees at least one connected peer — the +// proxy serving the agent-network endpoint. It requires ">=1 connected" rather +// than an exact "1/1" because proxy peers from earlier tests linger in the +// account as disconnected (each proxy container registers a fresh WireGuard key +// and the peer is not removed on teardown), so the count is e.g. "1/2". Only the +// live proxy can be connected, and the caller's subsequent chat is the real +// end-to-end assertion. +func (cl *Client) WaitProxyPeer(ctx context.Context, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + var last string + for time.Now().Before(deadline) { + out, _ := cl.Status(ctx) + last = out + if connectedPeers(out) >= 1 { + return nil + } + time.Sleep(3 * time.Second) + } + return fmt.Errorf("timed out waiting for a connected proxy peer; last status:\n%s", last) +} + +// connectedPeers parses the "Peers count: X/Y Connected" line from `netbird +// status` and returns X (the connected count), or 0 when absent/unparseable. +func connectedPeers(status string) int { + for _, line := range strings.Split(status, "\n") { + line = strings.TrimSpace(line) + rest, ok := strings.CutPrefix(line, "Peers count:") + if !ok { + continue + } + rest = strings.TrimSpace(rest) + slash := strings.IndexByte(rest, '/') + if slash <= 0 { + return 0 + } + n, err := strconv.Atoi(strings.TrimSpace(rest[:slash])) + if err != nil { + return 0 + } + return n + } + return 0 +} + +func (cl *Client) pollStatus(ctx context.Context, timeout time.Duration, want string) error { + deadline := time.Now().Add(timeout) + var last string + for time.Now().Before(deadline) { + out, _ := cl.Status(ctx) + last = out + if strings.Contains(out, want) { + return nil + } + time.Sleep(3 * time.Second) + } + return fmt.Errorf("timed out waiting for %q; last status:\n%s", want, last) +} + +const ( + // curlExitCouldNotResolve is curl's exit code for a DNS resolution failure, distinct from connection-level failures. + curlExitCouldNotResolve = 6 + // curlExitCouldNotConnect is curl's exit code for a connection that never + // established. The probe exists to WAKE the lazy proxy peer, so the first + // attempt legitimately arrives before WireGuard has brought the tunnel up + // and fails here — which is propagation, exactly like an early NXDOMAIN, + // and belongs inside the retry window rather than failing the test outright. + curlExitCouldNotConnect = 7 + // endpointProbeRetryWindow bounds retries of the transient failures above: the synthesized zone and the tunnel both land a beat after management connects. Still failing after this window is a real failure. + endpointProbeRetryWindow = 30 * time.Second + endpointProbeRetryInterval = 2 * time.Second +) + +// ResolveProxyIP GETs https:/// from the client's netns: any HTTP status proves DNS + tunnel and wakes the lazy proxy peer; DNS and connect failures retry, within endpointProbeRetryWindow. Returns the connected IP for --resolve pinning. +func (cl *Client) ResolveProxyIP(ctx context.Context, endpoint string) (string, error) { + args := []string{ + "run", "--rm", + "--network", "container:" + cl.container.GetContainerID(), + curlImage, + "-ksS", "-o", "/dev/null", + "--connect-timeout", "30", "--max-time", "60", + "-w", "%{remote_ip}", + "https://" + endpoint + "/", + } + deadline := time.Now().Add(endpointProbeRetryWindow) + for { + cmd := exec.CommandContext(ctx, "docker", args...) + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if err == nil { + ip := strings.TrimSpace(stdout.String()) + if ip == "" { + return "", fmt.Errorf("got an HTTP response from %s but no remote IP", endpoint) + } + return ip, nil + } + + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || !isTransientProbeExit(exitErr.ExitCode()) { + return "", fmt.Errorf("no HTTP response from %s: %w (%s)", endpoint, err, strings.TrimSpace(stderr.String())) + } + probeErr := fmt.Errorf("endpoint %s not reachable yet: %s", endpoint, strings.TrimSpace(stderr.String())) + if time.Until(deadline) < endpointProbeRetryInterval { + return "", probeErr + } + select { + case <-ctx.Done(): + return "", fmt.Errorf("%w (%w)", probeErr, ctx.Err()) + case <-time.After(endpointProbeRetryInterval): + } + } +} + +// isTransientProbeExit reports whether a curl exit code describes a state the +// endpoint is expected to pass THROUGH on its way up, rather than a settled +// failure. Anything else — TLS refusal, a protocol error, a bad argument — +// would still be failing after the retry window, so it fails immediately. +func isTransientProbeExit(code int) bool { + return code == curlExitCouldNotResolve || code == curlExitCouldNotConnect +} + +// Wire shapes for Chat. +const ( + // WireChat is the OpenAI-compatible /v1/chat/completions shape. + WireChat = "chat" + // WireMessages is the Anthropic /v1/messages shape. + WireMessages = "messages" + // WireVertex is the Anthropic-on-Vertex rawPredict shape: the client posts + // the full Vertex model path and the proxy mints the SA OAuth token. + WireVertex = "vertex" + // WireBedrock is the native AWS Bedrock InvokeModel shape: the model id + // travels in the URL path (/model/{id}/invoke), not the body, so the proxy + // routes by path. This is what a Bedrock SDK client sends and the shape the + // model-allowlist guardrail must enforce. + WireBedrock = "bedrock" +) + +// Chat issues a chat-completion POST to the agent-network endpoint over the +// client's tunnel, returning the HTTP status and response body. kind selects +// the wire shape: WireChat (OpenAI) or WireMessages (Anthropic). A non-empty +// sessionID is sent as the universal x-session-id header the proxy records. +func (cl *Client) Chat(ctx context.Context, endpoint, proxyIP, kind, model, prompt, sessionID string) (int, string, error) { + return cl.ChatPrefixed(ctx, endpoint, proxyIP, "", kind, model, prompt, sessionID) +} + +// ChatPrefixed is Chat with a base-URL path prefix prepended to the wire +// path, mirroring agents whose base URL carries a shape-selecting prefix that +// rides through to the upstream — e.g. Claude Code against a Kimi provider +// sets ANTHROPIC_BASE_URL=https:///anthropic so the proxy forwards +// /anthropic/v1/messages to Moonshot's Anthropic surface while the provider's +// upstream URL stays the bare https://api.moonshot.ai. Empty prefix is plain +// Chat. +func (cl *Client) ChatPrefixed(ctx context.Context, endpoint, proxyIP, pathPrefix, kind, model, prompt, sessionID string) (int, string, error) { + var path, body string + var headers []string + switch kind { + case WireMessages: + path = "/v1/messages" + headers = []string{"anthropic-version: 2023-06-01"} + body = fmt.Sprintf(`{"model":%q,"max_tokens":2048,"messages":[{"role":"user","content":%q}]}`, model, prompt) + default: + path = "/v1/chat/completions" + body = fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":%q}]}`, model, prompt) + } + return cl.post(ctx, endpoint, proxyIP, pathPrefix+path, body, withSessionID(headers, sessionID)) +} + +// ChatStream is Chat with "stream": true in the request body, so the proxy's +// request parser marks the call as streaming and its response parser takes the +// SSE accumulator rather than the buffered-body path. Pair it with a provider +// pointed at VLLM.StreamURL, which answers every request as an event stream. +func (cl *Client) ChatStream(ctx context.Context, endpoint, proxyIP, kind, model, prompt, sessionID string) (int, string, error) { + var path, body string + var headers []string + switch kind { + case WireMessages: + path = "/v1/messages" + headers = []string{"anthropic-version: 2023-06-01"} + body = fmt.Sprintf(`{"model":%q,"max_tokens":2048,"stream":true,"messages":[{"role":"user","content":%q}]}`, model, prompt) + default: + path = "/v1/chat/completions" + // include_usage is what makes a real OpenAI stream emit its final usage + // frame; without it the last chunk carries no tokens at all. + body = fmt.Sprintf(`{"model":%q,"stream":true,"stream_options":{"include_usage":true},"messages":[{"role":"user","content":%q}]}`, model, prompt) + } + return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(headers, sessionID)) +} + +// Vertex issues an Anthropic-on-Vertex rawPredict POST over the tunnel. Unlike +// Chat, the model is carried in the request path (project/region/model), so the +// proxy routes by path and mints the service-account OAuth token; the body uses +// the Vertex anthropic_version rather than a model field. A non-empty sessionID +// is sent as the universal x-session-id header the proxy records. +func (cl *Client) Vertex(ctx context.Context, endpoint, proxyIP, project, region, model, prompt, sessionID string) (int, string, error) { + path := fmt.Sprintf("/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:rawPredict", project, region, model) + body := fmt.Sprintf(`{"anthropic_version":"vertex-2023-10-16","max_tokens":2048,"messages":[{"role":"user","content":%q}]}`, prompt) + return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(nil, sessionID)) +} + +// Bedrock issues a native AWS Bedrock InvokeModel POST over the tunnel. The +// model id is carried in the request path (/model/{id}/invoke), so the proxy +// routes by path; the body uses the bedrock anthropic_version rather than a +// model field. A non-empty sessionID is sent as the universal x-session-id +// header the proxy records. +func (cl *Client) Bedrock(ctx context.Context, endpoint, proxyIP, model, prompt, sessionID string) (int, string, error) { + path := "/model/" + model + "/invoke" + body := fmt.Sprintf(`{"anthropic_version":"bedrock-2023-05-31","max_tokens":2048,"messages":[{"role":"user","content":%q}]}`, prompt) + return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(nil, sessionID)) +} + +// withSessionID appends the x-session-id header when sessionID is non-empty. +func withSessionID(headers []string, sessionID string) []string { + if sessionID == "" { + return headers + } + return append(headers, "x-session-id: "+sessionID) +} + +// Get issues a GET to the agent-network endpoint over the client's tunnel. +// Model discovery and the connection-warming probe are read-only endpoints +// that carry no body, so they can't go through the chat helpers. +func (cl *Client) Get(ctx context.Context, endpoint, proxyIP, path string, extraHeaders []string) (int, string, error) { + return cl.do(ctx, http.MethodGet, endpoint, proxyIP, path, "", extraHeaders) +} + +// PostJSON issues an arbitrary JSON POST over the client's tunnel, for wire +// shapes the typed helpers don't cover (token counting, say). +func (cl *Client) PostJSON(ctx context.Context, endpoint, proxyIP, path, body string, extraHeaders []string) (int, string, error) { + return cl.do(ctx, http.MethodPost, endpoint, proxyIP, path, body, extraHeaders) +} + +// post issues a JSON POST. Retained as the shorthand the chat helpers use. +func (cl *Client) post(ctx context.Context, endpoint, proxyIP, path, body string, extraHeaders []string) (int, string, error) { + return cl.do(ctx, http.MethodPost, endpoint, proxyIP, path, body, extraHeaders) +} + +// do runs curl in a throwaway container sharing the client's network +// namespace so the request traverses the WireGuard tunnel, pinning the endpoint +// to the proxy IP. It returns the HTTP status and response body. An empty body +// sends no payload, which is what a GET needs. +func (cl *Client) do(ctx context.Context, method, endpoint, proxyIP, path, body string, extraHeaders []string) (int, string, error) { + url := "https://" + endpoint + path + args := []string{ + "run", "--rm", + "--network", "container:" + cl.container.GetContainerID(), + curlImage, + "-sk", "--connect-timeout", "5", "--max-time", "90", + "--resolve", endpoint + ":443:" + proxyIP, + "-o", "/dev/stderr", "-w", "%{http_code}", + "-X", method, url, + "-H", "Content-Type: application/json", + } + for _, h := range extraHeaders { + args = append(args, "-H", h) + } + if body != "" { + args = append(args, "--data", body) + } + cmd := exec.CommandContext(ctx, "docker", args...) + // -w writes the status code to stdout; -o /dev/stderr writes the body to + // stderr so we can capture both separately. + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return 0, stderr.String(), fmt.Errorf("curl through tunnel: %w", err) + } + + code := 0 + _, _ = fmt.Sscanf(strings.TrimSpace(stdout.String()), "%d", &code) + return code, stderr.String(), nil +} + +// Logs returns the client container logs, for diagnostics on failure. +func (cl *Client) Logs(ctx context.Context) string { + return containerLogs(ctx, cl.container) +} + +// Terminate stops the client container. +func (cl *Client) Terminate(ctx context.Context) error { + if cl.container == nil { + return nil + } + return cl.container.Terminate(ctx) +} + +// containerLogs reads up to 4 MiB of a container's logs for diagnostics — enough for a whole provider-matrix run. +func containerLogs(ctx context.Context, c testcontainers.Container) string { + if c == nil { + return "" + } + r, err := c.Logs(ctx) + if err != nil { + return fmt.Sprintf("", err) + } + defer r.Close() + b, _ := io.ReadAll(io.LimitReader(r, 4<<20)) + return string(b) +} diff --git a/e2e/harness/combined.go b/e2e/harness/combined.go new file mode 100644 index 000000000..e03f9f256 --- /dev/null +++ b/e2e/harness/combined.go @@ -0,0 +1,327 @@ +//go:build e2e + +package harness + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/go-connections/nat" + "github.com/testcontainers/testcontainers-go" + tcexec "github.com/testcontainers/testcontainers-go/exec" + "github.com/testcontainers/testcontainers-go/network" + "github.com/testcontainers/testcontainers-go/wait" + + "github.com/netbirdio/netbird/shared/management/client/rest" +) + +const ( + combinedDockerfile = "combined/Dockerfile.multistage" + // defaultCombinedImage is the local tag the combined server is built under + // from combinedDockerfile, so the e2e exercises this branch's code. Override + // with NB_E2E_COMBINED_IMAGE: a value containing a "/" is pulled as a + // published image; a bare tag is built under that name instead. + defaultCombinedImage = "netbird-combined:e2e" + combinedHTTPPort = "8080/tcp" + + // combinedAlias is the combined server's network alias AND the deployment + // domain. The working manual setup uses a single NETBIRD_DOMAIN for the + // management exposed address, the proxy domain, and the agent-network + // cluster — so we mirror that: peers reach management/signal/relay at this + // name, the proxy registers this as its cluster, and the agent-network + // endpoint is .. + combinedAlias = "netbird.local" + combinedExposedURL = "http://" + combinedAlias + ":8080" + + // containerIssuer is the embedded IdP issuer, used only for internal JWT + // validation (peers authenticate with setup keys / proxy tokens, not OIDC), + // so the in-container localhost address is fine. + containerIssuer = "http://localhost:8080/oauth2" +) + +// Combined is a running combined NetBird server (management + signal + relay + +// STUN + embedded IdP) plus the connection details tests need. It owns the +// shared docker network that the proxy and client containers join. +type Combined struct { + container testcontainers.Container + network *testcontainers.DockerNetwork + // BaseURL is the host-reachable management API root, e.g. http://127.0.0.1:51234. + BaseURL string + // PAT is the admin Personal Access Token minted via Bootstrap. + PAT string + + api *rest.Client + workDir string +} + +// combinedOptions is what the CombinedOption values assemble. +type combinedOptions struct { + geolocation bool + env map[string]string +} + +// CombinedOption adjusts how StartCombined boots the server. The defaults suit a +// suite that only drives the API; the options exist for the ones that need more +// of the product than that. +type CombinedOption func(*combinedOptions) + +// WithGeolocation leaves the GeoLite database download enabled. It is off by +// default because the download adds startup latency that most suites get nothing +// for. A suite asserting on location-based posture checks needs it: management +// evaluates those rules against the database, and without it the rule fails +// instead of passing without having been checked. +func WithGeolocation() CombinedOption { + return func(o *combinedOptions) { o.geolocation = true } +} + +// WithServerEnv adds environment variables to the combined container, overriding +// the defaults on a key collision. For settings this harness does not model +// directly, so a suite needing one does not have to fork the harness to get it. +func WithServerEnv(env map[string]string) CombinedOption { + return func(o *combinedOptions) { + if o.env == nil { + o.env = map[string]string{} + } + for k, v := range env { + o.env[k] = v + } + } +} + +// combinedEnv is the combined container's environment: setup-PAT enabled so the +// caller can mint an admin token through /api/setup, geolocation off unless the +// suite asked for it, and whatever the suite added on top. +func combinedEnv(o combinedOptions) map[string]string { + env := map[string]string{ + "NB_SETUP_PAT_ENABLED": "true", + } + if !o.geolocation { + // Skip the GeoLite DB download — it blocks startup and agent-network + // ingest doesn't use geolocation. + env["NB_DISABLE_GEOLOCATION"] = "true" + } + for k, v := range o.env { + env[k] = v + } + return env +} + +// StartCombined builds the combined server from its multistage Dockerfile and +// boots it with setup-PAT enabled on a fresh shared network, returning once the +// API is serving. The caller still owns minting the admin PAT via Bootstrap. +func StartCombined(ctx context.Context, opts ...CombinedOption) (*Combined, error) { + var o combinedOptions + for _, opt := range opts { + opt(&o) + } + + root, err := repoRoot(ctx) + if err != nil { + return nil, err + } + + combinedImage, err := resolveImage(ctx, root, "NB_E2E_COMBINED_IMAGE", defaultCombinedImage, combinedDockerfile) + if err != nil { + return nil, err + } + + net, err := network.New(ctx) + if err != nil { + return nil, fmt.Errorf("create shared network: %w", err) + } + + // Work dir under /tmp so Docker Desktop file sharing (which excludes + // macOS's /var/folders TMPDIR) can bind-mount it. + workDir, err := os.MkdirTemp("/tmp", "nb-e2e-combined-*") + if err != nil { + _ = net.Remove(ctx) + return nil, fmt.Errorf("create work dir: %w", err) + } + + cfg := fmt.Sprintf(combinedConfigYAML, combinedExposedURL, !o.geolocation, containerIssuer) + if err := os.WriteFile(filepath.Join(workDir, "config.yaml"), []byte(cfg), 0o644); err != nil { //nolint:gosec // non-secret config, bind-mounted and read by the container + _ = net.Remove(ctx) + return nil, fmt.Errorf("write combined config: %w", err) + } + dataDir := filepath.Join(workDir, "data") + if err := os.MkdirAll(dataDir, 0o755); err != nil { + _ = net.Remove(ctx) + return nil, fmt.Errorf("create datadir: %w", err) + } + // The config's agentNetwork.pricingDefaultsFile is a bare filename, so the + // server resolves it against the datadir; write it there. It is an explicitly + // configured path, so a failure to load fails the server's startup — which + // surfaces here as the /api/instance readiness wait timing out. + if err := os.WriteFile(filepath.Join(dataDir, PricingDefaultsFileName), []byte(pricingDefaultsYAML), 0o644); err != nil { //nolint:gosec // non-secret config, bind-mounted and read by the container + _ = net.Remove(ctx) + return nil, fmt.Errorf("write pricing defaults: %w", err) + } + + req := testcontainers.ContainerRequest{ + Image: combinedImage, + ExposedPorts: []string{combinedHTTPPort}, + Networks: []string{net.Name}, + NetworkAliases: map[string][]string{net.Name: {combinedAlias}}, + Env: combinedEnv(o), + Cmd: []string{"--config", "/nb/config.yaml"}, + HostConfigModifier: func(hc *container.HostConfig) { + hc.Binds = append(hc.Binds, workDir+":/nb") + }, + WaitingFor: wait.ForHTTP("/api/instance"). + WithPort(combinedHTTPPort). + WithStatusCodeMatcher(func(status int) bool { return status == 200 }). + WithStartupTimeout(120 * time.Second), + } + + c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + _ = net.Remove(ctx) + return nil, fmt.Errorf("start combined container: %w", err) + } + + host, err := c.Host(ctx) + if err != nil { + _ = c.Terminate(ctx) + _ = net.Remove(ctx) + return nil, fmt.Errorf("container host: %w", err) + } + mapped, err := c.MappedPort(ctx, nat.Port(combinedHTTPPort)) + if err != nil { + _ = c.Terminate(ctx) + _ = net.Remove(ctx) + return nil, fmt.Errorf("mapped port: %w", err) + } + + return &Combined{ + container: c, + network: net, + BaseURL: fmt.Sprintf("http://%s:%s", host, mapped.Port()), + workDir: workDir, + }, nil +} + +// resolveImage returns the image to run for a component. By default it builds +// the image from the repo Dockerfile under localTag, so the e2e exercises the +// branch's code. The env override changes this: a value containing a "/" is a +// registry reference that testcontainers pulls (e.g. to test a published +// release); a bare tag is built under that name instead. +func resolveImage(ctx context.Context, root, envKey, localTag, dockerfile string) (string, error) { + if v := os.Getenv(envKey); v != "" { + if strings.Contains(v, "/") { + return v, nil + } + localTag = v + } + if err := buildImage(ctx, root, dockerfile, localTag); err != nil { + return "", err + } + return localTag, nil +} + +// buildImage builds an image from a repo Dockerfile via buildx with BuildKit, so +// the Dockerfile cache mounts are honored and unchanged layers are reused. The +// result is loaded into the docker image store so testcontainers runs it by tag. +// When NB_E2E_BUILDX_CACHE names a directory (CI, with a container-driver +// builder from docker/setup-buildx-action), layer cache is read from and written +// to it as a local cache so actions/cache can persist it across runs; the Go +// compile itself still re-runs, as BuildKit mount caches can't be exported. +func buildImage(ctx context.Context, root, dockerfile, tag string) error { + args := []string{"buildx", "build", "-f", dockerfile, "-t", tag, "--load"} + if dir := os.Getenv("NB_E2E_BUILDX_CACHE"); dir != "" { + args = append(args, + "--cache-from", "type=local,src="+dir, + "--cache-to", "type=local,dest="+dir+",mode=max", + ) + } + args = append(args, ".") + + cmd := exec.CommandContext(ctx, "docker", args...) + cmd.Dir = root + cmd.Env = append(os.Environ(), "DOCKER_BUILDKIT=1") + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("build image %s: %w\n%s", tag, err, string(out)) + } + return nil +} + +// CreateProxyTokenCLI mints a proxy access token via the server's `token +// create` CLI inside the container — the same path the manual install uses. +// This yields a GLOBAL (account-less) token, so the proxy serves the whole +// cluster (SynthesizeServicesForCluster); an account-scoped REST token instead +// drives the per-account path. Returns the plaintext token. +func (c *Combined) CreateProxyTokenCLI(ctx context.Context, name string) (string, error) { + code, reader, err := c.container.Exec(ctx, + []string{"/go/bin/netbird-server", "token", "create", "--name", name, "--config", "/nb/config.yaml"}, + tcexec.Multiplexed()) + if err != nil { + return "", fmt.Errorf("exec token create: %w", err) + } + out, _ := io.ReadAll(reader) + if code != 0 { + return "", fmt.Errorf("token create exited %d: %s", code, string(out)) + } + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Token:") { + tok := strings.TrimSpace(strings.TrimPrefix(line, "Token:")) + if tok != "" { + return tok, nil + } + } + } + return "", fmt.Errorf("token not found in CLI output: %s", string(out)) +} + +// SnapshotStoreDB copies the management sqlite store (with WAL/SHM sidecars) out of the bind-mounted +// data dir into dstDir and returns the copy's path; reading a copy avoids locking against live writes. +func (c *Combined) SnapshotStoreDB(dstDir string) (string, error) { + src := filepath.Join(c.workDir, "data", "store.db") + if _, err := os.Stat(src); err != nil { + return "", fmt.Errorf("management store not found at %s: %w", src, err) + } + dst := filepath.Join(dstDir, "store.db") + for _, suffix := range []string{"", "-wal", "-shm"} { + data, err := os.ReadFile(src + suffix) + if err != nil { + if os.IsNotExist(err) && suffix != "" { + continue // sidecar only exists in WAL mode + } + return "", fmt.Errorf("read %s: %w", src+suffix, err) + } + if err := os.WriteFile(dst+suffix, data, 0o600); err != nil { + return "", fmt.Errorf("write %s: %w", dst+suffix, err) + } + } + return dst, nil +} + +// Logs returns the combined server container logs, for diagnostics. +func (c *Combined) Logs(ctx context.Context) string { + return containerLogs(ctx, c.container) +} + +// Terminate stops the container, removes the shared network, and cleans the +// work dir. +func (c *Combined) Terminate(ctx context.Context) error { + var err error + if c.container != nil { + err = c.container.Terminate(ctx) + } + if c.network != nil { + _ = c.network.Remove(ctx) + } + if c.workDir != "" { + _ = os.RemoveAll(c.workDir) + } + return err +} diff --git a/e2e/harness/config.go b/e2e/harness/config.go new file mode 100644 index 000000000..f0952b18c --- /dev/null +++ b/e2e/harness/config.go @@ -0,0 +1,70 @@ +//go:build e2e + +package harness + +// combinedConfigYAML is a minimal combined-server config for tests: plain HTTP +// on :8080 (no TLS cert configured → the server serves HTTP and expects to sit +// behind a reverse proxy, which is exactly what we want for in-cluster tests), +// embedded IdP, local signal/relay/STUN, and a sqlite store under the mounted +// data dir. exposedAddress is the address peers use to reach this container; it +// is overridden per-run so the value matches the container's network alias. +// +// pricingDefaultsFile is deliberately a BARE FILENAME, not an absolute path: it +// must resolve against dataDir (→ /nb/data/), which is the resolution rule +// the combined server applies. It is also an EXPLICITLY configured path, so the +// server is required to load it — a broken path or malformed file fails startup +// rather than silently falling back to the compiled-in rates, and TestMain then +// fails with the container logs. +// +// disableGeoliteUpdate is a parameter rather than a fixed true because a suite +// that exercises geolocation needs the database: management can only evaluate a +// location rule with GeoLite loaded, and a rule it cannot evaluate fails rather +// than passing vacuously. See WithGeolocation. +const combinedConfigYAML = `server: + listenAddress: ":8080" + exposedAddress: "%s" + healthcheckAddress: ":9000" + metricsPort: 9090 + logLevel: "info" + logFile: "console" + authSecret: "e2e-relay-secret" + dataDir: "/nb/data" + disableAnonymousMetrics: true + disableGeoliteUpdate: %t + auth: + issuer: "%s" + store: + engine: "sqlite" + agentNetwork: + pricingDefaultsFile: "` + PricingDefaultsFileName + `" +` + +const ( + // PricingDefaultsFileName is the basename of the operator-supplied LLM + // pricing defaults file the combined server is configured to load. Written + // into the bind-mounted datadir by StartCombined. + PricingDefaultsFileName = "e2e_llm_pricing.yaml" + + // PricedDefaultModel is a real catalog model (openai surface) whose rates the + // defaults file below REPLACES. Tests drive it against the mock vLLM upstream + // and assert the file's rates were billed, which is only true if the file + // travelled: config → LoadFile → DefaultTable → synthesizer → the proxy's + // cost_meter defaults table. + PricedDefaultModel = "gpt-4.1-mini" + // PricedDefaultInputPer1k / PricedDefaultOutputPer1k are deliberately odd + // values that no compiled-in catalog entry carries (gpt-4.1-mini ships as + // 0.0004 / 0.0016), so a test asserting them cannot pass on the built-in + // table. + PricedDefaultInputPer1k = 0.0123 + PricedDefaultOutputPer1k = 0.0456 +) + +// pricingDefaultsYAML is the operator-supplied pricing defaults file. Its schema +// is surface -> model -> per-1k rates. Entries replace the compiled-in entry for +// the same surface+model whole; every other model keeps its built-in rates, so +// this file overriding one model must not disturb the rest of the table. +const pricingDefaultsYAML = `openai: + gpt-4.1-mini: + input_per_1k: 0.0123 + output_per_1k: 0.0456 +` diff --git a/e2e/harness/doc.go b/e2e/harness/doc.go new file mode 100644 index 000000000..937d8e664 --- /dev/null +++ b/e2e/harness/doc.go @@ -0,0 +1,13 @@ +//go:build e2e + +// Package harness provides a self-contained, OIDC-free way to stand up NetBird +// components in containers for end-to-end tests. It is feature-agnostic: any +// suite can ask for a live management server (with an admin PAT minted through +// the unauthenticated /api/setup bootstrap) and, later, a proxy and client. +// +// The harness compiles each component once in a cached builder container and +// mounts the resulting binary into a slim runtime container, so iterating on a +// branch doesn't pay a full image rebuild per run. Everything is gated behind +// the `e2e` build tag so normal builds and unit tests never pull in +// testcontainers. +package harness diff --git a/e2e/harness/options_test.go b/e2e/harness/options_test.go new file mode 100644 index 000000000..8a5557a83 --- /dev/null +++ b/e2e/harness/options_test.go @@ -0,0 +1,161 @@ +//go:build e2e + +package harness + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The options exist so a suite can ask for a deployment this harness would not +// otherwise give it. What they configure is a container environment and a config +// file, both assembled before anything is started, so they are checkable without +// Docker — which is the point: a wiring mistake here would otherwise only show up +// as a puzzling failure minutes into a container run. + +func TestCombinedEnvGeolocation(t *testing.T) { + var off combinedOptions + assert.Equal(t, "true", combinedEnv(off)["NB_DISABLE_GEOLOCATION"], + "geolocation should be off by default") + + var on combinedOptions + WithGeolocation()(&on) + assert.NotContains(t, combinedEnv(on), "NB_DISABLE_GEOLOCATION", + "WithGeolocation must leave NB_DISABLE_GEOLOCATION unset, so the server downloads the database") + assert.Equal(t, "true", combinedEnv(on)["NB_SETUP_PAT_ENABLED"], + "the setup PAT must stay enabled whatever else is configured; Bootstrap depends on it") +} + +// The config file carries the same decision as the environment variable, and the +// server needs both to agree: disableGeoliteUpdate suppresses the download even +// when geolocation itself is enabled. +func TestCombinedConfigGeolocation(t *testing.T) { + for _, tc := range []struct { + name string + opts []CombinedOption + want string + }{ + {name: "default", want: "disableGeoliteUpdate: true"}, + {name: "with geolocation", opts: []CombinedOption{WithGeolocation()}, want: "disableGeoliteUpdate: false"}, + } { + t.Run(tc.name, func(t *testing.T) { + var o combinedOptions + for _, opt := range tc.opts { + opt(&o) + } + cfg := fmt.Sprintf(combinedConfigYAML, combinedExposedURL, !o.geolocation, containerIssuer) + assert.Contains(t, cfg, tc.want, "geolocation not rendered as expected") + // The issuer is the last verb; a mis-ordered argument list would put + // the boolean here instead and the server would fail to start. + assert.Contains(t, cfg, `issuer: "`+containerIssuer+`"`, "issuer not rendered") + }) + } +} + +func TestWithServerEnvOverrides(t *testing.T) { + var o combinedOptions + WithServerEnv(map[string]string{"NB_LOG_LEVEL": "debug"})(&o) + WithServerEnv(map[string]string{"NB_SETUP_PAT_ENABLED": "false"})(&o) + + env := combinedEnv(o) + assert.Equal(t, "debug", env["NB_LOG_LEVEL"], "added variable missing") + assert.Equal(t, "false", env["NB_SETUP_PAT_ENABLED"], "a suite must be able to override a default") +} + +// Two agents on one network cannot share an alias, so the name has to reach both +// the alias and the hostname. The hostname is the one management records, so it is +// also what the peer is addressable by through the API. +func TestWithClientName(t *testing.T) { + o := clientOptions{name: clientAlias} + require.Equal(t, "client", o.name, "unexpected default client name") + + WithClientName("peer2")(&o) + assert.Equal(t, "peer2", o.name, "WithClientName did not take") +} + +// repoRoot has to recognise this module rather than merely finding a go.mod, or a +// suite in another module gets its own root and a build context without the +// component Dockerfiles in it. +func TestIsModule(t *testing.T) { + dir := t.TempDir() + + other := filepath.Join(dir, "go.mod") + require.NoError(t, os.WriteFile(other, []byte("module example.com/other\n\ngo 1.25\n"), 0o600)) + assert.False(t, isModule(other, modulePath), "another module's go.mod must not be taken for this repo") + + ours := filepath.Join(dir, "ours.mod") + require.NoError(t, os.WriteFile(ours, []byte("// a comment\n\nmodule "+modulePath+"\n\ngo 1.25\n"), 0o600)) + assert.True(t, isModule(ours, modulePath), "this repo's go.mod was not recognised") + + assert.False(t, isModule(filepath.Join(dir, "absent.mod"), modulePath), + "a missing go.mod must not report a match") +} + +// Running from inside the repo, repoRoot finds it by walking up — the module +// lookup is only the fallback, and this asserts the walk still wins so an in-repo +// run never depends on the module cache. +func TestRepoRootFindsThisRepo(t *testing.T) { + root, err := repoRoot(context.Background()) + require.NoError(t, err) + assert.True(t, isModule(filepath.Join(root, "go.mod"), modulePath), + "repoRoot returned %s, which is not this module", root) + + for _, f := range []string{combinedDockerfile, clientDockerfile} { + _, err := os.Stat(filepath.Join(root, f)) + assert.NoError(t, err, "%s is not present under the reported root %s", f, root) + } +} + +// A caller that vendors its dependencies puts the go command in automatic vendor +// mode, where `go list -m -f {{.Dir}}` succeeds and reports an EMPTY directory: +// vendor/ holds packages, not module source. Without -mod=readonly the lookup +// would come back empty and the harness would report a missing module for a +// dependency that is present. +func TestModuleDirResolvesUnderVendorMode(t *testing.T) { + if _, err := exec.LookPath("go"); err != nil { + t.Skip("no go tool on PATH") + } + ctx := context.Background() + + base := t.TempDir() + dep := filepath.Join(base, "dep") + main := filepath.Join(base, "main") + require.NoError(t, os.MkdirAll(dep, 0o750)) + require.NoError(t, os.MkdirAll(main, 0o750)) + + // A local replacement rather than a real dependency, so this needs no network. + require.NoError(t, os.WriteFile(filepath.Join(dep, "go.mod"), + []byte("module example.com/dep\n\ngo 1.25\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dep, "dep.go"), + []byte("package dep\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(main, "go.mod"), + []byte("module example.com/main\n\ngo 1.25\n\nrequire example.com/dep v0.0.0\n\nreplace example.com/dep v0.0.0 => ../dep\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(main, "main.go"), + []byte("package main\n\nimport _ \"example.com/dep\"\n\nfunc main() {}\n"), 0o600)) + + t.Chdir(main) + vendor := exec.CommandContext(ctx, "go", "mod", "vendor") + out, err := vendor.CombinedOutput() + require.NoError(t, err, "go mod vendor: %s", out) + + dir, err := moduleDir(ctx, "example.com/dep") + require.NoError(t, err, "the module must still resolve with a vendor directory present") + assert.Equal(t, dep, dir, "resolved the wrong directory") +} + +// A cancelled context has to stop the lookup rather than leaving the caller +// waiting on a subprocess it has already given up on. +func TestModuleDirHonoursContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := moduleDir(ctx, modulePath) + assert.ErrorIs(t, err, context.Canceled, "a cancelled context must stop the lookup") +} diff --git a/e2e/harness/paths.go b/e2e/harness/paths.go new file mode 100644 index 000000000..569c32efc --- /dev/null +++ b/e2e/harness/paths.go @@ -0,0 +1,84 @@ +//go:build e2e + +package harness + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// modulePath is this module, used both to recognise the repo when walking up +// from the working directory and to locate it when the suite lives elsewhere. +const modulePath = "github.com/netbirdio/netbird" + +// repoRoot returns the directory the component Dockerfiles are built from. +// +// Walking up from the working directory finds it for any test inside this repo, +// no matter which package it runs from. A suite in another module gets a +// different answer that way — its own module root, where combined/Dockerfile +// does not exist — so the ancestor has to be this module and not merely some +// module. When it is not, the build context is the extracted module directory of +// whichever version that suite depends on, which is the right one: the server it +// tests against is then built from the same revision as the client library it +// was compiled with. +func repoRoot(ctx context.Context) (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + for { + if isModule(filepath.Join(dir, "go.mod"), modulePath) { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return moduleDir(ctx, modulePath) +} + +// isModule reports whether the go.mod at path declares the given module. +func isModule(path, want string) bool { + b, err := os.ReadFile(path) + if err != nil { + return false + } + for _, line := range strings.Split(string(b), "\n") { + if rest, ok := strings.CutPrefix(strings.TrimSpace(line), "module "); ok { + return strings.TrimSpace(rest) == want + } + } + return false +} + +// moduleDir asks the go tool where a module's source is, which for a dependent +// module is its extracted copy in the module cache. The cache is read-only, and +// a Docker build context is only ever read. +// +// -mod=readonly is required rather than cosmetic. A caller that vendors its +// dependencies puts the go command in automatic vendor mode, where this lookup +// succeeds with an EMPTY directory — vendor/ holds packages, not module source, +// so there is nothing to report. Asking in readonly mode resolves against the +// module graph instead, which answers for both a cached module and a local +// replacement, and neither writes to go.mod. +func moduleDir(ctx context.Context, module string) (string, error) { + cmd := exec.CommandContext(ctx, "go", "list", "-mod=readonly", "-m", "-f", "{{.Dir}}", module) + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("locate %s: %w", module, err) + } + dir := strings.TrimSpace(string(out)) + if dir == "" { + return "", fmt.Errorf("locate %s: the go tool reported no directory; run `go mod download %s`", module, module) + } + if _, err := os.Stat(dir); err != nil { + return "", fmt.Errorf("locate %s: %w", module, err) + } + return dir, nil +} diff --git a/e2e/harness/proxy.go b/e2e/harness/proxy.go new file mode 100644 index 000000000..3d709b439 --- /dev/null +++ b/e2e/harness/proxy.go @@ -0,0 +1,132 @@ +//go:build e2e + +package harness + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +const ( + proxyDockerfile = "proxy/Dockerfile.multistage" + // defaultProxyImage is the local tag the reverse proxy is built under from + // proxyDockerfile. Override with NB_E2E_PROXY_IMAGE: a value with a "/" is + // pulled as a published image; a bare tag is built under that name. + defaultProxyImage = "netbird-reverse-proxy:e2e" + proxyAlias = "proxy" + + // AgentNetworkCluster is the proxy cluster the e2e provider bootstraps and + // the proxy serves. It must equal the management's exposed domain + // (combinedAlias) — the working manual setup uses one NETBIRD_DOMAIN for + // both. The agent-network endpoint is .. + AgentNetworkCluster = combinedAlias +) + +// Proxy is a running agent-network gateway (netbird proxy) container. +type Proxy struct { + container testcontainers.Container + workDir string +} + +// StartProxy builds the proxy image and runs it on the combined server's +// network, registered via the given account proxy token and serving the +// AgentNetworkCluster over a self-signed wildcard cert. It does not wait for +// peer connectivity — callers poll management for the proxy peer. +// StartProxy launches the reverse-proxy container. Optional envOverrides are +// merged into the container environment after the defaults, so callers can set +// or override any NB_PROXY_* var (e.g. NB_PROXY_TUNNEL_CACHE_TTL for tests that +// need a short authorization-cache window). +func StartProxy(ctx context.Context, c *Combined, proxyToken string, envOverrides ...map[string]string) (*Proxy, error) { + root, err := repoRoot(ctx) + if err != nil { + return nil, err + } + proxyImage, err := resolveImage(ctx, root, "NB_E2E_PROXY_IMAGE", defaultProxyImage, proxyDockerfile) + if err != nil { + return nil, err + } + + workDir, err := os.MkdirTemp("/tmp", "nb-e2e-proxy-*") + if err != nil { + return nil, fmt.Errorf("create proxy work dir: %w", err) + } + // MkdirTemp creates the dir 0700; widen it so the non-root proxy container + // can traverse the bind-mounted cert dir on Linux CI runners. + if err := os.Chmod(workDir, 0o755); err != nil { //nolint:gosec // throwaway e2e cert dir, must be traversable by the proxy container uid + return nil, fmt.Errorf("chmod proxy cert dir: %w", err) + } + if err := writeSelfSignedCert(workDir, []string{"*." + AgentNetworkCluster, AgentNetworkCluster}); err != nil { + return nil, err + } + + req := testcontainers.ContainerRequest{ + Image: proxyImage, + Networks: []string{c.network.Name}, + NetworkAliases: map[string][]string{c.network.Name: {proxyAlias}}, + Env: map[string]string{ + "NB_PROXY_TOKEN": proxyToken, + "NB_PROXY_MANAGEMENT_ADDRESS": combinedExposedURL, + "NB_PROXY_DOMAIN": AgentNetworkCluster, + "NB_PROXY_ADDRESS": ":443", + "NB_PROXY_CERTIFICATE_DIRECTORY": "/certs", + "NB_PROXY_HEALTH_ADDRESS": ":8081", + "NB_PROXY_LOG_LEVEL": "debug", + "NB_PROXY_PRIVATE": "true", + // Management is plain HTTP in-cluster, so allow the proxy token to + // ride a non-TLS gRPC connection. + "NB_PROXY_ALLOW_INSECURE": "true", + // The combined server multiplexes the relay over WebSocket on :8080 + // (no QUIC listener). The proxy's embedded relay client defaults to + // QUIC, which fails here and flaps the relay link, churning the + // proxy peer so it never stably registers. Force WS transport. + "NB_RELAY_TRANSPORT": "ws", + // Trace the embedded client (relay / signal / handshake) so + // peer-registration issues are visible in the proxy logs. + "NB_PROXY_CLIENT_LOG_LEVEL": "trace", + }, + HostConfigModifier: func(hc *container.HostConfig) { + hc.Binds = append(hc.Binds, workDir+":/certs") + hc.CapAdd = append(hc.CapAdd, "NET_ADMIN", "SYS_ADMIN", "SYS_RESOURCE", "NET_BIND_SERVICE") + }, + WaitingFor: wait.ForLog("Initial mapping sync complete").WithStartupTimeout(90 * time.Second), + } + + for _, ov := range envOverrides { + for k, v := range ov { + req.Env[k] = v + } + } + + ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + return nil, fmt.Errorf("start proxy container: %w", err) + } + + return &Proxy{container: ctr, workDir: workDir}, nil +} + +// Logs returns the proxy container logs, for diagnostics on failure. +func (p *Proxy) Logs(ctx context.Context) string { + return containerLogs(ctx, p.container) +} + +// Terminate stops the proxy container and cleans its work dir. +func (p *Proxy) Terminate(ctx context.Context) error { + var err error + if p.container != nil { + err = p.container.Terminate(ctx) + } + if p.workDir != "" { + _ = os.RemoveAll(p.workDir) + } + return err +} diff --git a/e2e/harness/upstream.go b/e2e/harness/upstream.go new file mode 100644 index 000000000..cdffe63b9 --- /dev/null +++ b/e2e/harness/upstream.go @@ -0,0 +1,107 @@ +//go:build e2e + +package harness + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +const ( + fakeUpstreamImage = "nginx:alpine" + fakeUpstreamAlias = "fakeupstream" + fakeUpstreamPort = "443/tcp" +) + +// fakeUpstreamNginxConf serves a canned OpenAI-shaped chat completion for any +// path over a self-signed certificate, so the proxy reaches it only when the +// provider opts into skipping TLS verification. +const fakeUpstreamNginxConf = `pid /tmp/nginx.pid; +events {} +http { + server { + listen 443 ssl; + ssl_certificate /certs/tls.crt; + ssl_certificate_key /certs/tls.key; + location / { + default_type application/json; + return 200 '{"id":"chatcmpl-e2e","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}'; + } + } +} +` + +// FakeUpstream is a self-signed HTTPS server on the combined server's network, +// used to exercise provider skip_tls_verification: a proxy that verifies the +// certificate rejects it, one that skips verification reaches it. +type FakeUpstream struct { + container testcontainers.Container + workDir string + // URL is the upstream URL providers point at (https://). + URL string +} + +// StartFakeUpstream runs the self-signed upstream on the shared network. +func StartFakeUpstream(ctx context.Context, c *Combined) (*FakeUpstream, error) { + workDir, err := os.MkdirTemp("/tmp", "nb-e2e-upstream-*") + if err != nil { + return nil, fmt.Errorf("create upstream work dir: %w", err) + } + // Widen so the (non-root worker) nginx container can traverse the bind mount. + if err := os.Chmod(workDir, 0o755); err != nil { //nolint:gosec // throwaway e2e cert dir + return nil, fmt.Errorf("chmod upstream dir: %w", err) + } + if err := writeSelfSignedCert(workDir, []string{fakeUpstreamAlias}); err != nil { + return nil, err + } + if err := os.WriteFile(filepath.Join(workDir, "nginx.conf"), []byte(fakeUpstreamNginxConf), 0o644); err != nil { //nolint:gosec // non-secret e2e config + return nil, fmt.Errorf("write nginx conf: %w", err) + } + + req := testcontainers.ContainerRequest{ + Image: fakeUpstreamImage, + ExposedPorts: []string{fakeUpstreamPort}, + Networks: []string{c.network.Name}, + NetworkAliases: map[string][]string{c.network.Name: {fakeUpstreamAlias}}, + Cmd: []string{"nginx", "-c", "/certs/nginx.conf", "-g", "daemon off;"}, + HostConfigModifier: func(hc *container.HostConfig) { + hc.Binds = append(hc.Binds, workDir+":/certs:ro") + }, + WaitingFor: wait.ForListeningPort(fakeUpstreamPort).WithStartupTimeout(60 * time.Second), + } + + ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + _ = os.RemoveAll(workDir) + return nil, fmt.Errorf("start fake upstream container: %w", err) + } + + return &FakeUpstream{container: ctr, workDir: workDir, URL: "https://" + fakeUpstreamAlias}, nil +} + +// Logs returns the upstream container logs, for diagnostics on failure. +func (u *FakeUpstream) Logs(ctx context.Context) string { + return containerLogs(ctx, u.container) +} + +// Terminate stops the upstream container and cleans its work dir. +func (u *FakeUpstream) Terminate(ctx context.Context) error { + var err error + if u.container != nil { + err = u.container.Terminate(ctx) + } + if u.workDir != "" { + _ = os.RemoveAll(u.workDir) + } + return err +} diff --git a/e2e/harness/vllm.go b/e2e/harness/vllm.go new file mode 100644 index 000000000..cf9316325 --- /dev/null +++ b/e2e/harness/vllm.go @@ -0,0 +1,232 @@ +//go:build e2e + +package harness + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +const ( + vllmImage = "nginx:alpine" + vllmAlias = "vllm" + vllmPort = "8000/tcp" + // vllmStreamPort serves the same wire shapes as an SSE stream. See the + // nginx config for why streaming lives on its own listener. + vllmStreamPort = "8001/tcp" + // VLLMModel is the served model id the mock advertises and echoes back. It + // matches a real small model commonly served by vLLM so the provider's + // enumerated model and the client's request line up. + VLLMModel = "Qwen/Qwen2.5-0.5B-Instruct" + // VLLMUnlistedModel is a second id the mock's model listing advertises but + // no test provider enumerates, so a filtered listing is observably shorter + // than the upstream's own. + VLLMUnlistedModel = "Qwen/Qwen2.5-7B-Instruct" +) + +// Token counts the mock reports per wire shape. Tests assert on these rather +// than on "> 0" so a response parsed with the wrong provider's parser (which +// would read a different field, or none) fails loudly instead of passing on +// a coincidental non-zero. +const ( + // VLLMChatInputTokens / VLLMChatOutputTokens ride the OpenAI usage block. + VLLMChatInputTokens = 11 + VLLMChatOutputTokens = 2 + // VLLMMessagesInputTokens / VLLMMessagesOutputTokens ride the Anthropic + // usage block, whose field names the OpenAI parser cannot read. + VLLMMessagesInputTokens = 17 + VLLMMessagesOutputTokens = 3 +) + +// Token counts the streaming surface reports. They differ from the +// non-streaming ones on purpose: a test that asserts these numbers proves the +// SSE accumulator ran, rather than a buffered JSON body having been parsed. +// +// Input and cache-read arrive on message_start; output arrives on +// message_delta and supersedes the seed value message_start carries. Any +// parser that cannot read message_start reports zero input tokens — which is +// exactly the bug these counts exist to catch. +const ( + VLLMStreamInputTokens = 29 + VLLMStreamOutputTokens = 5 + VLLMStreamCacheReadTokens = 7 +) + +// vllmNginxConf emulates a vLLM OpenAI-compatible server over plain HTTP (vLLM's +// default: no TLS, port 8000), and additionally answers the wire shapes the +// other catalog surfaces speak so one mock can stand in for every provider the +// proxy routes to. Running actual vLLM in CI is infeasible (GPU + multi-GB model +// download), so this stands in for the wire contract the proxy depends on. +// +// Each shape answers with its own vendor's usage block, so a response parsed +// under the wrong surface meters zero rather than passing by accident: +// +// - /v1/chat/completions (and any unmatched path): OpenAI chat completion. +// - /v1/messages: Anthropic Messages, snake_case usage plus a cache bucket. +// - /model/{id}/invoke: Bedrock InvokeModel, which carries the Anthropic body. +// - the token-counting endpoints: a count, with no usage block at all. +// +// The model listing advertises two models so a policy that authorises one +// produces an observably shorter list than the upstream's own. +const vllmNginxConf = `pid /tmp/nginx.pid; +events {} +http { + server { + listen 8000; + location = /v1/models { + default_type application/json; + return 200 '{"object":"list","data":[{"id":"Qwen/Qwen2.5-0.5B-Instruct","object":"model","owned_by":"vllm"},{"id":"Qwen/Qwen2.5-7B-Instruct","object":"model","owned_by":"vllm"}]}'; + } + location = /v1/messages { + default_type application/json; + return 200 '{"id":"msg_e2e","type":"message","role":"assistant","model":"claude-sonnet-5","content":[{"type":"text","text":"pong"}],"stop_reason":"end_turn","usage":{"input_tokens":17,"output_tokens":3,"cache_read_input_tokens":5}}'; + } + location = /v1/messages/count_tokens { + default_type application/json; + return 200 '{"input_tokens":7}'; + } + location ~ ^/model/.+/invoke$ { + default_type application/json; + return 200 '{"id":"msg_e2e_bedrock","type":"message","role":"assistant","content":[{"type":"text","text":"pong"}],"stop_reason":"end_turn","usage":{"input_tokens":17,"output_tokens":3,"cache_read_input_tokens":5}}'; + } + location ~ ^/model/.+/count-tokens$ { + default_type application/json; + return 200 '{"inputTokens":9}'; + } + location = /api/hello { + return 200; + } + location = /inference-profiles { + default_type application/json; + return 200 '{"inferenceProfileSummaries":[{"inferenceProfileId":"us.anthropic.claude-sonnet-5","status":"ACTIVE"}]}'; + } + location / { + default_type application/json; + return 200 '{"id":"chatcmpl-e2e-vllm","object":"chat.completion","created":1700000000,"model":"Qwen/Qwen2.5-0.5B-Instruct","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":11,"completion_tokens":2,"total_tokens":13}}'; + } + } + + # The streaming surface, on its own port so the response content type is a + # property of the listener rather than of a per-request branch: nginx sets + # Content-Type from default_type, which cannot be varied inside an "if", and + # a second Content-Type via add_header would leave the proxy reading the + # wrong one. A provider record pointed at this port streams every answer. + # + # Input and cache-read tokens ride message_start, output rides message_delta + # — the split that makes a stream different from a buffered body, and the + # reason a parser that ignores message_start meters input as zero. + server { + listen 8001; + location = /v1/messages { + default_type text/event-stream; + return 200 'event: message_start +data: {"type":"message_start","message":{"id":"msg_e2e_stream","type":"message","role":"assistant","model":"claude-sonnet-5","content":[],"usage":{"input_tokens":29,"output_tokens":1,"cache_read_input_tokens":7}}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"pong"}} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}} + +event: message_stop +data: {"type":"message_stop"} + +'; + } + location / { + default_type text/event-stream; + return 200 'data: {"choices":[{"delta":{"content":"pong"}}]} + +data: {"choices":[],"usage":{"prompt_tokens":29,"completion_tokens":5,"total_tokens":34}} + +data: [DONE] + +'; + } + } +} +` + +// VLLM is a mock vLLM OpenAI-compatible server on the combined server's network, +// reachable at http://vllm:8000. A "vllm" provider points at it to exercise the +// proxy's support for self-hosted OpenAI-compatible backends. +type VLLM struct { + container testcontainers.Container + workDir string + // URL is the upstream URL the vllm provider points at (http://:8000). + URL string + // StreamURL is the same mock's streaming listener. A provider pointed here + // answers every request as SSE, so the proxy's streaming accumulator runs + // instead of its buffered-body parser. + StreamURL string +} + +// StartVLLM runs the mock vLLM server on the shared network over plain HTTP. +func StartVLLM(ctx context.Context, c *Combined) (*VLLM, error) { + workDir, err := os.MkdirTemp("/tmp", "nb-e2e-vllm-*") + if err != nil { + return nil, fmt.Errorf("create vllm work dir: %w", err) + } + // Widen so the (non-root worker) nginx container can traverse the bind mount. + if err := os.Chmod(workDir, 0o755); err != nil { //nolint:gosec // throwaway e2e config dir + return nil, fmt.Errorf("chmod vllm dir: %w", err) + } + if err := os.WriteFile(filepath.Join(workDir, "nginx.conf"), []byte(vllmNginxConf), 0o644); err != nil { //nolint:gosec // non-secret e2e config + return nil, fmt.Errorf("write nginx conf: %w", err) + } + + req := testcontainers.ContainerRequest{ + Image: vllmImage, + ExposedPorts: []string{vllmPort, vllmStreamPort}, + Networks: []string{c.network.Name}, + NetworkAliases: map[string][]string{c.network.Name: {vllmAlias}}, + Cmd: []string{"nginx", "-c", "/conf/nginx.conf", "-g", "daemon off;"}, + HostConfigModifier: func(hc *container.HostConfig) { + hc.Binds = append(hc.Binds, workDir+":/conf:ro") + }, + WaitingFor: wait.ForAll( + wait.ForListeningPort(vllmPort), + wait.ForListeningPort(vllmStreamPort), + ).WithStartupTimeout(60 * time.Second), + } + + ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + _ = os.RemoveAll(workDir) + return nil, fmt.Errorf("start vllm container: %w", err) + } + + return &VLLM{ + container: ctr, + workDir: workDir, + URL: "http://" + vllmAlias + ":8000", + StreamURL: "http://" + vllmAlias + ":8001", + }, nil +} + +// Logs returns the vLLM container logs, for diagnostics on failure. +func (v *VLLM) Logs(ctx context.Context) string { + return containerLogs(ctx, v.container) +} + +// Terminate stops the vLLM container and cleans its work dir. +func (v *VLLM) Terminate(ctx context.Context) error { + var err error + if v.container != nil { + err = v.container.Terminate(ctx) + } + if v.workDir != "" { + _ = os.RemoveAll(v.workDir) + } + return err +} diff --git a/e2e/remotejobs/main_test.go b/e2e/remotejobs/main_test.go new file mode 100644 index 000000000..962a5b5d0 --- /dev/null +++ b/e2e/remotejobs/main_test.go @@ -0,0 +1,47 @@ +//go:build e2e + +// Package remotejobs holds the container-based e2e suite for the remote-jobs +// opt-in (PR #7153) and the debug-bundle job parameters anonymize_level / +// upload_url (PR #7147). A combined server is built and bootstrapped once per +// package run (TestMain) and shared via srv; each test registers its own client +// and cleans it up. +package remotejobs + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/netbirdio/netbird/e2e/harness" +) + +// srv is the shared combined server for the package, PAT-authenticated by the +// time any Test runs. +var srv *harness.Combined + +func TestMain(m *testing.M) { + os.Exit(run(m)) +} + +func run(m *testing.M) int { + // Generous timeout to cover a cold image build on first run. + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + var err error + srv, err = harness.StartCombined(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "e2e: start combined server: %v\n", err) + return 1 + } + defer func() { _ = srv.Terminate(context.Background()) }() + + if _, err := srv.Bootstrap(ctx); err != nil { + fmt.Fprintf(os.Stderr, "e2e: bootstrap admin PAT: %v\n", err) + return 1 + } + + return m.Run() +} diff --git a/e2e/remotejobs/remotejobs_test.go b/e2e/remotejobs/remotejobs_test.go new file mode 100644 index 000000000..48d0bd986 --- /dev/null +++ b/e2e/remotejobs/remotejobs_test.go @@ -0,0 +1,197 @@ +//go:build e2e + +package remotejobs + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +const ( + refusedReason = "remote jobs are not enabled on this peer" + // testUploadURL is the debug-bundle upload URL the job subtests pass; it only + // needs to be a well-formed https URL with a host (see ValidateBundleUploadURL). + testUploadURL = "https://uploads.example.com/bundle" +) + +// TestRemoteJobsOptInAndBundleParams exercises the two PRs end-to-end against a +// live management server and a real client: +// +// - #7153: the peer's remote-jobs opt-in defaults off, is reported to +// management (visible via the peers API as remote_jobs_allowed), and gates +// job execution on the client — a streamed job is refused until the peer +// opts in with `netbird up --allow-remote-jobs`, after which it runs. +// - #7147: the debug-bundle job's anonymize_level is validated (an unknown +// value is rejected at creation) and normalized (trimmed + lowercased) in +// the stored job the API returns. +func TestRemoteJobsOptInAndBundleParams(t *testing.T) { + ctx := context.Background() + + // A group for the setup key to auto-assign; peers must land in some group. + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-remotejobs"}) + require.NoError(t, err, "create group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-remotejobs", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + t.Cleanup(func() { _ = srv.API().SetupKeys.Delete(context.Background(), sk.Id) }) + + // Start the client with a plain `netbird up` (remote jobs NOT enabled). + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + + peerID := waitForPeer(ctx, t, cl.Hostname()) + + t.Run("opt-in flag defaults to false and is reported to management (#7153)", func(t *testing.T) { + p, err := srv.API().Peers.Get(ctx, peerID) + require.NoError(t, err) + allowed := remoteJobsAllowed(p) + require.NotNil(t, allowed, "remote_jobs_allowed must be present on the peer API") + assert.False(t, *allowed, "a peer that ran plain `netbird up` must default to opt-out") + }) + + t.Run("anonymize_level is validated and normalized (#7147)", func(t *testing.T) { + // Unknown level is rejected at job creation. + _, err := srv.API().Peers.Jobs(peerID).Create(ctx, bundleJob("bogus", testUploadURL)) + require.Error(t, err, "an unknown anonymize_level must be rejected") + assert.Contains(t, strings.ToLower(err.Error()), "anonymize_level", + "the rejection must name the offending field") + + // A messy but valid level is normalized (trimmed + lowercased) in the + // stored job the API echoes back. + job, err := srv.API().Peers.Jobs(peerID).Create(ctx, bundleJob(" Strict ", testUploadURL)) + require.NoError(t, err, "a valid anonymize_level must be accepted") + bw, err := job.Workload.AsBundleWorkloadResponse() + require.NoError(t, err, "job workload must be a bundle") + require.NotNil(t, bw.Parameters.AnonymizeLevel) + assert.Equal(t, "strict", *bw.Parameters.AnonymizeLevel, + "anonymize_level must be normalized to trimmed lowercase") + waitForJobTerminal(ctx, t, peerID, job.Id) // let it settle before the next create + }) + + t.Run("a job is refused while the peer has not opted in (#7153 enforcement)", func(t *testing.T) { + job, err := srv.API().Peers.Jobs(peerID).Create(ctx, bundleJob("default", testUploadURL)) + require.NoError(t, err, "job creation itself is allowed; enforcement is on the client") + final := waitForJobTerminal(ctx, t, peerID, job.Id) + assert.Equal(t, api.JobResponseStatusFailed, final.Status, "the client must refuse the job") + require.NotNil(t, final.FailedReason) + assert.Contains(t, *final.FailedReason, refusedReason, + "the failure must be the opt-out refusal, not some other error") + }) + + t.Run("opting in flips the flag and lets the job run (#7153)", func(t *testing.T) { + require.NoError(t, cl.Up(ctx, "--allow-remote-jobs"), "re-run up with --allow-remote-jobs") + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must reconnect") + + // The new opt-in must round-trip to management and surface on the API. + require.Eventually(t, func() bool { + p, err := srv.API().Peers.Get(ctx, peerID) + if err != nil { + return false + } + allowed := remoteJobsAllowed(p) + return allowed != nil && *allowed + }, 60*time.Second, 2*time.Second, "remote_jobs_allowed must become true after opt-in") + + // The same job that was refused before must now be accepted for + // execution: whatever its outcome, it must NOT be the opt-out refusal. + job, err := srv.API().Peers.Jobs(peerID).Create(ctx, bundleJob("default", testUploadURL)) + require.NoError(t, err) + final := waitForJobTerminal(ctx, t, peerID, job.Id) + if final.Status == api.JobResponseStatusFailed && final.FailedReason != nil { + assert.NotContains(t, *final.FailedReason, refusedReason, + "once opted in, the job must not be refused for opt-out; any failure must be for another reason (e.g. upload)") + } + }) +} + +// remoteJobsAllowed returns the peer's remote-jobs opt-in flag from the API +// response (nil if the peer or its local flags are absent). +func remoteJobsAllowed(p *api.Peer) *bool { + if p == nil || p.LocalFlags == nil { + return nil + } + return p.LocalFlags.RemoteJobsAllowed +} + +// bundleJob builds a debug-bundle job request with the given anonymize_level +// (omitted when empty) and upload_url (omitted when empty). +func bundleJob(anonymizeLevel, uploadURL string) api.JobRequest { + params := api.BundleParameters{ + Anonymize: true, + LogFileCount: 1, + } + if anonymizeLevel != "" { + params.AnonymizeLevel = &anonymizeLevel + } + if uploadURL != "" { + params.UploadUrl = &uploadURL + } + var wl api.WorkloadRequest + // FromBundleWorkloadRequest cannot fail for a well-formed value. + _ = wl.FromBundleWorkloadRequest(api.BundleWorkloadRequest{ + Type: api.WorkloadTypeBundle, + Parameters: params, + }) + return api.JobRequest{Workload: wl} +} + +// waitForPeer polls the peers API until the client that registered under the +// given hostname appears and returns its ID. Matching by hostname rather than +// taking the first list entry keeps the test correct if the account ever holds +// more than one peer (a shared bootstrap account, or a second client added to +// the package). +func waitForPeer(ctx context.Context, t *testing.T, hostname string) string { + t.Helper() + var peerID string + require.Eventually(t, func() bool { + peers, err := srv.API().Peers.List(ctx) + if err != nil { + return false + } + for _, p := range peers { + if p.Hostname == hostname { + peerID = p.Id + return true + } + } + return false + }, 60*time.Second, 2*time.Second, "the client peer must register with management") + return peerID +} + +// waitForJobTerminal polls a job until it leaves the pending state, then returns +// the final response. +func waitForJobTerminal(ctx context.Context, t *testing.T, peerID, jobID string) *api.JobResponse { + t.Helper() + var final *api.JobResponse + require.Eventually(t, func() bool { + j, err := srv.API().Peers.Jobs(peerID).Get(ctx, jobID) + if err != nil || j == nil { + return false + } + if j.Status == api.JobResponseStatusPending { + return false + } + final = j + return true + }, 120*time.Second, 2*time.Second, "job must reach a terminal state") + return final +} diff --git a/encryption/testprotos/generate.sh b/encryption/testprotos/generate.sh index 0ce6ebdea..ffbc481d6 100755 --- a/encryption/testprotos/generate.sh +++ b/encryption/testprotos/generate.sh @@ -1,2 +1,2 @@ -#!/bin/bash -protoc -I testprotos/ testprotos/testproto.proto --go_out=. \ No newline at end of file +#!/usr/bin/env bash +protoc -I testprotos/ testprotos/testproto.proto --go_out=. diff --git a/flow/client/client.go b/flow/client/client.go index 8ad637974..fc07db833 100644 --- a/flow/client/client.go +++ b/flow/client/client.go @@ -13,11 +13,9 @@ import ( "github.com/cenkalti/backoff/v4" log "github.com/sirupsen/logrus" "google.golang.org/grpc" - "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/keepalive" - "google.golang.org/grpc/status" nbgrpc "github.com/netbirdio/netbird/client/grpc" "github.com/netbirdio/netbird/flow/proto" @@ -111,7 +109,7 @@ func (c *GRPCClient) Close() error { func (c *GRPCClient) Send(event *proto.FlowEvent) error { c.mu.Lock() stream := c.stream - c.mu.Unlock() + defer c.mu.Unlock() // stream.Send() is not safe to call concurrently from multiple goroutines if stream == nil { return errors.New("stream not initialized") @@ -148,11 +146,14 @@ func (c *GRPCClient) Receive(ctx context.Context, interval time.Duration, msgHan streamStart := time.Now() - if err := c.receive(stream, msgHandler); err != nil { + // receive always returns a non-nil error once the stream breaks; + // handleRetryableError decides between reconnecting and exiting + // permanently on local context cancellation + err = c.receive(stream, msgHandler) + if !isContextDone(err) { log.Errorf("receive failed: %v", err) - return c.handleRetryableError(err, streamStart, backOff) } - return nil + return c.handleRetryableError(err, streamStart, backOff) } if err := backoff.Retry(operation, backOff); err != nil { @@ -301,12 +302,11 @@ func defaultBackoff(ctx context.Context, interval time.Duration) backoff.BackOff }, ctx) } +// isContextDone reports whether the local context has been canceled or has +// exceeded its deadline. It deliberately does not inspect gRPC status codes: +// a server- or proxy-sent codes.Canceled / codes.DeadlineExceeded must not +// short-circuit our retry loop, since retrying is the correct response when +// the local context is still alive. func isContextDone(err error) bool { - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return true - } - if s, ok := status.FromError(err); ok { - return s.Code() == codes.Canceled || s.Code() == codes.DeadlineExceeded - } - return false + return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) } diff --git a/flow/proto/flow.pb.go b/flow/proto/flow.pb.go index 04e6e3792..710024f0e 100644 --- a/flow/proto/flow.pb.go +++ b/flow/proto/flow.pb.go @@ -134,9 +134,11 @@ type FlowEvent struct { // When the event occurred Timestamp *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=timestamp,proto3" json:"timestamp,omitempty"` // Public key of the sending peer - PublicKey []byte `protobuf:"bytes,3,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` - FlowFields *FlowFields `protobuf:"bytes,4,opt,name=flow_fields,json=flowFields,proto3" json:"flow_fields,omitempty"` - IsInitiator bool `protobuf:"varint,5,opt,name=isInitiator,proto3" json:"isInitiator,omitempty"` + PublicKey []byte `protobuf:"bytes,3,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` + FlowFields *FlowFields `protobuf:"bytes,4,opt,name=flow_fields,json=flowFields,proto3" json:"flow_fields,omitempty"` + IsInitiator bool `protobuf:"varint,5,opt,name=isInitiator,proto3" json:"isInitiator,omitempty"` + WindowStart *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=window_start,json=windowStart,proto3" json:"window_start,omitempty"` + WindowEnd *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=window_end,json=windowEnd,proto3" json:"window_end,omitempty"` } func (x *FlowEvent) Reset() { @@ -206,6 +208,20 @@ func (x *FlowEvent) GetIsInitiator() bool { return false } +func (x *FlowEvent) GetWindowStart() *timestamppb.Timestamp { + if x != nil { + return x.WindowStart + } + return nil +} + +func (x *FlowEvent) GetWindowEnd() *timestamppb.Timestamp { + if x != nil { + return x.WindowEnd + } + return nil +} + type FlowEventAck struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -284,7 +300,6 @@ type FlowFields struct { // Layer 4 -specific information // // Types that are assignable to ConnectionInfo: - // // *FlowFields_PortInfo // *FlowFields_IcmpInfo ConnectionInfo isFlowFields_ConnectionInfo `protobuf_oneof:"connection_info"` @@ -297,6 +312,9 @@ type FlowFields struct { // Resource ID SourceResourceId []byte `protobuf:"bytes,14,opt,name=source_resource_id,json=sourceResourceId,proto3" json:"source_resource_id,omitempty"` DestResourceId []byte `protobuf:"bytes,15,opt,name=dest_resource_id,json=destResourceId,proto3" json:"dest_resource_id,omitempty"` + NumOfStarts uint64 `protobuf:"varint,16,opt,name=num_of_starts,json=numOfStarts,proto3" json:"num_of_starts,omitempty"` + NumOfEnds uint64 `protobuf:"varint,17,opt,name=num_of_ends,json=numOfEnds,proto3" json:"num_of_ends,omitempty"` + NumOfDrops uint64 `protobuf:"varint,18,opt,name=num_of_drops,json=numOfDrops,proto3" json:"num_of_drops,omitempty"` } func (x *FlowFields) Reset() { @@ -443,6 +461,27 @@ func (x *FlowFields) GetDestResourceId() []byte { return nil } +func (x *FlowFields) GetNumOfStarts() uint64 { + if x != nil { + return x.NumOfStarts + } + return 0 +} + +func (x *FlowFields) GetNumOfEnds() uint64 { + if x != nil { + return x.NumOfEnds + } + return 0 +} + +func (x *FlowFields) GetNumOfDrops() uint64 { + if x != nil { + return x.NumOfDrops + } + return 0 +} + type isFlowFields_ConnectionInfo interface { isFlowFields_ConnectionInfo() } @@ -579,7 +618,7 @@ var file_flow_proto_rawDesc = []byte{ 0x0a, 0x0a, 0x66, 0x6c, 0x6f, 0x77, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x04, 0x66, 0x6c, 0x6f, 0x77, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x22, 0xd4, 0x01, 0x0a, 0x09, 0x46, 0x6c, 0x6f, 0x77, 0x45, 0x76, 0x65, 0x6e, + 0x6f, 0x74, 0x6f, 0x22, 0xce, 0x02, 0x0a, 0x09, 0x46, 0x6c, 0x6f, 0x77, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x38, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, @@ -592,45 +631,59 @@ var file_flow_proto_rawDesc = []byte{ 0x77, 0x2e, 0x46, 0x6c, 0x6f, 0x77, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x52, 0x0a, 0x66, 0x6c, 0x6f, 0x77, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x12, 0x20, 0x0a, 0x0b, 0x69, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x74, 0x6f, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x69, - 0x73, 0x49, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x74, 0x6f, 0x72, 0x22, 0x4b, 0x0a, 0x0c, 0x46, 0x6c, - 0x6f, 0x77, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x41, 0x63, 0x6b, 0x12, 0x19, 0x0a, 0x08, 0x65, 0x76, - 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x65, 0x76, - 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x20, 0x0a, 0x0b, 0x69, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x69, - 0x61, 0x74, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x69, 0x73, 0x49, 0x6e, - 0x69, 0x74, 0x69, 0x61, 0x74, 0x6f, 0x72, 0x22, 0x9c, 0x04, 0x0a, 0x0a, 0x46, 0x6c, 0x6f, 0x77, - 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x12, 0x17, 0x0a, 0x07, 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x69, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x66, 0x6c, 0x6f, 0x77, 0x49, 0x64, 0x12, - 0x1e, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0a, 0x2e, - 0x66, 0x6c, 0x6f, 0x77, 0x2e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, - 0x17, 0x0a, 0x07, 0x72, 0x75, 0x6c, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, - 0x52, 0x06, 0x72, 0x75, 0x6c, 0x65, 0x49, 0x64, 0x12, 0x2d, 0x0a, 0x09, 0x64, 0x69, 0x72, 0x65, - 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0f, 0x2e, 0x66, 0x6c, - 0x6f, 0x77, 0x2e, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x64, 0x69, - 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x63, 0x6f, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x63, 0x6f, 0x6c, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x70, - 0x18, 0x06, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x70, - 0x12, 0x17, 0x0a, 0x07, 0x64, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x70, 0x18, 0x07, 0x20, 0x01, 0x28, - 0x0c, 0x52, 0x06, 0x64, 0x65, 0x73, 0x74, 0x49, 0x70, 0x12, 0x2d, 0x0a, 0x09, 0x70, 0x6f, 0x72, - 0x74, 0x5f, 0x69, 0x6e, 0x66, 0x6f, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x66, - 0x6c, 0x6f, 0x77, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x48, 0x00, 0x52, 0x08, - 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x2d, 0x0a, 0x09, 0x69, 0x63, 0x6d, 0x70, - 0x5f, 0x69, 0x6e, 0x66, 0x6f, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x66, 0x6c, - 0x6f, 0x77, 0x2e, 0x49, 0x43, 0x4d, 0x50, 0x49, 0x6e, 0x66, 0x6f, 0x48, 0x00, 0x52, 0x08, 0x69, - 0x63, 0x6d, 0x70, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x78, 0x5f, 0x70, 0x61, - 0x63, 0x6b, 0x65, 0x74, 0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x04, 0x52, 0x09, 0x72, 0x78, 0x50, - 0x61, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x74, 0x78, 0x5f, 0x70, 0x61, 0x63, - 0x6b, 0x65, 0x74, 0x73, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x04, 0x52, 0x09, 0x74, 0x78, 0x50, 0x61, - 0x63, 0x6b, 0x65, 0x74, 0x73, 0x12, 0x19, 0x0a, 0x08, 0x72, 0x78, 0x5f, 0x62, 0x79, 0x74, 0x65, - 0x73, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x04, 0x52, 0x07, 0x72, 0x78, 0x42, 0x79, 0x74, 0x65, 0x73, - 0x12, 0x19, 0x0a, 0x08, 0x74, 0x78, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x0d, 0x20, 0x01, - 0x28, 0x04, 0x52, 0x07, 0x74, 0x78, 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x2c, 0x0a, 0x12, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, - 0x64, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x10, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x64, 0x12, 0x28, 0x0a, 0x10, 0x64, 0x65, 0x73, - 0x74, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x0f, 0x20, - 0x01, 0x28, 0x0c, 0x52, 0x0e, 0x64, 0x65, 0x73, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x49, 0x64, 0x42, 0x11, 0x0a, 0x0f, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, + 0x73, 0x49, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x74, 0x6f, 0x72, 0x12, 0x3d, 0x0a, 0x0c, 0x77, 0x69, + 0x6e, 0x64, 0x6f, 0x77, 0x5f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, + 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0b, 0x77, 0x69, + 0x6e, 0x64, 0x6f, 0x77, 0x53, 0x74, 0x61, 0x72, 0x74, 0x12, 0x39, 0x0a, 0x0a, 0x77, 0x69, 0x6e, + 0x64, 0x6f, 0x77, 0x5f, 0x65, 0x6e, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, + 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, + 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x77, 0x69, 0x6e, 0x64, 0x6f, + 0x77, 0x45, 0x6e, 0x64, 0x22, 0x4b, 0x0a, 0x0c, 0x46, 0x6c, 0x6f, 0x77, 0x45, 0x76, 0x65, 0x6e, + 0x74, 0x41, 0x63, 0x6b, 0x12, 0x19, 0x0a, 0x08, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, + 0x20, 0x0a, 0x0b, 0x69, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x74, 0x6f, 0x72, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x69, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x74, 0x6f, + 0x72, 0x22, 0x82, 0x05, 0x0a, 0x0a, 0x46, 0x6c, 0x6f, 0x77, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, + 0x12, 0x17, 0x0a, 0x07, 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0c, 0x52, 0x06, 0x66, 0x6c, 0x6f, 0x77, 0x49, 0x64, 0x12, 0x1e, 0x0a, 0x04, 0x74, 0x79, 0x70, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0a, 0x2e, 0x66, 0x6c, 0x6f, 0x77, 0x2e, 0x54, + 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x72, 0x75, 0x6c, + 0x65, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x72, 0x75, 0x6c, 0x65, + 0x49, 0x64, 0x12, 0x2d, 0x0a, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0f, 0x2e, 0x66, 0x6c, 0x6f, 0x77, 0x2e, 0x44, 0x69, 0x72, + 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, + 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x0d, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x1b, 0x0a, + 0x09, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x70, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0c, + 0x52, 0x08, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x70, 0x12, 0x17, 0x0a, 0x07, 0x64, 0x65, + 0x73, 0x74, 0x5f, 0x69, 0x70, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x64, 0x65, 0x73, + 0x74, 0x49, 0x70, 0x12, 0x2d, 0x0a, 0x09, 0x70, 0x6f, 0x72, 0x74, 0x5f, 0x69, 0x6e, 0x66, 0x6f, + 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x66, 0x6c, 0x6f, 0x77, 0x2e, 0x50, 0x6f, + 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x48, 0x00, 0x52, 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, + 0x66, 0x6f, 0x12, 0x2d, 0x0a, 0x09, 0x69, 0x63, 0x6d, 0x70, 0x5f, 0x69, 0x6e, 0x66, 0x6f, 0x18, + 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x66, 0x6c, 0x6f, 0x77, 0x2e, 0x49, 0x43, 0x4d, + 0x50, 0x49, 0x6e, 0x66, 0x6f, 0x48, 0x00, 0x52, 0x08, 0x69, 0x63, 0x6d, 0x70, 0x49, 0x6e, 0x66, + 0x6f, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x78, 0x5f, 0x70, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x18, + 0x0a, 0x20, 0x01, 0x28, 0x04, 0x52, 0x09, 0x72, 0x78, 0x50, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x73, + 0x12, 0x1d, 0x0a, 0x0a, 0x74, 0x78, 0x5f, 0x70, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x18, 0x0b, + 0x20, 0x01, 0x28, 0x04, 0x52, 0x09, 0x74, 0x78, 0x50, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x12, + 0x19, 0x0a, 0x08, 0x72, 0x78, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x0c, 0x20, 0x01, 0x28, + 0x04, 0x52, 0x07, 0x72, 0x78, 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x19, 0x0a, 0x08, 0x74, 0x78, + 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x04, 0x52, 0x07, 0x74, 0x78, + 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x2c, 0x0a, 0x12, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, + 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x0e, 0x20, 0x01, 0x28, + 0x0c, 0x52, 0x10, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x49, 0x64, 0x12, 0x28, 0x0a, 0x10, 0x64, 0x65, 0x73, 0x74, 0x5f, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0e, 0x64, + 0x65, 0x73, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x64, 0x12, 0x22, 0x0a, + 0x0d, 0x6e, 0x75, 0x6d, 0x5f, 0x6f, 0x66, 0x5f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x73, 0x18, 0x10, + 0x20, 0x01, 0x28, 0x04, 0x52, 0x0b, 0x6e, 0x75, 0x6d, 0x4f, 0x66, 0x53, 0x74, 0x61, 0x72, 0x74, + 0x73, 0x12, 0x1e, 0x0a, 0x0b, 0x6e, 0x75, 0x6d, 0x5f, 0x6f, 0x66, 0x5f, 0x65, 0x6e, 0x64, 0x73, + 0x18, 0x11, 0x20, 0x01, 0x28, 0x04, 0x52, 0x09, 0x6e, 0x75, 0x6d, 0x4f, 0x66, 0x45, 0x6e, 0x64, + 0x73, 0x12, 0x20, 0x0a, 0x0c, 0x6e, 0x75, 0x6d, 0x5f, 0x6f, 0x66, 0x5f, 0x64, 0x72, 0x6f, 0x70, + 0x73, 0x18, 0x12, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0a, 0x6e, 0x75, 0x6d, 0x4f, 0x66, 0x44, 0x72, + 0x6f, 0x70, 0x73, 0x42, 0x11, 0x0a, 0x0f, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x6e, 0x66, 0x6f, 0x22, 0x48, 0x0a, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, @@ -683,17 +736,19 @@ var file_flow_proto_goTypes = []interface{}{ var file_flow_proto_depIdxs = []int32{ 7, // 0: flow.FlowEvent.timestamp:type_name -> google.protobuf.Timestamp 4, // 1: flow.FlowEvent.flow_fields:type_name -> flow.FlowFields - 0, // 2: flow.FlowFields.type:type_name -> flow.Type - 1, // 3: flow.FlowFields.direction:type_name -> flow.Direction - 5, // 4: flow.FlowFields.port_info:type_name -> flow.PortInfo - 6, // 5: flow.FlowFields.icmp_info:type_name -> flow.ICMPInfo - 2, // 6: flow.FlowService.Events:input_type -> flow.FlowEvent - 3, // 7: flow.FlowService.Events:output_type -> flow.FlowEventAck - 7, // [7:8] is the sub-list for method output_type - 6, // [6:7] is the sub-list for method input_type - 6, // [6:6] is the sub-list for extension type_name - 6, // [6:6] is the sub-list for extension extendee - 0, // [0:6] is the sub-list for field type_name + 7, // 2: flow.FlowEvent.window_start:type_name -> google.protobuf.Timestamp + 7, // 3: flow.FlowEvent.window_end:type_name -> google.protobuf.Timestamp + 0, // 4: flow.FlowFields.type:type_name -> flow.Type + 1, // 5: flow.FlowFields.direction:type_name -> flow.Direction + 5, // 6: flow.FlowFields.port_info:type_name -> flow.PortInfo + 6, // 7: flow.FlowFields.icmp_info:type_name -> flow.ICMPInfo + 2, // 8: flow.FlowService.Events:input_type -> flow.FlowEvent + 3, // 9: flow.FlowService.Events:output_type -> flow.FlowEventAck + 9, // [9:10] is the sub-list for method output_type + 8, // [8:9] is the sub-list for method input_type + 8, // [8:8] is the sub-list for extension type_name + 8, // [8:8] is the sub-list for extension extendee + 0, // [0:8] is the sub-list for field type_name } func init() { file_flow_proto_init() } diff --git a/flow/proto/flow.proto b/flow/proto/flow.proto index ff5c50282..1c9e728d2 100644 --- a/flow/proto/flow.proto +++ b/flow/proto/flow.proto @@ -24,6 +24,9 @@ message FlowEvent { FlowFields flow_fields = 4; bool isInitiator = 5; + + google.protobuf.Timestamp window_start = 6; + google.protobuf.Timestamp window_end = 7; } message FlowEventAck { @@ -75,6 +78,9 @@ message FlowFields { bytes source_resource_id = 14; bytes dest_resource_id = 15; + uint64 num_of_starts = 16; + uint64 num_of_ends = 17; + uint64 num_of_drops = 18; } // Flow event types diff --git a/flow/proto/flow_grpc.pb.go b/flow/proto/flow_grpc.pb.go index b790f86a2..9ae9702a5 100644 --- a/flow/proto/flow_grpc.pb.go +++ b/flow/proto/flow_grpc.pb.go @@ -1,4 +1,8 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc v3.21.9 +// source: flow.proto package proto @@ -11,15 +15,19 @@ import ( // This is a compile-time assertion to ensure that this generated file // is compatible with the grpc package it is being compiled against. -// Requires gRPC-Go v1.32.0 or later. -const _ = grpc.SupportPackageIsVersion7 +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + FlowService_Events_FullMethodName = "/flow.FlowService/Events" +) // FlowServiceClient is the client API for FlowService service. // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. type FlowServiceClient interface { // Client to receiver streams of events and acknowledgements - Events(ctx context.Context, opts ...grpc.CallOption) (FlowService_EventsClient, error) + Events(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[FlowEvent, FlowEventAck], error) } type flowServiceClient struct { @@ -30,54 +38,40 @@ func NewFlowServiceClient(cc grpc.ClientConnInterface) FlowServiceClient { return &flowServiceClient{cc} } -func (c *flowServiceClient) Events(ctx context.Context, opts ...grpc.CallOption) (FlowService_EventsClient, error) { - stream, err := c.cc.NewStream(ctx, &FlowService_ServiceDesc.Streams[0], "/flow.FlowService/Events", opts...) +func (c *flowServiceClient) Events(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[FlowEvent, FlowEventAck], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &FlowService_ServiceDesc.Streams[0], FlowService_Events_FullMethodName, cOpts...) if err != nil { return nil, err } - x := &flowServiceEventsClient{stream} + x := &grpc.GenericClientStream[FlowEvent, FlowEventAck]{ClientStream: stream} return x, nil } -type FlowService_EventsClient interface { - Send(*FlowEvent) error - Recv() (*FlowEventAck, error) - grpc.ClientStream -} - -type flowServiceEventsClient struct { - grpc.ClientStream -} - -func (x *flowServiceEventsClient) Send(m *FlowEvent) error { - return x.ClientStream.SendMsg(m) -} - -func (x *flowServiceEventsClient) Recv() (*FlowEventAck, error) { - m := new(FlowEventAck) - if err := x.ClientStream.RecvMsg(m); err != nil { - return nil, err - } - return m, nil -} +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type FlowService_EventsClient = grpc.BidiStreamingClient[FlowEvent, FlowEventAck] // FlowServiceServer is the server API for FlowService service. // All implementations must embed UnimplementedFlowServiceServer -// for forward compatibility +// for forward compatibility. type FlowServiceServer interface { // Client to receiver streams of events and acknowledgements - Events(FlowService_EventsServer) error + Events(grpc.BidiStreamingServer[FlowEvent, FlowEventAck]) error mustEmbedUnimplementedFlowServiceServer() } -// UnimplementedFlowServiceServer must be embedded to have forward compatible implementations. -type UnimplementedFlowServiceServer struct { -} +// UnimplementedFlowServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedFlowServiceServer struct{} -func (UnimplementedFlowServiceServer) Events(FlowService_EventsServer) error { - return status.Errorf(codes.Unimplemented, "method Events not implemented") +func (UnimplementedFlowServiceServer) Events(grpc.BidiStreamingServer[FlowEvent, FlowEventAck]) error { + return status.Error(codes.Unimplemented, "method Events not implemented") } func (UnimplementedFlowServiceServer) mustEmbedUnimplementedFlowServiceServer() {} +func (UnimplementedFlowServiceServer) testEmbeddedByValue() {} // UnsafeFlowServiceServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to FlowServiceServer will @@ -87,34 +81,22 @@ type UnsafeFlowServiceServer interface { } func RegisterFlowServiceServer(s grpc.ServiceRegistrar, srv FlowServiceServer) { + // If the following call panics, it indicates UnimplementedFlowServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } s.RegisterService(&FlowService_ServiceDesc, srv) } func _FlowService_Events_Handler(srv interface{}, stream grpc.ServerStream) error { - return srv.(FlowServiceServer).Events(&flowServiceEventsServer{stream}) + return srv.(FlowServiceServer).Events(&grpc.GenericServerStream[FlowEvent, FlowEventAck]{ServerStream: stream}) } -type FlowService_EventsServer interface { - Send(*FlowEventAck) error - Recv() (*FlowEvent, error) - grpc.ServerStream -} - -type flowServiceEventsServer struct { - grpc.ServerStream -} - -func (x *flowServiceEventsServer) Send(m *FlowEventAck) error { - return x.ServerStream.SendMsg(m) -} - -func (x *flowServiceEventsServer) Recv() (*FlowEvent, error) { - m := new(FlowEvent) - if err := x.ServerStream.RecvMsg(m); err != nil { - return nil, err - } - return m, nil -} +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type FlowService_EventsServer = grpc.BidiStreamingServer[FlowEvent, FlowEventAck] // FlowService_ServiceDesc is the grpc.ServiceDesc for FlowService service. // It's only intended for direct use with grpc.RegisterService, diff --git a/flow/proto/generate.sh b/flow/proto/generate.sh index 6bbf78e61..a031245fd 100755 --- a/flow/proto/generate.sh +++ b/flow/proto/generate.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash set -e if ! which realpath > /dev/null 2>&1 diff --git a/formatter/hook/hook.go b/formatter/hook/hook.go index f0ee509f8..69758566d 100644 --- a/formatter/hook/hook.go +++ b/formatter/hook/hook.go @@ -99,6 +99,9 @@ func addFields(entry *logrus.Entry) { if ctxAccountID, ok := entry.Context.Value(context.AccountIDKey).(string); ok { entry.Data[context.AccountIDKey] = ctxAccountID } + if ctxUserAgent, ok := entry.Context.Value(context.UserAgentKey).(string); ok { + entry.Data[context.UserAgentKey] = ctxUserAgent + } if ctxInitiatorID, ok := entry.Context.Value(context.UserIDKey).(string); ok { entry.Data[context.UserIDKey] = ctxInitiatorID } diff --git a/funding.json b/funding.json index 6b509a992..34ee9fe46 100644 --- a/funding.json +++ b/funding.json @@ -6,7 +6,7 @@ "name": "NetBird GmbH", "email": "hello@netbird.io", "phone": "", - "description": "NetBird GmbH is a Berlin-based software company specializing in the development of open-source network security solutions. Network security is utterly complex and expensive, accessible only to companies with multi-million dollar IT budgets. In contrast, there are millions of companies left behind. Our mission is to create an advanced network and cybersecurity platform that is both easy-to-use and affordable for teams of all sizes and budgets. By leveraging the open-source strategy and technological advancements, NetBird aims to set the industry standard for connecting and securing IT infrastructure.", + "description": "NetBird GmbH is a Berlin-based software company specializing in the development of open source network security solutions. Network security is utterly complex and expensive, accessible only to companies with multi-million dollar IT budgets. In contrast, there are millions of companies left behind. Our mission is to create an advanced network and cybersecurity platform that is both easy-to-use and affordable for teams of all sizes and budgets. By leveraging the open source strategy and technological advancements, NetBird aims to set the industry standard for connecting and securing IT infrastructure.", "webpageUrl": { "url": "https://github.com/netbirdio" } @@ -15,7 +15,7 @@ { "guid": "netbird", "name": "NetBird", - "description": "NetBird is a configuration-free peer-to-peer private network and a centralized access control system combined in a single open-source platform. It makes it easy to create secure WireGuard-based private networks for your organization or home.", + "description": "NetBird is a configuration-free peer-to-peer private network and a centralized access control system combined in a single open source platform. It makes it easy to create secure WireGuard-based private networks for your organization or home.", "webpageUrl": { "url": "https://github.com/netbirdio/netbird" }, @@ -59,7 +59,7 @@ "guid": "support-yearly", "status": "active", "name": "Support Open Source Development and Maintenance - Yearly", - "description": "This will help us partially cover the yearly cost of maintaining the open-source NetBird project.", + "description": "This will help us partially cover the yearly cost of maintaining the open source NetBird project.", "amount": 100000, "currency": "USD", "frequency": "yearly", @@ -72,7 +72,7 @@ "guid": "support-one-time-year", "status": "active", "name": "Support Open Source Development and Maintenance - One Year", - "description": "This will help us partially cover the yearly cost of maintaining the open-source NetBird project.", + "description": "This will help us partially cover the yearly cost of maintaining the open source NetBird project.", "amount": 100000, "currency": "USD", "frequency": "one-time", @@ -85,7 +85,7 @@ "guid": "support-one-time-monthly", "status": "active", "name": "Support Open Source Development and Maintenance - Monthly", - "description": "This will help us partially cover the monthly cost of maintaining the open-source NetBird project.", + "description": "This will help us partially cover the monthly cost of maintaining the open source NetBird project.", "amount": 10000, "currency": "USD", "frequency": "monthly", @@ -98,7 +98,7 @@ "guid": "support-monthly", "status": "active", "name": "Support Open Source Development and Maintenance - One Month", - "description": "This will help us partially cover the monthly cost of maintaining the open-source NetBird project.", + "description": "This will help us partially cover the monthly cost of maintaining the open source NetBird project.", "amount": 10000, "currency": "USD", "frequency": "monthly", diff --git a/go.mod b/go.mod index 3691b5d14..2b4bcd064 100644 --- a/go.mod +++ b/go.mod @@ -1,80 +1,94 @@ module github.com/netbirdio/netbird -go 1.25.5 +go 1.26.0 + +// Pin the toolchain to a patch release >= go1.26.2 +// See https://go.dev/issue/77875. +toolchain go1.26.7 require ( - cunicu.li/go-rosenpass v0.4.0 + cunicu.li/go-rosenpass v0.5.42 github.com/cenkalti/backoff/v4 v4.3.0 - github.com/cloudflare/circl v1.3.3 // indirect + github.com/cloudflare/circl v1.6.3 // indirect github.com/golang/protobuf v1.5.4 github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 github.com/kardianos/service v1.2.3-0.20240613133416-becf2eb62b83 github.com/onsi/ginkgo v1.16.5 - github.com/onsi/gomega v1.27.6 + github.com/onsi/gomega v1.34.1 github.com/rs/cors v1.8.0 github.com/sirupsen/logrus v1.9.4 - github.com/spf13/cobra v1.10.1 - github.com/spf13/pflag v1.0.9 + github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 github.com/vishvananda/netlink v1.3.1 - golang.org/x/crypto v0.49.0 - golang.org/x/sys v0.42.0 - golang.zx2c4.com/wireguard v0.0.0-20230704135630-469159ecf7d1 - golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 + golang.org/x/crypto v0.55.0 + golang.org/x/sys v0.47.0 + golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 + golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 golang.zx2c4.com/wireguard/windows v0.5.3 google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 - gopkg.in/natefinch/lumberjack.v2 v2.2.1 ) require ( - fyne.io/fyne/v2 v2.7.0 - fyne.io/systray v1.12.1-0.20260116214250-81f8e1a496f9 + github.com/DeRuina/timberjack v1.4.2 + github.com/Microsoft/go-winio v0.6.2 github.com/awnumar/memguard v0.23.0 github.com/aws/aws-sdk-go-v2 v1.38.3 + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 github.com/aws/aws-sdk-go-v2/config v1.31.6 github.com/aws/aws-sdk-go-v2/credentials v1.18.10 github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3 github.com/c-robinson/iplib v1.0.3 github.com/caddyserver/certmagic v0.21.3 - github.com/cilium/ebpf v0.15.0 + github.com/cilium/ebpf v0.19.0 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/coreos/go-oidc/v3 v3.18.0 github.com/creack/pty v1.1.24 github.com/crowdsecurity/crowdsec v1.7.7 github.com/crowdsecurity/go-cs-bouncer v0.0.21 - github.com/dexidp/dex v0.0.0-00010101000000-000000000000 + github.com/dexidp/dex v2.13.0+incompatible github.com/dexidp/dex/api/v2 v2.4.0 + github.com/docker/docker v28.0.1+incompatible + github.com/docker/go-connections v0.6.0 + github.com/ebitengine/purego v0.9.1 github.com/eko/gocache/lib/v4 v4.2.0 github.com/eko/gocache/store/go_cache/v4 v4.2.2 github.com/eko/gocache/store/redis/v4 v4.2.2 github.com/fsnotify/fsnotify v1.9.0 github.com/gliderlabs/ssh v0.3.8 - github.com/go-jose/go-jose/v4 v4.1.3 - github.com/godbus/dbus/v5 v5.1.0 - github.com/golang-jwt/jwt/v5 v5.3.0 - github.com/golang/mock v1.6.0 + github.com/go-jose/go-jose/v4 v4.1.4 + github.com/go-ole/go-ole v1.3.0 + github.com/gobwas/ws v1.4.0 + github.com/goccy/go-yaml v1.18.0 + github.com/godbus/dbus/v5 v5.2.2 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/go-cmp v0.7.0 github.com/google/gopacket v1.1.19 github.com/google/nftables v0.3.0 - github.com/gopacket/gopacket v1.1.1 + github.com/gopacket/gopacket v1.4.0 github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.2-0.20240212192251-757544f21357 + github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 github.com/hashicorp/go-version v1.7.0 - github.com/jackc/pgx/v5 v5.5.5 + github.com/jackc/pgx/v5 v5.10.0 github.com/libdns/route53 v1.5.0 - github.com/libp2p/go-nat v0.2.0 - github.com/libp2p/go-netroute v0.2.1 + github.com/libp2p/go-netroute v0.4.0 github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81 + github.com/magefile/mage v1.17.2 github.com/mdlayher/socket v0.5.1 - github.com/miekg/dns v1.1.59 + github.com/mdp/qrterminal/v3 v3.2.1 + github.com/miekg/dns v1.1.72 github.com/mitchellh/hashstructure/v2 v2.0.2 + github.com/moby/moby/api v1.54.1 + github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8 github.com/netbirdio/management-integrations/integrations v0.0.0-20260416163311-004852ffaf34 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 + github.com/ory/dockertest/v4 v4.0.0 github.com/oschwald/maxminddb-golang v1.12.0 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 @@ -88,13 +102,14 @@ require ( 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 + github.com/prometheus/client_model v0.6.2 + github.com/quic-go/quic-go v0.62.0 github.com/redis/go-redis/v9 v9.7.3 github.com/rs/xid v1.3.0 - github.com/shirou/gopsutil/v3 v3.24.4 + github.com/shirou/gopsutil/v4 v4.25.8 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/stretchr/testify v1.12.1 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 @@ -103,6 +118,7 @@ require ( github.com/ti-mo/conntrack v0.5.1 github.com/ti-mo/netfilter v0.5.2 github.com/vmihailenco/msgpack/v5 v5.4.1 + github.com/wailsapp/wails/v3 v3.0.0-beta.3 github.com/yusufpapurcu/wmi v1.2.4 github.com/zcalusic/sysinfo v1.1.3 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 @@ -110,45 +126,47 @@ require ( go.opentelemetry.io/otel/exporters/prometheus v0.64.0 go.opentelemetry.io/otel/metric v1.43.0 go.opentelemetry.io/otel/sdk/metric v1.43.0 - go.uber.org/mock v0.5.2 + go.uber.org/mock v0.6.0 go.uber.org/zap v1.27.0 goauthentik.io/api/v3 v3.2023051.3 - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b - golang.org/x/mobile v0.0.0-20251113184115-a159579294ab - golang.org/x/mod v0.33.0 - golang.org/x/net v0.52.0 + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f + golang.org/x/mobile v0.0.0-20260816165457-f98cc9b3c733 + golang.org/x/mod v0.39.0 + golang.org/x/net v0.58.0 golang.org/x/oauth2 v0.36.0 - golang.org/x/sync v0.20.0 - golang.org/x/term v0.41.0 + golang.org/x/sync v0.22.0 + golang.org/x/term v0.45.0 golang.org/x/time v0.15.0 google.golang.org/api v0.276.0 + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/mysql v1.5.7 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-20260219192049-0f2374377e89 + howett.net/plist v1.0.2-0.20250314012144-ee69052608d9 ) require ( cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect - dario.cat/mergo v1.0.1 // indirect + dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.1.1 // indirect + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect github.com/AppsFlyer/go-sundheit v0.6.0 // indirect - github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect - github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect - github.com/BurntSushi/toml v1.5.0 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Azure/go-ntlmssp v0.1.0 // indirect + github.com/BurntSushi/toml v1.6.0 // indirect github.com/Masterminds/goutils v1.1.1 // indirect - github.com/Masterminds/semver/v3 v3.3.0 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect - github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/adrg/xdg v0.5.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/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/awnumar/memcall v0.4.0 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 // indirect @@ -165,9 +183,12 @@ require ( github.com/aws/smithy-go v1.23.0 // indirect github.com/beevik/etree v1.6.0 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/caddyserver/zerossl v0.1.3 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect @@ -175,23 +196,13 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/docker v28.0.1+incompatible // indirect - github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect - github.com/ebitengine/purego v0.8.4 // 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 - github.com/fyne-io/glfw-js v0.3.0 // indirect - github.com/fyne-io/image v0.1.1 // indirect - github.com/fyne-io/oksvg v0.2.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.1 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect - github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect - github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect - github.com/go-ldap/ldap/v3 v3.4.12 // indirect + github.com/go-ldap/ldap/v3 v3.4.13 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-openapi/analysis v0.23.0 // indirect github.com/go-openapi/errors v0.22.2 // indirect github.com/go-openapi/jsonpointer v0.21.1 // indirect @@ -202,47 +213,56 @@ require ( github.com/go-openapi/swag v0.23.1 // indirect github.com/go-openapi/validate v0.24.0 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect - github.com/go-text/render v0.2.0 // indirect - github.com/go-text/typesetting v0.2.1 // indirect - github.com/goccy/go-yaml v1.18.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/go-webauthn/webauthn v0.16.4 // indirect + github.com/go-webauthn/x v0.2.3 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect - github.com/google/btree v1.1.2 // indirect + github.com/golang/mock v1.6.0 // indirect + github.com/google/btree v1.1.3 // indirect github.com/google/go-querystring v1.1.0 // indirect + github.com/google/go-tpm v0.9.8 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect github.com/googleapis/gax-go/v2 v2.21.0 // indirect github.com/gorilla/handlers v1.5.2 // indirect - github.com/hack-pad/go-indexeddb v0.3.2 // indirect - github.com/hack-pad/safejs v0.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.7 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/huin/goupnp v1.2.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect - github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackpal/go-nat-pmp v1.0.2 // indirect - github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect github.com/josharian/intern v1.0.0 // indirect - github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect github.com/kelseyhightower/envconfig v1.4.0 // indirect - github.com/klauspost/compress v1.18.0 // indirect - github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/klauspost/compress v1.18.3 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/koron/go-ssdp v0.0.4 // indirect github.com/kr/fs v0.1.0 // indirect - github.com/lib/pq v1.10.9 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/lib/pq v1.12.3 // 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.10 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect - github.com/mattn/go-sqlite3 v1.14.32 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.42 // 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 @@ -251,47 +271,43 @@ require ( github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/moby/client v0.4.0 // indirect github.com/moby/patternmatcher v0.6.0 // indirect github.com/moby/sys/sequential v0.5.0 // indirect github.com/moby/sys/user v0.3.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect - github.com/moby/term v0.5.0 // indirect + github.com/moby/term v0.5.2 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect - github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect github.com/nxadm/tail v1.4.11 // indirect github.com/oklog/ulid v1.3.1 // indirect - github.com/onsi/ginkgo/v2 v2.9.5 // indirect + github.com/openbao/openbao/api/v2 v2.5.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/philhofer/fwd v1.2.0 // 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 github.com/pion/transport/v2 v2.2.4 // indirect github.com/pion/turn/v4 v4.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect - github.com/prometheus/client_model v0.6.2 // indirect + github.com/pquerna/otp v1.5.0 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/otlptranslator v1.0.0 // indirect github.com/prometheus/procfs v0.19.2 // indirect github.com/russellhaering/goxmldsig v1.6.0 // indirect - github.com/rymdport/portal v0.4.2 // indirect - github.com/shirou/gopsutil/v4 v4.25.8 // indirect - github.com/shoenig/go-m1cpu v0.2.1 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect - github.com/spf13/cast v1.7.0 // indirect - github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect - github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect - github.com/stretchr/objx v0.5.2 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/stretchr/objx v0.5.3 // indirect + github.com/tinylib/msgp v1.6.3 // indirect github.com/tklauser/go-sysconf v0.3.15 // indirect github.com/tklauser/numcpus v0.10.0 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/wlynxg/anet v0.0.5 // indirect - github.com/yuin/goldmark v1.7.8 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/zeebo/blake3 v0.2.3 // indirect go.mongodb.org/mongo-driver v1.17.9 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect @@ -300,28 +316,33 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/image v0.33.0 // indirect - golang.org/x/text v0.35.0 // indirect - golang.org/x/tools v0.42.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/text v0.41.0 // indirect + golang.org/x/tools v0.49.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect gopkg.in/square/go-jose.v2 v2.6.0 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + rsc.io/qr v0.2.0 // indirect ) replace github.com/kardianos/service => github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 replace github.com/getlantern/systray => github.com/netbirdio/systray v0.0.0-20231030152038-ef1ed2a27949 -replace golang.zx2c4.com/wireguard => github.com/netbirdio/wireguard-go v0.0.0-20260107100953-33b7c9d03db0 +replace golang.zx2c4.com/wireguard => github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a -replace github.com/cloudflare/circl => github.com/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6 +replace github.com/cloudflare/circl => codeberg.org/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6 replace github.com/pion/ice/v4 => github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51 -replace github.com/libp2p/go-netroute => github.com/netbirdio/go-netroute v0.0.0-20240611143515-f59b0e1d3944 +replace github.com/dexidp/dex => github.com/netbirdio/dex v0.244.1-0.20260716205454-a163de3129e5 -replace github.com/dexidp/dex => github.com/netbirdio/dex v0.244.0 +replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1 replace github.com/mailru/easyjson => github.com/netbirdio/easyjson v0.9.0 + +replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260825085513-5f07a01f7a78 + +tool go.uber.org/mock/mockgen diff --git a/go.sum b/go.sum index 4f7c7b803..6a9cf29ba 100644 --- a/go.sum +++ b/go.sum @@ -5,35 +5,39 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3R cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -cunicu.li/go-rosenpass v0.4.0 h1:LtPtBgFWY/9emfgC4glKLEqS0MJTylzV6+ChRhiZERw= -cunicu.li/go-rosenpass v0.4.0/go.mod h1:MPbjH9nxV4l3vEagKVdFNwHOketqgS5/To1VYJplf/M= -dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= -dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +codeberg.org/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6 h1:b8xUw3004wk+3ipBhu0VU4RtUJsegMIiqjxSK4++lzA= +codeberg.org/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6/go.mod h1:+CauBF6R70Jqcyl8N2hC8pAXYbWkGIezuSbuGLtRhnw= +cunicu.li/go-rosenpass v0.5.42 h1:fRDsGwCxd7DhDgZI1Pxeo8GtNyq8BESZJ7w2/BGGJtU= +cunicu.li/go-rosenpass v0.5.42/go.mod h1:YRBeyKOe/gWpSX2kpDUec5p9t0XOLsshTguId5gTGVg= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -fyne.io/fyne/v2 v2.7.0 h1:GvZSpE3X0liU/fqstInVvRsaboIVpIWQ4/sfjDGIGGQ= -fyne.io/fyne/v2 v2.7.0/go.mod h1:xClVlrhxl7D+LT+BWYmcrW4Nf+dJTvkhnPgji7spAwE= -fyne.io/systray v1.12.1-0.20260116214250-81f8e1a496f9 h1:829+77I4TaMrcg9B3wf+gHhdSgoCVEgH2czlPXPbfj4= -fyne.io/systray v1.12.1-0.20260116214250-81f8e1a496f9/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/AppsFlyer/go-sundheit v0.6.0 h1:d2hBvCjBSb2lUsEWGfPigr4MCOt04sxB+Rppl0yUMSk= github.com/AppsFlyer/go-sundheit v0.6.0/go.mod h1:LDdBHD6tQBtmHsdW+i1GwdTt6Wqc0qazf5ZEJVTbTME= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= -github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A= +github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/DeRuina/timberjack v1.4.2 h1:4bKlzhKdsR+2oNkgef9mqb4n11ICow8VK88RfzJPzN8= +github.com/DeRuina/timberjack v1.4.2/go.mod h1:RLoeQrwrCGIEF8gO5nV5b/gMD0QIy7bzQhBUgpp1EqE= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= -github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= -github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= 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/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= +github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= @@ -89,6 +93,8 @@ github.com/beevik/etree v1.6.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sL github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -105,18 +111,22 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 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/cilium/ebpf v0.19.0 h1:Ro/rE64RmFBeA9FGjcTc+KmCeY6jXmryu6FfnzPRIao= +github.com/cilium/ebpf v0.19.0/go.mod h1:fLCgMo3l8tZmAdM3B2XqdFzXBpwkcSTroaVqN08OWVY= 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/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= 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= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/coreos/go-iptables v0.7.0 h1:XWM3V+MPRr5/q51NuWSgU0fqMad64Zyxs8ZUoMsamr8= github.com/coreos/go-iptables v0.7.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= -github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk= -github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= +github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= +github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -128,14 +138,10 @@ github.com/crowdsecurity/go-cs-bouncer v0.0.21 h1:arPz0VtdVSaz+auOSfHythzkZVLyy1 github.com/crowdsecurity/go-cs-bouncer v0.0.21/go.mod h1:4JiH0XXA4KKnnWThItUpe5+heJHWzsLOSA2IWJqUDBA= github.com/crowdsecurity/go-cs-lib v0.0.25 h1:Ov6VPW9yV+OPsbAIQk1iTkEWhwkpaG0v3lrBzeqjzj4= github.com/crowdsecurity/go-cs-lib v0.0.25/go.mod h1:X0GMJY2CxdA1S09SpuqIKaWQsvRGxXmecUp9cP599dE= -github.com/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6 h1:/DS5cDX3FJdl+XaN2D7XAwFpuanTxnp52DBLZAaJKx0= -github.com/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6/go.mod h1:+CauBF6R70Jqcyl8N2hC8pAXYbWkGIezuSbuGLtRhnw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dexidp/dex/api/v2 v2.4.0 h1:gNba7n6BKVp8X4Jp24cxYn5rIIGhM6kDOXcZoL6tr9A= -github.com/dexidp/dex/api/v2 v2.4.0/go.mod h1:/p550ADvFFh7K95VmhUD+jgm15VdaNnab9td8DHOpyI= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 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= @@ -146,51 +152,41 @@ github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pM github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= 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.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= -github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= +github.com/ebitengine/purego v0.9.1/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= github.com/eko/gocache/store/go_cache/v4 v4.2.2/go.mod h1:T9zkHokzr8K9EiC7RfMbDg6HSwaV6rv3UdcNu13SGcA= github.com/eko/gocache/store/redis/v4 v4.2.2 h1:Thw31fzGuH3WzJywsdbMivOmP550D6JS7GDHhvCJPA0= github.com/eko/gocache/store/redis/v4 v4.2.2/go.mod h1:LaTxLKx9TG/YUEybQvPMij++D7PBTIJ4+pzvk0ykz0w= -github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= -github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko= -github.com/fredbi/uri v1.1.1/go.mod h1:4+DZQ5zBjEwQCDmXW5JdIjz0PUA+yJbvtBv+u+adr5o= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs= -github.com/fyne-io/gl-js v0.2.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI= -github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk= -github.com/fyne-io/glfw-js v0.3.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk= -github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA= -github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM= -github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8= -github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI= +github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= +github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= -github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA= -github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= -github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= -github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4= -github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= +github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= +github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ= +github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -219,30 +215,39 @@ github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3Bum github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM= 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-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6 h1:teYtXy9B7y5lHTp8V9KPxpYRAVA7dozigQcMiBust1s= +github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI= 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= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= -github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= -github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= -github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc= -github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU= -github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8= -github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M= -github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0= -github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-webauthn/webauthn v0.16.4 h1:R9jqR/cYZa7hRquFF7Za/8qoH/K/TIs1/Q/4CyGN+1Q= +github.com/go-webauthn/webauthn v0.16.4/go.mod h1:SU2ljAgToTV/YLPI0C05QS4qn+e04WpB5g1RMfcZfS4= +github.com/go-webauthn/x v0.2.3 h1:8oArS+Rc1SWFLXhE17KZNx258Z4kUSyaDgsSncCO5RA= +github.com/go-webauthn/x v0.2.3/go.mod h1:tM04GF3V6VYq79AZMl7vbj4q6pz9r7L2criWRzbWhPk= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= -github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -259,28 +264,30 @@ github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiu github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= -github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= github.com/google/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg= github.com/google/nftables v0.3.0/go.mod h1:BCp9FsrbF1Fn/Yu6CLUc9GGZFw/+hsxfluNXXmxBfRM= -github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y= -github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= +github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6 h1:k7nVchz72niMH6YLQNvHSdIE7iqsQxK1P41mySCvssg= +github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -289,8 +296,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI= github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4= -github.com/gopacket/gopacket v1.1.1 h1:zbx9F9d6A7sWNkFKrvMBZTfGgxFoY4NgUudFVVHMfcw= -github.com/gopacket/gopacket v1.1.1/go.mod h1:HavMeONEl7W9036of9LbSWoonqhH7HA1+ZRO+rMIvFs= +github.com/gopacket/gopacket v1.4.0 h1:cr1OlFpzksCkZHNO0eLjaSSOrMQnpPXg0j6qHIY3y2U= +github.com/gopacket/gopacket v1.4.0/go.mod h1:EpvsxINeehp5qj4YMKMLf2/dekdhKn2IIAO/ZOifS7o= github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= @@ -299,22 +306,32 @@ github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.2-0.20240212192251-757544f2 github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.2-0.20240212192251-757544f21357/go.mod h1:w9Y7gY31krpLmrVU5ZPG9H7l9fZuRu5/3R3S3FMtVQ4= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= -github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A= -github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= -github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8= -github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 h1:ET4pqyjiGmY09R5y+rSd70J2w45CtbWDNvGqWp/R3Ng= github.com/hashicorp/go-secure-stdlib/base62 v0.1.2/go.mod h1:EdWO6czbmthiwZ3/PUsDV+UD1D5IRU4ActiaWGwt0Yw= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= @@ -324,12 +341,12 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= -github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= -github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= -github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= @@ -344,8 +361,7 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6 github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= -github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE= -github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -358,19 +374,19 @@ github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbd github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/jsimonetti/rtnetlink/v2 v2.0.1 h1:xda7qaHDSVOsADNouv7ukSuicKZO7GgVUCXxpaIEIlM= +github.com/jsimonetti/rtnetlink/v2 v2.0.1/go.mod h1:7MoNYNbb3UaDHtF8udiJo/RH6VsTKP1pqKLUTVCvToE= github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= -github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= -github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= -github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0= github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= @@ -385,26 +401,33 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/libdns/libdns v0.2.2 h1:O6ws7bAfRPaBsgAYt8MDe2HcNBGC29hkZ9MX2eUSX3s= github.com/libdns/libdns v0.2.2/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= github.com/libdns/route53 v1.5.0 h1:2SKdpPFl/qgWsXQvsLNJJAoX7rSxlk7zgoL4jnWdXVA= github.com/libdns/route53 v1.5.0/go.mod h1:joT4hKmaTNKHEwb7GmZ65eoDz1whTu7KKYPS8ZqIh6Q= -github.com/libp2p/go-nat v0.2.0 h1:Tyz+bUFAYqGyJ/ppPPymMGbIgNRH+WqC5QrT5fKrrGk= -github.com/libp2p/go-nat v0.2.0/go.mod h1:3MJr+GRpRkyT65EpVPBstXLvOlAPzUVlG6Pwg9ohLJk= +github.com/libp2p/go-netroute v0.4.0 h1:sZZx9hyANYUx9PZyqcgE/E1GUG3iEtTZHUEvdtXT7/Q= +github.com/libp2p/go-netroute v0.4.0/go.mod h1:Nkd5ShYgSMS5MUKy/MU2T57xFoOKvvLR92Lic48LEyA= github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81 h1:J56rFEfUTFT9j9CiRXhi1r8lUJ4W5idG3CiaBZGojNU= github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81/go.mod h1:RD8ML/YdXctQ7qbcizZkw5mZ6l8Ogrl1dodBzVJduwI= -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/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40= +github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= 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-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= +github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= 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= @@ -413,10 +436,12 @@ github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0 github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= +github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= +github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/mholt/acmez/v2 v2.0.1 h1:3/3N0u1pLjMK4sNEAFSI+bcvzbPhRpY383sy1kLHJ6k= github.com/mholt/acmez/v2 v2.0.1/go.mod h1:fX4c9r5jYwMyMsC+7tkYRxHibkOTgta5DIFGoe67e1U= -github.com/miekg/dns v1.1.59 h1:C9EXc/UToRwKLhK5wKU/I4QVsBUc8kE6MkHBkeypWZs= -github.com/miekg/dns v1.1.59/go.mod h1:nZpewl5p6IvctfgrckopVx2OlSEHPRO/U4SYkRklrEk= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= @@ -429,6 +454,10 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= +github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= +github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= @@ -437,20 +466,22 @@ github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= -github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= -github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/netbirdio/dex v0.244.0 h1:1GOvi8wnXYassnKGildzNqRHq0RbcfEUw7LKYpKIN7U= -github.com/netbirdio/dex v0.244.0/go.mod h1:STGInJhPcAflrHmDO7vyit2kSq03PdL+8zQPoGALtcU= +github.com/netbirdio/dex v0.244.1-0.20260716205454-a163de3129e5 h1:3PwQv8aR46qN2u16+Dv6udnH3sbVKX5KrGwF35CKSI0= +github.com/netbirdio/dex v0.244.1-0.20260716205454-a163de3129e5/go.mod h1:IHH+H8vK2GfqtIt5u/5OdPh18yk0oDHuj2vz5+Goetg= +github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1 h1:neE7z+FPUkldl3faK/Jt+hJK2L+1XfQ1W33TQhU9m88= +github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1/go.mod h1:awuTyT29CYALpEyET0S307EgNlPWrc7fFKRAyhsO45M= github.com/netbirdio/easyjson v0.9.0 h1:6Nw2lghSVuy8RSkAYDhDv1thBVEmfVbKZnV7T7Z6Aus= github.com/netbirdio/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/netbirdio/go-netroute v0.0.0-20240611143515-f59b0e1d3944 h1:TDtJKmM6Sf8uYFx/dMeqNOL90KUoRscdfpFZ3Im89uk= -github.com/netbirdio/go-netroute v0.0.0-20240611143515-f59b0e1d3944/go.mod h1:sHA6TRxjQ6RLbnI+3R4DZo2Eseg/iKiPRfNmcuNySVQ= +github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8 h1:pBxXEsxcsO3qVUND//5j1kelYlO57x5IrRviNF0+0iA= +github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8/go.mod h1:mFViabv4PpnoDw9w7W21a7xux6APA4q7KQZRsv4BCl8= 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-20260416163311-004852ffaf34 h1:g74mB64wnjCagzE1spKgPfTI/ont1SdSL3uX5bOecgM= @@ -459,12 +490,10 @@ github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9ax 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= github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ= -github.com/netbirdio/wireguard-go v0.0.0-20260107100953-33b7c9d03db0 h1:h/QnNzm7xzHPm+gajcblYUOclrW2FeNeDlUNj6tTWKQ= -github.com/netbirdio/wireguard-go v0.0.0-20260107100953-33b7c9d03db0/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= -github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk= -github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ= +github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260825085513-5f07a01f7a78 h1:B/jRv24jnFeoA+VccxoCx6K94PUgsqR9wnshpeu9M+8= +github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260825085513-5f07a01f7a78/go.mod h1:/6QR46/nhGCSADHbS++XtDb9dkTnenTHlGskTPRo9S0= +github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a h1:3CWK+yTvRKOcC0Q8VCTGy4l60TEb27CQVS7LkMxwjmw= +github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= @@ -479,16 +508,20 @@ github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+W github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= -github.com/onsi/ginkgo/v2 v2.9.5 h1:+6Hr4uxzP4XIUyAkg61dWBw8lb/gc4/X5luuxN/EC+Q= -github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k= +github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA= +github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -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/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/openbao/openbao/api/v2 v2.5.1 h1:Br79D6L20SbAa5P7xqENxmvv8LyI4HoKosPy7klhn4o= +github.com/openbao/openbao/api/v2 v2.5.1/go.mod h1:Dh5un77tqGgMbmlVEqjqN+8/dMyUohnkaQVg/wXW0Ig= 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.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/ory/dockertest/v4 v4.0.0 h1:i19aFsO/VXE0VrMk4ifnKW4G/KIJ93PCjLOslxXoPME= +github.com/ory/dockertest/v4 v4.0.0/go.mod h1:b5Ofu8VIxWNhXFvQcLu17pRNQdoUBKtXBW74G4Ygzx8= 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= @@ -497,6 +530,8 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 h1:E7Kmf11E4K7B5hDti2K2NqPb1nlYlGYsu02S1JNd/Bs= github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= github.com/pion/dtls/v2 v2.2.10 h1:u2Axk+FyIR1VFTPurktB+1zoEPGIW3bmyj3LEFrXjAA= github.com/pion/dtls/v2 v2.2.10/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE= @@ -528,16 +563,15 @@ github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g 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= -github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= -github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= github.com/pkg/sftp v1.13.9 h1:4NGkvGudBL7GteO3m6qnaQ4pC0Kvf0onSVc9gR3EWBw= github.com/pkg/sftp v1.13.9/go.mod h1:OBN7bVXdstkFFN/gdnHPUb5TE8eb8G1Rp9wCItqjkkA= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= +github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -548,8 +582,10 @@ github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEo github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= -github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk= -github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= +github.com/quic-go/quic-go v0.62.0 h1:ZHDjCk5OacATwGvs8PWE97CTvX7AqZiVoW7++ZOXTf8= +github.com/quic-go/quic-go v0.62.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -561,18 +597,10 @@ github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/russellhaering/goxmldsig v1.6.0 h1:8fdWXEPh2k/NZNQBPFNoVfS3JmzS4ZprY/sAOpKQLks= github.com/russellhaering/goxmldsig v1.6.0/go.mod h1:TrnaquDcYxWXfJrOjeMBTX4mLBeYAqaHEyUeWPxZlBM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -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/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/shirou/gopsutil/v4 v4.25.8 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970= github.com/shirou/gopsutil/v4 v4.25.8/go.mod h1:q9QdMmfAOVIw7a+eF86P7ISEU6ka+NLgkUxlopV4RwI= -github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= -github.com/shoenig/go-m1cpu v0.2.1 h1:yqRB4fvOge2+FyRXFkXqsyMoqPazv14Yyy+iyccT2E4= -github.com/shoenig/go-m1cpu v0.2.1/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w= -github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= -github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk= -github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= @@ -581,23 +609,20 @@ github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EE github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8= github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E= -github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= -github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= -github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= -github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= -github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= -github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= -github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= @@ -607,9 +632,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -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/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= 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= @@ -624,10 +648,10 @@ github.com/ti-mo/conntrack v0.5.1 h1:opEwkFICnDbQc0BUXl73PHBK0h23jEIFVjXsqvF4GY0 github.com/ti-mo/conntrack v0.5.1/go.mod h1:T6NCbkMdVU4qEIgwL0njA6lw/iCAbzchlnwm1Sa314o= github.com/ti-mo/netfilter v0.5.2 h1:CTjOwFuNNeZ9QPdRXt1MZFLFUf84cKtiQutNauHWd40= github.com/ti-mo/netfilter v0.5.2/go.mod h1:Btx3AtFiOVdHReTDmP9AE+hlkOcvIy403u7BXXbWZKo= -github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= +github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= -github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= @@ -642,12 +666,12 @@ github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAh github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= -github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zcalusic/sysinfo v1.1.3 h1:u/AVENkuoikKuIZ4sUEJ6iibpmQP6YpGD8SSMCrqAF0= @@ -686,14 +710,17 @@ go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lI go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= -go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= goauthentik.io/api/v3 v3.2023051.3 h1:NebAhD/TeTWNo/9X3/Uj+rM5fG1HaiLOlKTNLQv9Qq4= goauthentik.io/api/v3 v3.2023051.3/go.mod h1:nYECml4jGbp/541hj8GcylKQG1gVBsKppHy4+7G8u4U= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -707,15 +734,13 @@ golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1m golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= -golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ= -golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20251113184115-a159579294ab h1:Iqyc+2zr7aGyLuEadIm0KRJP0Wwt+fhlXLa51Fxf1+Q= -golang.org/x/mobile v0.0.0-20251113184115-a159579294ab/go.mod h1:Eq3Nh/5pFSWug2ohiudJ1iyU59SO78QFuh4qTTN++I0= +golang.org/x/mobile v0.0.0-20260816165457-f98cc9b3c733 h1:XKMObIaAElmkdO+4SQh1iCfzwciZHJi1OblnX9BED9k= +golang.org/x/mobile v0.0.0-20260816165457-f98cc9b3c733/go.mod h1:jMwjxoDSx9jqhNaZqPnr6nnKzb7cs+Dy1Czk7wdX+R8= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -725,8 +750,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74= +golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= @@ -745,8 +770,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= @@ -761,8 +786,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -787,6 +812,7 @@ golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -794,11 +820,10 @@ golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -811,8 +836,8 @@ golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -824,8 +849,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -839,16 +864,16 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= -golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 h1:CawjfCvYQH2OU3/TnxLx97WDSUDRABfT18pCOYwc2GE= -golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6/go.mod h1:3rxYc4HtVcSG9gVaTs2GEBdehh+sYPOwKtyUWEOTb80= +golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU= +golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ= golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= @@ -885,8 +910,6 @@ gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8 gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= -gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI= gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= @@ -909,7 +932,13 @@ 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.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= -gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89 h1:mGJaeA61P8dEHTqdvAgc70ZIV3QoUoJcXCRyyjO26OA= gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q= +howett.net/plist v1.0.2-0.20250314012144-ee69052608d9 h1:eeH1AIcPvSc0Z25ThsYF+Xoqbn0CI/YnXVYoTLFdGQw= +howett.net/plist v1.0.2-0.20250314012144-ee69052608d9/go.mod h1:fyFX5Hj5tP1Mpk8obqA9MZgXT416Q5711SDT7dQLTLk= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/idp/dex/config.go b/idp/dex/config.go index 7f5300f14..00b5ce745 100644 --- a/idp/dex/config.go +++ b/idp/dex/config.go @@ -51,6 +51,70 @@ type YAMLConfig struct { // StaticPasswords cause the server use this list of passwords rather than // querying the storage. StaticPasswords []Password `yaml:"staticPasswords" json:"staticPasswords"` + + // Sessions holds authentication session configuration. + // Requires DEX_SESSIONS_ENABLED=true feature flag. + Sessions *Sessions `yaml:"sessions" json:"sessions"` + + // MFA holds multi-factor authentication configuration. + MFA MFAConfig `yaml:"mfa" json:"mfa"` +} + +type Sessions struct { + // CookieName is the name of the session cookie. Defaults to "dex_session". + CookieName string `yaml:"cookieName" json:"cookieName"` + // AbsoluteLifetime is the maximum session lifetime from creation. Defaults to "24h". + AbsoluteLifetime string `yaml:"absoluteLifetime" json:"absoluteLifetime"` + // ValidIfNotUsedFor is the idle timeout. Defaults to "1h". + ValidIfNotUsedFor string `yaml:"validIfNotUsedFor" json:"validIfNotUsedFor"` + // RememberMeCheckedByDefault controls the default state of the "remember me" checkbox. + RememberMeCheckedByDefault *bool `yaml:"rememberMeCheckedByDefault" json:"rememberMeCheckedByDefault"` + // CookieEncryptionKey is the AES key for encrypting session cookies. + // Must be 16, 24, or 32 bytes for AES-128, AES-192, or AES-256. + // If empty, cookies are not encrypted. + CookieEncryptionKey string `yaml:"cookieEncryptionKey" json:"cookieEncryptionKey"` + // SSOSharedWithDefault is the default SSO sharing policy for clients without explicit ssoSharedWith. + // "all" = share with all clients, "none" = share with no one (default: "none"). + SSOSharedWithDefault string `yaml:"ssoSharedWithDefault" json:"ssoSharedWithDefault"` +} + +type MFAConfig struct { + Authenticators []MFAAuthenticator `yaml:"authenticators" json:"authenticators"` +} + +type MFAAuthenticator struct { + ID string `yaml:"id" json:"id"` + Type string `yaml:"type" json:"type"` + Config map[string]interface{} `yaml:"config" json:"config"` + + ConnectorTypes []string `yaml:"connectorTypes" json:"connectorTypes"` +} + +type TOTPConfig struct { + Issuer string `yaml:"issuer" json:"issuer"` +} + +// WebAuthnConfig holds configuration for a WebAuthn authenticator. +type WebAuthnConfig struct { + // RPDisplayName is the human-readable relying party name shown in the browser + // dialog during key registration and authentication (e.g., "My Company SSO"). + RPDisplayName string `yaml:"rpDisplayName" json:"rpDisplayName"` + // RPID is the relying party identifier — must match the domain in the browser + // address bar. If empty, derived from the issuer URL hostname. + // Example: "auth.example.com" + RPID string `yaml:"rpID" json:"rpID"` + // RPOrigins is the list of allowed origins for WebAuthn ceremonies. + // If empty, derived from the issuer URL (scheme + host). + // Example: ["https://auth.example.com"] + RPOrigins []string `yaml:"rpOrigins" json:"rpOrigins"` + // AttestationPreference controls what attestation data the authenticator should provide: + // "none" — don't request attestation (simpler, more private) + // "indirect" — authenticator may anonymize attestation (default) + // "direct" — request full attestation (for enterprise key model verification) + AttestationPreference string `yaml:"attestationPreference" json:"attestationPreference"` + // Timeout is the duration allowed for the browser WebAuthn ceremony + // (registration or login). Defaults to "60s". + Timeout string `yaml:"timeout" json:"timeout"` } // Web is the config format for the HTTP server. @@ -116,7 +180,6 @@ type Storage struct { Config map[string]interface{} `yaml:"config" json:"config"` } -// Password represents a static user configuration type Password storage.Password func (p *Password) UnmarshalYAML(node *yaml.Node) error { @@ -193,7 +256,7 @@ func (c *Connector) ToStorageConnector() (storage.Connector, error) { // are stored with types that Dex can open. func mapConnectorToDex(connType string, config map[string]interface{}) (string, map[string]interface{}) { switch connType { - case "oidc", "zitadel", "entra", "okta", "pocketid", "authentik", "keycloak": + case "oidc", "zitadel", "entra", "okta", "pocketid", "authentik", "keycloak", "adfs": return "oidc", applyOIDCDefaults(connType, config) default: return connType, config @@ -218,6 +281,8 @@ func applyOIDCDefaults(connType string, config map[string]interface{}) map[strin setDefault(augmented, "claimMapping", map[string]string{"email": "preferred_username"}) case "okta", "pocketid": augmented["scopes"] = []string{"openid", "profile", "email", "groups"} + case "adfs": + augmented["scopes"] = []string{"openid", "profile", "email", "allatclaims"} } return augmented @@ -243,7 +308,7 @@ func (s *Storage) OpenStorage(logger *slog.Logger) (storage.Storage, error) { if file == "" { return nil, fmt.Errorf("sqlite3 storage requires 'file' config") } - return (&sql.SQLite3{File: file}).Open(logger) + return newSQLite3(file).Open(logger) case "postgres": dsn, _ := s.Config["dsn"].(string) if dsn == "" { @@ -427,9 +492,98 @@ func (c *YAMLConfig) Validate() error { if !c.EnablePasswordDB && len(c.StaticPasswords) != 0 { return fmt.Errorf("cannot specify static passwords without enabling password db") } + return nil } +func buildTotpConfig(auth MFAAuthenticator) (*server.TOTPProvider, error) { + data, err := json.Marshal(auth.Config) + if err != nil { + return nil, fmt.Errorf("failed to marshal TOTP config id: %s - %w", auth.ID, err) + } + + var cfg TOTPConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse TOTP config id: %s - %w", auth.ID, err) + } + + return server.NewTOTPProvider(cfg.Issuer, auth.ConnectorTypes), nil +} + +func buildWebAuthnConfig(auth MFAAuthenticator, issuerURL string) (*server.WebAuthnProvider, error) { + data, err := json.Marshal(auth.Config) + if err != nil { + return nil, fmt.Errorf("failed to marshal WebAuthn config id: %s - %w", auth.ID, err) + } + + var cfg WebAuthnConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse WebAuthn config id: %s - %w", auth.ID, err) + } + + provider, err := server.NewWebAuthnProvider(cfg.RPDisplayName, cfg.RPID, cfg.RPOrigins, + cfg.AttestationPreference, cfg.Timeout, issuerURL, auth.ConnectorTypes) + if err != nil { + return nil, fmt.Errorf("failed to create WebAuthn provider id: %s - err: %w", auth.ID, err) + } + + return provider, nil +} + +func buildMFAProviders(authenticators []MFAAuthenticator, issuerURL string, logger *slog.Logger) map[string]server.MFAProvider { + if len(authenticators) == 0 { + return nil + } + + providers := make(map[string]server.MFAProvider, len(authenticators)) + for _, auth := range authenticators { + switch auth.Type { + case "TOTP": + provider, err := buildTotpConfig(auth) + if err != nil { + logger.Error("failed to parse TOTP config", "id", auth.ID, "err", err) + continue + } + providers[auth.ID] = provider + logger.Info("MFA authenticator configured", "id", auth.ID, "type", auth.Type) + case "WebAuthn": + provider, err := buildWebAuthnConfig(auth, issuerURL) + if err != nil { + logger.Error("failed to parse WebAuthn config", "id", auth.ID, "err", err) + continue + } + providers[auth.ID] = provider + logger.Info("MFA authenticator configured", "id", auth.ID, "type", auth.Type) + default: + logger.Error("unknown MFA authenticator type, skipping", "id", auth.ID, "type", auth.Type) + } + } + return providers +} + +func buildSessionsConfig(sessions *Sessions) *server.SessionConfig { + if sessions == nil { + return nil + } + + if sessions.RememberMeCheckedByDefault == nil { + defaultRememberMeCheckedByDefault := false + sessions.RememberMeCheckedByDefault = &defaultRememberMeCheckedByDefault + } + + absoluteLifetime, _ := parseDuration(sessions.AbsoluteLifetime) + validIfNotUsedFor, _ := parseDuration(sessions.ValidIfNotUsedFor) + + return &server.SessionConfig{ + CookieEncryptionKey: []byte(sessions.CookieEncryptionKey), + CookieName: sessions.CookieName, + AbsoluteLifetime: absoluteLifetime, + ValidIfNotUsedFor: validIfNotUsedFor, + RememberMeCheckedByDefault: *sessions.RememberMeCheckedByDefault, + SSOSharedWithDefault: sessions.SSOSharedWithDefault, + } +} + // ToServerConfig converts YAMLConfig to dex server.Config func (c *YAMLConfig) ToServerConfig(stor storage.Storage, logger *slog.Logger) server.Config { cfg := server.Config{ @@ -446,6 +600,8 @@ func (c *YAMLConfig) ToServerConfig(stor storage.Storage, logger *slog.Logger) s Dir: c.Frontend.Dir, Extra: c.Frontend.Extra, }, + SessionConfig: buildSessionsConfig(c.Sessions), + MFAProviders: buildMFAProviders(c.MFA.Authenticators, c.Issuer, logger), } // Use embedded NetBird-styled templates if no custom dir specified @@ -457,12 +613,11 @@ func (c *YAMLConfig) ToServerConfig(stor storage.Storage, logger *slog.Logger) s cfg.SupportedResponseTypes = c.OAuth2.ResponseTypes } - // Apply expiry settings - if c.Expiry.SigningKeys != "" { - if d, err := parseDuration(c.Expiry.SigningKeys); err == nil { - cfg.RotateKeysAfter = d - } + if len(c.OAuth2.GrantTypes) > 0 { + cfg.AllowedGrantTypes = c.OAuth2.GrantTypes } + + // Apply expiry settings if c.Expiry.IDTokens != "" { if d, err := parseDuration(c.Expiry.IDTokens); err == nil { cfg.IDTokensValidFor = d diff --git a/idp/dex/connector.go b/idp/dex/connector.go index ba2bb1f00..fb20fdcc3 100644 --- a/idp/dex/connector.go +++ b/idp/dex/connector.go @@ -89,21 +89,33 @@ func (p *Provider) ListConnectors(ctx context.Context) ([]*ConnectorConfig, erro } // UpdateConnector updates an existing connector in Dex storage. -// It merges incoming updates with existing values to prevent data loss on partial updates. +// It overlays user-mutable config fields (issuer, clientID, clientSecret, +// redirectURI) onto the stored connector config, and updates the connector name +// when cfg.Name is set. Empty fields on cfg leave stored values unchanged, so +// partial updates preserve create-time defaults such as scopes, claimMapping, +// and userIDKey. func (p *Provider) UpdateConnector(ctx context.Context, cfg *ConnectorConfig) error { if err := p.storage.UpdateConnector(ctx, cfg.ID, func(old storage.Connector) (storage.Connector, error) { - oldCfg, err := p.parseStorageConnector(old) - if err != nil { - return storage.Connector{}, fmt.Errorf("failed to parse existing connector: %w", err) + if cfg.Type != "" && cfg.Type != inferIdentityProviderType(old.Type, cfg.ID, nil) { + return storage.Connector{}, errors.New("connector type change not allowed") } - mergeConnectorConfig(cfg, oldCfg) - - storageConn, err := p.buildStorageConnector(cfg) + configData, err := overlayConnectorConfig(old.Config, cfg) if err != nil { - return storage.Connector{}, fmt.Errorf("failed to build connector: %w", err) + return storage.Connector{}, fmt.Errorf("failed to overlay connector config: %w", err) } - return storageConn, nil + + name := cfg.Name + if name == "" { + name = old.Name + } + + return storage.Connector{ + ID: cfg.ID, + Type: old.Type, + Name: name, + Config: configData, + }, nil }); err != nil { return fmt.Errorf("failed to update connector: %w", err) } @@ -112,23 +124,27 @@ func (p *Provider) UpdateConnector(ctx context.Context, cfg *ConnectorConfig) er return nil } -// mergeConnectorConfig preserves existing values for empty fields in the update. -func mergeConnectorConfig(cfg, oldCfg *ConnectorConfig) { - if cfg.ClientSecret == "" { - cfg.ClientSecret = oldCfg.ClientSecret +// overlayConnectorConfig writes only the user-mutable fields onto the existing +// stored config, preserving every other field (scopes, claimMapping, userIDKey, +// insecure flags, etc.). Empty fields on cfg leave the existing value alone. +func overlayConnectorConfig(oldConfig []byte, cfg *ConnectorConfig) ([]byte, error) { + var m map[string]any + if err := decodeConnectorConfig(oldConfig, &m); err != nil { + return nil, err } - if cfg.RedirectURI == "" { - cfg.RedirectURI = oldCfg.RedirectURI + if cfg.Issuer != "" { + m["issuer"] = cfg.Issuer } - if cfg.Issuer == "" && cfg.Type == oldCfg.Type { - cfg.Issuer = oldCfg.Issuer + if cfg.ClientID != "" { + m["clientID"] = cfg.ClientID } - if cfg.ClientID == "" { - cfg.ClientID = oldCfg.ClientID + if cfg.ClientSecret != "" { + m["clientSecret"] = cfg.ClientSecret } - if cfg.Name == "" { - cfg.Name = oldCfg.Name + if cfg.RedirectURI != "" { + m["redirectURI"] = cfg.RedirectURI } + return encodeConnectorConfig(m) } // DeleteConnector removes a connector from Dex storage. @@ -168,7 +184,7 @@ func (p *Provider) buildStorageConnector(cfg *ConnectorConfig) (storage.Connecto var err error switch cfg.Type { - case "oidc", "zitadel", "entra", "okta", "pocketid", "authentik", "keycloak": + case "oidc", "zitadel", "entra", "okta", "pocketid", "authentik", "keycloak", "adfs": dexType = "oidc" configData, err = buildOIDCConnectorConfig(cfg, redirectURI) case "google": @@ -216,10 +232,16 @@ func buildOIDCConnectorConfig(cfg *ConnectorConfig, redirectURI string) ([]byte, oidcConfig["getUserInfo"] = true case "entra": oidcConfig["claimMapping"] = map[string]string{"email": "preferred_username"} + // Use the Entra Object ID (oid) instead of the default OIDC sub claim. + // Entra issues sub as a per-app pairwise identifier that does not match + // the stable Object ID. + oidcConfig["userIDKey"] = "oid" case "okta": oidcConfig["scopes"] = []string{"openid", "profile", "email", "groups"} case "pocketid": oidcConfig["scopes"] = []string{"openid", "profile", "email", "groups"} + case "adfs": + oidcConfig["scopes"] = []string{"openid", "profile", "email", "allatclaims"} } return encodeConnectorConfig(oidcConfig) } @@ -283,7 +305,7 @@ func inferIdentityProviderType(dexType, connectorID string, _ map[string]interfa // inferOIDCProviderType infers the specific OIDC provider from connector ID func inferOIDCProviderType(connectorID string) string { connectorIDLower := strings.ToLower(connectorID) - for _, provider := range []string{"pocketid", "zitadel", "entra", "okta", "authentik", "keycloak"} { + for _, provider := range []string{"pocketid", "zitadel", "entra", "okta", "authentik", "keycloak", "adfs"} { if strings.Contains(connectorIDLower, provider) { return provider } diff --git a/idp/dex/connector_test.go b/idp/dex/connector_test.go new file mode 100644 index 000000000..4253e02b7 --- /dev/null +++ b/idp/dex/connector_test.go @@ -0,0 +1,205 @@ +package dex + +import ( + "context" + "encoding/json" + "log/slog" + "os" + "path/filepath" + "testing" + + "github.com/dexidp/dex/storage" + "github.com/dexidp/dex/storage/sql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestProvider(t *testing.T) (*Provider, func()) { + t.Helper() + tmpDir, err := os.MkdirTemp("", "dex-connector-test-*") + require.NoError(t, err) + + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + s, err := (&sql.SQLite3{File: filepath.Join(tmpDir, "dex.db")}).Open(logger) + require.NoError(t, err) + + return &Provider{storage: s, logger: logger}, func() { + _ = s.Close() + _ = os.RemoveAll(tmpDir) + } +} + +func TestBuildOIDCConnectorConfig_EntraSetsUserIDKey(t *testing.T) { + cfg := &ConnectorConfig{ + ID: "entra-test", + Name: "Entra", + Type: "entra", + Issuer: "https://login.microsoftonline.com/tid/v2.0", + ClientID: "client-id", + ClientSecret: "client-secret", + } + data, err := buildOIDCConnectorConfig(cfg, "https://example.com/oauth2/callback") + require.NoError(t, err) + + var m map[string]any + require.NoError(t, json.Unmarshal(data, &m)) + + assert.Equal(t, "oid", m["userIDKey"], "entra connectors must default userIDKey to oid") + assert.Equal(t, map[string]any{"email": "preferred_username"}, m["claimMapping"]) +} + +func TestBuildOIDCConnectorConfig_NonEntraDoesNotSetUserIDKey(t *testing.T) { + // ensures the Entra userIDKey override does not leak into other OIDC providers, + // which already use a stable sub claim. + for _, typ := range []string{"oidc", "zitadel", "okta", "pocketid", "authentik", "keycloak", "adfs"} { + t.Run(typ, func(t *testing.T) { + data, err := buildOIDCConnectorConfig(&ConnectorConfig{Type: typ}, "https://example.com/oauth2/callback") + require.NoError(t, err) + var m map[string]any + require.NoError(t, json.Unmarshal(data, &m)) + _, ok := m["userIDKey"] + assert.False(t, ok, "%s connectors must not have userIDKey set", typ) + }) + } +} + +func TestUpdateConnector_PreservesCreateTimeDefaults(t *testing.T) { + ctx := context.Background() + p, cleanup := newTestProvider(t) + defer cleanup() + + created, err := p.CreateConnector(ctx, &ConnectorConfig{ + ID: "entra-test", + Name: "Entra", + Type: "entra", + Issuer: "https://login.microsoftonline.com/tid/v2.0", + ClientID: "client-id", + ClientSecret: "old-secret", + RedirectURI: "https://example.com/oauth2/callback", + }) + require.NoError(t, err) + require.Equal(t, "entra-test", created.ID) + + // Rotate only the client secret. + err = p.UpdateConnector(ctx, &ConnectorConfig{ + ID: "entra-test", + Type: "entra", + ClientSecret: "new-secret", + }) + require.NoError(t, err) + + conn, err := p.storage.GetConnector(ctx, "entra-test") + require.NoError(t, err) + var m map[string]any + require.NoError(t, json.Unmarshal(conn.Config, &m)) + + assert.Equal(t, "new-secret", m["clientSecret"], "clientSecret should be rotated") + assert.Equal(t, "client-id", m["clientID"], "clientID must survive (overlay should leave it alone)") + assert.Equal(t, "https://login.microsoftonline.com/tid/v2.0", m["issuer"]) + assert.Equal(t, "oid", m["userIDKey"], "userIDKey must survive update") + assert.Equal(t, map[string]any{"email": "preferred_username"}, m["claimMapping"], "claimMapping must survive update") +} + +func TestUpdateConnector_DoesNotAddUserIDKeyToExistingConnector(t *testing.T) { + ctx := context.Background() + p, cleanup := newTestProvider(t) + defer cleanup() + + // Seed a connector directly into storage without userIDKey + preFixConfig, err := json.Marshal(map[string]any{ + "issuer": "https://login.microsoftonline.com/tid/v2.0", + "clientID": "client-id", + "clientSecret": "old-secret", + "redirectURI": "https://example.com/oauth2/callback", + "scopes": []string{"openid", "profile", "email"}, + "claimMapping": map[string]string{"email": "preferred_username"}, + }) + require.NoError(t, err) + + require.NoError(t, p.storage.CreateConnector(ctx, storage.Connector{ + ID: "entra-prefix", + Type: "oidc", + Name: "Entra", + Config: preFixConfig, + })) + + // Rotate client secret via UpdateConnector. + err = p.UpdateConnector(ctx, &ConnectorConfig{ + ID: "entra-prefix", + Type: "entra", + ClientSecret: "new-secret", + }) + require.NoError(t, err) + + conn, err := p.storage.GetConnector(ctx, "entra-prefix") + require.NoError(t, err) + var m map[string]any + require.NoError(t, json.Unmarshal(conn.Config, &m)) + + assert.Equal(t, "new-secret", m["clientSecret"]) + _, has := m["userIDKey"] + assert.False(t, has, "userIDKey must not be auto-added to a connector that did not have it before") +} + +func TestUpdateConnector_RejectsTypeChange(t *testing.T) { + ctx := context.Background() + p, cleanup := newTestProvider(t) + defer cleanup() + + _, err := p.CreateConnector(ctx, &ConnectorConfig{ + ID: "entra-test", + Name: "Entra", + Type: "entra", + Issuer: "https://login.microsoftonline.com/tid/v2.0", + ClientID: "client-id", + ClientSecret: "secret", + RedirectURI: "https://example.com/oauth2/callback", + }) + require.NoError(t, err) + + // Attempt to switch the connector to okta. + err = p.UpdateConnector(ctx, &ConnectorConfig{ + ID: "entra-test", + Type: "okta", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "connector type change not allowed") + + // stored connector type/config unchanged after the rejected update. + conn, err := p.storage.GetConnector(ctx, "entra-test") + require.NoError(t, err) + assert.Equal(t, "oidc", conn.Type) + var m map[string]any + require.NoError(t, json.Unmarshal(conn.Config, &m)) + assert.Equal(t, "oid", m["userIDKey"]) +} + +func TestUpdateConnector_AllowsSameTypeUpdate(t *testing.T) { + ctx := context.Background() + p, cleanup := newTestProvider(t) + defer cleanup() + + _, err := p.CreateConnector(ctx, &ConnectorConfig{ + ID: "entra-test", + Name: "Entra", + Type: "entra", + Issuer: "https://login.microsoftonline.com/old/v2.0", + ClientID: "client-id", + ClientSecret: "secret", + RedirectURI: "https://example.com/oauth2/callback", + }) + require.NoError(t, err) + + err = p.UpdateConnector(ctx, &ConnectorConfig{ + ID: "entra-test", + Type: "entra", + Issuer: "https://login.microsoftonline.com/new/v2.0", + }) + require.NoError(t, err) + + conn, err := p.storage.GetConnector(ctx, "entra-test") + require.NoError(t, err) + var m map[string]any + require.NoError(t, json.Unmarshal(conn.Config, &m)) + assert.Equal(t, "https://login.microsoftonline.com/new/v2.0", m["issuer"]) +} diff --git a/idp/dex/provider.go b/idp/dex/provider.go index 24aed1b99..f40b96a58 100644 --- a/idp/dex/provider.go +++ b/idp/dex/provider.go @@ -18,9 +18,9 @@ import ( dexapi "github.com/dexidp/dex/api/v2" "github.com/dexidp/dex/server" + "github.com/dexidp/dex/server/signer" "github.com/dexidp/dex/storage" - "github.com/dexidp/dex/storage/sql" - jose "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4" "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "golang.org/x/crypto/bcrypt" @@ -40,6 +40,8 @@ type Config struct { GRPCAddr string } +const LocalConnectorID = "local" + // Provider wraps a Dex server type Provider struct { config *Config @@ -70,13 +72,13 @@ func NewProvider(ctx context.Context, config *Config) (*Provider, error) { logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) // Ensure data directory exists - if err := os.MkdirAll(config.DataDir, 0700); err != nil { + if err := os.MkdirAll(config.DataDir, 0o700); err != nil { return nil, fmt.Errorf("failed to create data directory: %w", err) } // Initialize SQLite storage dbPath := filepath.Join(config.DataDir, "oidc.db") - sqliteConfig := &sql.SQLite3{File: dbPath} + sqliteConfig := newSQLite3(dbPath) stor, err := sqliteConfig.Open(logger) if err != nil { return nil, fmt.Errorf("failed to open storage: %w", err) @@ -101,6 +103,15 @@ func NewProvider(ctx context.Context, config *Config) (*Provider, error) { return nil, fmt.Errorf("failed to create refresh token policy: %w", err) } + localSignerConfig := signer.LocalConfig{ + KeysRotationPeriod: "6h", + } + + localSigner, err := localSignerConfig.Open(ctx, stor, 24*time.Hour, time.Now, logger) + if err != nil { + return nil, fmt.Errorf("failed to create local signer: %w", err) + } + // Build Dex server config - use Dex's types directly dexConfig := server.Config{ Issuer: issuer, @@ -110,12 +121,12 @@ func NewProvider(ctx context.Context, config *Config) (*Provider, error) { ContinueOnConnectorFailure: true, Logger: logger, PrometheusRegistry: prometheus.NewRegistry(), - RotateKeysAfter: 6 * time.Hour, IDTokensValidFor: 24 * time.Hour, RefreshTokenPolicy: refreshPolicy, Web: server.WebConfig{ Issuer: "NetBird", }, + Signer: localSigner, } dexSrv, err := server.NewServer(ctx, dexConfig) @@ -167,6 +178,14 @@ func NewProviderFromYAML(ctx context.Context, yamlConfig *YAMLConfig) (*Provider return nil, fmt.Errorf("failed to create refresh token policy: %w", err) } + localSigner, err := getSigner(ctx, stor, yamlConfig, logger) + if err != nil { + stor.Close() + return nil, fmt.Errorf("failed to create local signer: %w", err) + } + + dexConfig.Signer = localSigner + dexSrv, err := server.NewServer(ctx, dexConfig) if err != nil { stor.Close() @@ -182,6 +201,32 @@ func NewProviderFromYAML(ctx context.Context, yamlConfig *YAMLConfig) (*Provider }, nil } +func getSigner(ctx context.Context, stor storage.Storage, yamlConfig *YAMLConfig, logger *slog.Logger) (signer.Signer, error) { + // Parse expiry durations + idTokensValidFor := 24 * time.Hour // default + if yamlConfig.Expiry.IDTokens != "" { + var err error + idTokensValidFor, err = parseDuration(yamlConfig.Expiry.IDTokens) + if err != nil { + return nil, fmt.Errorf("invalid config value %q for id token expiry: %v", yamlConfig.Expiry.IDTokens, err) + } + } + + localSignerConfig := &signer.LocalConfig{ + KeysRotationPeriod: "720h", // 30 Days + } + + if yamlConfig.Expiry.SigningKeys != "" { + if _, err := parseDuration(yamlConfig.Expiry.SigningKeys); err != nil { + return nil, fmt.Errorf("invalid config value %q for signing key expiry: %v", yamlConfig.Expiry.SigningKeys, err) + } + + localSignerConfig.KeysRotationPeriod = yamlConfig.Expiry.SigningKeys + } + + return localSignerConfig.Open(ctx, stor, idTokensValidFor, time.Now, logger) +} + // initializeStorage sets up connectors, passwords, and clients in storage func initializeStorage(ctx context.Context, stor storage.Storage, cfg *YAMLConfig) error { if cfg.EnablePasswordDB { @@ -241,6 +286,8 @@ func ensureStaticClients(ctx context.Context, stor storage.Storage, clients []st old.RedirectURIs = client.RedirectURIs old.Name = client.Name old.Public = client.Public + old.PostLogoutRedirectURIs = client.PostLogoutRedirectURIs + old.MFAChain = client.MFAChain return old, nil }); err != nil { return fmt.Errorf("failed to update client %s: %w", client.ID, err) @@ -253,9 +300,6 @@ func ensureStaticClients(ctx context.Context, stor storage.Storage, clients []st func buildDexConfig(yamlConfig *YAMLConfig, stor storage.Storage, logger *slog.Logger) server.Config { cfg := yamlConfig.ToServerConfig(stor, logger) cfg.PrometheusRegistry = prometheus.NewRegistry() - if cfg.RotateKeysAfter == 0 { - cfg.RotateKeysAfter = 24 * 30 * time.Hour - } if cfg.IDTokensValidFor == 0 { cfg.IDTokensValidFor = 24 * time.Hour } @@ -450,10 +494,76 @@ func (p *Provider) Storage() storage.Storage { return p.storage } +// SetClientsMFAChain updates the MFAChain field on OAuth2 clients in Dex storage. +// Pass a non-empty slice (e.g. []string{"default-totp"}) to enable MFA, or nil to disable it. +func SetClientsMFAChain(ctx context.Context, st storage.Storage, clientIDs []string, mfaChain []string) error { + previousChains := make(map[string][]string, len(clientIDs)) + for _, clientID := range clientIDs { + client, err := st.GetClient(ctx, clientID) + if err != nil { + return fmt.Errorf("failed to get client %s before MFA chain update: %w", clientID, err) + } + previousChains[clientID] = cloneMFAChain(client.MFAChain) + } + + updatedClientIDs := make([]string, 0, len(clientIDs)) + for _, clientID := range clientIDs { + if err := st.UpdateClient(ctx, clientID, func(old storage.Client) (storage.Client, error) { + old.MFAChain = cloneMFAChain(mfaChain) + return old, nil + }); err != nil { + if rollbackErr := rollbackClientsMFAChain(ctx, st, updatedClientIDs, previousChains); rollbackErr != nil { + return fmt.Errorf("failed to update MFA chain on client %s: %w (also failed to roll back previous MFA chains: %v)", clientID, err, rollbackErr) + } + return fmt.Errorf("failed to update MFA chain on client %s: %w", clientID, err) + } + updatedClientIDs = append(updatedClientIDs, clientID) + } + return nil +} + +func rollbackClientsMFAChain(ctx context.Context, st storage.Storage, clientIDs []string, previousChains map[string][]string) error { + var rollbackErrs []error + for i := len(clientIDs) - 1; i >= 0; i-- { + clientID := clientIDs[i] + previousChain := cloneMFAChain(previousChains[clientID]) + if err := st.UpdateClient(ctx, clientID, func(old storage.Client) (storage.Client, error) { + old.MFAChain = previousChain + return old, nil + }); err != nil { + rollbackErrs = append(rollbackErrs, fmt.Errorf("client %s: %w", clientID, err)) + } + } + return errors.Join(rollbackErrs...) +} + +func cloneMFAChain(chain []string) []string { + if chain == nil { + return nil + } + return append([]string(nil), chain...) +} + +// SetClientsMFAChain updates the MFAChain field on the dashboard and CLI OAuth2 clients. +// Pass a non-empty slice (e.g. []string{"default-totp"}) to enable MFA, or nil to disable it. +func (p *Provider) SetClientsMFAChain(ctx context.Context, clientIDs []string, mfaChain []string) error { + return SetClientsMFAChain(ctx, p.storage, clientIDs, mfaChain) +} + // Handler returns the Dex server as an http.Handler for embedding in another server. // The handler expects requests with path prefix "/oauth2/". func (p *Provider) Handler() http.Handler { - return p.dexServer + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Dex's /logout endpoint requires id_token_hint for RP-initiated logout with + // post_logout_redirect_uri. If the dashboard calls logout without one, avoid + // rendering Dex's non-actionable Bad Request page and send the user home. + if strings.HasSuffix(r.URL.Path, "/logout") && r.FormValue("id_token_hint") == "" { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + + p.dexServer.ServeHTTP(w, r) + }) } // CreateUser creates a new user with the given email, username, and password. @@ -477,7 +587,7 @@ func (p *Provider) CreateUser(ctx context.Context, email, username, password str // Encode the user ID in Dex's format: base64(protobuf{user_id, connector_id}) // This matches the format Dex uses in JWT tokens - encodedID := EncodeDexUserID(userID, "local") + encodedID := EncodeDexUserID(userID, LocalConnectorID) return encodedID, nil } @@ -552,6 +662,13 @@ func DecodeDexUserID(encodedID string) (userID, connectorID string, err error) { return userID, connectorID, nil } +// IsLocalUserID reports whether encodedID is a Dex subject for the built-in +// local password connector. +func IsLocalUserID(encodedID string) bool { + _, connectorID, err := DecodeDexUserID(encodedID) + return err == nil && connectorID == LocalConnectorID +} + // GetUser returns a user by email func (p *Provider) GetUser(ctx context.Context, email string) (storage.Password, error) { return p.storage.GetPassword(ctx, email) diff --git a/idp/dex/provider_test.go b/idp/dex/provider_test.go index 4ed89fd2e..5e132d544 100644 --- a/idp/dex/provider_test.go +++ b/idp/dex/provider_test.go @@ -3,17 +3,54 @@ package dex import ( "context" "encoding/json" + "errors" + "io" "log/slog" + "net/http" + "net/http/httptest" "os" "path/filepath" "testing" "github.com/dexidp/dex/storage" + "github.com/dexidp/dex/storage/memory" sqllib "github.com/dexidp/dex/storage/sql" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +type updateFailingStorage struct { + storage.Storage + failClientID string +} + +func (s *updateFailingStorage) UpdateClient(ctx context.Context, id string, updater func(storage.Client) (storage.Client, error)) error { + if id == s.failClientID { + return errors.New("forced update failure") + } + return s.Storage.UpdateClient(ctx, id, updater) +} + +func TestSetClientsMFAChainRollsBackUpdatedClients(t *testing.T) { + ctx := context.Background() + st := memory.New(slog.New(slog.NewTextHandler(io.Discard, nil))) + + require.NoError(t, st.CreateClient(ctx, storage.Client{ID: "client-1", MFAChain: []string{"old-1"}})) + require.NoError(t, st.CreateClient(ctx, storage.Client{ID: "client-2", MFAChain: []string{"old-2"}})) + + err := SetClientsMFAChain(ctx, &updateFailingStorage{Storage: st, failClientID: "client-2"}, []string{"client-1", "client-2"}, []string{"new"}) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to update MFA chain on client client-2") + + client1, err := st.GetClient(ctx, "client-1") + require.NoError(t, err) + require.Equal(t, []string{"old-1"}, client1.MFAChain) + + client2, err := st.GetClient(ctx, "client-2") + require.NoError(t, err) + require.Equal(t, []string{"old-2"}, client2.MFAChain) +} + func TestUserCreationFlow(t *testing.T) { ctx := context.Background() @@ -113,6 +150,26 @@ func TestDecodeDexUserID(t *testing.T) { } } +func TestIsLocalUserID(t *testing.T) { + tests := []struct { + name string + encodedID string + want bool + }{ + {name: "local connector", encodedID: EncodeDexUserID("7aad8c05-3287-473f-b42a-365504bf25e7", "local"), want: true}, + {name: "federated connector", encodedID: EncodeDexUserID("entra-user", "entra"), want: false}, + {name: "non-dex external IdP id", encodedID: "google-oauth2|1234567890", want: false}, + {name: "invalid base64", encodedID: "not-valid-base64!!!", want: false}, + {name: "empty", encodedID: "", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, IsLocalUserID(tt.encodedID)) + }) + } +} + func TestEncodeDexUserID(t *testing.T) { userID := "7aad8c05-3287-473f-b42a-365504bf25e7" connectorID := "local" @@ -144,6 +201,30 @@ func TestEncodeDexUserID_MatchesDexFormat(t *testing.T) { assert.Equal(t, knownEncodedID, reEncoded) } +func TestHandlerRedirectsLogoutWithoutIDTokenHint(t *testing.T) { + ctx := context.Background() + + tmpDir, err := os.MkdirTemp("", "dex-logout-handler-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + provider, err := NewProvider(ctx, &Config{ + Issuer: "http://localhost:5556/oauth2", + Port: 5556, + DataDir: tmpDir, + }) + require.NoError(t, err) + defer func() { _ = provider.Stop(ctx) }() + + req := httptest.NewRequest(http.MethodGet, "/oauth2/logout?post_logout_redirect_uri=https://example.com", nil) + rec := httptest.NewRecorder() + + provider.Handler().ServeHTTP(rec, req) + + require.Equal(t, http.StatusSeeOther, rec.Code) + require.Equal(t, "/", rec.Header().Get("Location")) +} + func TestCreateUserInTempDB(t *testing.T) { ctx := context.Background() @@ -549,3 +630,90 @@ enablePasswordDB: true assert.True(t, cfg.ContinueOnConnectorFailure, "buildDexConfig must set ContinueOnConnectorFailure to true so management starts even if an external IdP is down") } + +func TestToServerConfig_WiresGrantTypes(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "dex-grants-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + stor := openTestStorage(t, tmpDir) + defer stor.Close() + + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + + grants := []string{"authorization_code", "refresh_token"} + cfg := &YAMLConfig{Issuer: "http://localhost:5599/oauth2", OAuth2: OAuth2{GrantTypes: grants}} + assert.Equal(t, grants, cfg.ToServerConfig(stor, logger).AllowedGrantTypes) + + empty := &YAMLConfig{Issuer: "http://localhost:5599/oauth2"} + assert.Empty(t, empty.ToServerConfig(stor, logger).AllowedGrantTypes) +} + +func newDeviceGuardProvider(t *testing.T, grantTypesYAML string) *Provider { + t.Helper() + + tmpDir, err := os.MkdirTemp("", "dex-devguard-*") + require.NoError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(tmpDir) }) + + yamlContent := ` +issuer: http://localhost:5599/oauth2 +storage: + type: sqlite3 + config: + file: ` + filepath.Join(tmpDir, "dex.db") + ` +web: + http: 127.0.0.1:5599 +enablePasswordDB: true +` + grantTypesYAML + + configPath := filepath.Join(tmpDir, "config.yaml") + require.NoError(t, os.WriteFile(configPath, []byte(yamlContent), 0644)) + + yamlConfig, err := LoadConfig(configPath) + require.NoError(t, err) + + provider, err := NewProviderFromYAML(context.Background(), yamlConfig) + require.NoError(t, err) + t.Cleanup(func() { _ = provider.Stop(context.Background()) }) + return provider +} + +func TestHandler_BlocksDeviceEndpointsWhenDeviceGrantDisabled(t *testing.T) { + provider := newDeviceGuardProvider(t, ` +oauth2: + grantTypes: + - authorization_code + - refresh_token +`) + + devicePaths := []string{ + "/oauth2/device", + "/oauth2/device/code", + "/oauth2/device/token", + "/oauth2/device/auth/verify_code", + "/oauth2/device/callback", + } + for _, path := range devicePaths { + for _, method := range []string{http.MethodGet, http.MethodPost} { + req := httptest.NewRequest(method, path, nil) + rec := httptest.NewRecorder() + provider.Handler().ServeHTTP(rec, req) + assert.Equal(t, http.StatusNotFound, rec.Code, "%s %s must be blocked", method, path) + } + } + + req := httptest.NewRequest(http.MethodGet, "/oauth2/.well-known/openid-configuration", nil) + rec := httptest.NewRecorder() + provider.Handler().ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) +} + +func TestHandler_AllowsDeviceEndpointsWhenGrantsDefault(t *testing.T) { + provider := newDeviceGuardProvider(t, "") + + req := httptest.NewRequest(http.MethodPost, "/oauth2/device/code", nil) + rec := httptest.NewRecorder() + provider.Handler().ServeHTTP(rec, req) + assert.NotEqual(t, http.StatusNotFound, rec.Code) +} diff --git a/idp/dex/sqlite_cgo.go b/idp/dex/sqlite_cgo.go new file mode 100644 index 000000000..5de66f647 --- /dev/null +++ b/idp/dex/sqlite_cgo.go @@ -0,0 +1,15 @@ +//go:build cgo + +package dex + +import ( + sql "github.com/dexidp/dex/storage/sql" +) + +// newSQLite3 builds the dex SQLite3 config. CGO builds use the upstream +// struct that takes a File path. Non-CGO builds get an empty stub whose +// Open() returns the dex "SQLite not available" error — correct behaviour +// for binaries that can't link sqlite3 (e.g. cross-compiled ARM targets). +func newSQLite3(file string) *sql.SQLite3 { + return &sql.SQLite3{File: file} +} diff --git a/idp/dex/sqlite_nocgo.go b/idp/dex/sqlite_nocgo.go new file mode 100644 index 000000000..4def12143 --- /dev/null +++ b/idp/dex/sqlite_nocgo.go @@ -0,0 +1,15 @@ +//go:build !cgo + +package dex + +import ( + sql "github.com/dexidp/dex/storage/sql" +) + +// newSQLite3 for non-CGO builds. The dex SQLite3 stub has no fields and its +// Open() returns an error documenting the missing CGO support — correct +// behaviour for cross-compiled artefacts that never actually run the +// embedded IdP. The `file` argument is ignored. +func newSQLite3(_ string) *sql.SQLite3 { + return &sql.SQLite3{} +} diff --git a/idp/dex/web/templates/home.html b/idp/dex/web/templates/home.html new file mode 100644 index 000000000..be7c938ae --- /dev/null +++ b/idp/dex/web/templates/home.html @@ -0,0 +1,12 @@ +{{ template "header.html" . }} + + + + +{{ template "footer.html" . }} diff --git a/idp/dex/web/templates/logout.html b/idp/dex/web/templates/logout.html new file mode 100644 index 000000000..b623d35af --- /dev/null +++ b/idp/dex/web/templates/logout.html @@ -0,0 +1,14 @@ +{{ template "header.html" . }} + +
                                                +

                                                Logged Out

                                                +

                                                You have been successfully logged out.

                                                + + {{ if .BackURL }} + + {{ end }} +
                                                + +{{ template "footer.html" . }} diff --git a/idp/dex/web/templates/password.html b/idp/dex/web/templates/password.html index 1d1b8282e..e1bfa7258 100755 --- a/idp/dex/web/templates/password.html +++ b/idp/dex/web/templates/password.html @@ -18,6 +18,7 @@ id="login" name="login" class="nb-input" + autocomplete="username" placeholder="Enter your {{ .UsernamePrompt | lower }}" {{ if .Username }}value="{{ .Username }}"{{ else }}autofocus{{ end }} required @@ -31,6 +32,7 @@ id="password" name="password" class="nb-input" + autocomplete="current-password" placeholder="Enter your password" {{ if .Invalid }}autofocus{{ end }} required diff --git a/idp/dex/web/templates/totp_verify.html b/idp/dex/web/templates/totp_verify.html new file mode 100644 index 000000000..8286418f0 --- /dev/null +++ b/idp/dex/web/templates/totp_verify.html @@ -0,0 +1,44 @@ +{{ template "header.html" . }} + +
                                                +

                                                Two-factor authentication

                                                + {{ if not (eq .QRCode "") }} +

                                                Scan the QR code below using your authenticator app, then enter the code.

                                                +
                                                + QR code +
                                                + {{ else }} +

                                                Enter the code from your authenticator app.

                                                + {{ end }} + +
                                                + {{ if .Invalid }} +
                                                + Invalid code. Please try again. +
                                                + {{ end }} + +
                                                + + +
                                                + + +
                                                +
                                                + +{{ template "footer.html" . }} diff --git a/idp/dex/web/templates/webauthn_verify.html b/idp/dex/web/templates/webauthn_verify.html new file mode 100644 index 000000000..be7c938ae --- /dev/null +++ b/idp/dex/web/templates/webauthn_verify.html @@ -0,0 +1,12 @@ +{{ template "header.html" . }} + + + + +{{ template "footer.html" . }} diff --git a/infrastructure_files/getting-started-enterprise.sh b/infrastructure_files/getting-started-enterprise.sh new file mode 100755 index 000000000..3f7cf6357 --- /dev/null +++ b/infrastructure_files/getting-started-enterprise.sh @@ -0,0 +1,840 @@ +#!/bin/bash + +set -e +set -o pipefail + +# NetBird Enterprise — Getting Started +# Single-node bootstrap for a self-hosted NetBird Enterprise stack with the +# embedded identity provider. Owner is created via first-login flow. + +SED_STRIP_PADDING='s/=//g' + +NETBIRD_EULA_URL="https://netbird.io/self-hosted-EULA" + +# Static IP for Traefik inside the compose bridge network. The management +# server trusts X-Forwarded-* headers from this address only. +TRAEFIK_IP="172.30.0.10" + +LICENSE_VERDICT="unknown" +LICENSE_LOG_LINES="" + +check_docker_compose() { + if ! command -v docker &> /dev/null && ! command -v docker-compose &> /dev/null; then + echo "Docker is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/engine/install/" > /dev/stderr + exit 1 + fi + + if docker compose version &> /dev/null; then + echo "docker compose" + return + fi + if command -v docker-compose &> /dev/null && docker-compose version &> /dev/null; then + echo "docker-compose" + return + fi + + echo "Docker Compose is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/compose/install/" > /dev/stderr + exit 1 +} + +check_openssl() { + if ! command -v openssl &> /dev/null; then + echo "openssl is not installed or not in PATH." > /dev/stderr + exit 1 + fi +} + +rand_secret() { + openssl rand -base64 32 | sed "$SED_STRIP_PADDING" +} + +rand_b64_key() { + openssl rand -base64 32 +} + +check_nb_domain() { + local domain="$1" + if [[ -z "$domain" ]]; then + echo "The domain cannot be empty." > /dev/stderr + return 1 + fi + if [[ "$domain" == "netbird.example.com" ]]; then + echo "The domain cannot be netbird.example.com" > /dev/stderr + return 1 + fi + if [[ "$domain" =~ ^[0-9.]+$ ]]; then + echo "An IP address is not allowed. A real DNS-resolvable domain is required for TLS and the embedded IdP issuer." > /dev/stderr + return 1 + fi + if [[ ! "$domain" =~ ^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$ ]]; then + echo "The value '$domain' is not a valid FQDN. A real DNS-resolvable domain is required for TLS and the embedded IdP issuer." > /dev/stderr + return 1 + fi + return 0 +} + +check_domain_resolves() { + local domain="$1" + if command -v getent &> /dev/null && getent hosts "$domain" &> /dev/null; then return 0; fi + if command -v host &> /dev/null && host "$domain" &> /dev/null; then return 0; fi + if command -v dig &> /dev/null && [[ -n "$(dig +short "$domain" 2>/dev/null)" ]]; then return 0; fi + if command -v nslookup &> /dev/null && nslookup "$domain" &> /dev/null; then return 0; fi + return 1 +} + +read_nb_domain() { + local value="" + echo -n "Enter the FQDN for NetBird (must resolve via DNS, e.g. netbird.my-domain.com): " > /dev/stderr + read -r value < /dev/tty + if ! check_nb_domain "$value"; then + read_nb_domain + return + fi + if ! check_domain_resolves "$value"; then + echo "" > /dev/stderr + echo "Warning: '$value' does not resolve via DNS from this host." > /dev/stderr + echo "Traefik will not be able to issue TLS certificates until it does." > /dev/stderr + local confirm="" + echo -n "Continue anyway? [y/N]: " > /dev/stderr + read -r confirm < /dev/tty + if [[ ! "$confirm" =~ ^[Yy]$ ]]; then + read_nb_domain + return + fi + fi + echo "$value" +} + +read_letsencrypt_email() { + if [[ -n "${NETBIRD_LETSENCRYPT_EMAIL:-}" ]]; then + echo "$NETBIRD_LETSENCRYPT_EMAIL" + return + fi + local value="" + echo "Enter your email for Let's Encrypt certificate notifications." > /dev/stderr + echo -n "Email address: " > /dev/stderr + read -r value < /dev/tty + if [[ -z "$value" ]]; then + echo "Email is required for Let's Encrypt." > /dev/stderr + read_letsencrypt_email + return + fi + echo "$value" +} + +read_required() { + local prompt="$1" + local value="" + while [[ -z "$value" ]]; do + echo -n "$prompt: " > /dev/stderr + read -r value < /dev/tty + if [[ -z "$value" ]]; then + echo "Value cannot be empty." > /dev/stderr + fi + done + echo "$value" +} + +read_secret() { + local prompt="$1" + local value="" + while [[ -z "$value" ]]; do + echo -n "$prompt: " > /dev/stderr + read -rs value < /dev/tty + echo "" > /dev/stderr + if [[ -z "$value" ]]; then + echo "Value cannot be empty." > /dev/stderr + fi + done + echo "$value" +} + +# read_yes_no "" [] +read_yes_no() { + local prompt="$1" + local default="${2:-n}" + local hint + if [[ "$default" == "y" ]]; then + hint="[Y/n]" + else + hint="[y/N]" + fi + echo -n "${prompt} ${hint}: " > /dev/stderr + local ans="" + read -r ans < /dev/tty + if [[ -z "$ans" ]]; then + ans="$default" + fi + case "$ans" in + [Yy] | [Yy][Ee][Ss]) echo "yes" ;; + *) echo "no" ;; + esac +} + +# Gate the install on explicit acceptance of the NetBird On-Premise EULA. +require_eula_acceptance() { + cat > /dev/stderr < /dev/stderr + return 0 + fi + + local ans="" + echo -n 'Type "accept" to agree, or anything else to abort: ' > /dev/stderr + read -r ans < /dev/tty + if [[ "$ans" != "accept" ]]; then + echo "" > /dev/stderr + echo "EULA not accepted. Aborting installation." > /dev/stderr + exit 1 + fi + echo "" > /dev/stderr +} + +wait_postgres() { + set +e + echo -n "Waiting for postgres to become ready" + local counter=1 + while true; do + if $DOCKER_COMPOSE_COMMAND exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" &> /dev/null; then + break + fi + if [[ $counter -eq 60 ]]; then + echo "" + echo "Postgres is taking too long. Recent logs:" + $DOCKER_COMPOSE_COMMAND logs --tail=20 postgres + exit 1 + fi + echo -n " ." + sleep 2 + counter=$((counter + 1)) + done + echo " done" + set -e +} + +wait_for_license_verdict() { + local counter=0 + local logs="" + + echo -n "Waiting for the server to validate the license" + while [[ $counter -lt 60 ]]; do + logs=$($DOCKER_COMPOSE_COMMAND logs --no-color --tail=all netbird-server 2>/dev/null || true) + + if grep -qi "license invalidated" <<< "$logs"; then + echo " rejected" + LICENSE_VERDICT="rejected" + LICENSE_LOG_LINES=$(grep -i "license" <<< "$logs" | tail -n 5 || true) + return 0 + fi + + if grep -qi "license validated" <<< "$logs"; then + echo " ok" + LICENSE_VERDICT="ok" + return 0 + fi + + echo -n " ." + sleep 2 + counter=$((counter + 1)) + done + + echo " no verdict in 120s" + LICENSE_VERDICT="unknown" + LICENSE_LOG_LINES=$(grep -iE "failed to validate license|error validating license" <<< "$logs" | tail -n 3 || true) + return 0 +} + +report_license_verdict() { + if [[ "$LICENSE_VERDICT" == "ok" ]]; then + return 0 + fi + + if [[ "$LICENSE_VERDICT" == "unknown" ]]; then + echo "" + echo " ⚠ The server logged no license verdict within 120s." + if [[ -n "$LICENSE_LOG_LINES" ]]; then + echo " It was still reporting validation errors:" + while IFS= read -r line; do + [[ -n "$line" ]] && echo " $line" + done <<< "$LICENSE_LOG_LINES" + fi + echo "" + echo " Check the verdict with:" + echo "" + echo " $DOCKER_COMPOSE_COMMAND logs netbird-server | grep -i license" + return 0 + fi + + local unreachable="false" + if grep -qi "couldn't be validated with the license server" <<< "$LICENSE_LOG_LINES"; then + unreachable="true" + fi + + echo "" + if [[ "$unreachable" == "true" ]]; then + echo " ⚠ The server could not validate the license:" + else + echo " ⚠ The server rejected the license key:" + fi + while IFS= read -r line; do + [[ -n "$line" ]] && echo " $line" + done <<< "$LICENSE_LOG_LINES" + echo "" + echo " The stack is up, and only the license check did not pass." + echo "" + if [[ "$unreachable" == "true" ]]; then + echo " The license server could not be reached, so the key itself was" + echo " never checked. Confirm this host has outbound access to the" + echo " license server, then restart:" + else + echo " Check the reason the server gave above, verify that" + echo " NETBIRD_LICENSE_KEY in .env matches the key you were issued," + echo " then restart:" + fi + echo "" + echo " $DOCKER_COMPOSE_COMMAND up -d" + return 0 +} + +init_environment() { + check_openssl + DOCKER_COMPOSE_COMMAND=$(check_docker_compose) + + if [[ -f .env ]] || [[ -f docker-compose.yml ]] || [[ -f config.yaml ]]; then + echo "Generated files already exist in $(pwd)." + echo "If you want to reinitialize the environment, please remove them first:" + echo " $DOCKER_COMPOSE_COMMAND down --volumes # removes all containers and volumes" + echo " rm -f .env docker-compose.yml config.yaml" + echo "Be aware this will remove all data from the database." + exit 1 + fi + + require_eula_acceptance + NETBIRD_EULA_ACCEPTED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ) + + echo "NetBird Enterprise bootstrap" + echo "" + echo "Traffic flow:" + echo " Enables traffic events logging on the management server." + echo " When enabled, the NetBird stack also runs NATS along with two" + echo " additional containers: netbird-receiver (the traffic log receiver" + echo " service) and netbird-enricher (the traffic log enricher service)." + echo " It still has to be turned on from the dashboard settings afterwards." + echo " See https://docs.netbird.io/manage/activity/traffic-events-logging" + NETBIRD_TRAFFIC_FLOW=$(read_yes_no "Enable traffic flow" "n") + + echo "" + NETBIRD_DOMAIN=$(read_nb_domain) + + echo "" + NETBIRD_LETSENCRYPT_EMAIL=$(read_letsencrypt_email) + + echo "" + + NETBIRD_LICENSE_KEY=$(read_secret "Enter license key (input hidden)") + + POSTGRES_USER="netbird" + POSTGRES_DB="netbird" + POSTGRES_PASSWORD=$(rand_secret) + NETBIRD_ENCRYPTION_KEY=$(rand_b64_key) + NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY=$(rand_b64_key) + NETBIRD_RELAY_AUTH_SECRET=$(rand_secret) + + POSTGRES_DSN="host=postgres user=${POSTGRES_USER} password=${POSTGRES_PASSWORD} dbname=${POSTGRES_DB} port=5432 sslmode=disable TimeZone=UTC" + NETBIRD_RELAY_ENDPOINT="rels://${NETBIRD_DOMAIN}:443" + + echo "" + echo "Selected:" + echo " Traffic flow: ${NETBIRD_TRAFFIC_FLOW}" + echo " Domain: ${NETBIRD_DOMAIN}" + echo " ACME email: ${NETBIRD_LETSENCRYPT_EMAIL}" + echo "" + echo "Rendering files into $(pwd) ..." + install -m 600 /dev/null .env + render_env >> .env + render_docker_compose > docker-compose.yml + + if [[ -z "${NETBIRD_LICENSE_SERVER_BASE_URL:-}" ]]; then + sed -i.bak '/NETBIRD_LICENSE_SERVER_BASE_URL/d' docker-compose.yml && rm -f docker-compose.yml.bak + fi + install -m 600 /dev/null config.yaml + render_config_yaml >> config.yaml + + echo "" + echo "Pulling images ..." + $DOCKER_COMPOSE_COMMAND pull + + echo "" + echo "Starting postgres ..." + $DOCKER_COMPOSE_COMMAND up -d postgres + sleep 2 + wait_postgres + + echo "" + echo "Starting remaining services ..." + $DOCKER_COMPOSE_COMMAND up -d + + echo "" + wait_for_license_verdict + + echo "" + echo "Done." + echo "" + echo "Dashboard: https://${NETBIRD_DOMAIN}" + echo "" + echo "Open the dashboard in a browser to complete the first-login owner setup." + echo "All configuration and secrets are stored (mode 600) in $(pwd)/.env" + echo "" + echo "Tail logs:" + echo " cd $(pwd) && $DOCKER_COMPOSE_COMMAND logs -f netbird-server traefik" + + report_license_verdict + + if [[ "$LICENSE_VERDICT" == "rejected" ]]; then + exit 1 + fi +} + +# ------------------------------------------------------------------ +# Renderers +# ------------------------------------------------------------------ + +render_env() { + cat <&2 <<'EOF' +ERROR: This legacy installation script has been retired and no longer runs. -# NetBird Getting Started with Dex IDP -# This script sets up NetBird with Dex as the identity provider +Dex support is not deprecated. For new deployments, use getting-started.sh: -# Sed pattern to strip base64 padding characters -SED_STRIP_PADDING='s/=//g' +https://docs.netbird.io/selfhosted/selfhosted-quickstart -check_docker_compose() { - if command -v docker-compose &> /dev/null - then - echo "docker-compose" - return - fi - if docker compose --help &> /dev/null - then - echo "docker compose" - return - fi +The current installer includes NetBird's embedded Dex-based identity provider. +Local users and external identity providers can be managed through the +NetBird Dashboard: - echo "docker-compose is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/engine/install/" > /dev/stderr - exit 1 -} +https://docs.netbird.io/selfhosted/identity-providers/local -check_jq() { - if ! command -v jq &> /dev/null - then - echo "jq is not installed or not in PATH, please install with your package manager. e.g. sudo apt install jq" > /dev/stderr - exit 1 - fi - return 0 -} - -get_main_ip_address() { - if [[ "$OSTYPE" == "darwin"* ]]; then - interface=$(route -n get default | grep 'interface:' | awk '{print $2}') - ip_address=$(ifconfig "$interface" | grep 'inet ' | awk '{print $2}') - else - interface=$(ip route | grep default | awk '{print $5}' | head -n 1) - ip_address=$(ip addr show "$interface" | grep 'inet ' | awk '{print $2}' | cut -d'/' -f1) - fi - - echo "$ip_address" - return 0 -} - -check_nb_domain() { - DOMAIN=$1 - if [[ "$DOMAIN-x" == "-x" ]]; then - echo "The NETBIRD_DOMAIN variable cannot be empty." > /dev/stderr - return 1 - fi - - if [[ "$DOMAIN" == "netbird.example.com" ]]; then - echo "The NETBIRD_DOMAIN cannot be netbird.example.com" > /dev/stderr - return 1 - fi - return 0 -} - -read_nb_domain() { - READ_NETBIRD_DOMAIN="" - echo -n "Enter the domain you want to use for NetBird (e.g. netbird.my-domain.com): " > /dev/stderr - read -r READ_NETBIRD_DOMAIN < /dev/tty - if ! check_nb_domain "$READ_NETBIRD_DOMAIN"; then - read_nb_domain - fi - echo "$READ_NETBIRD_DOMAIN" - return 0 -} - -get_turn_external_ip() { - TURN_EXTERNAL_IP_CONFIG="#external-ip=" - IP=$(curl -s -4 https://jsonip.com | jq -r '.ip') - if [[ "x-$IP" != "x-" ]]; then - TURN_EXTERNAL_IP_CONFIG="external-ip=$IP" - fi - echo "$TURN_EXTERNAL_IP_CONFIG" - return 0 -} - -wait_dex() { - set +e - echo -n "Waiting for Dex to become ready (via $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN)" - counter=1 - while true; do - # Check Dex through Caddy proxy (also validates TLS is working) - if curl -sk -f -o /dev/null "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/dex/.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 dex - fi - echo -n " ." - sleep 2 - counter=$((counter + 1)) - done - echo " done" - set -e - return 0 -} - -init_environment() { - CADDY_SECURE_DOMAIN="" - NETBIRD_PORT=80 - NETBIRD_HTTP_PROTOCOL="http" - NETBIRD_RELAY_PROTO="rel" - TURN_USER="self" - TURN_PASSWORD=$(openssl rand -base64 32 | sed "$SED_STRIP_PADDING") - NETBIRD_RELAY_AUTH_SECRET=$(openssl rand -base64 32 | sed "$SED_STRIP_PADDING") - TURN_MIN_PORT=49152 - TURN_MAX_PORT=65535 - TURN_EXTERNAL_IP_CONFIG=$(get_turn_external_ip) - - # Generate secrets for Dex - DEX_DASHBOARD_CLIENT_SECRET=$(openssl rand -base64 32 | sed "$SED_STRIP_PADDING") - - # Generate admin password - NETBIRD_ADMIN_PASSWORD=$(openssl rand -base64 16 | sed "$SED_STRIP_PADDING") - - if ! check_nb_domain "$NETBIRD_DOMAIN"; then - NETBIRD_DOMAIN=$(read_nb_domain) - fi - - if [[ "$NETBIRD_DOMAIN" == "use-ip" ]]; then - NETBIRD_DOMAIN=$(get_main_ip_address) - else - NETBIRD_PORT=443 - CADDY_SECURE_DOMAIN=", $NETBIRD_DOMAIN:$NETBIRD_PORT" - NETBIRD_HTTP_PROTOCOL="https" - NETBIRD_RELAY_PROTO="rels" - fi - - check_jq - - DOCKER_COMPOSE_COMMAND=$(check_docker_compose) - - if [[ -f dex.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 dex.yaml dashboard.env turnserver.conf management.json relay.env" - echo "Be aware that this will remove all data from the database, and you will have to reconfigure the dashboard." - exit 1 - fi - - echo Rendering initial files... - render_docker_compose > docker-compose.yml - render_caddyfile > Caddyfile - render_dex_config > dex.yaml - render_dashboard_env > dashboard.env - render_management_json > management.json - render_turn_server_conf > turnserver.conf - render_relay_env > relay.env - - echo -e "\nStarting Dex IDP\n" - $DOCKER_COMPOSE_COMMAND up -d caddy dex - - # Wait for Dex to be ready (through caddy proxy) - sleep 3 - wait_dex - - echo -e "\nStarting NetBird services\n" - $DOCKER_COMPOSE_COMMAND up -d - - echo -e "\nDone!\n" - echo "You can access the NetBird dashboard at $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN" - echo "" - echo "Login with the following credentials:" - install -m 600 /dev/null .env - printf 'Email: admin@%s\nPassword: %s\n' \ - "$NETBIRD_DOMAIN" "$NETBIRD_ADMIN_PASSWORD" >> .env - echo "Email: admin@$NETBIRD_DOMAIN" - echo "Password: $NETBIRD_ADMIN_PASSWORD" - echo "" - echo "Dex admin UI is not available (Dex has no built-in UI)." - echo "To add more users, edit dex.yaml and restart: $DOCKER_COMPOSE_COMMAND restart dex" - return 0 -} - -render_caddyfile() { - cat < /dev/null; then - ADMIN_PASSWORD_HASH=$(htpasswd -bnBC 10 "" "$NETBIRD_ADMIN_PASSWORD" | tr -d ':\n') - elif command -v python3 &> /dev/null; then - ADMIN_PASSWORD_HASH=$(python3 -c "import bcrypt; print(bcrypt.hashpw('$NETBIRD_ADMIN_PASSWORD'.encode(), bcrypt.gensalt(rounds=10)).decode())" 2>/dev/null || echo "") - fi - - # Fallback to a known hash if we can't generate one - if [[ -z "$ADMIN_PASSWORD_HASH" ]]; then - # This is hash of "password" - user should change it - ADMIN_PASSWORD_HASH='$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W' - NETBIRD_ADMIN_PASSWORD="password" - echo "Warning: Could not generate password hash. Using default password: password. Please change it in dex.yaml" > /dev/stderr - fi - - cat </dev/null || cat /proc/sys/kernel/random/uuid 2>/dev/null || echo "admin-user-id-001")" - -# Optional: Add external identity provider connectors -# connectors: -# - type: github -# id: github -# name: GitHub -# config: -# clientID: \$GITHUB_CLIENT_ID -# clientSecret: \$GITHUB_CLIENT_SECRET -# redirectURI: $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/dex/callback -# -# - type: ldap -# id: ldap -# name: LDAP -# config: -# host: ldap.example.com:636 -# insecureNoSSL: false -# bindDN: cn=admin,dc=example,dc=com -# bindPW: admin -# userSearch: -# baseDN: ou=users,dc=example,dc=com -# filter: "(objectClass=person)" -# username: uid -# idAttr: uid -# emailAttr: mail -# nameAttr: cn -EOF - return 0 -} - -render_turn_server_conf() { - cat <&2 <<'EOF' +ERROR: This legacy installation script has been retired and no longer runs. -handle_request_command_status() { - PARSED_RESPONSE=$1 - FUNCTION_NAME=$2 - RESPONSE=$3 - if [[ $PARSED_RESPONSE -ne 0 ]]; then - echo "ERROR calling $FUNCTION_NAME:" $(echo "$RESPONSE" | jq -r '.message') > /dev/stderr - exit 1 - fi -} +Zitadel support and existing Zitadel deployments are not deprecated. -handle_zitadel_request_response() { - PARSED_RESPONSE=$1 - FUNCTION_NAME=$2 - RESPONSE=$3 - if [[ $PARSED_RESPONSE == "null" ]]; then - echo "ERROR calling $FUNCTION_NAME:" $(echo "$RESPONSE" | jq -r '.message') > /dev/stderr - exit 1 - fi - sleep 1 -} +For new deployments, use getting-started.sh: -check_docker_compose() { - if command -v docker-compose &> /dev/null - then - echo "docker-compose" - return - fi - if docker compose --help &> /dev/null - then - echo "docker compose" - return - fi +https://docs.netbird.io/selfhosted/selfhosted-quickstart - echo "docker-compose is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/engine/install/" > /dev/stderr - exit 1 -} +The current installer includes NetBird's embedded Dex-based identity provider. +Zitadel can be added as an external identity provider directly through the +NetBird Dashboard: -check_jq() { - if ! command -v jq &> /dev/null - then - echo "jq is not installed or not in PATH, please install with your package manager. e.g. sudo apt install jq" > /dev/stderr - exit 1 - fi -} +https://docs.netbird.io/selfhosted/identity-providers/zitadel -wait_crdb() { - set +e - while true; do - if $DOCKER_COMPOSE_COMMAND exec -T zdb curl -sf -o /dev/null 'http://localhost:8080/health?ready=1'; then - break - fi - echo -n " ." - sleep 5 - done - echo " done" - set -e -} +Standalone Zitadel and other custom identity-provider deployments remain +supported through the advanced guide: -init_crdb() { - if [[ $ZITADEL_DATABASE == "cockroach" ]]; then - echo -e "\nInitializing Zitadel's CockroachDB\n\n" - $DOCKER_COMPOSE_COMMAND up -d zdb - echo "" - # shellcheck disable=SC2028 - echo -n "Waiting CockroachDB to become ready" - wait_crdb - $DOCKER_COMPOSE_COMMAND exec -T zdb /bin/bash -c "cp /cockroach/certs/* /zitadel-certs/ && cockroach cert create-client --overwrite --certs-dir /zitadel-certs/ --ca-key /zitadel-certs/ca.key zitadel_user && chown -R 1000:1000 /zitadel-certs/" - handle_request_command_status $? "init_crdb failed" "" - fi -} +https://docs.netbird.io/selfhosted/selfhosted-guide -get_main_ip_address() { - if [[ "$OSTYPE" == "darwin"* ]]; then - interface=$(route -n get default | grep 'interface:' | awk '{print $2}') - ip_address=$(ifconfig "$interface" | grep 'inet ' | awk '{print $2}') - else - interface=$(ip route | grep default | awk '{print $5}' | head -n 1) - ip_address=$(ip addr show "$interface" | grep 'inet ' | awk '{print $2}' | cut -d'/' -f1) - fi - - echo "$ip_address" -} - -wait_pat() { - PAT_PATH=$1 - set +e - while true; do - if [[ -f "$PAT_PATH" ]]; then - break - fi - echo -n " ." - sleep 1 - done - echo " done" - set -e -} - -wait_api() { - INSTANCE_URL=$1 - PAT=$2 - set +e - counter=1 - while true; do - FLAGS="-s" - if [[ $counter -eq 45 ]]; then - FLAGS="-v" - echo "" - fi - - curl $FLAGS --fail --connect-timeout 1 -o /dev/null "$INSTANCE_URL/auth/v1/users/me" -H "Authorization: Bearer $PAT" - if [[ $? -eq 0 ]]; then - break - fi - if [[ $counter -eq 45 ]]; then - echo "" - echo "Unable to connect to Zitadel for more than 45s, please check the output above, your firewall rules and the caddy container logs to confirm if there are any issues provisioning TLS certificates" - fi - echo -n " ." - sleep 1 - counter=$((counter + 1)) - done - echo " done" - set -e -} - -create_new_project() { - INSTANCE_URL=$1 - PAT=$2 - PROJECT_NAME="NETBIRD" - - RESPONSE=$( - curl -sS -X POST "$INSTANCE_URL/management/v1/projects" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - -d '{"name": "'"$PROJECT_NAME"'"}' - ) - PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.id') - handle_zitadel_request_response "$PARSED_RESPONSE" "create_new_project" "$RESPONSE" - echo "$PARSED_RESPONSE" -} - -create_new_application() { - INSTANCE_URL=$1 - PAT=$2 - APPLICATION_NAME=$3 - BASE_REDIRECT_URL1=$4 - BASE_REDIRECT_URL2=$5 - LOGOUT_URL=$6 - ZITADEL_DEV_MODE=$7 - DEVICE_CODE=$8 - - if [[ $DEVICE_CODE == "true" ]]; then - GRANT_TYPES='["OIDC_GRANT_TYPE_AUTHORIZATION_CODE","OIDC_GRANT_TYPE_DEVICE_CODE","OIDC_GRANT_TYPE_REFRESH_TOKEN"]' - else - GRANT_TYPES='["OIDC_GRANT_TYPE_AUTHORIZATION_CODE","OIDC_GRANT_TYPE_REFRESH_TOKEN"]' - fi - - RESPONSE=$( - curl -sS -X POST "$INSTANCE_URL/management/v1/projects/$PROJECT_ID/apps/oidc" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "'"$APPLICATION_NAME"'", - "redirectUris": [ - "'"$BASE_REDIRECT_URL1"'", - "'"$BASE_REDIRECT_URL2"'" - ], - "postLogoutRedirectUris": [ - "'"$LOGOUT_URL"'" - ], - "RESPONSETypes": [ - "OIDC_RESPONSE_TYPE_CODE" - ], - "grantTypes": '"$GRANT_TYPES"', - "appType": "OIDC_APP_TYPE_USER_AGENT", - "authMethodType": "OIDC_AUTH_METHOD_TYPE_NONE", - "version": "OIDC_VERSION_1_0", - "devMode": '"$ZITADEL_DEV_MODE"', - "accessTokenType": "OIDC_TOKEN_TYPE_JWT", - "accessTokenRoleAssertion": true, - "skipNativeAppSuccessPage": true - }' - ) - - PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.clientId') - handle_zitadel_request_response "$PARSED_RESPONSE" "create_new_application" "$RESPONSE" - echo "$PARSED_RESPONSE" -} - -create_service_user() { - INSTANCE_URL=$1 - PAT=$2 - - RESPONSE=$( - curl -sS -X POST "$INSTANCE_URL/management/v1/users/machine" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - -d '{ - "userName": "netbird-service-account", - "name": "Netbird Service Account", - "description": "Netbird Service Account for IDP management", - "accessTokenType": "ACCESS_TOKEN_TYPE_JWT" - }' - ) - PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.userId') - handle_zitadel_request_response "$PARSED_RESPONSE" "create_service_user" "$RESPONSE" - echo "$PARSED_RESPONSE" -} - -create_service_user_secret() { - INSTANCE_URL=$1 - PAT=$2 - USER_ID=$3 - - RESPONSE=$( - curl -sS -X PUT "$INSTANCE_URL/management/v1/users/$USER_ID/secret" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - -d '{}' - ) - SERVICE_USER_CLIENT_ID=$(echo "$RESPONSE" | jq -r '.clientId') - handle_zitadel_request_response "$SERVICE_USER_CLIENT_ID" "create_service_user_secret_id" "$RESPONSE" - SERVICE_USER_CLIENT_SECRET=$(echo "$RESPONSE" | jq -r '.clientSecret') - handle_zitadel_request_response "$SERVICE_USER_CLIENT_SECRET" "create_service_user_secret" "$RESPONSE" -} - -add_organization_user_manager() { - INSTANCE_URL=$1 - PAT=$2 - USER_ID=$3 - - RESPONSE=$( - curl -sS -X POST "$INSTANCE_URL/management/v1/orgs/me/members" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - -d '{ - "userId": "'"$USER_ID"'", - "roles": [ - "ORG_USER_MANAGER" - ] - }' - ) - PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.details.creationDate') - handle_zitadel_request_response "$PARSED_RESPONSE" "add_organization_user_manager" "$RESPONSE" - echo "$PARSED_RESPONSE" -} - -create_admin_user() { - INSTANCE_URL=$1 - PAT=$2 - USERNAME=$3 - PASSWORD=$4 - RESPONSE=$( - curl -sS -X POST "$INSTANCE_URL/management/v1/users/human/_import" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - -d '{ - "userName": "'"$USERNAME"'", - "profile": { - "firstName": "Zitadel", - "lastName": "Admin" - }, - "email": { - "email": "'"$USERNAME"'", - "isEmailVerified": true - }, - "password": "'"$PASSWORD"'", - "passwordChangeRequired": true - }' - ) - PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.userId') - handle_zitadel_request_response "$PARSED_RESPONSE" "create_admin_user" "$RESPONSE" - echo "$PARSED_RESPONSE" -} - -add_instance_admin() { - INSTANCE_URL=$1 - PAT=$2 - USER_ID=$3 - - RESPONSE=$( - curl -sS -X POST "$INSTANCE_URL/admin/v1/members" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - -d '{ - "userId": "'"$USER_ID"'", - "roles": [ - "IAM_OWNER" - ] - }' - ) - PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.details.creationDate') - handle_zitadel_request_response "$PARSED_RESPONSE" "add_instance_admin" "$RESPONSE" - echo "$PARSED_RESPONSE" -} - -delete_auto_service_user() { - INSTANCE_URL=$1 - PAT=$2 - - RESPONSE=$( - curl -sS -X GET "$INSTANCE_URL/auth/v1/users/me" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - ) - USER_ID=$(echo "$RESPONSE" | jq -r '.user.id') - handle_zitadel_request_response "$USER_ID" "delete_auto_service_user_get_user" "$RESPONSE" - - RESPONSE=$( - curl -sS -X DELETE "$INSTANCE_URL/admin/v1/members/$USER_ID" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - ) - PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.details.changeDate') - handle_zitadel_request_response "$PARSED_RESPONSE" "delete_auto_service_user_remove_instance_permissions" "$RESPONSE" - - RESPONSE=$( - curl -sS -X DELETE "$INSTANCE_URL/management/v1/orgs/me/members/$USER_ID" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - ) - PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.details.changeDate') - handle_zitadel_request_response "$PARSED_RESPONSE" "delete_auto_service_user_remove_org_permissions" "$RESPONSE" - echo "$PARSED_RESPONSE" -} - -delete_default_zitadel_admin() { - INSTANCE_URL=$1 - PAT=$2 - - # Search for the default zitadel-admin user - RESPONSE=$( - curl -sS -X POST "$INSTANCE_URL/management/v1/users/_search" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - -d '{ - "queries": [ - { - "userNameQuery": { - "userName": "zitadel-admin@", - "method": "TEXT_QUERY_METHOD_STARTS_WITH" - } - } - ] - }' - ) - - DEFAULT_ADMIN_ID=$(echo "$RESPONSE" | jq -r '.result[0].id // empty') - - if [ -n "$DEFAULT_ADMIN_ID" ] && [ "$DEFAULT_ADMIN_ID" != "null" ]; then - echo "Found default zitadel-admin user with ID: $DEFAULT_ADMIN_ID" - - RESPONSE=$( - curl -sS -X DELETE "$INSTANCE_URL/management/v1/users/$DEFAULT_ADMIN_ID" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - ) - PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.details.changeDate // "deleted"') - handle_zitadel_request_response "$PARSED_RESPONSE" "delete_default_zitadel_admin" "$RESPONSE" - - else - echo "Default zitadel-admin user not found: $RESPONSE" - fi -} - -init_zitadel() { - echo -e "\nInitializing Zitadel with NetBird's applications\n" - INSTANCE_URL="$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN" - - TOKEN_PATH=./machinekey/zitadel-admin-sa.token - - echo -n "Waiting for Zitadel's PAT to be created " - wait_pat "$TOKEN_PATH" - echo "Reading Zitadel PAT" - PAT=$(cat $TOKEN_PATH) - if [ "$PAT" = "null" ]; then - echo "Failed requesting getting Zitadel PAT" - exit 1 - fi - - echo -n "Waiting for Zitadel to become ready " - wait_api "$INSTANCE_URL" "$PAT" - - echo "Deleting default zitadel-admin user..." - delete_default_zitadel_admin "$INSTANCE_URL" "$PAT" - - # create the zitadel project - echo "Creating new zitadel project" - PROJECT_ID=$(create_new_project "$INSTANCE_URL" "$PAT") - - ZITADEL_DEV_MODE=false - BASE_REDIRECT_URL=$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN - if [[ $NETBIRD_HTTP_PROTOCOL == "http" ]]; then - ZITADEL_DEV_MODE=true - fi - - # create zitadel spa applications - echo "Creating new Zitadel SPA Dashboard application" - DASHBOARD_APPLICATION_CLIENT_ID=$(create_new_application "$INSTANCE_URL" "$PAT" "Dashboard" "$BASE_REDIRECT_URL/nb-auth" "$BASE_REDIRECT_URL/nb-silent-auth" "$BASE_REDIRECT_URL/" "$ZITADEL_DEV_MODE" "false") - - echo "Creating new Zitadel SPA Cli application" - CLI_APPLICATION_CLIENT_ID=$(create_new_application "$INSTANCE_URL" "$PAT" "Cli" "http://localhost:53000/" "http://localhost:54000/" "http://localhost:53000/" "true" "true") - - MACHINE_USER_ID=$(create_service_user "$INSTANCE_URL" "$PAT") - - SERVICE_USER_CLIENT_ID="null" - SERVICE_USER_CLIENT_SECRET="null" - - create_service_user_secret "$INSTANCE_URL" "$PAT" "$MACHINE_USER_ID" - - DATE=$(add_organization_user_manager "$INSTANCE_URL" "$PAT" "$MACHINE_USER_ID") - - ZITADEL_ADMIN_USERNAME="admin@$NETBIRD_DOMAIN" - ZITADEL_ADMIN_PASSWORD="$(openssl rand -base64 32 | sed 's/=//g')@" - - HUMAN_USER_ID=$(create_admin_user "$INSTANCE_URL" "$PAT" "$ZITADEL_ADMIN_USERNAME" "$ZITADEL_ADMIN_PASSWORD") - - DATE="null" - - DATE=$(add_instance_admin "$INSTANCE_URL" "$PAT" "$HUMAN_USER_ID") - - DATE="null" - DATE=$(delete_auto_service_user "$INSTANCE_URL" "$PAT") - if [ "$DATE" = "null" ]; then - echo "Failed deleting auto service user" - echo "Please remove it manually" - fi - - export NETBIRD_AUTH_CLIENT_ID=$DASHBOARD_APPLICATION_CLIENT_ID - export NETBIRD_AUTH_CLIENT_ID_CLI=$CLI_APPLICATION_CLIENT_ID - export NETBIRD_IDP_MGMT_CLIENT_ID=$SERVICE_USER_CLIENT_ID - export NETBIRD_IDP_MGMT_CLIENT_SECRET=$SERVICE_USER_CLIENT_SECRET - export ZITADEL_ADMIN_USERNAME - export ZITADEL_ADMIN_PASSWORD -} - -check_nb_domain() { - DOMAIN=$1 - if [ "$DOMAIN-x" == "-x" ]; then - echo "The NETBIRD_DOMAIN variable cannot be empty." > /dev/stderr - return 1 - fi - - if [ "$DOMAIN" == "netbird.example.com" ]; then - echo "The NETBIRD_DOMAIN cannot be netbird.example.com" > /dev/stderr - return 1 - fi - return 0 -} - -read_nb_domain() { - READ_NETBIRD_DOMAIN="" - echo -n "Enter the domain you want to use for NetBird (e.g. netbird.my-domain.com): " > /dev/stderr - read -r READ_NETBIRD_DOMAIN < /dev/tty - if ! check_nb_domain "$READ_NETBIRD_DOMAIN"; then - read_nb_domain - fi - echo "$READ_NETBIRD_DOMAIN" -} - -get_turn_external_ip() { - TURN_EXTERNAL_IP_CONFIG="#external-ip=" - IP=$(curl -s -4 https://jsonip.com | jq -r '.ip') - if [[ "x-$IP" != "x-" ]]; then - TURN_EXTERNAL_IP_CONFIG="external-ip=$IP" - fi - echo "$TURN_EXTERNAL_IP_CONFIG" -} - -initEnvironment() { - CADDY_SECURE_DOMAIN="" - ZITADEL_EXTERNALSECURE="false" - ZITADEL_TLS_MODE="disabled" - ZITADEL_MASTERKEY="$(openssl rand -base64 32 | head -c 32)" - NETBIRD_PORT=80 - NETBIRD_HTTP_PROTOCOL="http" - NETBIRD_RELAY_PROTO="rel" - TURN_USER="self" - TURN_PASSWORD=$(openssl rand -base64 32 | sed 's/=//g') - NETBIRD_RELAY_AUTH_SECRET=$(openssl rand -base64 32 | sed 's/=//g') - TURN_MIN_PORT=49152 - TURN_MAX_PORT=65535 - TURN_EXTERNAL_IP_CONFIG=$(get_turn_external_ip) - - if ! check_nb_domain "$NETBIRD_DOMAIN"; then - NETBIRD_DOMAIN=$(read_nb_domain) - fi - - if [ "$NETBIRD_DOMAIN" == "use-ip" ]; then - NETBIRD_DOMAIN=$(get_main_ip_address) - else - ZITADEL_EXTERNALSECURE="true" - ZITADEL_TLS_MODE="external" - NETBIRD_PORT=443 - CADDY_SECURE_DOMAIN=", $NETBIRD_DOMAIN:$NETBIRD_PORT" - NETBIRD_HTTP_PROTOCOL="https" - NETBIRD_RELAY_PROTO="rels" - fi - - if [[ "$OSTYPE" == "darwin"* ]]; then - ZIDATE_TOKEN_EXPIRATION_DATE=$(date -u -v+30M "+%Y-%m-%dT%H:%M:%SZ") - else - ZIDATE_TOKEN_EXPIRATION_DATE=$(date -u -d "+30 minutes" "+%Y-%m-%dT%H:%M:%SZ") - fi - - check_jq - - DOCKER_COMPOSE_COMMAND=$(check_docker_compose) - - if [ -f zitadel.env ]; 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 zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json relay.env" - echo "Be aware that this will remove all data from the database, and you will have to reconfigure the dashboard." - exit 1 - fi - - if [[ $ZITADEL_DATABASE == "cockroach" ]]; then - echo "Use CockroachDB as Zitadel database." - ZDB=$(renderDockerComposeCockroachDB) - ZITADEL_DB_ENV=$(renderZitadelCockroachDBEnv) - else - echo "Use Postgres as default Zitadel database." - echo "For using CockroachDB please the environment variable 'export ZITADEL_DATABASE=cockroach'." - POSTGRES_ROOT_PASSWORD="$(openssl rand -base64 32 | sed 's/=//g')@" - POSTGRES_ZITADEL_PASSWORD="$(openssl rand -base64 32 | sed 's/=//g')@" - ZDB=$(renderDockerComposePostgres) - ZITADEL_DB_ENV=$(renderZitadelPostgresEnv) - renderPostgresEnv > zdb.env - fi - - echo Rendering initial files... - renderDockerCompose > docker-compose.yml - renderCaddyfile > Caddyfile - renderZitadelEnv > zitadel.env - echo "" > dashboard.env - echo "" > turnserver.conf - echo "" > management.json - echo "" > relay.env - - mkdir -p machinekey - chmod 777 machinekey - - init_crdb - - echo -e "\nStarting Zitadel IDP for user management\n\n" - $DOCKER_COMPOSE_COMMAND up -d caddy zitadel - init_zitadel - - echo -e "\nRendering NetBird files...\n" - renderTurnServerConf > turnserver.conf - renderManagementJson > management.json - renderDashboardEnv > dashboard.env - renderRelayEnv > relay.env - - echo -e "\nStarting NetBird services\n" - $DOCKER_COMPOSE_COMMAND up -d - echo -e "\nDone!\n" - echo "You can access the NetBird dashboard at $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN" - echo "Login with the following credentials:" - install -m 600 /dev/null .env - printf 'Username: %s\nPassword: %s\n' \ - "$ZITADEL_ADMIN_USERNAME" "$ZITADEL_ADMIN_PASSWORD" >> .env - echo "Username: $ZITADEL_ADMIN_USERNAME" - echo "Password: $ZITADEL_ADMIN_PASSWORD" -} - -renderCaddyfile() { - cat < /dev/null - then - echo "docker-compose" - return - fi - if docker compose --help &> /dev/null - then - echo "docker compose" - return +check_docker_sock_perms() { + local sock="${DOCKER_HOST:-unix:///var/run/docker.sock}" + sock="${sock#unix://}" + + if [[ ! -S "$sock" ]]; then + return 0 fi - echo "docker-compose is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/engine/install/" > /dev/stderr + if [[ ! -r "$sock" ]] || [[ ! -w "$sock" ]]; then + local group + if [[ "${OSTYPE}" == "darwin"* ]]; then + group="$(stat -f '%Sg' "$sock")" + else + group="$(stat -c '%G' "$sock")" + fi + + echo "Cannot access Docker socket: $sock" > /dev/stderr + echo "" > /dev/stderr + echo "Socket permissions:" > /dev/stderr + ls -l "$sock" > /dev/stderr + echo "" > /dev/stderr + + if [[ "$group" == "docker" ]]; then + echo "Your user may need to be added to the '$group' group:" > /dev/stderr + echo " sudo usermod -aG $group \"$USER\"" > /dev/stderr + echo "Then log out and back in, or run this for the current shell:" > /dev/stderr + echo " newgrp $group" > /dev/stderr + echo "Note: newgrp is temporary; usermod is the permanent group change." > /dev/stderr + else + echo "The Docker socket is owned by the '$group' group, which is not the standard 'docker' group." > /dev/stderr + echo "For safety, this script will not suggest adding your user to '$group'." > /dev/stderr + echo "Instead, either run this script with appropriate privileges (for example, via sudo) or follow Docker's post-install steps to configure access via the 'docker' group:" > /dev/stderr + echo " https://docs.docker.com/engine/install/linux-postinstall/" > /dev/stderr + fi + + exit 1 + fi + return 0 +} + +check_docker_compose() { + if ! command -v docker &> /dev/null && ! command -v docker-compose &> /dev/null; then + echo "Docker is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/engine/install/" > /dev/stderr + exit 1 + fi + + if docker compose version &> /dev/null; then + echo "docker compose" + return + fi + if command -v docker-compose &> /dev/null && docker-compose version &> /dev/null; then + echo "docker-compose" + return + fi + + echo "Docker Compose is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/compose/install/" > /dev/stderr exit 1 } @@ -58,16 +101,89 @@ get_main_ip_address() { } check_nb_domain() { - DOMAIN=$1 - if [[ "$DOMAIN-x" == "-x" ]]; then + local domain="$1" + + if [[ -z "$domain" ]]; then echo "The NETBIRD_DOMAIN variable cannot be empty." > /dev/stderr return 1 fi - - if [[ "$DOMAIN" == "netbird.example.com" ]]; then + if [[ "$domain" == "use-ip" ]]; then + return 0 + fi + if [[ "$domain" == "netbird.example.com" ]]; then echo "The NETBIRD_DOMAIN cannot be netbird.example.com" > /dev/stderr return 1 fi + if [[ "$domain" =~ ^[0-9.]+$ ]]; then + echo "'$domain' is an IP address. Use 'use-ip' to install on this host's IP over HTTP, or an FQDN to get a TLS certificate." > /dev/stderr + return 1 + fi + if [[ ! "$domain" =~ ^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$ ]]; then + echo "'$domain' is not a valid FQDN. It needs at least one dot (e.g. netbird.my-domain.com), with no scheme, port or trailing dot." > /dev/stderr + return 1 + fi + return 0 +} + +check_domain_resolves() { + local domain="$1" + if command -v getent &> /dev/null && getent hosts "$domain" &> /dev/null; then return 0; fi + if command -v host &> /dev/null && host "$domain" &> /dev/null; then return 0; fi + if command -v dig &> /dev/null && [[ -n "$(dig +short "$domain" 2>/dev/null)" ]]; then return 0; fi + if command -v nslookup &> /dev/null && nslookup "$domain" &> /dev/null; then return 0; fi + return 1 +} + +# Non-interactive configuration +# ------------------------------ +# Every prompt below can be pre-answered with an environment variable, so the +# script runs unattended (cloud-init, CI, Terraform, curl | bash). resolve() +# is the single place that decides env var vs prompt vs default; the read_* +# helpers stay pure prompts. +# +# Supported env vars: +# NETBIRD_DOMAIN domain/FQDN (required) +# NETBIRD_LETSENCRYPT_EMAIL ACME email (required for built-in Traefik) +# NETBIRD_AGENT_NETWORK true enables the agent-network preset +# NETBIRD_REVERSE_PROXY_TYPE 0-5 (default 0 = built-in Traefik) +# NETBIRD_ENABLE_PROXY true/false (default false) +# NETBIRD_ENABLE_CROWDSEC true/false (default false) +# NETBIRD_TRAEFIK_EXTERNAL_NETWORK external-Traefik network (type 1) +# NETBIRD_TRAEFIK_ENTRYPOINT external-Traefik entrypoint (type 1, default websecure) +# NETBIRD_TRAEFIK_CERTRESOLVER external-Traefik cert resolver (type 1) +# NETBIRD_BIND_LOCALHOST_ONLY true/false (default true, types 2-5) +# NETBIRD_EXTERNAL_PROXY_NETWORK docker network to join (types 2-4) +# NETBIRD_NON_INTERACTIVE true forces unattended mode even with a TTY + +# tty_available succeeds only when we may prompt: never when the operator has +# set NETBIRD_NON_INTERACTIVE=true, otherwise only when /dev/tty can actually +# be opened. A PTY can be attached in automation (CI runners, some +# provisioners), so the env override is the authoritative signal and the +# /dev/tty probe is the fallback. /dev/tty is a world-rw device node even with +# no terminal, so a permission test ([ -r ]) is not enough - we must open it. +tty_available() { + [[ "${NETBIRD_NON_INTERACTIVE:-}" == "true" ]] && return 1 + { true < /dev/tty; } 2>/dev/null +} + +# resolve ENV_VAR_NAME DEFAULT PROMPT_FN [prompt args...] +# env var set and non-empty -> its value +# interactive -> PROMPT_FN "$@" (prompt behavior unchanged) +# otherwise -> DEFAULT, or abort when DEFAULT is "required" +resolve() { + local env_name="$1" default="$2" prompt_fn="$3" + shift 3 + local env_value="${!env_name:-}" + if [[ -n "$env_value" ]]; then + echo "$env_value" + elif tty_available; then + "$prompt_fn" "$@" + elif [[ "$default" == "required" ]]; then + echo "$env_name is required for a non-interactive install." > /dev/stderr + exit 1 + else + echo "$default" + fi return 0 } @@ -77,7 +193,22 @@ read_nb_domain() { read -r READ_NETBIRD_DOMAIN < /dev/tty if ! check_nb_domain "$READ_NETBIRD_DOMAIN"; then read_nb_domain + return fi + + if [[ "$READ_NETBIRD_DOMAIN" != "use-ip" ]] && ! check_domain_resolves "$READ_NETBIRD_DOMAIN"; then + local confirm="" + echo "" > /dev/stderr + echo "Warning: '$READ_NETBIRD_DOMAIN' does not resolve via DNS from this host." > /dev/stderr + echo "TLS certificate issuance and client connections will fail until it does." > /dev/stderr + echo -n "Continue anyway? [y/N]: " > /dev/stderr + read -r confirm < /dev/tty + if [[ ! "$confirm" =~ ^[Yy]$ ]]; then + read_nb_domain + return + fi + fi + echo "$READ_NETBIRD_DOMAIN" return 0 } @@ -231,7 +362,20 @@ get_upstream_host() { wait_management_proxy() { local proxy_container="${1:-traefik}" + local use_docker_logs=false set +e + + if [[ "$proxy_container" == "detect-traefik" ]]; then + proxy_container=$(docker ps --format "{{.ID}}\t{{.Image}}\t{{.Ports}}" \ + | awk -F'\t' '$2 ~ /traefik/ && $3 ~ /:(80|443)->/ {print $1; exit}') + + if [[ -z "$proxy_container" ]]; then + echo "Warning: could not auto-detect Traefik container, log output will be skipped on timeout." > /dev/stderr + else + use_docker_logs=true + fi + fi + echo -n "Waiting for NetBird server to become ready" counter=1 while true; do @@ -242,7 +386,13 @@ wait_management_proxy() { if [[ $counter -eq 60 ]]; then echo "" echo "Taking too long. Checking logs..." - $DOCKER_COMPOSE_COMMAND logs --tail=20 "$proxy_container" + if [[ -n "$proxy_container" ]]; then + if [[ "$use_docker_logs" == "true" ]]; then + docker logs --tail=20 "$proxy_container" + else + $DOCKER_COMPOSE_COMMAND logs --tail=20 "$proxy_container" + fi + fi $DOCKER_COMPOSE_COMMAND logs --tail=20 netbird-server fi echo -n " ." @@ -289,14 +439,16 @@ initialize_default_values() { NETBIRD_RELAY_AUTH_SECRET=$(openssl rand -base64 32 | sed "$SED_STRIP_PADDING") # Note: DataStoreEncryptionKey must keep base64 padding (=) for Go's base64.StdEncoding DATASTORE_ENCRYPTION_KEY=$(openssl rand -base64 32) + SESSION_COOKIE_ENCRYPTION_KEY=$(openssl rand -base64 32) NETBIRD_STUN_PORT=3478 # Docker images - DASHBOARD_IMAGE="netbirdio/dashboard:latest" + DASHBOARD_IMAGE=${DASHBOARD_IMAGE:-"netbirdio/dashboard:latest"} # Combined server replaces separate signal, relay, and management containers - NETBIRD_SERVER_IMAGE="netbirdio/netbird-server:latest" - NETBIRD_PROXY_IMAGE="netbirdio/reverse-proxy:latest" - + NETBIRD_SERVER_IMAGE=${NETBIRD_SERVER_IMAGE:-"netbirdio/netbird-server:latest"} + NETBIRD_PROXY_IMAGE=${NETBIRD_PROXY_IMAGE:-"netbirdio/reverse-proxy:latest"} + TRAEFIK_IMAGE=${TRAEFIK_IMAGE:-"traefik:v3.6"} + CROWDSEC_IMAGE=${CROWDSEC_IMAGE:-"crowdsecurity/crowdsec:v1.7.7"} # Reverse proxy configuration REVERSE_PROXY_TYPE="0" TRAEFIK_EXTERNAL_NETWORK="" @@ -322,8 +474,26 @@ initialize_default_values() { } configure_domain() { + # Domain is validated (not a free-form value), so it keeps its own guard + # rather than going through resolve(): a valid NETBIRD_DOMAIN is used as-is, + # otherwise we prompt, or abort when there is no terminal to prompt on. + local prompted="false" if ! check_nb_domain "$NETBIRD_DOMAIN"; then + if ! tty_available; then + if [[ -n "$NETBIRD_DOMAIN" ]]; then + echo "NETBIRD_DOMAIN='$NETBIRD_DOMAIN' cannot be used for a non-interactive install." > /dev/stderr + else + echo "NETBIRD_DOMAIN is required for a non-interactive install." > /dev/stderr + fi + exit 1 + fi NETBIRD_DOMAIN=$(read_nb_domain) + prompted="true" + fi + + if [[ "$prompted" == "false" && "$NETBIRD_DOMAIN" != "use-ip" ]] && ! check_domain_resolves "$NETBIRD_DOMAIN"; then + echo "Warning: '$NETBIRD_DOMAIN' does not resolve via DNS from this host." > /dev/stderr + echo "TLS certificate issuance and client connections will fail until it does." > /dev/stderr fi if [[ "$NETBIRD_DOMAIN" == "use-ip" ]]; then @@ -338,36 +508,69 @@ configure_domain() { return 0 } +apply_agent_network_preset() { + # Agent-network turnkey install: built-in Traefik + NetBird Proxy with + # NB_PROXY_PRIVATE=true, dashboard locked to agent-network-only mode. + # Bypasses every reverse-proxy / proxy / CrowdSec prompt. The only + # inputs we still need from the operator are the domain (handled by + # configure_domain via NETBIRD_DOMAIN env var or interactive prompt) + # and the ACME email — both honor env vars first and fall back to a + # prompt only when unset. CrowdSec is intentionally off. + REVERSE_PROXY_TYPE="0" + ENABLE_PROXY="true" + ENABLE_CROWDSEC="false" + + TRAEFIK_ACME_EMAIL=$(resolve NETBIRD_LETSENCRYPT_EMAIL required read_traefik_acme_email) + + echo "" > /dev/stderr + echo "Agent-network preset enabled (NETBIRD_AGENT_NETWORK=true):" > /dev/stderr + echo " - reverse proxy: built-in Traefik" > /dev/stderr + echo " - NetBird Proxy: enabled with NB_PROXY_PRIVATE=true" > /dev/stderr + echo " - server image: ${NETBIRD_SERVER_IMAGE}" > /dev/stderr + echo " - proxy image: ${NETBIRD_PROXY_IMAGE}" > /dev/stderr + echo " - dashboard: NETBIRD_AGENT_NETWORK_ONLY=true" > /dev/stderr + echo " - CrowdSec: disabled" > /dev/stderr + echo " - Let's Encrypt email: ${TRAEFIK_ACME_EMAIL}" > /dev/stderr + echo "" > /dev/stderr +} + configure_reverse_proxy() { - # Prompt for reverse proxy type - REVERSE_PROXY_TYPE=$(read_reverse_proxy_type) + # Short-circuit: agent-network preset locks every reverse-proxy / + # proxy / CrowdSec choice and bypasses the interactive prompts. + if [[ "${NETBIRD_AGENT_NETWORK}" == "true" ]]; then + apply_agent_network_preset + return 0 + fi + + # Reverse proxy type (env NETBIRD_REVERSE_PROXY_TYPE, else prompt, else 0) + REVERSE_PROXY_TYPE=$(resolve NETBIRD_REVERSE_PROXY_TYPE 0 read_reverse_proxy_type) # 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) + TRAEFIK_ACME_EMAIL=$(resolve NETBIRD_LETSENCRYPT_EMAIL required read_traefik_acme_email) + ENABLE_PROXY=$(resolve NETBIRD_ENABLE_PROXY false read_enable_proxy) if [[ "$ENABLE_PROXY" == "true" ]]; then - ENABLE_CROWDSEC=$(read_enable_crowdsec) + ENABLE_CROWDSEC=$(resolve NETBIRD_ENABLE_CROWDSEC false read_enable_crowdsec) fi 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) - TRAEFIK_CERTRESOLVER=$(read_traefik_certresolver) + TRAEFIK_EXTERNAL_NETWORK=$(resolve NETBIRD_TRAEFIK_EXTERNAL_NETWORK "" read_traefik_network) + TRAEFIK_ENTRYPOINT=$(resolve NETBIRD_TRAEFIK_ENTRYPOINT websecure read_traefik_entrypoint) + TRAEFIK_CERTRESOLVER=$(resolve NETBIRD_TRAEFIK_CERTRESOLVER "" read_traefik_certresolver) fi # Handle port binding for external proxy options (2-5) if [[ "$REVERSE_PROXY_TYPE" -ge 2 ]]; then - BIND_LOCALHOST_ONLY=$(read_port_binding_preference) + BIND_LOCALHOST_ONLY=$(resolve NETBIRD_BIND_LOCALHOST_ONLY true read_port_binding_preference) fi # Handle Docker network prompts for external proxies (options 2-4) case "$REVERSE_PROXY_TYPE" in - 2) EXTERNAL_PROXY_NETWORK=$(read_proxy_docker_network "Nginx") ;; - 3) EXTERNAL_PROXY_NETWORK=$(read_proxy_docker_network "Nginx Proxy Manager") ;; - 4) EXTERNAL_PROXY_NETWORK=$(read_proxy_docker_network "Caddy") ;; + 2) EXTERNAL_PROXY_NETWORK=$(resolve NETBIRD_EXTERNAL_PROXY_NETWORK "" read_proxy_docker_network "Nginx") ;; + 3) EXTERNAL_PROXY_NETWORK=$(resolve NETBIRD_EXTERNAL_PROXY_NETWORK "" read_proxy_docker_network "Nginx Proxy Manager") ;; + 4) EXTERNAL_PROXY_NETWORK=$(resolve NETBIRD_EXTERNAL_PROXY_NETWORK "" read_proxy_docker_network "Caddy") ;; *) ;; # No network prompt for other options esac return 0 @@ -430,7 +633,8 @@ generate_configuration_files() { # Common files for all configurations render_dashboard_env > dashboard.env - render_combined_yaml > config.yaml + install -m 600 /dev/null config.yaml + render_combined_yaml >> config.yaml return 0 } @@ -459,7 +663,7 @@ start_services_and_show_instructions() { 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}') + /go/bin/netbird-server admin 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 @@ -518,7 +722,7 @@ start_services_and_show_instructions() { $DOCKER_COMPOSE_COMMAND up -d sleep 3 - wait_management_direct + wait_management_proxy detect-traefik echo -e "$MSG_DONE" print_post_setup_instructions @@ -544,8 +748,13 @@ start_services_and_show_instructions() { print_post_setup_instructions echo "" - echo -n "Press Enter when your reverse proxy is configured (or Ctrl+C to exit)... " - read -r < /dev/tty + if tty_available; then + echo -n "Press Enter when your reverse proxy is configured (or Ctrl+C to exit)... " + read -r < /dev/tty + else + echo "Non-interactive mode: starting NetBird containers now. Finish configuring" + echo "your reverse proxy using the instructions above so it can reach them." + fi echo -e "$MSG_STARTING_SERVICES" $DOCKER_COMPOSE_COMMAND up -d @@ -561,12 +770,15 @@ start_services_and_show_instructions() { } init_environment() { + # Check if docker compose is installed using check_docker_compose function + DOCKER_COMPOSE_COMMAND=$(check_docker_compose) + check_docker_sock_perms + initialize_default_values configure_domain configure_reverse_proxy check_jq - DOCKER_COMPOSE_COMMAND=$(check_docker_compose) check_existing_installation generate_configuration_files @@ -637,7 +849,7 @@ render_docker_compose_traefik_builtin() { if [[ "$ENABLE_CROWDSEC" == "true" ]]; then crowdsec_service=" crowdsec: - image: crowdsecurity/crowdsec:v1.7.7 + image: $CROWDSEC_IMAGE container_name: netbird-crowdsec restart: unless-stopped networks: [netbird] @@ -668,7 +880,7 @@ render_docker_compose_traefik_builtin() { services: # Traefik reverse proxy (automatic TLS via Let's Encrypt) traefik: - image: traefik:v3.6 + image: $TRAEFIK_IMAGE container_name: netbird-traefik restart: unless-stopped networks: @@ -752,7 +964,7 @@ $traefik_dynamic_volume labels: - traefik.enable=true # gRPC router (needs h2c backend for HTTP/2 cleartext) - - traefik.http.routers.netbird-grpc.rule=Host(\`$NETBIRD_DOMAIN\`) && (PathPrefix(\`/signalexchange.SignalExchange/\`) || PathPrefix(\`/management.ManagementService/\`)) + - traefik.http.routers.netbird-grpc.rule=Host(\`$NETBIRD_DOMAIN\`) && (PathPrefix(\`/signalexchange.SignalExchange/\`) || PathPrefix(\`/management.ManagementService/\`) || PathPrefix(\`/management.ProxyService/\`)) - traefik.http.routers.netbird-grpc.entrypoints=websecure - traefik.http.routers.netbird-grpc.tls=true - traefik.http.routers.netbird-grpc.tls.certresolver=letsencrypt @@ -811,6 +1023,7 @@ server: auth: issuer: "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/oauth2" signKeyRefreshEnabled: true + sessionCookieEncryptionKey: "$SESSION_COOKIE_ENCRYPTION_KEY" dashboardRedirectURIs: - "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/nb-auth" - "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/nb-silent-auth" @@ -847,6 +1060,15 @@ NGINX_SSL_PORT=443 # Letsencrypt LETSENCRYPT_DOMAIN=none EOF + + if [[ "${NETBIRD_AGENT_NETWORK}" == "true" ]]; then + cat < /dev/null && ! command -v docker-compose &> /dev/null; then + echo "Docker is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/engine/install/" > /dev/stderr + exit 1 + fi + + if docker compose version &> /dev/null; then + echo "docker compose" + return + fi + if command -v docker-compose &> /dev/null && docker-compose version &> /dev/null; then + echo "docker-compose" + return + fi + + echo "Docker Compose is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/compose/install/" > /dev/stderr + exit 1 +} + +check_yq() { + if ! command -v yq &> /dev/null; then + cat > /dev/stderr <<'EOF' +yq is required to parse and update YAML safely. + + macOS: brew install yq + Linux: https://github.com/mikefarah/yq/releases (download binary into PATH) + Debian: apt-get install yq (Note: must be the mikefarah Go yq, not the Python wrapper.) + +EOF + exit 1 + fi + if ! yq --version 2>&1 | grep -q "mikefarah"; then + echo "yq is present but appears to be the wrong implementation. The mikefarah Go-based yq is required (https://github.com/mikefarah/yq)." > /dev/stderr + exit 1 + fi +} + +check_openssl() { + if ! command -v openssl &> /dev/null; then + echo "openssl is not installed or not in PATH." > /dev/stderr + exit 1 + fi +} + +rand_password() { + openssl rand -hex 32 +} + +read_required() { + local prompt="$1" + local value="" + while [[ -z "$value" ]]; do + echo -n "$prompt: " > /dev/stderr + read -r value < /dev/tty + if [[ -z "$value" ]]; then + echo "Value cannot be empty." > /dev/stderr + fi + done + echo "$value" +} + +read_secret() { + local prompt="$1" + local value="" + while [[ -z "$value" ]]; do + echo -n "$prompt: " > /dev/stderr + read -rs value < /dev/tty + echo "" > /dev/stderr + if [[ -z "$value" ]]; then + echo "Value cannot be empty." > /dev/stderr + fi + done + echo "$value" +} + +read_yes_no() { + local prompt="$1" + local default="${2:-n}" + local hint + if [[ "$default" == "y" ]]; then + hint="[Y/n]" + else + hint="[y/N]" + fi + echo -n "${prompt} ${hint}: " > /dev/stderr + local ans="" + read -r ans < /dev/tty + if [[ -z "$ans" ]]; then + ans="$default" + fi + case "$ans" in + [Yy] | [Yy][Ee][Ss]) echo "yes" ;; + *) echo "no" ;; + esac +} + +# Gate the migration on explicit acceptance of the NetBird On-Premise EULA. +require_eula_acceptance() { + cat > /dev/stderr < /dev/stderr + return 0 + fi + + local ans="" + echo -n 'Type "accept" to agree, or anything else to abort: ' > /dev/stderr + read -r ans < /dev/tty + if [[ "$ans" != "accept" ]]; then + echo "" > /dev/stderr + echo "EULA not accepted. Aborting migration." > /dev/stderr + exit 1 + fi + echo "" > /dev/stderr +} + +# --------------------------------------------------------------------------- +# Detection — read the operator's existing compose to find service names and +# paths we need to override. Bail loudly if shape isn't recognised. +# --------------------------------------------------------------------------- + +detect_combined_service() { + yq eval '.services | to_entries | map(select(.value.image | test("^(ghcr\\.io/)?netbirdio/netbird-server([:@]|$)"))) | .[0].key // ""' "$COMPOSE_FILE" +} + +detect_dashboard_service() { + yq eval '.services | to_entries | map(select(.value.image | test("^(ghcr\\.io/)?netbirdio/dashboard([:@]|$)"))) | .[0].key // ""' "$COMPOSE_FILE" +} + +detect_config_yaml_host_path() { + yq eval ".services[\"$COMBINED_SERVICE\"].volumes[] | select(. | test(\":/etc/netbird/config.yaml\")) | sub(\":/etc/netbird/config.yaml.*\"; \"\") // \"\"" "$COMPOSE_FILE" | head -1 +} + +detect_data_volume() { + yq eval ".services[\"$COMBINED_SERVICE\"].volumes[] | select(. | test(\":/var/lib/netbird\")) | sub(\":/var/lib/netbird.*\"; \"\") // \"\"" "$COMPOSE_FILE" | head -1 +} + +detect_exposed_address() { + yq eval '.server.exposedAddress // ""' "$CONFIG_YAML_HOST" +} + +# The engine is a config.yaml-only setting — there is no env override for it +# (combined/cmd/root.go reads it from YAML and derives the env vars), so +# config.yaml is authoritative. Absent means the sqlite default. +detect_store_engine() { + local engine + engine=$(yq eval '.server.store.engine // ""' "$CONFIG_YAML_HOST") + if [[ -z "$engine" ]] || [[ "$engine" == "null" ]]; then + engine="sqlite" + fi + echo "$engine" | tr '[:upper:]' '[:lower:]' +} + +detect_store_dsn() { + yq eval '.server.store.dsn // ""' "$CONFIG_YAML_HOST" +} + +# config.yaml is where a combined deployment carries its DSN; this only covers +# hand-rolled installs that keep it in the environment instead. +detect_store_dsn_from_compose() { + # `compose config` re-escapes a literal $ as $$ on the way out, so undo that + # to get the value the container actually receives. + $DOCKER_COMPOSE_COMMAND config 2>/dev/null | yq eval " + .services[\"$COMBINED_SERVICE\"].environment.NB_STORE_ENGINE_POSTGRES_DSN // + .services[\"$COMBINED_SERVICE\"].environment.NETBIRD_STORE_ENGINE_POSTGRES_DSN // \"\" + " - 2>/dev/null | sed 's/\$\$/$/g' +} + +# Reads either DSN form: "host=db ..." or "postgres://user:pass@db:5432/name". +dsn_host() { + local dsn="$1" + case "$dsn" in + *://*) printf '%s' "$dsn" | sed -n 's,^[a-zA-Z+]*://\([^/?]*\).*,\1,p' | sed -e 's,.*@,,' -e 's,:.*,,' ;; + *) printf '%s' "$dsn" | sed -n 's/.*[[:space:]]*host=\([^[:space:]]*\).*/\1/p' ;; + esac +} + +# flow-enricher is its own container, so a loopback host or a socket path would +# reach the enricher rather than Postgres. Only flag hosts we can positively +# identify — an unparseable DSN must not leave the operator with no way forward. +dsn_host_reachable() { + local dsn="$1" + case "$(dsn_host "$dsn")" in + localhost | 127.* | ::1 | 0.0.0.0 | /*) return 1 ;; + *) return 0 ;; + esac +} + +# Names the compose service running this deployment's Postgres, for depends_on. +# Empty means external — the DSN host matched no service. A DSN with no readable +# host falls back to matching on image. +detect_postgres_service() { + local host + host=$(dsn_host "$POSTGRES_DSN") + if [[ -n "$host" ]]; then + if [[ "$(host="$host" yq eval '.services | has(env(host))' "$COMPOSE_FILE" 2>/dev/null)" == "true" ]]; then + echo "$host" + fi + return + fi + yq eval '.services | to_entries | map(select(.value.image // "" | test("(^|/)(postgres|postgis|pgvector|timescaledb)(:|@|$)"))) | .[0].key // ""' "$COMPOSE_FILE" +} + +# depends_on: service_healthy is only legal if the service defines a healthcheck. +detect_postgres_depends_condition() { + local tag + tag=$(yq eval ".services[\"$POSTGRES_SERVICE\"].healthcheck | tag" "$COMPOSE_FILE" 2>/dev/null) + if [[ "$tag" == "!!map" ]]; then + echo "service_healthy" + else + echo "service_started" + fi +} + +env_value() { + local value="$1" + value=$(printf '%s' "$value" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\$/$$/g') + printf '"%s"' "$value" +} + +detect_compose_network() { + local tag + tag=$(yq eval ".services[\"$COMBINED_SERVICE\"].networks | tag" "$COMPOSE_FILE" 2>/dev/null) + case "$tag" in + "!!seq") + yq eval ".services[\"$COMBINED_SERVICE\"].networks[0]" "$COMPOSE_FILE" + ;; + "!!map") + yq eval ".services[\"$COMBINED_SERVICE\"].networks | keys | .[0]" "$COMPOSE_FILE" + ;; + *) + echo "default" + ;; + esac +} + +# --------------------------------------------------------------------------- +# Renderers +# --------------------------------------------------------------------------- + +# Build docker-compose.override.yml from the steps the operator selected. +# Service names match what we detected on the operator's side. +render_override() { + cat < "$ENTERPRISE_CONFIG_FILE" + + if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then + # Fresh Postgres: point every store section at it. migrate-store carries the + # SQLite contents across. + POSTGRES_DSN="$POSTGRES_DSN" yq eval -i ' + .server.store.engine = "postgres" | + .server.store.dsn = strenv(POSTGRES_DSN) | + .server.activityStore.engine = "postgres" | + .server.activityStore.dsn = strenv(POSTGRES_DSN) | + .server.authStore.engine = "postgres" | + .server.authStore.dsn = strenv(POSTGRES_DSN) + ' "$ENTERPRISE_CONFIG_FILE" + fi + # Otherwise the store config is the operator's and stays untouched. + # activityStore and authStore do not inherit from server.store — each falls + # back to its own SQLite file under dataDir — so repointing them at Postgres + # here would silently strand the existing audit log and the embedded IdP's + # users, with no migrate-store run to carry them over. + + if [[ "$ENABLE_FLOW" == "yes" ]]; then + NETBIRD_DOMAIN="$NETBIRD_DOMAIN" yq eval -i ' + .server.trafficFlow.enabled = true | + .server.trafficFlow.address = strenv(NETBIRD_DOMAIN) | + .server.trafficFlow.interval = "60s" + ' "$ENTERPRISE_CONFIG_FILE" + fi +} + +# --------------------------------------------------------------------------- +# Execution steps +# --------------------------------------------------------------------------- + +combined_container_id() { + $DOCKER_COMPOSE_COMMAND ps -aq "$COMBINED_SERVICE" 2>/dev/null | head -1 +} + +container_data_mount() { + local container="$1" + [[ -n "$container" ]] || return 0 + docker inspect "$container" --format \ + '{{range .Mounts}}{{if eq .Destination "/var/lib/netbird"}}{{if .Name}}{{.Name}}{{else}}{{.Source}}{{end}}{{end}}{{end}}' 2>/dev/null +} + +# The name comes from the container, so `-v` cannot invent an empty volume here. +# 0 = empty, 1 = holds data, 2 = could not determine. A failed listing must not +# be reported as empty: that would abort a healthy migration over a pull error +# or an unreadable bind mount. +data_dir_state() { + local src="$1" out + if [[ "$src" == /* ]]; then + [[ -d "$src" ]] || return 2 + out=$(ls -A "$src" 2>/dev/null) || return 2 + else + docker volume inspect "$src" &> /dev/null || return 0 + out=$(docker run --rm -v "${src}:/d:ro" busybox sh -c 'ls -A /d' 2>/dev/null) || return 2 + fi + [[ -z "$out" ]] && return 0 + return 1 +} + +check_data_directory() { + [[ "$MIGRATE_POSTGRES" == "yes" ]] || return 0 + + local container + container=$(combined_container_id) + if [[ -z "$container" ]]; then + echo "" > /dev/stderr + echo "No container found for service '$COMBINED_SERVICE'." > /dev/stderr + echo "The migration backs up the store by copying it out of that container," > /dev/stderr + echo "so it has to exist. Start the deployment and re-run:" > /dev/stderr + echo " $DOCKER_COMPOSE_COMMAND up -d" > /dev/stderr + exit 1 + fi + + local src + src=$(container_data_mount "$container") + if [[ -z "$src" ]]; then + echo "" > /dev/stderr + echo "The '$COMBINED_SERVICE' container has nothing mounted at /var/lib/netbird." > /dev/stderr + echo "Cannot locate the NetBird store to back it up." > /dev/stderr + exit 1 + fi + + local state=0 + data_dir_state "$src" || state=$? + if [[ $state -eq 0 ]]; then + echo "" > /dev/stderr + echo "The NetBird data directory is empty:" > /dev/stderr + echo " $src" > /dev/stderr + echo "There is nothing to migrate. Check that you are running this from the" > /dev/stderr + echo "deployment directory of the NetBird install you mean to migrate." > /dev/stderr + exit 1 + fi + if [[ $state -eq 2 ]]; then + echo " ⚠ Could not read $src to confirm it holds data — continuing." > /dev/stderr + echo " The backup step still fails loudly if it turns out to be empty." > /dev/stderr + fi + + echo " Data directory: $src" +} + +# Only for the Postgres volume, which has no container to read it off yet. +resolve_compose_volume() { + local short="$1" + local actual + # Resolve project-prefixed volume name from Docker Compose config first. + actual=$($DOCKER_COMPOSE_COMMAND config 2>/dev/null | yq eval ".volumes.\"$short\".name" - 2>/dev/null) + if [[ -n "$actual" && "$actual" != "null" ]]; then + echo "$actual" + return + fi + # Relative bind mount: docker-compose resolves it against the compose + # file's directory, but `docker run -v` resolves it against the current + # working directory. Normalize to an absolute path so both interpretations + # agree (and the printed revert command works from any CWD). + if [[ "$short" == ./* || "$short" == ../* ]]; then + local compose_dir + compose_dir="$(cd "$(dirname "$COMPOSE_FILE")" && pwd)" + ( + cd "$compose_dir" + cd "$(dirname "$short")" + printf '%s/%s\n' "$(pwd)" "$(basename "$short")" + ) + return + fi + # Not a named volume (e.g. an absolute bind-mount path) — use it as-is. + echo "$short" +} + +backup_sqlite() { + BACKUP_DIR="$(pwd)/backups/sqlite-pre-enterprise-$(date +%Y%m%d-%H%M%S)" + mkdir -p "$BACKUP_DIR" + + local container + container=$(combined_container_id) + if [[ -z "$container" ]]; then + echo " ⚠ No container found for '$COMBINED_SERVICE' — cannot back up the store." > /dev/stderr + exit 1 + fi + + echo "Backing up the NetBird store to $BACKUP_DIR ..." + docker cp "${container}:/var/lib/netbird/." "$BACKUP_DIR/" + + local copied + copied=$(find "$BACKUP_DIR" -mindepth 1 | head -1) + if [[ -z "$copied" ]]; then + echo " ⚠ Backup directory is empty — /var/lib/netbird held no data. Aborting." > /dev/stderr + exit 1 + fi + echo " done" +} + +run_migrate_store() { + echo "Running migrate-store (SQLite → Postgres) ..." + $DOCKER_COMPOSE_COMMAND run --rm "$COMBINED_SERVICE" migrate-store --config /etc/netbird/config.yaml.enterprise --verify + echo " done" +} + +# --------------------------------------------------------------------------- +# Rollback — a failed run must not leave the operator with a stopped stack and +# half-written artifacts. +# --------------------------------------------------------------------------- + +# Resolve the name Compose would give the Postgres volume before the override +# exists, so a leftover volume can be spotted up front. +compose_project_name() { + local container project + container=$($DOCKER_COMPOSE_COMMAND ps -aq 2>/dev/null | head -1) + if [[ -n "$container" ]]; then + project=$(docker inspect "$container" \ + --format '{{index .Config.Labels "com.docker.compose.project"}}' 2>/dev/null) + if [[ -n "$project" ]]; then + echo "$project" + return 0 + fi + fi + project=$($DOCKER_COMPOSE_COMMAND config 2>/dev/null | yq eval '.name // ""' - 2>/dev/null) + if [[ -n "$project" ]] && [[ "$project" != "null" ]]; then + echo "$project" + fi + return 0 +} + +postgres_volume_name() { + local project + project=$(compose_project_name) + if [[ -n "$project" ]]; then + echo "${project}_netbird_postgres" + fi + return 0 +} + +# Postgres skips initdb when its data directory is non-empty, so a volume left +# behind by an interrupted run would keep the old password and old contents, +# and migrate-store would fail against it. +check_stale_postgres_volume() { + [[ "$MIGRATE_POSTGRES" == "yes" ]] || return 0 + + PG_VOLUME_NAME=$(postgres_volume_name) + if [[ -z "$PG_VOLUME_NAME" ]]; then + echo "" + echo " ⚠ Could not determine the Compose project name, so a Postgres volume" + echo " left over from an earlier attempt cannot be checked for. If a" + echo " previous run failed, remove it before continuing:" + echo " docker volume ls | grep netbird_postgres" + return 0 + fi + docker volume inspect "$PG_VOLUME_NAME" &> /dev/null || return 0 + + echo "" + echo " ⚠ A Postgres volume from an earlier attempt already exists:" + echo " $PG_VOLUME_NAME" + echo " Postgres does not re-initialise a non-empty data directory, so the" + echo " migration would run against stale credentials and stale data." + local remove + remove=$(read_yes_no " Remove it and continue?" "y") + if [[ "$remove" != "yes" ]]; then + echo "" > /dev/stderr + echo "Aborted. Remove it manually with: docker volume rm $PG_VOLUME_NAME" > /dev/stderr + exit 1 + fi + docker volume rm "$PG_VOLUME_NAME" > /dev/null + echo " Removed." +} + +# Undo whatever this run changed and start the previous deployment again. +rollback() { + ROLLBACK_STATE="done" + + echo "" + echo "──────────────────────────────────────────────────────────────────────" + echo " Migration failed — restoring the previous deployment" + echo "──────────────────────────────────────────────────────────────────────" + + # Resolve while the override is still present; without it Compose no longer + # knows about the Postgres volume. + local pg_volume="$PG_VOLUME_NAME" + if [[ -z "$pg_volume" ]] && [[ "$MIGRATE_POSTGRES" == "yes" ]]; then + pg_volume=$(postgres_volume_name) + fi + + echo "" + echo "Stopping services ..." + $DOCKER_COMPOSE_COMMAND down || true + + echo "Removing generated files ..." + rm -f "$OVERRIDE_FILE" "$ENTERPRISE_CONFIG_FILE" + + # Restore .env to exactly what it was, or remove it if this run created it. + if [[ "$ENV_EXISTED" == "yes" ]] && [[ -f "$ENV_BACKUP" ]]; then + mv -f "$ENV_BACKUP" .env || echo " ⚠ Could not restore .env from $ENV_BACKUP." > /dev/stderr + elif [[ "$ENV_EXISTED" == "no" ]]; then + rm -f .env || true + fi + + # Only ever the volume this run created — never the NetBird data volume. + if [[ -n "$pg_volume" ]] && [[ "$pg_volume" != "null" ]]; then + echo "Removing Postgres volume $pg_volume ..." + docker volume rm "$pg_volume" &> /dev/null || true + fi + + echo "Starting the previous deployment ..." + if ! $DOCKER_COMPOSE_COMMAND up -d; then + echo "" + echo " ⚠ Could not start the previous deployment automatically." > /dev/stderr + echo " Run: $DOCKER_COMPOSE_COMMAND up -d" > /dev/stderr + fi + + echo "" + echo "Rolled back. Your docker-compose.yml, config.yaml and the NetBird data" + echo "volume were never modified." + if [[ -n "$BACKUP_DIR" ]] && [[ -d "$BACKUP_DIR" ]]; then + echo "The SQLite backup taken during this run is kept at:" + echo " $BACKUP_DIR" + fi + echo "──────────────────────────────────────────────────────────────────────" +} + +on_exit() { + local code=$? + trap - EXIT + if [[ $code -ne 0 ]] && [[ "$ROLLBACK_STATE" == "armed" ]]; then + rollback + fi + exit $code +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +# Already on Postgres: there is nothing to provision and nothing to migrate. +# The enterprise image reads the very same store config the community image +# did, so step 2 collapses to a no-op and the run is a plain image swap. +configure_existing_postgres() { + EXISTING_POSTGRES="yes" + MIGRATE_POSTGRES="no" + + # DSN first — detect_postgres_service prefers the host it names. + POSTGRES_DSN=$(detect_store_dsn) + if [[ -z "$POSTGRES_DSN" ]] || [[ "$POSTGRES_DSN" == "null" ]]; then + POSTGRES_DSN=$(detect_store_dsn_from_compose) + fi + if [[ "$POSTGRES_DSN" == "null" ]]; then + POSTGRES_DSN="" + fi + + POSTGRES_SERVICE=$(detect_postgres_service) + if [[ -n "$POSTGRES_SERVICE" ]]; then + POSTGRES_DEPENDS_CONDITION=$(detect_postgres_depends_condition) + fi + + echo "Step 2: Postgres migration not needed — this deployment already runs on" + echo " Postgres. Its store configuration is reused as-is and left" + echo " untouched; no database is created and no data is moved." + if [[ -n "$POSTGRES_SERVICE" ]]; then + echo " Postgres service: $POSTGRES_SERVICE (in $COMPOSE_FILE)" + else + echo " Postgres service: managed outside $COMPOSE_FILE" + fi +} + +configure_sqlite_store() { + MIGRATE_POSTGRES=$(read_yes_no "Step 2: Migrate storage from SQLite to Postgres? (recommended)" "n") + [[ "$MIGRATE_POSTGRES" == "yes" ]] || return 0 + + # The override would otherwise merge into a service of the same name and + # quietly rewrite its image and credentials. + local existing + existing=$(yq eval '.services | has("postgres")' "$COMPOSE_FILE") + if [[ "$existing" == "true" ]]; then + echo "" > /dev/stderr + echo "$COMPOSE_FILE already defines a service named 'postgres', but config.yaml" > /dev/stderr + echo "still has server.store.engine: sqlite. This script would add its own" > /dev/stderr + echo "'postgres' service and Compose would merge the two." > /dev/stderr + echo "" > /dev/stderr + echo "Point server.store.engine at that Postgres yourself, or rename the service," > /dev/stderr + echo "then re-run." > /dev/stderr + exit 1 + fi + + echo "" + echo " ⚠ Data will be migrated from SQLite to Postgres. The SQLite store" + echo " will be backed up automatically. To fully revert later, restore" + echo " that backup and delete docker-compose.override.yml +" + echo " config.yaml.enterprise." + local confirm + confirm=$(read_yes_no " Continue?" "y") + if [[ "$confirm" != "yes" ]]; then + MIGRATE_POSTGRES="no" + echo " Skipping Postgres migration." + return 0 + fi + + POSTGRES_PASSWORD=$(rand_password) + POSTGRES_SERVICE="postgres" + POSTGRES_DEPENDS_CONDITION="service_healthy" + POSTGRES_DSN="host=postgres user=netbird password=${POSTGRES_PASSWORD} dbname=netbird port=5432 sslmode=disable" +} + +# mysql, or something this script has never seen. Swapping the images is still +# valid; touching the store is not. +configure_unsupported_store() { + MIGRATE_POSTGRES="no" + echo " ⚠ server.store.engine is '$STORE_ENGINE'. This script only migrates" + echo " SQLite to Postgres, and traffic flow requires Postgres, so both are" + echo " unavailable here. The store configuration will be left untouched." + echo "" + local proceed + proceed=$(read_yes_no "Step 2 skipped. Continue with the image swap only?" "n") + if [[ "$proceed" != "yes" ]]; then + echo "Aborted." + exit 0 + fi +} + +init_migration() { + DOCKER_COMPOSE_COMMAND=$(check_docker_compose) + check_yq + check_openssl + + COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}" + + if [[ ! -f "$COMPOSE_FILE" ]]; then + echo "$COMPOSE_FILE not found in $(pwd)." > /dev/stderr + exit 1 + fi + if [[ -f "$OVERRIDE_FILE" ]] || [[ -f "$ENTERPRISE_CONFIG_FILE" ]]; then + echo "Migration artifacts already exist in $(pwd):" + [[ -f "$OVERRIDE_FILE" ]] && echo " $OVERRIDE_FILE" + [[ -f "$ENTERPRISE_CONFIG_FILE" ]] && echo " $ENTERPRISE_CONFIG_FILE" + echo "" + echo "Either you've already migrated, or a previous run was interrupted." + echo "To re-run cleanly: rm -f $OVERRIDE_FILE $ENTERPRISE_CONFIG_FILE" + exit 1 + fi + + COMBINED_SERVICE=$(detect_combined_service) + DASHBOARD_SERVICE=$(detect_dashboard_service) + CONFIG_YAML_HOST=$(detect_config_yaml_host_path) + DATA_VOLUME=$(detect_data_volume) + COMPOSE_NETWORK=$(detect_compose_network) + + if [[ -z "$COMBINED_SERVICE" ]]; then + echo "Could not find a service running netbirdio/netbird-server or ghcr.io/netbirdio/netbird-server in $COMPOSE_FILE." > /dev/stderr + echo "This script targets the community combined-server deployment." > /dev/stderr + exit 1 + fi + if [[ -z "$DASHBOARD_SERVICE" ]]; then + echo "Could not find a service running netbirdio/dashboard or ghcr.io/netbirdio/dashboard in $COMPOSE_FILE." > /dev/stderr + exit 1 + fi + if [[ -z "$CONFIG_YAML_HOST" ]]; then + echo "Could not find a config.yaml mount on $COMBINED_SERVICE (expected to bind-mount to /etc/netbird/config.yaml)." > /dev/stderr + exit 1 + fi + if [[ ! -f "$CONFIG_YAML_HOST" ]]; then + echo "config.yaml host file not found at $CONFIG_YAML_HOST." > /dev/stderr + exit 1 + fi + if [[ -z "$DATA_VOLUME" ]]; then + echo "Could not find a volume mounted at /var/lib/netbird on $COMBINED_SERVICE." > /dev/stderr + exit 1 + fi + + STORE_ENGINE=$(detect_store_engine) + + echo "Detected existing deployment:" + echo " Combined service: $COMBINED_SERVICE" + echo " Dashboard: $DASHBOARD_SERVICE" + echo " config.yaml: $CONFIG_YAML_HOST" + echo " Data volume: $DATA_VOLUME" + echo " Network: $COMPOSE_NETWORK" + echo " Store engine: $STORE_ENGINE" + echo "" + + require_eula_acceptance + NETBIRD_EULA_ACCEPTED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ) + + local proceed + proceed=$(read_yes_no "Proceed with migration?" "y") + if [[ "$proceed" != "yes" ]]; then + echo "Aborted." + exit 0 + fi + + # Step 1 — always (this is the point of the script) + MIGRATE_IMAGES="yes" + echo "" + echo "Step 1: Image swap (community → Enterprise). License key required." + NB_LICENSE_KEY=$(read_secret " License key") + + # Step 2 — what this does depends on what the deployment already stores in. + echo "" + case "$STORE_ENGINE" in + postgres) configure_existing_postgres ;; + sqlite) configure_sqlite_store ;; + *) configure_unsupported_store ;; + esac + + # Step 3 — optional, only if Postgres is on (flow requires Postgres) + echo "" + if [[ "$MIGRATE_POSTGRES" == "yes" ]] || [[ "$EXISTING_POSTGRES" == "yes" ]]; then + ENABLE_FLOW=$(read_yes_no "Step 3: Enable traffic flow? (requires Postgres)" "n") + if [[ "$ENABLE_FLOW" == "yes" ]]; then + # Auth secret MUST match server.authSecret from config.yaml + NB_FLOW_AUTH_SECRET=$(yq eval '.server.authSecret // ""' "$CONFIG_YAML_HOST") + if [[ -z "$NB_FLOW_AUTH_SECRET" ]] || [[ "$NB_FLOW_AUTH_SECRET" == "null" ]]; then + echo "Could not read server.authSecret from $CONFIG_YAML_HOST." > /dev/stderr + echo "Flow receiver auth must match the combined server's authSecret." > /dev/stderr + exit 1 + fi + + NETBIRD_DOMAIN=$(detect_exposed_address) + if [[ -z "$NETBIRD_DOMAIN" ]] || [[ "$NETBIRD_DOMAIN" == "null" ]]; then + NETBIRD_DOMAIN=$(read_required " Public NetBird URL (e.g. https://netbird.example.com)") + fi + # Strip protocol + port to leave just the hostname for the Traefik Host() rule. + NETBIRD_HOSTNAME=$(echo "$NETBIRD_DOMAIN" | sed -E 's,^https?://,,' | sed 's,:.*,,' | sed 's,/.*,,') + + # We need the encryption key from the existing config.yaml for the enricher + NETBIRD_ENCRYPTION_KEY=$(yq eval '.server.store.encryptionKey // ""' "$CONFIG_YAML_HOST") + if [[ -z "$NETBIRD_ENCRYPTION_KEY" ]] || [[ "$NETBIRD_ENCRYPTION_KEY" == "null" ]]; then + echo "Could not read server.store.encryptionKey from $CONFIG_YAML_HOST." > /dev/stderr + exit 1 + fi + + # flow-enricher talks to Postgres directly, so this is the one place an + # existing deployment's DSN is actually needed — and the one place a host + # that only works from inside the server container shows up. + while :; do + local dsn_problem="" + if [[ -z "$POSTGRES_DSN" ]]; then + dsn_problem="No DSN could be read from $CONFIG_YAML_HOST or from the $COMBINED_SERVICE environment." + elif ! dsn_host_reachable "$POSTGRES_DSN"; then + dsn_problem="Its host '$(dsn_host "$POSTGRES_DSN")' only resolves inside the server container." + fi + [[ -n "$dsn_problem" ]] || break + + echo "" + echo " The flow enricher reaches Postgres from a container of its own." + echo " $dsn_problem" + echo " Enter a DSN reachable from other containers, or press Ctrl-C to abort." + POSTGRES_DSN=$(read_required " Postgres DSN (host=… user=… password=… dbname=… port=5432 sslmode=disable)") + done + + # Only where the operator owns Postgres: a DSN entered above may name a + # different host. The sqlite path creates its own service, nothing to find. + if [[ "$EXISTING_POSTGRES" == "yes" ]]; then + POSTGRES_SERVICE=$(detect_postgres_service) + if [[ -n "$POSTGRES_SERVICE" ]]; then + POSTGRES_DEPENDS_CONDITION=$(detect_postgres_depends_condition) + fi + fi + fi + else + ENABLE_FLOW="no" + echo "Step 3 (traffic flow) skipped — requires Postgres." + fi + + # config.yaml.enterprise only exists to hold changes; without any there is + # nothing to generate and the server keeps running on its own config.yaml. + if [[ "$MIGRATE_POSTGRES" == "yes" ]] || [[ "$ENABLE_FLOW" == "yes" ]]; then + ENTERPRISE_CONFIG="yes" + fi + + check_data_directory + check_stale_postgres_volume +} + +wait_for_license_verdict() { + local counter=0 + local logs="" + + echo -n "Waiting for the server to validate the license" + while [[ $counter -lt 60 ]]; do + + logs=$($DOCKER_COMPOSE_COMMAND logs --no-color --tail=all "$COMBINED_SERVICE" 2>/dev/null || true) + + if grep -qi "license invalidated" <<< "$logs"; then + echo " rejected" + LICENSE_VERDICT="rejected" + LICENSE_LOG_LINES=$(grep -i "license" <<< "$logs" | tail -n 5 || true) + return 0 + fi + + if grep -qi "license validated" <<< "$logs"; then + echo " ok" + LICENSE_VERDICT="ok" + return 0 + fi + + echo -n " ." + sleep 2 + counter=$((counter + 1)) + done + + echo " no verdict in 120s" + LICENSE_VERDICT="unknown" + LICENSE_LOG_LINES=$(grep -iE "failed to validate license|error validating license" <<< "$logs" | tail -n 3 || true) + return 0 +} + +apply_changes() { + # From here on a failure must roll the deployment back. + ROLLBACK_STATE="armed" + + echo "" + echo "Writing $OVERRIDE_FILE ..." + install -m 644 /dev/null "$OVERRIDE_FILE" + render_override > "$OVERRIDE_FILE" + + if [[ -z "${NETBIRD_LICENSE_SERVER_BASE_URL:-}" ]]; then + sed -i.bak '/NETBIRD_LICENSE_SERVER_BASE_URL/d' "$OVERRIDE_FILE" && rm -f "$OVERRIDE_FILE.bak" + fi + + if [[ "$ENTERPRISE_CONFIG" == "yes" ]]; then + echo "Writing $ENTERPRISE_CONFIG_FILE ..." + install -m 600 /dev/null "$ENTERPRISE_CONFIG_FILE" + render_enterprise_config + fi + + # Persist secrets that the override file references via env interpolation. + # We write them to a .env file in the current directory; docker compose + # picks it up automatically. + echo "Writing .env additions (mode 600) ..." + local ENV_FILE=".env" + # Snapshot the operator's .env so a rollback can restore it byte for byte. + if [[ -f "$ENV_FILE" ]]; then + ENV_EXISTED="yes" + ENV_BACKUP="${ENV_FILE}.pre-enterprise-$(date +%Y%m%d-%H%M%S)" + cp -p "$ENV_FILE" "$ENV_BACKUP" + else + ENV_EXISTED="no" + fi + touch "$ENV_FILE" + chmod 600 "$ENV_FILE" + { + echo "" + echo "# Added by migrate-to-enterprise.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "# NetBird On-Premise EULA accepted at install time" + echo "NETBIRD_EULA_ACCEPTED=yes" + echo "NETBIRD_EULA_ACCEPTED_AT=${NETBIRD_EULA_ACCEPTED_AT}" + echo "NETBIRD_EULA_URL=${NETBIRD_EULA_URL}" + echo "NB_LICENSE_KEY=${NB_LICENSE_KEY}" + if [[ -n "${NETBIRD_LICENSE_SERVER_BASE_URL:-}" ]]; then + echo "NETBIRD_LICENSE_SERVER_BASE_URL=${NETBIRD_LICENSE_SERVER_BASE_URL}" + fi + if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then + echo "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}" + fi + if [[ "$ENABLE_FLOW" == "yes" ]]; then + # Own variable name rather than NB_STORE_ENGINE_POSTGRES_DSN so that a + # deployment already setting that one keeps its own value. + echo "NB_ENTERPRISE_POSTGRES_DSN=$(env_value "$POSTGRES_DSN")" + echo "NB_FLOW_AUTH_SECRET=${NB_FLOW_AUTH_SECRET}" + echo "NETBIRD_ENCRYPTION_KEY=${NETBIRD_ENCRYPTION_KEY}" + fi + } >> "$ENV_FILE" + + echo "" + echo "Pulling enterprise images ..." + $DOCKER_COMPOSE_COMMAND pull + + if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then + echo "" + # Stop, but keep the containers: the backup reads the store out of one. + echo "Stopping services so the store is quiescent ..." + $DOCKER_COMPOSE_COMMAND stop + + backup_sqlite + + echo "" + echo "Removing stopped containers (volumes preserved) ..." + $DOCKER_COMPOSE_COMMAND down + + echo "" + echo "Starting Postgres ..." + $DOCKER_COMPOSE_COMMAND up -d postgres + + # Wait for healthy + local counter=0 + echo -n "Waiting for Postgres to become ready" + while ! $DOCKER_COMPOSE_COMMAND exec -T postgres pg_isready -U netbird -d netbird &> /dev/null; do + echo -n " ." + sleep 2 + counter=$((counter + 1)) + if [[ $counter -ge 60 ]]; then + echo "" + echo "Postgres did not become ready in 120s. Recent logs:" + $DOCKER_COMPOSE_COMMAND logs --tail=20 postgres + exit 1 + fi + done + echo " done" + + run_migrate_store + fi + + echo "" + echo "Bringing up all services ..." + $DOCKER_COMPOSE_COMMAND up -d + + echo "" + wait_for_license_verdict + + echo "" + echo "Migration complete." + + if [[ "$LICENSE_VERDICT" == "rejected" ]]; then + local unreachable="false" + if grep -qi "couldn't be validated with the license server" <<< "$LICENSE_LOG_LINES"; then + unreachable="true" + fi + + echo "" + if [[ "$unreachable" == "true" ]]; then + echo " ⚠ The server could not validate the license:" + else + echo " ⚠ The server rejected the license key:" + fi + while IFS= read -r line; do + [[ -n "$line" ]] && echo " $line" + done <<< "$LICENSE_LOG_LINES" + echo "" + echo " The migration itself completed: the images and any migrated data" + echo " are in place, and only the license check did not pass." + echo "" + if [[ "$unreachable" == "true" ]]; then + echo " The license server could not be reached, so the key itself was" + echo " never checked. Confirm this host has outbound access to the" + echo " license server, then restart:" + else + echo " Check the reason the server gave above, verify that" + echo " NB_LICENSE_KEY in .env matches the key you were issued, then" + echo " restart:" + fi + echo "" + echo " $DOCKER_COMPOSE_COMMAND up -d" + elif [[ "$LICENSE_VERDICT" == "unknown" ]]; then + echo "" + echo " ⚠ The server logged no license verdict within 120s." + if [[ -n "$LICENSE_LOG_LINES" ]]; then + echo " It was still reporting validation errors:" + while IFS= read -r line; do + [[ -n "$line" ]] && echo " $line" + done <<< "$LICENSE_LOG_LINES" + fi + echo "" + echo " Check the verdict with:" + echo "" + echo " $DOCKER_COMPOSE_COMMAND logs $COMBINED_SERVICE | grep -i license" + fi + + # Nothing left to undo. + ROLLBACK_STATE="disarmed" +} + +print_summary() { + echo "" + echo "──────────────────────────────────────────────────────────────────────" + echo " Summary" + echo "──────────────────────────────────────────────────────────────────────" + echo " Images: swapped to enterprise" + if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then + echo " Storage: Postgres (data migrated from SQLite)" + elif [[ "$EXISTING_POSTGRES" == "yes" ]]; then + echo " Storage: Postgres (pre-existing, configuration unchanged)" + else + echo " Storage: $STORE_ENGINE (unchanged)" + fi + [[ "$ENABLE_FLOW" == "yes" ]] && echo " Traffic flow: enabled" + [[ "$ENABLE_FLOW" != "yes" ]] && echo " Traffic flow: disabled" + case "$LICENSE_VERDICT" in + ok) echo " License: validated by the server" ;; + rejected) echo " License: REJECTED - see above, the install is not usable yet" ;; + *) echo " License: not confirmed (no verdict in the logs yet)" ;; + esac + echo "" + echo " Generated files (next to your docker-compose.yml):" + echo " $OVERRIDE_FILE" + [[ "$ENTERPRISE_CONFIG" == "yes" ]] && echo " $ENTERPRISE_CONFIG_FILE" + echo " .env (license key + secrets, mode 600)" + [[ "$ENV_EXISTED" == "yes" ]] && [[ -f "$ENV_BACKUP" ]] && echo " $ENV_BACKUP (.env as it was before this run)" + [[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " backups/sqlite-pre-enterprise-*/ (SQLite backup)" + echo "" + echo " Tail logs:" + echo " $DOCKER_COMPOSE_COMMAND logs -f $COMBINED_SERVICE" + echo "" + echo "──────────────────────────────────────────────────────────────────────" + echo " To revert" + echo "──────────────────────────────────────────────────────────────────────" + if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then + # Resolve the project-prefixed volume name now, before the override is gone. + local pg_volume + pg_volume=$(resolve_compose_volume "netbird_postgres") + echo " # Stop, but keep the containers so the store can be copied back in:" + echo " $DOCKER_COMPOSE_COMMAND stop" + echo " # Restore SQLite from the backup created during this run:" + echo " docker cp ${BACKUP_DIR}/. \$($DOCKER_COMPOSE_COMMAND ps -aq $COMBINED_SERVICE):/var/lib/netbird/" + echo " $DOCKER_COMPOSE_COMMAND down" + echo " docker volume rm $pg_volume" + else + echo " $DOCKER_COMPOSE_COMMAND down" + fi + if [[ "$ENTERPRISE_CONFIG" == "yes" ]]; then + echo " rm -f $OVERRIDE_FILE $ENTERPRISE_CONFIG_FILE" + else + echo " rm -f $OVERRIDE_FILE" + fi + if [[ "$ENV_EXISTED" == "yes" ]] && [[ -f "$ENV_BACKUP" ]]; then + echo " mv $ENV_BACKUP .env # restores .env as it was before this run" + elif [[ "$ENV_EXISTED" == "no" ]]; then + echo " rm -f .env # created by this run" + else + echo " # Remove migrate-to-enterprise.sh additions from .env (search for the timestamp marker)" + fi + echo " $DOCKER_COMPOSE_COMMAND up -d" + echo "──────────────────────────────────────────────────────────────────────" +} + +# --------------------------------------------------------------------------- +# Run +# --------------------------------------------------------------------------- + +trap on_exit EXIT +# Turn signals into a normal exit so the EXIT trap can roll back. +trap 'exit 130' INT TERM + +init_migration +apply_changes +print_summary + +# A rejected license leaves a migrated but unusable install. Say so in the exit +# code too, or a wrapper script reads this run as a clean success. +if [[ "$LICENSE_VERDICT" == "rejected" ]]; then + exit 1 +fi +exit 0 diff --git a/infrastructure_files/observability/grafana/dashboards/client.json b/infrastructure_files/observability/grafana/dashboards/client.json new file mode 100644 index 000000000..05306a972 --- /dev/null +++ b/infrastructure_files/observability/grafana/dashboards/client.json @@ -0,0 +1,1107 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "Prometheus", + "description": "", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "__elements": {}, + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "11.1.1" + }, + { + "type": "datasource", + "id": "prometheus", + "name": "Prometheus", + "version": "1.0.0" + }, + { + "type": "panel", + "id": "stat", + "name": "Stat", + "version": "" + }, + { + "type": "panel", + "id": "timeseries", + "name": "Time series", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "panels": [], + "title": "Connection state", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 1 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 1 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "netbird_management_connected{job=~\"$job\",instance=~\"$instance\"}", + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Management connected", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 1 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 1 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "netbird_signal_connected{job=~\"$job\",instance=~\"$instance\"}", + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Signal connected", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 1 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 1 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "netbird_peers{job=~\"$job\",instance=~\"$instance\"}", + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Known peers", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 1 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 1 + }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(netbird_peers_connected{job=~\"$job\",instance=~\"$instance\"})", + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Connected peers", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 8, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 5 + }, + "id": 6, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "netbird_peers_connected{job=~\"$job\",instance=~\"$instance\"}", + "instant": false, + "legendFormat": "{{connection_type}}", + "range": true, + "refId": "A" + } + ], + "title": "Connected peers by connection type", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 8, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 5 + }, + "id": 7, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "netbird_peer_latency_seconds{job=~\"$job\",instance=~\"$instance\"}", + "instant": false, + "legendFormat": "{{peer}}", + "range": true, + "refId": "A" + } + ], + "title": "Peer latency", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 13 + }, + "id": 8, + "panels": [], + "title": "Peer connection establishment", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 8, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 14 + }, + "id": 9, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.5,sum(increase(netbird_peer_connection_stage_duration_seconds_bucket{job=~\"$job\",instance=~\"$instance\",stage=\"total\"}[$__rate_interval])) by (le,connection_type))", + "instant": false, + "legendFormat": "{{connection_type}}", + "range": true, + "refId": "A" + } + ], + "title": "Connection establishment duration (p50)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 8, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 14 + }, + "id": 10, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.5,sum(increase(netbird_peer_connection_stage_duration_seconds_bucket{job=~\"$job\",instance=~\"$instance\"}[$__rate_interval])) by (le,stage))", + "instant": false, + "legendFormat": "{{stage}}", + "range": true, + "refId": "A" + } + ], + "title": "Connection establishment stages (p50)", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 22 + }, + "id": 11, + "panels": [], + "title": "Management interactions", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 8, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 23 + }, + "id": 12, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.5,sum(increase(netbird_sync_duration_seconds_bucket{job=~\"$job\",instance=~\"$instance\"}[$__rate_interval])) by (le))", + "instant": false, + "legendFormat": "sync", + "range": true, + "refId": "A" + } + ], + "title": "Sync processing duration (p50)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 8, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 23 + }, + "id": 13, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.5,sum(increase(netbird_sync_phase_duration_seconds_bucket{job=~\"$job\",instance=~\"$instance\"}[$__rate_interval])) by (le,phase))", + "instant": false, + "legendFormat": "{{phase}}", + "range": true, + "refId": "A" + } + ], + "title": "Sync phase duration (p50)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 8, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 23 + }, + "id": 14, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.5,sum(increase(netbird_login_duration_seconds_bucket{job=~\"$job\",instance=~\"$instance\"}[$__rate_interval])) by (le,success))", + "instant": false, + "legendFormat": "success={{success}}", + "range": true, + "refId": "A" + } + ], + "title": "Login duration (p50)", + "type": "timeseries" + } + ], + "schemaVersion": 39, + "tags": [ + "netbird", + "client" + ], + "templating": { + "list": [ + { + "current": {}, + "hide": 0, + "includeAll": false, + "multi": false, + "name": "datasource", + "options": [], + "query": "prometheus", + "queryValue": "", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "type": "datasource" + }, + { + "current": {}, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "definition": "label_values(netbird_management_connected,job)", + "hide": 0, + "includeAll": true, + "multi": true, + "name": "job", + "options": [], + "query": { + "qryType": 1, + "query": "label_values(netbird_management_connected,job)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + }, + { + "current": {}, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "definition": "label_values(netbird_management_connected{job=~\"$job\"},instance)", + "hide": 0, + "includeAll": true, + "multi": true, + "name": "instance", + "options": [], + "query": { + "qryType": 1, + "query": "label_values(netbird_management_connected{job=~\"$job\"},instance)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + } + ] + }, + "time": { + "from": "now-24h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Netbird / Client", + "uid": "netbird-client-v001", + "version": 1, + "weekStart": "" +} diff --git a/infrastructure_files/observability/grafana/dashboards/management-enterprise.json b/infrastructure_files/observability/grafana/dashboards/management-enterprise.json new file mode 100644 index 000000000..481050a02 --- /dev/null +++ b/infrastructure_files/observability/grafana/dashboards/management-enterprise.json @@ -0,0 +1,8857 @@ +{ + "annotations": [ + { + "kind": "AnnotationQuery", + "spec": { + "builtIn": true, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "query": { + "datasource": { + "name": "-- Grafana --" + }, + "group": "grafana", + "kind": "DataQuery", + "spec": {}, + "version": "v0" + } + } + }, + { + "kind": "AnnotationQuery", + "spec": { + "enable": true, + "hide": false, + "iconColor": "red", + "name": "Deployments", + "query": { + "datasource": { + "name": "grafana" + }, + "group": "datasource", + "kind": "DataQuery", + "spec": { + "expr": "", + "interval": "", + "refId": "Anno", + "tags": [], + "type": "tags" + }, + "version": "v0" + } + } + } + ], + "cursorSync": "Crosshair", + "editable": true, + "elements": { + "panel-1": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "exemplar": false, + "expr": "histogram_quantile(0.99, sum by(le) (rate(management_grpc_updatechannel_queue_length_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])))", + "format": "table", + "fullMetaSearch": false, + "includeNullMetadata": false, + "instant": false, + "legendFormat": "Connected grpc streams", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "p99 of the update-channel queue length: how many network map updates are waiting to be delivered to a peer. The channel buffer is 100 messages - approaching it means updates start getting dropped and peers fall behind.", + "id": 1, + "links": [], + "title": "UpdateChannel Queue length max", + "vizConfig": { + "group": "stat", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 2, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": 0 + }, + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 50 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-10": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by(le) (rate(management_http_request_duration_ms_total_milliseconds_bucket{job=~\"$job\", instance=~\"$instance\", type=\"read\"}[$__rate_interval])))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "HTTP read duration", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "p95 duration of read API requests (GET/OPTIONS), from the total request duration histogram tagged type=read.", + "id": 10, + "links": [], + "title": "Read HTTP API Requests p95 Duration (GET/OPTIONS)", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 32, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "line" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "orange", + "value": 200 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-11": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(environment) (rate(management_updatechannel_close_one_duration_micro_microseconds_count{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])*60)", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "CloseOne", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum(rate(management_updatechannel_send_duration_micro_count{job=~\"$job\", instance=~\"$instance\", host=\"$host\"}[$__rate_interval]))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "Send", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "B" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum(rate(management_updatechannel_create_duration_micro_count{job=~\"$job\", instance=~\"$instance\", host=\"$host\"}[$__rate_interval])*60)", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "Create", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "C" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "rate(management_updatechannel_get_all_duration_micro_count{job=~\"$job\", instance=~\"$instance\", host=\"$host\"}[$__rate_interval])*60", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "GetAll", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "D" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "rate(management_updatechannel_haschannel_duration_micro_count{job=~\"$job\", instance=~\"$instance\", host=\"$host\"}[$__rate_interval])*60", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "HasChannel", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "E" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "rate(management_updatechannel_close_multiple_channels_count{job=~\"$job\", instance=~\"$instance\", host=\"$host\"}[$__rate_interval])*60", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "CloseMultiple", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "F" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Rate per minute of update-channel operations: close one, send update, create, get all connected peers, has-channel and close multiple. Together they show how much channel churn an instance is handling.", + "id": 11, + "links": [], + "title": "Update Channel operations", + "vizConfig": { + "group": "barchart", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 69, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "fieldMinMax": false, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "reqpm" + }, + "overrides": [] + }, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true, + "sortBy": "Mean", + "sortDesc": true + }, + "orientation": "auto", + "showValue": "auto", + "stacking": "normal", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 100 + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-12": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": true, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "rate(management_updatechannel_create_duration_micro_microseconds_count{job=~\"$job\", instance=~\"$instance\", closed=\"true\"}[$__rate_interval])", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "{{host}}", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": true, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(environment) (rate(management_updatechannel_create_duration_micro_microseconds_count{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "{{host}}", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "B" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "__expr__" + }, + "group": "__expr__", + "kind": "DataQuery", + "spec": { + "expression": "100-$A/$B*100", + "type": "math" + }, + "version": "v0" + }, + "refId": "C" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Tracks how often a new peer update channel replaces a channel that was still open (closed=true), i.e. peers reconnecting before management noticed the previous stream was gone. The plotted expression is 100 - recreated/total, so the line drops as recreations rise.", + "id": 12, + "links": [], + "title": "Percentage of Recreated channels", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "axisSoftMax": 100, + "axisSoftMin": 0, + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 32, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "line" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "percent" + }, + "overrides": [ + { + "matcher": { + "id": "byFrameRefID", + "options": "C" + }, + "properties": [ + { + "id": "displayName", + "value": "Recreation" + } + ] + } + ] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-13": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by(le) (rate(management_updatechannel_close_one_duration_micro_microseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "CloseOne", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by(le) (rate(management_updatechannel_send_duration_micro_microseconds_bucket{job=~\"$job\", instance=~\"$instance\", host=\"$host\"}[$__rate_interval])))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "Send", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "B" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by(le) (rate(management_updatechannel_create_duration_micro_microseconds_bucket{job=~\"$job\", instance=~\"$instance\", host=\"$host\"}[$__rate_interval])))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "Create", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "C" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by(le) (rate(management_updatechannel_get_all_duration_micro_microseconds_bucket{job=~\"$job\", instance=~\"$instance\", host=\"$host\"}[$__rate_interval])))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "GetAll", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "D" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by(le) (rate(management_updatechannel_haschannel_duration_micro_microseconds_bucket{job=~\"$job\", instance=~\"$instance\", host=\"$host\"}[$__rate_interval])))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "CloseMultiple", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "E" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by(le) (rate(management_updatechannel_close_multiple_channels_bucket{job=~\"$job\", instance=~\"$instance\", host=\"$host\"}[$__rate_interval])))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "HasChannel", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "F" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "p95 duration of each update-channel operation, in microseconds. These are in-memory operations behind a lock, so growth here means lock contention rather than I/O.", + "id": 13, + "links": [], + "title": "Update Channel methods p95 Duration", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "fieldMinMax": false, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "µs" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-14": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "exemplar": false, + "expr": "sum(management_grpc_connected_streams_ratio{job=~\"$job\", instance=~\"$instance\"})", + "format": "time_series", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "Connected grpc streams", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Total number of peers currently holding an open gRPC Sync stream across all management instances (management.grpc.connected.streams). A sudden drop means peers were disconnected and will come back as a Login/Sync reconnect storm.", + "id": 14, + "links": [], + "title": "Connected peers", + "vizConfig": { + "group": "stat", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 2, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": 0 + }, + { + "color": "red", + "value": 0 + }, + { + "color": "#EAB839", + "value": 8000 + }, + { + "color": "green", + "value": 11000 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": true, + "textMode": "auto", + "wideLayout": true + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-15": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "management_grpc_connected_streams_ratio{job=~\"$job\", instance=~\"$instance\"}", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "{{host}}", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "B" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "editorMode": "builder", + "expr": "sum by(instance) (management_grpc_connected_streams_ratio{job=~\"$job\", instance=~\"$instance\"})", + "instant": false, + "legendFormat": "Total", + "range": true + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Open peer Sync streams over time, in total per instance (A) and as raw series (B). Steps down mark instance restarts or LB rebalancing; the ramp back up is the reconnect wave.", + "id": 15, + "links": [], + "title": "Connected peers historical", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 32, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "line" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-16": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by(le) (rate(management_grpc_sync_request_duration_ms_milliseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "GRPC Sync duration", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "p95 duration of the Sync gRPC request - the time management needs to authenticate the peer, build its first network map and open the update channel. Primary latency SLI for peer connects.", + "id": 16, + "links": [], + "title": "gRPC Sync Request p95 Duration", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 32, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "line" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 200 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-17": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by(le) (rate(management_grpc_login_request_duration_ms_milliseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "GRPC Login duration", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "p95 duration of the Login gRPC request (peer authentication plus initial configuration and relay credentials). Rises when the IdP, the store or account locking is slow.", + "id": 17, + "links": [], + "title": "gRPC Login Request p95 Duration", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 32, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "line" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 200 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-18": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(le) (increase(management_account_get_peer_network_map_duration_ms_milliseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "format": "heatmap", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Distribution of the time taken to build the network map returned to a single peer, in milliseconds.", + "id": 18, + "links": [], + "title": "GetPeerNetworkMap Latency", + "vizConfig": { + "group": "heatmap", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Oranges", + "steps": 64 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-09 + }, + "legend": { + "placement": "bottom", + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "axisWidth": 60, + "decimals": 1, + "reverse": false, + "unit": "ms" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-19": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "exemplar": false, + "expr": "sum by(le) (increase(management_account_network_map_object_count_objects_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "format": "heatmap", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "interval": "", + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Distribution of the number of objects (peers, routes, firewall rules, DNS entries and so on) in the network maps sent to clients. Large maps make both calculation and serialization more expensive.", + "id": 19, + "links": [], + "title": "NetworkMap Objects", + "vizConfig": { + "group": "heatmap", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Oranges", + "steps": 64 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-09 + }, + "legend": { + "placement": "bottom", + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "axisWidth": 60, + "decimals": 0, + "reverse": false, + "unit": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-2": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "exemplar": false, + "expr": "sum by(environment) (rate(management_grpc_login_request_counter_total{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "login", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "exemplar": false, + "expr": "sum by(environment) (rate(management_grpc_sync_request_counter_total{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "sync", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "B" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "exemplar": false, + "expr": "sum by(environment) (rate(management_grpc_key_request_counter_total{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "get key", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "C" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Rate of the three peer-facing gRPC calls: Login (authenticate, return initial config and relay credentials), Sync (open the update channel and receive network map updates) and GetServerKey (fetch the server WireGuard public key). Elevated Login/Sync rates usually mean peers are reconnecting in a loop.", + "id": 2, + "links": [], + "title": "gRPC Requests", + "vizConfig": { + "group": "barchart", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 51, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "fullHighlight": true, + "groupWidth": 0.7, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "orientation": "auto", + "showValue": "auto", + "stacking": "normal", + "text": {}, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 100 + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-20": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(le) (increase(management_updatechannel_tosyncresponse_duration_micro_microseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "format": "heatmap", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Distribution of the time taken to convert a calculated network map into the gRPC SyncResponse sent to the peer, in microseconds.", + "id": 20, + "links": [], + "title": "To Sync Response", + "vizConfig": { + "group": "heatmap", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Oranges", + "steps": 64 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-09 + }, + "legend": { + "placement": "bottom", + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "axisWidth": 60, + "decimals": 1, + "reverse": false, + "unit": "µs" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-23": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum by(le) (increase(management_grpc_sync_request_duration_p95_by_account_ms_milliseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "format": "heatmap", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Distribution of per-account p95 Sync durations - each sample is one account's p95, flushed every 60s. Shows whether slow Syncs are spread across the fleet or concentrated in a few accounts.", + "id": 23, + "links": [], + "title": "GRPC Sync Latency", + "vizConfig": { + "group": "heatmap", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.6, + "fill": "dark-red", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Purples", + "steps": 128 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-09 + }, + "legend": { + "placement": "bottom", + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "axisWidth": 60, + "decimals": 1, + "reverse": false, + "unit": "ms" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-24": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(le) (increase(management_grpc_login_request_duration_ms_milliseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "format": "heatmap", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Distribution of Login gRPC request durations across all peers. The right tail is what a peer experiences on startup; samples beyond 7s are what the high-latency counters in the Debugging row track.", + "id": 24, + "links": [], + "title": "GRPC Login Latency", + "vizConfig": { + "group": "heatmap", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Purples", + "steps": 128 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-09 + }, + "legend": { + "placement": "bottom", + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "axisWidth": 60, + "decimals": 1, + "reverse": false, + "unit": "ms" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-25": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "rate(management_grpc_login_request_counter_total{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "{{host}}", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "editorMode": "code", + "expr": "sum by(environment) (rate(management_grpc_login_request_counter_total{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "Total", + "range": true + }, + "version": "v0" + }, + "refId": "B" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Rate of Login requests, per instance (A) and in total (B). Login is the most expensive peer call (IdP plus store writes); a sustained high rate means clients are restarting or failing to hold their Sync stream.", + "id": 25, + "links": [], + "title": "gRPC Login Request rate", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 32, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "line" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 200 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-26": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "rate(management_grpc_sync_request_counter_total{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "{{host}}", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "editorMode": "code", + "expr": "sum by(environment) (rate(management_grpc_sync_request_counter_total{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "Total", + "range": true + }, + "version": "v0" + }, + "refId": "B" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Rate of Sync requests, per instance (A) and in total (B). Every Sync opens or re-opens a peer update channel, so a spike here is a reconnect storm and predicts CPU spent on network map calculation.", + "id": 26, + "links": [], + "title": "gRPC Sync Request rate", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 32, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "line" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 200 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-27": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(le) (increase(management_grpc_updatechannel_queue_length_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "format": "heatmap", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Distribution of update-channel queue lengths across all peers. Mass moving to the right shows a growing backlog of undelivered network map updates.", + "id": 27, + "links": [], + "title": "Update Channel heat map", + "vizConfig": { + "group": "heatmap", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Greens", + "steps": 73 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-09 + }, + "legend": { + "placement": "bottom", + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "axisWidth": 60, + "decimals": 1, + "reverse": false, + "unit": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-29": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(endpoint) (rate(management_http_response_counter_total{job=~\"$job\", instance=~\"$instance\", method=\"GET\", code=~\"^2.+\"}[$__rate_interval])*60) > 10", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{endpoint}} > 10", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(environment) (rate(management_http_response_counter_total{job=~\"$job\", instance=~\"$instance\", host=\"$host\", method=\"GET\", code=~\"^2.+\"}[$__rate_interval])*60)", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "Total", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "B" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Successful (2xx) GET requests per minute broken down by endpoint, limited to endpoints above 10 rpm. Shows which read endpoints dashboard and API clients hit hardest.", + "id": 29, + "links": [], + "title": "HTTP Read request counter", + "vizConfig": { + "group": "barchart", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 0, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "fieldMinMax": false, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true, + "sortBy": "Mean", + "sortDesc": true + }, + "orientation": "auto", + "showValue": "auto", + "stacking": "normal", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 100 + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-30": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(endpoint) (rate(management_http_response_counter_total{job=~\"$job\", instance=~\"$instance\", method!~\"(GET|OPTIONS)\", code=~\"^2.+\"}[$__rate_interval])*60) > 1", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{endpoint}} > 1", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": true, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(environment) (rate(management_http_response_counter_total{job=~\"$job\", instance=~\"$instance\", host=\"$host\", method!~\"(GET|OPTIONS)\", code=~\"^2.+\"}[$__rate_interval])*60)", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "Total", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "B" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Successful (2xx) non-GET/OPTIONS requests per minute by endpoint, limited to endpoints above 1 rpm. Writes take account locks and fan out network map updates, so read this together with the Network Map row.", + "id": 30, + "links": [], + "title": "HTTP Write request counter", + "vizConfig": { + "group": "barchart", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 0, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "fieldMinMax": false, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqpm" + }, + "overrides": [] + }, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true, + "sortBy": "Last *", + "sortDesc": true + }, + "orientation": "auto", + "showValue": "auto", + "stacking": "normal", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 100 + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-31": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(le) (increase(management_store_persistence_duration_ms_milliseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "format": "heatmap", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Distribution of how long it takes to save or delete an account in the store (management.store.persistence.duration.ms). Widening buckets point at database write pressure.", + "id": 31, + "links": [], + "title": "Store Persistence latency", + "vizConfig": { + "group": "heatmap", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.6, + "fill": "dark-red", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Reds", + "steps": 128 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-09 + }, + "legend": { + "placement": "bottom", + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "axisWidth": 60, + "decimals": 1, + "reverse": false, + "unit": "ms" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-32": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(le) (increase(management_store_transaction_duration_ms_milliseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "format": "heatmap", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Distribution of store transaction execution time. Nearly every API and gRPC call runs through a transaction, so this is the first place to look when latency rises everywhere at once.", + "id": 32, + "links": [], + "title": "Store Transaction latency", + "vizConfig": { + "group": "heatmap", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.6, + "fill": "dark-red", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Reds", + "steps": 128 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-09 + }, + "legend": { + "placement": "bottom", + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "axisWidth": 60, + "decimals": 1, + "reverse": false, + "unit": "ms" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-33": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(environment) (rate(management_account_peer_meta_update_counter_total{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])*60)", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{endpoint}} > 10", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Rate per minute of peers reporting changed metadata (OS, version, hostname, addresses). Each change persists the peer and can trigger an account peers update.", + "id": 33, + "links": [], + "title": "Peer meta updates counter", + "vizConfig": { + "group": "barchart", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 0, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "fieldMinMax": false, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqpm" + }, + "overrides": [] + }, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true, + "sortBy": "Mean", + "sortDesc": true + }, + "orientation": "auto", + "showValue": "auto", + "stacking": "normal", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 100 + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-34": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(environment) (rate(management_grpc_sync_request_blocked_counter_total{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "GRPC Sync request rate", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Rate of Sync requests rejected because the peer is blocked (login expired, peer disabled or account restricted). A rising line means clients retrying without valid access.", + "id": 34, + "links": [], + "title": "gRPC Sync Request Blocked rate", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 32, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "line" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 0 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-35": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(environment) (rate(management_grpc_login_request_blocked_counter_total{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "GRPC Login request rate", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Rate of Login requests rejected because the peer is blocked. Persistent volume here is usually expired peer logins retrying in a loop.", + "id": 35, + "links": [], + "title": "gRPC Login Request Blocked rate", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 32, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "line" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 0 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-36": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(le) (increase(management_account_update_account_peers_duration_ms_milliseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "format": "heatmap", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Distribution of the time taken by an account peers update: preparing the data and pushing a fresh network map to every peer in the account, in milliseconds. Scales with account size.", + "id": 36, + "links": [], + "title": "UpdateAccountPeers Latency", + "vizConfig": { + "group": "heatmap", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Oranges", + "steps": 64 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-09 + }, + "legend": { + "placement": "bottom", + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "axisWidth": 60, + "decimals": 1, + "reverse": false, + "unit": "ms" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-37": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(le) (increase(management_updatechannel_calc_networkmap_duration_ms_milliseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "format": "heatmap", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Distribution of the time taken to calculate one peer's network map, in milliseconds. This is the dominant CPU cost when many peers reconnect at once.", + "id": 37, + "links": [], + "title": "Network Map Calculation", + "vizConfig": { + "group": "heatmap", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Oranges", + "steps": 64 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-09 + }, + "legend": { + "placement": "bottom", + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "axisWidth": 60, + "decimals": 1, + "reverse": false, + "unit": "ms" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-39": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(le) (increase(management_updatechannel_calc_posturechecks_duration_micro_microseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "format": "heatmap", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Distribution of the time taken to evaluate a peer's posture checks while building its network map, in microseconds.", + "id": 39, + "links": [], + "title": "Posture Check Calculation", + "vizConfig": { + "group": "heatmap", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Oranges", + "steps": 64 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-09 + }, + "legend": { + "placement": "bottom", + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "axisWidth": 60, + "decimals": 1, + "reverse": false, + "unit": "µs" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-45": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "editorMode": "code", + "exemplar": false, + "expr": "sort_desc(\n topk(\n 10,\n sum by (account_id) (\n increase(management_grpc_login_request_high_latency_counter_total{job=~\"$job\", instance=~\"$instance\"}[$__interval])\n )\n )\n)", + "format": "table", + "instant": true, + "legendFormat": "__auto", + "range": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [ + { + "group": "organize", + "kind": "Transformation", + "spec": { + "options": { + "excludeByName": { + "Time": true + }, + "includeByName": {}, + "indexByName": {}, + "renameByName": { + "Time": "" + } + } + } + } + ] + } + }, + "description": "Top 10 accounts by number of Login requests that exceeded the 7s high-latency threshold.", + "id": 45, + "links": [], + "title": "High Login Latency Ranking (login count per account)", + "vizConfig": { + "group": "table", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": false, + "footer": { + "reducers": [] + }, + "inspect": false, + "tooltip": { + "placement": "auto" + }, + "wrapHeaderText": false + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "cellHeight": "sm", + "showHeader": true, + "sortBy": [ + { + "desc": false, + "displayName": "Time" + } + ] + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-46": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "editorMode": "code", + "exemplar": false, + "expr": "sort_desc(\n topk(\n 10,\n sum by (account_id) (\n increase(management_grpc_sync_request_high_latency_counter_total{job=~\"$job\", instance=~\"$instance\"}[$__interval])\n )\n )\n)", + "format": "table", + "instant": true, + "legendFormat": "__auto", + "range": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [ + { + "group": "organize", + "kind": "Transformation", + "spec": { + "options": { + "excludeByName": { + "Time": true + }, + "includeByName": {}, + "indexByName": {}, + "renameByName": { + "Time": "" + } + } + } + } + ] + } + }, + "description": "Top 10 accounts by number of Sync requests that exceeded the 7s high-latency threshold. Use it to find which tenant is behind a latency spike.", + "id": 46, + "links": [], + "title": "High Sync Latency Ranking (sync count per account)", + "vizConfig": { + "group": "table", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": false, + "footer": { + "reducers": [] + }, + "inspect": false, + "tooltip": { + "placement": "auto" + }, + "wrapHeaderText": false + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "cellHeight": "sm", + "showHeader": true, + "sortBy": [ + { + "desc": false, + "displayName": "Time" + } + ] + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-47": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "editorMode": "code", + "expr": "count(\n sum by (account_id) (\n increase(management_grpc_sync_request_high_latency_counter_total{job=~\"$job\", instance=~\"$instance\"}[$__interval])\n ) > 1\n)", + "legendFormat": "__auto", + "range": true + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Number of distinct accounts with more than one slow (>7s) Sync in the interval - tells you whether a latency spike is fleet-wide or limited to a few tenants.", + "id": 47, + "links": [], + "title": "Accounts with high Sync latency", + "vizConfig": { + "group": "gauge", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "barShape": "flat", + "barWidthFactor": 0.5, + "effects": { + "barGlow": false, + "centerGlow": false, + "gradient": false + }, + "endpointMarker": "point", + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "segmentCount": 1, + "segmentSpacing": 0.3, + "shape": "gauge", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto", + "sparkline": false, + "textMode": "auto" + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-48": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "editorMode": "code", + "expr": "count(\n sum by (account_id) (\n increase(management_grpc_login_request_high_latency_counter_total{job=~\"$job\", instance=~\"$instance\"}[$__interval])\n ) > 1\n)", + "legendFormat": "__auto", + "range": true + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Number of distinct accounts with more than one slow (>7s) Login in the interval.", + "id": 48, + "links": [], + "title": "Accounts with high Login latency", + "vizConfig": { + "group": "gauge", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "barShape": "flat", + "barWidthFactor": 0.5, + "effects": { + "barGlow": false, + "centerGlow": false, + "gradient": false + }, + "endpointMarker": "point", + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "segmentCount": 1, + "segmentSpacing": 0.3, + "shape": "gauge", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto", + "sparkline": false, + "textMode": "auto" + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-50": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${logs_datasource}" + }, + "group": "loki", + "kind": "DataQuery", + "spec": { + "direction": "backward", + "editorMode": "code", + "expr": "sort_desc(\ntopk(\n 20,\n sum by (peerID) (\n count_over_time(\n {job=\"$log_job\"} |= \"Sync took\" | regexp `peerID:\\s(?P[^,\\]]+)` [$__range]\n )\n )\n)\n)", + "queryType": "instant" + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [ + { + "group": "organize", + "kind": "Transformation", + "spec": { + "options": { + "excludeByName": { + "Time": true + }, + "includeByName": {}, + "indexByName": {}, + "renameByName": { + "Value #A": "number of times ", + "peerID": "peerPubKey" + } + } + } + } + ] + } + }, + "description": "Top 20 peers by number of Sync log lines in the selected range, parsed out of the management logs to find individual peers stuck in a reconnect loop. Pick the log stream with the `log stream` variable; the query matches the `Sync took` debug line, so management must run at debug level for this panel to have data.", + "id": 50, + "links": [], + "title": "Highest Syncs by peer key", + "vizConfig": { + "group": "table", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": false, + "footer": { + "reducers": [] + }, + "inspect": false, + "tooltip": { + "placement": "auto" + }, + "wrapHeaderText": false + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "labels" + }, + "properties": [ + { + "id": "custom.width", + "value": 180 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "peerPubKey" + }, + "properties": [ + { + "id": "custom.width", + "value": 455 + } + ] + } + ] + }, + "options": { + "cellHeight": "sm", + "showHeader": true, + "sortBy": [ + { + "desc": true, + "displayName": "number of times " + } + ] + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-52": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "editorMode": "builder", + "expr": "wsproxy_active_connections{job=~\"$job\", instance=~\"$instance\"}", + "legendFormat": "{{__name__}}", + "range": true + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Currently open WebSocket proxy connections. The wsproxy fronts gRPC for browser-based clients, so this is the number of connected web clients.", + "id": 52, + "links": [], + "title": "Proxy Active Connections", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [], + "displayMode": "list", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-53": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "editorMode": "builder", + "expr": "delta(wsproxy_bytes_transferred_total{job=~\"$job\", instance=~\"$instance\"}[$__interval])", + "legendFormat": "{{direction}}", + "range": true + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Bytes transferred through the WebSocket proxy per interval, split by direction.", + "id": 53, + "links": [], + "title": "Proxy Traffic by Direction", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "decbytes" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [], + "displayMode": "list", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-54": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "editorMode": "builder", + "expr": "increase(wsproxy_errors_total{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])", + "legendFormat": "{{error_type}}", + "range": true + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "WebSocket proxy errors grouped by error type - dial failures, read/write errors and protocol issues on browser client connections.", + "id": 54, + "links": [], + "title": "Errors by Type", + "vizConfig": { + "group": "barchart", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "orientation": "auto", + "showValue": "auto", + "stacking": "none", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0 + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-65": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "editorMode": "builder", + "exemplar": false, + "expr": "management_grpc_connected_streams_ratio{job=~\"$job\", instance=~\"$instance\"}", + "instant": true, + "legendFormat": "{{host}}", + "range": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Open peer Sync streams per management instance. Use it to confirm the load balancer spreads streams evenly - a skewed instance carries proportionally more update-channel and network map work.", + "id": 65, + "links": [], + "title": "Connected Peers per Server", + "vizConfig": { + "group": "gauge", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "barShape": "flat", + "barWidthFactor": 0.5, + "effects": { + "barGlow": false, + "centerGlow": false, + "gradient": false + }, + "endpointMarker": "point", + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "segmentCount": 1, + "segmentSpacing": 0.3, + "shape": "gauge", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto", + "sparkline": false, + "textMode": "auto" + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-66": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(host) (rate(management_http_response_counter_total{job=~\"$job\", instance=~\"$instance\", method=\"GET\", code=~\"^2.+\"}[$__rate_interval]) * 60)", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{host}}", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "B" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Successful GET requests per minute grouped by management instance - read load distribution across the fleet.", + "id": 66, + "links": [], + "title": "HTTP Read request counter", + "vizConfig": { + "group": "barchart", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 0, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "fieldMinMax": false, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true, + "sortBy": "Mean", + "sortDesc": true + }, + "orientation": "auto", + "showValue": "auto", + "stacking": "normal", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 100 + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-67": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(host) (rate(management_http_response_counter_total{job=~\"$job\", instance=~\"$instance\", method!~\"(GET|OPTIONS)\", code=~\"^2.+\"}[$__rate_interval])*60)", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{host}}", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "B" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Successful write requests per minute grouped by management instance - write load distribution across the fleet.", + "id": 67, + "links": [], + "title": "HTTP Write request counter", + "vizConfig": { + "group": "barchart", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 0, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "fieldMinMax": false, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqpm" + }, + "overrides": [] + }, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true, + "sortBy": "Last *", + "sortDesc": true + }, + "orientation": "auto", + "showValue": "auto", + "stacking": "normal", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 100 + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-7": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by(le) (rate(management_http_request_duration_ms_total_milliseconds_bucket{job=~\"$job\", instance=~\"$instance\", type=\"write\"}[$__rate_interval])))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "HTTP write duration", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "p95 duration of write API requests (PUT/POST/DELETE), tagged type=write. Writes acquire account locks and trigger peer updates, so they are normally slower than reads.", + "id": 7, + "links": [], + "title": "Write HTTP API Requests p95 Duration (PUT/POST/DELETE)", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 32, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "line" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 200 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-74": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(le) (increase(management_pat_usage_distribution_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "format": "heatmap", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Distribution of personal access token usage counts, sampled once per minute per token. Mass in the high buckets means a single PAT is hammering the API.", + "id": 74, + "links": [], + "title": "PAT usage counter", + "vizConfig": { + "group": "heatmap", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Purples", + "steps": 128 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-09 + }, + "legend": { + "placement": "bottom", + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "axisWidth": 60, + "decimals": 1, + "reverse": false, + "unit": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-76": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "exemplar": false, + "expr": "gnatsd_connz_subscriptions{job=~\"$job\", instance=~\"$instance\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": true, + "legendFormat": "{{host}}", + "range": false, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Current subscriptions held by each client connected to NATS (connz). Signal instances subscribe per connected peer, so this tracks how peers are spread across signal nodes.", + "id": 76, + "links": [], + "title": "Subscriptions per node", + "vizConfig": { + "group": "stat", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-77": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "exemplar": false, + "expr": "gnatsd_connz_subscriptions{job=~\"$job\", instance=~\"$instance\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "{{host}}", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Subscriptions per connected NATS client over time. Steps mark signal instances restarting and re-subscribing their peers.", + "id": 77, + "links": [], + "title": "Subscriptions per node", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [], + "displayMode": "list", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-78": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "gnatsd_connz_num_connections{job=~\"$job\", instance=~\"$instance\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{host}}", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Number of client connections on each NATS node - effectively how many signal instances are attached to that node.", + "id": 78, + "links": [], + "title": "Signal nodes connected per NATs", + "vizConfig": { + "group": "stat", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-79": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum by(environment) (rate(gnatsd_connz_out_msgs{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Rate of messages NATS delivers out to its clients: the peer signalling throughput flowing through the cluster.", + "id": 79, + "links": [], + "title": "Messages per Second", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "mps" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [], + "displayMode": "list", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-8": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by(le) (rate(management_http_request_duration_ms_milliseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "HTTP request duration", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "p95 duration of incoming REST API requests by endpoint and method (management.http.request.duration.ms). Covers dashboard and API clients only - peer traffic is on the gRPC panels.", + "id": 8, + "links": [], + "title": "HTTP API Requests p95 Duration", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 32, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "line" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 200 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-80": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum by(environment) (rate(gnatsd_connz_out_bytes{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Rate of bytes NATS delivers out to its clients - signalling bandwidth, useful next to the message rate to spot unusually large payloads.", + "id": 80, + "links": [], + "title": "Data per Second", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [], + "displayMode": "list", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-81": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum by(environment) (increase(gnatsd_varz_slow_consumers{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Increase of the NATS slow-consumer counter. Anything above zero means a signal node could not read fast enough and NATS dropped its connection, so peers lose signalling until it reconnects.", + "id": 81, + "links": [], + "title": "Slow consumers detected", + "vizConfig": { + "group": "barchart", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "decimals": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "orientation": "auto", + "showValue": "auto", + "stacking": "none", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 100 + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-82": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "gnatsd_varz_subscriptions{job=~\"$job\", instance=~\"$instance\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{host}}", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Total subscriptions registered on each NATS server (varz). Grows roughly with the number of connected peers.", + "id": 82, + "links": [], + "title": "Total number of NATS subscriptions", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [], + "displayMode": "list", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-83": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "process_resident_memory_bytes{job=~\"$job\", instance=~\"$instance\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{instance}}", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Resident memory of the scraped processes. process_resident_memory_bytes is exposed by every Go/Prometheus process, so narrow the job and instance variables to the NATS nodes to read this panel as NATS memory.", + "id": 83, + "links": [], + "title": "NATS memory consumption", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [ + "lastNotNull", + "max", + "min" + ], + "displayMode": "table", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-84": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "idelta(management_grpc_sync_request_duration_p95_by_account_ms_milliseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[2m])", + "format": "heatmap", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Per-account Sync p95 samples as a heatmap (2m deltas). Use it to spot a handful of large or slow accounts pulling the overall Sync latency up.", + "id": 84, + "links": [], + "title": "GRPC Sync Latency By Account", + "vizConfig": { + "group": "heatmap", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.6, + "fill": "dark-red", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Purples", + "steps": 128 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-09 + }, + "legend": { + "placement": "bottom", + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "axisWidth": 60, + "decimals": 1, + "reverse": false, + "unit": "ms" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-85": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "idelta(management_grpc_login_request_duration_p95_by_account_ms_milliseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[2m])", + "format": "heatmap", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Per-account Login p95 samples as a heatmap (2m deltas). Highlights accounts whose logins are much slower than the fleet average.", + "id": 85, + "links": [], + "title": "GRPC Login Latency By Account", + "vizConfig": { + "group": "heatmap", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Purples", + "steps": 128 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-09 + }, + "legend": { + "placement": "bottom", + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "axisWidth": 60, + "decimals": 1, + "reverse": false, + "unit": "ms" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-86": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by(le) (rate(management_grpc_sync_request_duration_p95_by_account_ms_milliseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "GRPC Sync duration", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "p95 of the per-account Sync p95 distribution - the latency seen by the worst-served accounts rather than by the average request. Diverges from the global p95 when only a few accounts are hurting.", + "id": 86, + "links": [], + "title": "gRPC Sync Request p95 Duration (by Account)", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 32, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "line" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 500 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-87": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by(le) (rate(management_grpc_login_request_duration_p95_by_account_ms_milliseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "GRPC Login duration", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "p95 of the per-account Login p95 distribution - login latency for the worst-served accounts rather than the average request.", + "id": 87, + "links": [], + "title": "gRPC Login Request p95 Duration (by Account)", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 32, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "line" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 200 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-88": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "exemplar": false, + "expr": "sum by(environment) (increase(management_account_update_account_peers_counter_total{job=~\"$job\", instance=~\"$instance\"}[2m])) / 2", + "format": "time_series", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "interval": "", + "legendFormat": "{{environment}}", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Account peers updates triggered per minute (2m increase halved). Each trigger recalculates and pushes network maps to every peer in the account.", + "id": 88, + "links": [], + "title": "NetworkMap Triggers Total", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "continuous-BlYlRd", + "seriesBy": "last" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 19, + "gradientMode": "scheme", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineStyle": { + "fill": "solid" + }, + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "cpm" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [], + "displayMode": "list", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-89": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "exemplar": false, + "expr": "sum by(resource) (increase(management_account_update_account_peers_counter_total{job=~\"$job\", instance=~\"$instance\"}[2m]))", + "format": "time_series", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "interval": "", + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Account peers updates over 2m intervals, grouped by the resource that triggered them - the time series behind the pie chart above.", + "id": 89, + "links": [], + "title": "NetworkMap Triggers By Source", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic", + "seriesBy": "max" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.9, + "drawStyle": "line", + "fillOpacity": 50, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineStyle": { + "fill": "solid" + }, + "lineWidth": 2, + "pointSize": 1, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "always", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "cpm" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [], + "displayMode": "list", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-9": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(environment) (rate(management_idp_authenticate_request_counter_total{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])*60)", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "authenticate (mgtm->auth0)", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "rate(management_idp_update_user_meta_counter_total{job=~\"$job\", instance=~\"$instance\", host=\"$host\"}[$interval])*60", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "update user meta", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "B" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "rate(management_idp_get_account_counter_total{job=~\"$job\", instance=~\"$instance\", host=\"$host\"}[$__rate_interval])*60", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "get account", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "C" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Requests per minute that management sends to the configured identity provider: authenticate (service token refresh), user metadata updates and account lookups. IdP throttling shows up here before it shows up as Login latency.", + "id": 9, + "links": [], + "title": "IdP Requests", + "vizConfig": { + "group": "barchart", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 69, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "orientation": "auto", + "showValue": "never", + "stacking": "normal", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 100 + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-90": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "editorMode": "code", + "exemplar": false, + "expr": "sum by(resource) (increase(management_account_update_account_peers_counter_total{job=~\"$job\", instance=~\"$instance\"}[1h]))", + "instant": true, + "legendFormat": "__auto", + "range": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Account peers updates over the last hour, grouped by the resource that triggered them (policy, group, peer, route, user, network, ...). Shows which kind of configuration change generates the most fan-out work.", + "id": 90, + "links": [], + "title": "NetworkMap Triggers by source in last hour", + "vizConfig": { + "group": "piechart", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "#73BF69", + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + } + }, + "overrides": [] + }, + "options": { + "displayLabels": [ + "percent" + ], + "legend": { + "displayMode": "list", + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "pieType": "donut", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "sort": "desc", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-91": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "editorMode": "builder", + "exemplar": false, + "expr": "sum by(resource) (increase(management_network_map_counter_total{job=~\"$job\", instance=~\"$instance\"}[1h]))", + "instant": true, + "legendFormat": "__auto", + "range": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Network maps computed in the last hour, grouped by the resource whose change triggered the computation.", + "id": 91, + "links": [], + "title": "NetworkMaps by source in last hour", + "vizConfig": { + "group": "piechart", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "#73BF69", + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + } + }, + "overrides": [] + }, + "options": { + "displayLabels": [ + "percent" + ], + "legend": { + "displayMode": "list", + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "pieType": "donut", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "sort": "desc", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-92": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "exemplar": false, + "expr": "sum by(environment) (increase(management_network_map_counter_total{job=~\"$job\", instance=~\"$instance\"}[2m])) / 2", + "format": "time_series", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "interval": "", + "legendFormat": "{{environment}}", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Network maps computed per minute (2m increase halved). One account peers update fans out into one map per peer, so this - not the trigger count - is the real CPU driver.", + "id": 92, + "links": [], + "title": "NetworkMaps Total", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "continuous-BlYlRd", + "seriesBy": "last" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 19, + "gradientMode": "scheme", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineStyle": { + "fill": "solid" + }, + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "cpm" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [], + "displayMode": "list", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-93": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "exemplar": false, + "expr": "sum by(resource) (increase(management_network_map_counter_total{job=~\"$job\", instance=~\"$instance\"}[2m])) / 2", + "format": "time_series", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "interval": "", + "legendFormat": "{{resource}}", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Network maps computed per minute, grouped by the resource that triggered the computation.", + "id": 93, + "links": [], + "title": "NetworkMaps Total By Source", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic", + "seriesBy": "max" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.9, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineStyle": { + "fill": "solid" + }, + "lineWidth": 1, + "pointSize": 1, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "always", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "cpm" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [], + "displayMode": "list", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + } + }, + "layout": { + "kind": "RowsLayout", + "spec": { + "rows": [ + { + "kind": "RowsLayoutRow", + "spec": { + "collapse": false, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-14" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 0 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-65" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 0 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-15" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 8 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-2" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 8 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-23" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 16 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-24" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 16 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-16" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 24 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-17" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 24 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-84" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 32 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-85" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 32 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-86" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 40 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-87" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 40 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-26" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 48 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-25" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 48 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-34" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 56 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-35" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 56 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-31" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 64 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-32" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 64 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-9" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 72 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-8" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 72 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-29" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 80 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-30" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 80 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-66" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 88 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-67" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 88 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-10" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 96 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-7" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 96 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-33" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 104 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-74" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 104 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-11" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 112 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-13" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 112 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-12" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 120 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-1" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 120 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-27" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 128 + } + } + ] + } + }, + "title": "General" + } + }, + { + "kind": "RowsLayoutRow", + "spec": { + "collapse": true, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-76" + }, + "height": 8, + "width": 24, + "x": 0, + "y": 0 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-77" + }, + "height": 8, + "width": 24, + "x": 0, + "y": 8 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-78" + }, + "height": 8, + "width": 24, + "x": 0, + "y": 16 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-79" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 24 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-80" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 24 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-81" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 32 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-82" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 32 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-83" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 40 + } + } + ] + } + }, + "title": "NATS" + } + }, + { + "kind": "RowsLayoutRow", + "spec": { + "collapse": true, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-46" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 0 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-45" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 0 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-47" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 8 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-48" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 8 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-50" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 16 + } + } + ] + } + }, + "title": "Debugging" + } + }, + { + "kind": "RowsLayoutRow", + "spec": { + "collapse": true, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-37" + }, + "height": 8, + "width": 23, + "x": 0, + "y": 0 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-20" + }, + "height": 8, + "width": 23, + "x": 0, + "y": 8 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-39" + }, + "height": 8, + "width": 23, + "x": 0, + "y": 16 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-36" + }, + "height": 8, + "width": 23, + "x": 0, + "y": 24 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-18" + }, + "height": 8, + "width": 23, + "x": 0, + "y": 32 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-19" + }, + "height": 8, + "width": 23, + "x": 0, + "y": 40 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-90" + }, + "height": 19, + "width": 6, + "x": 0, + "y": 48 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-88" + }, + "height": 10, + "width": 17, + "x": 6, + "y": 48 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-89" + }, + "height": 9, + "width": 17, + "x": 6, + "y": 58 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-91" + }, + "height": 19, + "width": 6, + "x": 0, + "y": 67 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-92" + }, + "height": 10, + "width": 17, + "x": 6, + "y": 67 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-93" + }, + "height": 9, + "width": 17, + "x": 6, + "y": 77 + } + } + ] + } + }, + "title": "Network Map" + } + }, + { + "kind": "RowsLayoutRow", + "spec": { + "collapse": true, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-52" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 0 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-53" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 0 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-54" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 8 + } + } + ] + } + }, + "title": "Browser Client" + } + } + ] + } + }, + "links": [], + "liveNow": false, + "preload": false, + "tags": [], + "timeSettings": { + "autoRefresh": "auto", + "autoRefreshIntervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "fiscalYearStartMonth": 0, + "from": "now-1h", + "hideTimepicker": false, + "timezone": "browser", + "to": "now" + }, + "title": "Management generic", + "variables": [ + { + "kind": "DatasourceVariable", + "spec": { + "allowCustomValue": true, + "current": { + "text": "", + "value": "" + }, + "description": "Prometheus data source scraping the management metrics.", + "hide": "dontHide", + "includeAll": false, + "label": "Data source", + "multi": false, + "name": "datasource", + "options": [], + "pluginId": "prometheus", + "refresh": "onDashboardLoad", + "regex": "", + "skipUrlSync": false + } + }, + { + "kind": "DatasourceVariable", + "spec": { + "allowCustomValue": true, + "current": { + "text": "", + "value": "" + }, + "description": "Loki data source holding the management logs.", + "hide": "dontHide", + "includeAll": false, + "label": "Logs data source", + "multi": false, + "name": "logs_datasource", + "options": [], + "pluginId": "loki", + "refresh": "onDashboardLoad", + "regex": "", + "skipUrlSync": false + } + }, + { + "kind": "QueryVariable", + "spec": { + "allValue": ".*", + "allowCustomValue": true, + "current": { + "text": "All", + "value": [ + "$__all" + ] + }, + "definition": "label_values(job)", + "description": "Prometheus scrape job(s) exposing the management metrics.", + "hide": "dontHide", + "includeAll": true, + "label": "job", + "multi": true, + "name": "job", + "options": [], + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "qryType": 1, + "query": "label_values(job)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "version": "v0" + }, + "refresh": "onDashboardLoad", + "regex": "", + "regexApplyTo": "value", + "skipUrlSync": false, + "sort": "alphabeticalAsc" + } + }, + { + "kind": "QueryVariable", + "spec": { + "allValue": ".*", + "allowCustomValue": true, + "current": { + "text": "All", + "value": [ + "$__all" + ] + }, + "definition": "label_values(up{job=~\"$job\"},instance)", + "description": "Instance(s) of the selected job(s). Depends on the job variable.", + "hide": "dontHide", + "includeAll": true, + "label": "instance", + "multi": true, + "name": "instance", + "options": [], + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "qryType": 1, + "query": "label_values(up{job=~\"$job\"},instance)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "version": "v0" + }, + "refresh": "onDashboardLoad", + "regex": "", + "regexApplyTo": "value", + "skipUrlSync": false, + "sort": "alphabeticalAsc" + } + }, + { + "kind": "QueryVariable", + "spec": { + "allowCustomValue": true, + "current": { + "text": "", + "value": "" + }, + "definition": "label_values(job)", + "description": "Loki stream carrying the management logs (adjust the label if your setup does not use `job`).", + "hide": "dontHide", + "includeAll": false, + "label": "log stream", + "multi": false, + "name": "log_job", + "options": [], + "query": { + "datasource": { + "name": "${logs_datasource}" + }, + "group": "loki", + "kind": "DataQuery", + "spec": { + "label": "job", + "refId": "LokiVariableQueryEditor-VariableQuery", + "type": 1 + }, + "version": "v0" + }, + "refresh": "onDashboardLoad", + "regex": "", + "regexApplyTo": "value", + "skipUrlSync": false, + "sort": "alphabeticalAsc" + } + }, + { + "kind": "CustomVariable", + "spec": { + "allValue": "5m", + "allowCustomValue": true, + "current": { + "text": "60s", + "value": "60s" + }, + "hide": "dontHide", + "includeAll": false, + "label": "interval", + "multi": false, + "name": "interval", + "options": [], + "query": "5m,60s", + "skipUrlSync": false, + "valuesFormat": "csv" + } + }, + { + "kind": "QueryVariable", + "spec": { + "allowCustomValue": true, + "current": { + "text": "All", + "value": "$__all" + }, + "definition": "label_values(management_store_query_count_total,method)", + "hide": "hideVariable", + "includeAll": true, + "multi": true, + "name": "method", + "options": [], + "query": { + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "qryType": 1, + "query": "label_values(management_store_query_count_total,method)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "version": "v0" + }, + "refresh": "onDashboardLoad", + "regex": "", + "regexApplyTo": "value", + "skipUrlSync": false, + "sort": "disabled" + } + }, + { + "kind": "QueryVariable", + "spec": { + "allowCustomValue": true, + "current": { + "text": "All", + "value": [ + "$__all" + ] + }, + "definition": "label_values(aws_rds_cpuutilization_average,dimension_DBClusterIdentifier)", + "hide": "hideVariable", + "includeAll": true, + "multi": true, + "name": "dimension_DBClusterIdentifier", + "options": [], + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "qryType": 1, + "query": "label_values(aws_rds_cpuutilization_average,dimension_DBClusterIdentifier)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "version": "v0" + }, + "refresh": "onDashboardLoad", + "regex": "", + "regexApplyTo": "value", + "skipUrlSync": false, + "sort": "disabled" + } + } + ] +} diff --git a/integration_tests/management/network_map_db/account_settings_test.go b/integration_tests/management/network_map_db/account_settings_test.go new file mode 100644 index 000000000..d7927aaf1 --- /dev/null +++ b/integration_tests/management/network_map_db/account_settings_test.go @@ -0,0 +1,58 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "testing" + "time" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetAccountSettings(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into accounts (id, settings_peer_login_expiration_enabled, settings_peer_login_expiration, settings_peer_inactivity_expiration_enabled, + settings_peer_inactivity_expiration, settings_dns_domain, settings_ipv6_enabled_groups, settings_routing_peer_dns_resolution_enabled, + settings_lazy_connection_enabled, settings_auto_update_version, settings_auto_update_always, settings_metrics_push_enabled) + values('account-3',null,null,null,null,null,null,null,null,null,null,null)`) + + accountSettings, err := conn(t, ctx).GetAccountSettings(ctx, "account-1") + assert.NoError(t, err) + assert.Equal(t, accountSettings, nmdata.AccountSettingsInfo{ + PeerLoginExpirationEnabled: true, + PeerLoginExpiration: 86400000000000 * time.Nanosecond, + PeerInactivityExpirationEnabled: false, + PeerInactivityExpiration: 86400000000000 * time.Nanosecond, + DNSDomain: "", + IPv6EnabledGroups: []string{"group-one-resource-id"}, + RoutingPeerDNSResolutionEnabled: false, + LazyConnectionEnabled: false, + AutoUpdateVersion: "disabled", + AutoUpdateAlways: false, + MetricsPushEnabled: false, + }) + + accountSettings, err = conn(t, ctx).GetAccountSettings(ctx, "account-2") + assert.NoError(t, err) + assert.Equal(t, accountSettings, nmdata.AccountSettingsInfo{ + PeerLoginExpirationEnabled: true, + PeerLoginExpiration: 86400000000000 * time.Nanosecond, + PeerInactivityExpirationEnabled: false, + PeerInactivityExpiration: 86400000000000 * time.Nanosecond, + DNSDomain: "", + IPv6EnabledGroups: []string{"group-two-resources-id"}, + RoutingPeerDNSResolutionEnabled: false, + LazyConnectionEnabled: false, + AutoUpdateVersion: "disabled", + AutoUpdateAlways: false, + MetricsPushEnabled: false, + }) + + accountSettings, err = conn(t, ctx).GetAccountSettings(ctx, "account-3") + assert.NoError(t, err) + assert.Equal(t, accountSettings, nmdata.AccountSettingsInfo{}) +} diff --git a/integration_tests/management/network_map_db/base_data.sql b/integration_tests/management/network_map_db/base_data.sql new file mode 100644 index 000000000..136df00ac --- /dev/null +++ b/integration_tests/management/network_map_db/base_data.sql @@ -0,0 +1,53 @@ +insert into accounts (id, network_identifier, network_net, network_net_v6, network_dns, network_serial,dns_settings_disabled_management_groups, + settings_peer_login_expiration_enabled, settings_peer_login_expiration, settings_peer_inactivity_expiration_enabled, + settings_peer_inactivity_expiration, settings_dns_domain, settings_ipv6_enabled_groups, settings_routing_peer_dns_resolution_enabled, + settings_lazy_connection_enabled, settings_auto_update_version, settings_auto_update_always, settings_metrics_push_enabled) +VALUES('account-1','network-1','{"IP":"100.103.0.0","Mask":"//8AAA=="}','{"IP":"fdde:e995:fd38:a465::","Mask":"//////////8AAAAAAAAAAA=="}','',1,'["disabled-group-1","disabled-group-2"]', + true, 86400000000000, false, + 86400000000000, null, '["group-one-resource-id"]', false, + false, 'disabled', false, false); +insert into accounts (id, network_identifier, network_net, network_net_v6, network_dns, network_serial,dns_settings_disabled_management_groups, + settings_peer_login_expiration_enabled, settings_peer_login_expiration, settings_peer_inactivity_expiration_enabled, + settings_peer_inactivity_expiration, settings_dns_domain, settings_ipv6_enabled_groups, settings_routing_peer_dns_resolution_enabled, + settings_lazy_connection_enabled, settings_auto_update_version, settings_auto_update_always, settings_metrics_push_enabled) +VALUES('account-2','network-2','{"IP":"110.0.0.0","Mask":"//8AAA=="}','{"IP":"fddf:e995:fd38:a465::","Mask":"//////////8AAAAAAAAAAA=="}','',2,null, + true, 86400000000000, false, + 86400000000000, null, '["group-two-resources-id"]', false, + false, 'disabled', false, false); +insert into groups (id, account_id, name, resources, public_id) VALUES('group-one-resource-id','account-1','group-1-name', '[{"ID":"host-id-1","Type":"host"}]','group-one-resource-id-public'); +insert into groups (id, account_id, name, resources, public_id) VALUES('group-two-resources-id','account-1','group-2-name', '[{"ID":"subnet-id-1","Type":"subnet"}, {"ID":"host-id-2","Type":"host"}]','group-two-resources-id-public'); +insert into groups (id, account_id, name, resources, public_id) VALUES('group-no-resources-id','account-1','group-3-name', null,'group-no-resources-id-public'); +insert into group_peers (account_id, peer_id, group_id) VALUES('account-1','peer-id-1','group-one-resource-id'); +insert into group_peers (account_id, peer_id, group_id) VALUES('account-1','peer-id-2','group-two-resources-id'); +insert into group_peers (account_id, peer_id, group_id) VALUES('account-1','peer-id-3','group-two-resources-id'); +insert into peers (id, account_id, "key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, + meta_capabilities, meta_flags, meta_sync_message_version, + location_country_code, location_city_name, location_connection_ip) + values('peer-id-1','account-1','key-1','ssh-key-1','peer-1','["extra-peer-1"]','user-id-1',true,true,'2026-08-06 13:25:59.12999','"10.10.10.1"','"fdf4:ba80:6aa5:89f1:44d7:8701:8699:4940"', + false,true,true,'cluster-1.netbird.services', + '0.76.0','linux','26.4.1','6.8.0-134-generic','[{"NetIP":"fe80::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ac"},{"NetIP":"192.168.16.1/20","Mac":"00:15:5d:24:0c:ac"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]', + '[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1, + 'DE','Berlin','"46.201.148.187"'); +insert into peers (id,account_id,"key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, + meta_capabilities, meta_flags, meta_sync_message_version, + location_country_code, location_city_name, location_connection_ip) + values('peer-id-2','account-1','key-2','ssh-key-2','peer-2','["extra-peer-2"]','user-id-2',true,true,'2026-08-06 14:25:59.12999','"10.10.100.1"','"fdf5:ba80:6aa5:89f1:44d7:8701:8699:4940"', + false,true,true,'cluster-2.netbird.services', + '0.76.1','linux','26.4.2','6.8.0-135-generic','[{"NetIP":"fe81::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ad"},{"NetIP":"192.168.17.1/20","Mac":"00:15:5d:24:0c:ad"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]', + '[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',0, + 'DE','Berlin','"46.201.149.187"'); +insert into peers (id,account_id,"key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, + meta_capabilities, meta_flags, meta_sync_message_version, + location_country_code, location_city_name, location_connection_ip) + values('peer-id-3','account-1','key-3','ssh-key-3','peer-3','["extra-peer-3"]','user-id-3',true,true,'2026-08-06 12:25:59.12999','"10.10.200.1"','"fdf6:ba80:6aa5:89f1:44d7:8701:8699:4940"', + false,true,true,'cluster-3.netbird.services', + '0.76.2','linux','26.4.3','6.8.0-136-generic','[{"NetIP":"fe82::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ae"},{"NetIP":"192.168.18.1/20","Mac":"00:15:5d:24:0c:ae"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]', + '[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1, + 'DE','Berlin','"46.201.150.187"'); + diff --git a/integration_tests/management/network_map_db/dns_settings_test.go b/integration_tests/management/network_map_db/dns_settings_test.go new file mode 100644 index 000000000..95ac84aed --- /dev/null +++ b/integration_tests/management/network_map_db/dns_settings_test.go @@ -0,0 +1,25 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "testing" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetDnsSettings(t *testing.T) { + ctx := context.TODO() + + settings, err := conn(t, ctx).GetDnsSettings(ctx, "account-1") + assert.NoError(t, err) + assert.Equal(t, settings, nmdata.DNSSettings{ + DisabledManagementGroups: []string{"disabled-group-1", "disabled-group-2"}, + }) + + settings, err = conn(t, ctx).GetDnsSettings(ctx, "account-2") + assert.NoError(t, err) + assert.Equal(t, settings, nmdata.DNSSettings{}) +} diff --git a/integration_tests/management/network_map_db/dns_test.go b/integration_tests/management/network_map_db/dns_test.go new file mode 100644 index 000000000..33023061d --- /dev/null +++ b/integration_tests/management/network_map_db/dns_test.go @@ -0,0 +1,80 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "testing" + + "github.com/miekg/dns" + "github.com/netbirdio/netbird/shared/management/networkmap" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetAppliedZoneCandidatesViaPgxConnection(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into zones (id, account_id, domain, enabled, enable_search_domain, distribution_groups) + VALUES('zone-1','account-1','test-1.com',true,true,'["group-one-resource-id"]')`) + execQuery(t, ctx, + `insert into zones (id, account_id, domain, enabled, enable_search_domain, distribution_groups) + VALUES('zone-2','account-1','test-2.com',true,false,'["group-two-resources-id"]')`) + execQuery(t, ctx, + `insert into zones (id, account_id, domain, enabled, enable_search_domain, distribution_groups) + VALUES('zone-3','account-1','test-3.com',false,true,'["group-one-resource-id"]')`) + execQuery(t, ctx, + `insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-1','account-1','zone-1','test.test-1.com','A',1800,'1.1.1.1')`) + execQuery(t, ctx, + `insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-2','account-1','zone-1','test2.test-1.com','A',1800,'1.1.1.2')`) + execQuery(t, ctx, + `insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-3','account-1','zone-1','test3.test-1.com','CNAME',1800,'test4.test-1.com')`) + execQuery(t, ctx, + `insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-4','account-1','zone-2','test2.test-2.com','CNAME',1800,'test3.test-2.com')`) + execQuery(t, ctx, + `insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-5','account-1','zone-3','test.test-3.com','A',1800,'1.1.1.3')`) + + zoneCandidates, err := conn(t, ctx).GetAppliedZoneCandidates(ctx, "account-1") + assert.NoError(t, err) + + // Zone domains and record names are fully qualified, and the zone is served + // non-authoritatively — the account-side builder + // (types.buildAppliedZoneCandidates) states the same shape, and both feed the + // one client-facing map, so the two have to agree. + assert.Contains(t, zoneCandidates, networkmap.AppliedZoneCandidate{ + DistributionGroups: []string{"group-one-resource-id"}, + Zone: nmdata.CustomZone{ + Domain: "test-1.com.", + SearchDomainDisabled: false, + NonAuthoritative: true, + Records: []nmdata.SimpleRecord{ + {Name: "test.test-1.com.", Type: int(dns.TypeA), Class: "IN", TTL: 1800, RData: "1.1.1.1"}, + {Name: "test2.test-1.com.", Type: int(dns.TypeA), Class: "IN", TTL: 1800, RData: "1.1.1.2"}, + {Name: "test3.test-1.com.", Type: int(dns.TypeCNAME), Class: "IN", TTL: 1800, RData: "test4.test-1.com."}, + }, + }, + }) + assert.Contains(t, zoneCandidates, networkmap.AppliedZoneCandidate{ + DistributionGroups: []string{"group-two-resources-id"}, + Zone: nmdata.CustomZone{ + Domain: "test-2.com.", + SearchDomainDisabled: true, + NonAuthoritative: true, + Records: []nmdata.SimpleRecord{ + {Name: "test2.test-2.com.", Type: int(dns.TypeCNAME), Class: "IN", TTL: 1800, RData: "test3.test-2.com."}, + }, + }, + }) + + // A zone an admin switched off reaches no peer. + for _, candidate := range zoneCandidates { + assert.NotEqual(t, "test-3.com.", candidate.Zone.Domain, "disabled zone must not be a candidate") + assert.NotEqual(t, "test-3.com", candidate.Zone.Domain, "disabled zone must not be a candidate") + } +} diff --git a/integration_tests/management/network_map_db/domain_test.go b/integration_tests/management/network_map_db/domain_test.go new file mode 100644 index 000000000..8434a76c3 --- /dev/null +++ b/integration_tests/management/network_map_db/domain_test.go @@ -0,0 +1,39 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "database/sql" + "testing" + + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + "github.com/stretchr/testify/assert" +) + +func TestGetDomains(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into domains (id, account_id, domain, target_cluster) + VALUES('domain-1','account-1','test-1.com','target-1.cluster.local')`) + execQuery(t, ctx, + `insert into domains (id, account_id, domain, target_cluster) + VALUES('domain-2','account-1','test-2.com','target-2.cluster.local')`) + execQuery(t, ctx, + `insert into domains (id, account_id, domain, target_cluster) + VALUES('domain-3','account-1',null,null)`) + + domains, err := conn(t, ctx).GetDomains(ctx, "account-1") + assert.NoError(t, err) + assert.Len(t, domains, 2) + + assert.Contains(t, domains, networkmapdb.Domain{ + Domain: sql.NullString{String: "test-1.com", Valid: true}, + TargetCluster: sql.NullString{String: "target-1.cluster.local", Valid: true}, + }) + assert.Contains(t, domains, networkmapdb.Domain{ + Domain: sql.NullString{String: "test-2.com", Valid: true}, + TargetCluster: sql.NullString{String: "target-2.cluster.local", Valid: true}, + }) +} diff --git a/integration_tests/management/network_map_db/group_test.go b/integration_tests/management/network_map_db/group_test.go new file mode 100644 index 000000000..3ccf96eb0 --- /dev/null +++ b/integration_tests/management/network_map_db/group_test.go @@ -0,0 +1,54 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "testing" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetGroups(t *testing.T) { + ctx := context.TODO() + + groups, resourceToGroupIdx, err := conn(t, ctx).GetGroups(ctx, "account-1") + assert.NoError(t, err) + assert.Contains(t, + groups, + nmdata.Group{ID: "group-one-resource-id", Name: "group-1-name", PublicID: "group-one-resource-id-public", Resources: []nmdata.Resource{{ID: "host-id-1", Type: "host"}}, Peers: []string{"peer-id-1"}}, + ) + assert.NotNil(t, resourceToGroupIdx["host-id-1"]["group-one-resource-id"]) + assert.Contains(t, + groups, + nmdata.Group{ID: "group-two-resources-id", Name: "group-2-name", PublicID: "group-two-resources-id-public", + Resources: []nmdata.Resource{{ID: "subnet-id-1", Type: "subnet"}, {ID: "host-id-2", Type: "host"}}, + Peers: []string{"peer-id-2", "peer-id-3"}}, + ) + assert.NotNil(t, resourceToGroupIdx["host-id-2"]["group-two-resources-id"]) + assert.NotNil(t, resourceToGroupIdx["subnet-id-1"]["group-two-resources-id"]) + assert.Contains(t, + groups, + nmdata.Group{ID: "group-no-resources-id", Name: "group-3-name", PublicID: "group-no-resources-id-public"}) +} + +// Verify handling of empty fields in groups table +// Verify that group's PublicID gets populated on retrieval +// TODO (dmitri) PublicID should not be populated with delta updates, +// which require stable PublicIDs +func TestGetGroupsWithoutExpectedFields(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + "insert into accounts (id) VALUES('random-id')") + + execQuery(t, ctx, + "insert into groups (id, account_id) VALUES('g2-test-group-id-1','random-id')") + + groups, _, err := conn(t, ctx).GetGroups(ctx, "random-id") + assert.NoError(t, err) + require.Len(t, groups, 1) + assert.NotEmpty(t, groups[0].PublicID) +} diff --git a/integration_tests/management/network_map_db/main_test.go b/integration_tests/management/network_map_db/main_test.go new file mode 100644 index 000000000..78c8c8ec8 --- /dev/null +++ b/integration_tests/management/network_map_db/main_test.go @@ -0,0 +1,99 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + _ "embed" + "os" + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql" + networkmap_sqlite "github.com/netbirdio/netbird/management/internals/network_map_db/sqlite" + "github.com/netbirdio/netbird/management/server/types" +) + +//go:embed base_data.sql +var baseData string + +var ( + pgstore *networkmap_pgsql.PgStore + sqlitestore *networkmap_sqlite.SqliteStore + engine string +) + +func TestMain(m *testing.M) { + var cleanup func() + kind, _ := os.LookupEnv("NETBIRD_STORE_ENGINE") + switch kind { + case string(types.PostgresStoreEngine): + engine = string(types.PostgresStoreEngine) + pgstore, cleanup = createPGTestStore(baseData) + pgstore.UsingTimeZone(time.UTC) + case "", string(types.SqliteStoreEngine): + engine = string(types.SqliteStoreEngine) + sqlitestore, cleanup = createSqliteTestStore(baseData) + default: + log.Fatalf("unsupported db '%s' in NETBIRD_STORE_ENGINE env var", kind) + } + + code := m.Run() + + cleanup() + os.Exit(code) +} + +func conn(t *testing.T, ctx context.Context) networkmapdb.NetworkMapDBStoreConn { + t.Helper() + switch engine { + case string(types.PostgresStoreEngine): + c, err := pgstore.Pool.Acquire(ctx) + assert.NoError(t, err) + return pgstore.UsingConnection(c.Conn()) + case string(types.SqliteStoreEngine): + return sqlitestore.UsingConn() + } + log.Fatalf("unknown db engine kind %s", engine) + return nil +} + +func store(t *testing.T) networkmapdb.NetworkMapDBStore { + t.Helper() + switch engine { + case string(types.PostgresStoreEngine): + return pgstore + case string(types.SqliteStoreEngine): + return sqlitestore + } + log.Fatalf("unknown db engine kind %s", engine) + return nil +} + +func execQuery(t *testing.T, ctx context.Context, q string) { + t.Helper() + switch engine { + case string(types.PostgresStoreEngine): + _, err := pgstore.Pool.Exec(ctx, q) + assert.NoError(t, err) + case string(types.SqliteStoreEngine): + _, err := sqlitestore.Db.ExecContext(ctx, q) + assert.NoError(t, err) + } +} + +// use to parse time in time.RFC3339Nano format +// returns the time in the UTC time zone +func mustParseTime(t string) *time.Time { + tt, err := time.Parse(time.RFC3339Nano, t) + if err != nil { + panic(err) + } + + utc := tt.UTC() + return &utc +} diff --git a/integration_tests/management/network_map_db/nameserver_test.go b/integration_tests/management/network_map_db/nameserver_test.go new file mode 100644 index 000000000..d6243a6e3 --- /dev/null +++ b/integration_tests/management/network_map_db/nameserver_test.go @@ -0,0 +1,61 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "net/netip" + "testing" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetNameServerGroups(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into name_server_groups (id, public_id, name, description, name_servers, groups, domains, enabled, search_domains_enabled, "primary", account_id) + VALUES('nsgroup-1','nsgroup-1-public','nsgroup-1','nsgroup-1','[{"IP":"192.168.31.2","NSType":1,"Port":53}]','["group-one-resource-id"]','["test-1.com"]',TRUE,FALSE,TRUE,'account-1')`) + execQuery(t, ctx, + `insert into name_server_groups (id, public_id, name, description, name_servers, groups, domains, enabled, search_domains_enabled,"primary",account_id) + VALUES('nsgroup-2','nsgroup-2-public','nsgroup-2','nsgroup-2','[{"IP":"192.168.32.3","NSType":1,"Port":53}]','["group-one-resource-id","group-no-resources-id"]','["test-1.com","test-2.com"]',TRUE,FALSE,TRUE,'account-1')`) + execQuery(t, ctx, + `insert into name_server_groups (id, public_id, name, description, name_servers, groups, domains, enabled, search_domains_enabled,"primary",account_id) + VALUES('nsgroup-3','nsgroup-3-public',null,null,null,null,null,TRUE,FALSE,FALSE,'account-1')`) + + nsgroups, err := conn(t, ctx).GetNameServerGroups(ctx, "account-1") + assert.NoError(t, err) + + assert.Contains(t, nsgroups, nmdata.NameServerGroup{ + ID: "nsgroup-1", + PublicID: "nsgroup-1-public", + Name: "nsgroup-1", + Description: "nsgroup-1", + NameServers: []nmdata.NameServer{{IP: netip.MustParseAddr("192.168.31.2"), NSType: 1, Port: 53}}, + Groups: []string{"group-one-resource-id"}, + Domains: []string{"test-1.com"}, + Primary: true, + SearchDomainsEnabled: false, + Enabled: true, + }) + assert.Contains(t, nsgroups, nmdata.NameServerGroup{ + ID: "nsgroup-2", + PublicID: "nsgroup-2-public", + Name: "nsgroup-2", + Description: "nsgroup-2", + NameServers: []nmdata.NameServer{{IP: netip.MustParseAddr("192.168.32.3"), NSType: 1, Port: 53}}, + Groups: []string{"group-one-resource-id", "group-no-resources-id"}, + Domains: []string{"test-1.com", "test-2.com"}, + Primary: true, + SearchDomainsEnabled: false, + Enabled: true, + }) + assert.Contains(t, nsgroups, nmdata.NameServerGroup{ + ID: "nsgroup-3", + PublicID: "nsgroup-3-public", + Primary: false, + SearchDomainsEnabled: false, + Enabled: true, + }) +} diff --git a/integration_tests/management/network_map_db/network_map_data.sql b/integration_tests/management/network_map_db/network_map_data.sql new file mode 100644 index 000000000..d94e2f4aa --- /dev/null +++ b/integration_tests/management/network_map_db/network_map_data.sql @@ -0,0 +1,108 @@ +insert into accounts (id, network_identifier, network_net, network_net_v6, network_dns, network_serial,dns_settings_disabled_management_groups, + settings_peer_login_expiration_enabled, settings_peer_login_expiration, settings_peer_inactivity_expiration_enabled, + settings_peer_inactivity_expiration, settings_dns_domain, settings_ipv6_enabled_groups, settings_routing_peer_dns_resolution_enabled, + settings_lazy_connection_enabled, settings_auto_update_version, settings_auto_update_always, settings_metrics_push_enabled) +VALUES('account-33','network-331','{"IP":"100.103.0.0","Mask":"//8AAA=="}','{"IP":"fdde:e995:fd38:a465::","Mask":"//////////8AAAAAAAAAAA=="}','',1,'["disabled-group-1","disabled-group-2"]', + true, 86400000000000, false, + 86400000000000, null, '["33-group-one-resource-id"]', false, + false, 'disabled', false, false); +insert into groups (id, account_id, name, resources, public_id) VALUES('33-group-one-resource-id','account-33','group-1-name', '[{"ID":"host-id-1","Type":"host"}]','group-one-resource-id-public'); +insert into groups (id, account_id, name, resources, public_id) VALUES('33-group-two-resources-id','account-33','group-2-name', '[{"ID":"subnet-id-1","Type":"subnet"}, {"ID":"host-id-2","Type":"host"}]','33-group-two-resources-id-public'); +insert into groups (id, account_id, name, resources, public_id) VALUES('33-group-no-resources-id','account-33','group-3-name', null,'33-group-no-resources-id-public'); +insert into group_peers (account_id, peer_id, group_id) VALUES('account-33','peer-id-331','33-group-one-resource-id'); +insert into group_peers (account_id, peer_id, group_id) VALUES('account-33','peer-id-332','33-group-two-resources-id'); +insert into group_peers (account_id, peer_id, group_id) VALUES('account-33','peer-id-333','33-group-two-resources-id'); +insert into peers (id, account_id, "key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, + meta_capabilities, meta_flags, meta_sync_message_version, + location_country_code, location_city_name, location_connection_ip) + values('peer-id-331','account-33','key-331','ssh-key-1','peer-1','["extra-peer-1"]','user-id-1',true,true,'2026-08-06 13:25:59.12999','"10.10.10.1"','"fdf4:ba80:6aa5:89f1:44d7:8701:8699:4940"', + false,true,true,'cluster-1.netbird.services', + '0.76.0','linux','26.4.1','6.8.0-134-generic','[{"NetIP":"fe80::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ac"},{"NetIP":"192.168.16.1/20","Mac":"00:15:5d:24:0c:ac"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]', + '[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1, + 'DE','Berlin','"46.201.148.187"'); +insert into peers (id,account_id,"key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, + meta_capabilities, meta_flags, meta_sync_message_version, + location_country_code, location_city_name, location_connection_ip) + values('peer-id-332','account-33','key-332','ssh-key-2','peer-2','["extra-peer-2"]','user-id-2',true,true,'2026-08-06 14:25:59.12999','"10.10.100.1"','"fdf5:ba80:6aa5:89f1:44d7:8701:8699:4940"', + false,true,true,'cluster-2.netbird.services', + '0.76.1','linux','26.4.2','6.8.0-135-generic','[{"NetIP":"fe81::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ad"},{"NetIP":"192.168.17.1/20","Mac":"00:15:5d:24:0c:ad"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]', + '[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',0, + 'DE','Berlin','"46.201.149.187"'); +insert into peers (id,account_id,"key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, + meta_capabilities, meta_flags, meta_sync_message_version, + location_country_code, location_city_name, location_connection_ip) + values('peer-id-333','account-33','key-333','ssh-key-3','peer-3','["extra-peer-3"]','user-id-3',true,true,'2026-08-06 12:25:59.12999','"10.10.200.1"','"fdf6:ba80:6aa5:89f1:44d7:8701:8699:4940"', + false,true,true,'cluster-3.netbird.services', + '0.76.2','linux','26.4.3','6.8.0-136-generic','[{"NetIP":"fe82::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ae"},{"NetIP":"192.168.18.1/20","Mac":"00:15:5d:24:0c:ae"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]', + '[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1, + 'DE','Berlin','"46.201.150.187"'); + +insert into zones (id, account_id, domain, enabled, enable_search_domain, distribution_groups) + VALUES('zone-331','account-33','test-331.com',true,true,'["33-group-one-resource-id"]'); +insert into zones (id, account_id, domain, enabled, enable_search_domain, distribution_groups) + VALUES('zone-332','account-33','disabled-331.com',false,true,'["33-group-one-resource-id"]'); +insert into zones (id, account_id, domain, enabled, enable_search_domain, distribution_groups) + VALUES('zone-333','account-33','search-off-331.com',true,false,'["33-group-two-resources-id"]'); +insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-333','account-33','zone-332','test.disabled-331.com','A',1800,'1.1.1.9'); +insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-334','account-33','zone-333','test.search-off-331.com','A',1800,'1.1.1.3'); +insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-335','account-33','zone-333','alias.search-off-331.com','CNAME',1800,'test.search-off-331.com'); +insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-331','account-33','zone-331','test.test-331.com','A',1800,'1.1.1.1'); +insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-332','account-33','zone-331','test2.test-331.com','A',1800,'1.1.1.2'); + +insert into domains (id, account_id, domain, target_cluster) + VALUES('domain-331','account-33','test-331.com','target-1.cluster.local'); + +insert into name_server_groups (id, public_id, name, description, name_servers, groups, domains, enabled, search_domains_enabled, "primary", account_id) + VALUES('nsgroup-331','nsgroup-1-public','nsgroup-1','nsgroup-1','[{"IP":"192.168.31.2","NSType":1,"Port":53}]','["33-group-one-resource-id"]','["test-1.com"]',TRUE,FALSE,TRUE,'account-33'); +insert into name_server_groups (id, public_id, name, description, name_servers, groups, domains, enabled, search_domains_enabled,"primary",account_id) + VALUES('nsgroup-332','nsgroup-2-public','nsgroup-2','nsgroup-2','[{"IP":"192.168.32.3","NSType":1,"Port":53}]','["33-group-one-resource-id","33-group-no-resources-id"]','["test-1.com","test-2.com"]',TRUE,FALSE,TRUE,'account-33'); + +insert into network_resources (id, account_id, network_id, public_id, name, description, type, domain, prefix, enabled) + VALUES('net-resource-331','account-33','network-331','net-resource-public-1','network-resource-1','network-resource-1','subnet','','"10.0.0.0/16"',TRUE); +insert into network_resources (id, account_id, network_id, public_id, name, description, type, domain, prefix, enabled) + VALUES('net-resource-332','account-33','network-332','net-resource-public-2','network-resource-2','network-resource-2','domain','test.com','',TRUE); + +insert into network_routers (id, account_id, public_id, peer, network_id, masquerade, metric, enabled, peer_groups) + VALUES('test-nr-id-331','account-33','public-id-1','peer-id-331','network-id-1',TRUE,999,TRUE,'["33-group-one-resource-id"]'); +insert into network_routers (id, account_id, public_id, peer, network_id, masquerade, metric, enabled, peer_groups) + VALUES('test-nr-id-332','account-33','public-id-2','','network-id-2',TRUE,333,TRUE,'["33-group-two-resources-id","33-group-no-resources-id"]'); + +insert into networks (id, account_id, public_id) VALUES('network-331','account-33','network-1-public'); +insert into networks (id, account_id, public_id) VALUES('network-332','account-33','network-2-public'); + +insert into policies (id, public_id, account_id, enabled, source_posture_checks) + values('policy-331','policy-1-public','account-33',true,'["posture-checks-1","posture-checks-2"]'); +insert into policy_rules (id, policy_id, enabled, action, protocol, bidirectional, sources, destinations, + source_resource, destination_resource, ports, port_ranges, + authorized_groups, authorized_user) + values('policy-331-rule-1','policy-331',true,'accept','tcp',true,'["33-group-one-resource-id","33-group-two-resources-id"]','["33-group-one-resource-id","33-group-two-resources-id"]', + '{"ID":"host-id-1","Type":"host"}','{"ID":"domain-331","Type":"domain"}','["8080","8443"]', '[{"Start":8080,"End":8090}]', + '{"33-group-one-resource-id":["user-1", "user-2"]}','user-3'); + +insert into posture_checks (id, account_id, public_id, checks) + VALUES('posturecheck-331','account-33','posturecheck-1-public', + '{"NBVersionCheck":{"MinVersion":"0.25.0"}, + "OSVersionCheck":{"Darwin":{"MinVersion":"12.0"}}, + "GeoLocationCheck":{"Locations":[{"CountryCode":"FI","CityName":""}],"Action":"allow"}, + "PeerNetworkRangeCheck":{"Action":"deny","Ranges":["192.168.0.1/24"]}}'); + +insert into routes (id, account_id, public_id, network, domains, keep_route, net_id, description, + peer, peer_groups, network_type, masquerade, metric, enabled, + groups, access_control_groups, skip_auto_apply) + VALUES('route-331','account-33','route-1-public','"172.0.0.0/16"','["test-1.com"]',true,'route-331-net-id','route-1', + 'peer-id-331','["33-group-one-resource-id"]',1,true,9999,true, + '["33-group-one-resource-id"]','["33-group-one-resource-id"]',false); + +insert into services (id, account_id, enabled, private, access_groups, proxy_cluster, domain) + values('service-331','account-33',true,true,'["33-group-one-resource-id"]','test-1.com','test-332.com'); diff --git a/integration_tests/management/network_map_db/network_map_data_golden.json b/integration_tests/management/network_map_db/network_map_data_golden.json new file mode 100644 index 000000000..bf194851e --- /dev/null +++ b/integration_tests/management/network_map_db/network_map_data_golden.json @@ -0,0 +1,555 @@ +{ + "Peers": { + "peer-id-331": { + "ID": "peer-id-331", + "Key": "key-331", + "SSHKey": "ssh-key-1", + "DNSLabel": "peer-1", + "UserID": "user-id-1", + "SSHEnabled": true, + "LoginExpirationEnabled": true, + "LastLogin": "2026-08-06T13:25:59.12999Z", + "IP": "10.10.10.1", + "IPv6": "fdf4:ba80:6aa5:89f1:44d7:8701:8699:4940", + "RequiresApproval": false, + "Connected": true, + "ExtraDNSLabels": [ + "extra-peer-1" + ], + "Meta": { + "WtVersion": "0.76.0", + "GoOS": "linux", + "OSVersion": "26.4.1", + "KernelVersion": "6.8.0-134-generic", + "NetworkAddresses": [ + { + "NetIP": "fe80::8b4c:973f:a76b:3771/64" + }, + { + "NetIP": "192.168.16.1/20" + } + ], + "Files": [ + { + "Path": "/usr/bin/netbird", + "ProcessIsRunning": false + } + ], + "Capabilities": [ + 1, + 2 + ], + "Flags": { + "ServerSSHAllowed": true, + "DisableIPv6": false + }, + "SyncMessageVersion": 1 + }, + "ProxyMeta": { + "Embedded": true, + "Cluster": "cluster-1.netbird.services" + }, + "Location": { + "CountryCode": "DE", + "CityName": "Berlin", + "ConnectionIP": "46.201.148.187" + } + }, + "peer-id-332": { + "ID": "peer-id-332", + "Key": "key-332", + "SSHKey": "ssh-key-2", + "DNSLabel": "peer-2", + "UserID": "user-id-2", + "SSHEnabled": true, + "LoginExpirationEnabled": true, + "LastLogin": "2026-08-06T14:25:59.12999Z", + "IP": "10.10.100.1", + "IPv6": "fdf5:ba80:6aa5:89f1:44d7:8701:8699:4940", + "RequiresApproval": false, + "Connected": true, + "ExtraDNSLabels": [ + "extra-peer-2" + ], + "Meta": { + "WtVersion": "0.76.1", + "GoOS": "linux", + "OSVersion": "26.4.2", + "KernelVersion": "6.8.0-135-generic", + "NetworkAddresses": [ + { + "NetIP": "fe81::8b4c:973f:a76b:3771/64" + }, + { + "NetIP": "192.168.17.1/20" + } + ], + "Files": [ + { + "Path": "/usr/bin/netbird", + "ProcessIsRunning": false + } + ], + "Capabilities": [ + 1, + 2 + ], + "Flags": { + "ServerSSHAllowed": true, + "DisableIPv6": false + }, + "SyncMessageVersion": 0 + }, + "ProxyMeta": { + "Embedded": true, + "Cluster": "cluster-2.netbird.services" + }, + "Location": { + "CountryCode": "DE", + "CityName": "Berlin", + "ConnectionIP": "46.201.149.187" + } + }, + "peer-id-333": { + "ID": "peer-id-333", + "Key": "key-333", + "SSHKey": "ssh-key-3", + "DNSLabel": "peer-3", + "UserID": "user-id-3", + "SSHEnabled": true, + "LoginExpirationEnabled": true, + "LastLogin": "2026-08-06T12:25:59.12999Z", + "IP": "10.10.200.1", + "IPv6": "fdf6:ba80:6aa5:89f1:44d7:8701:8699:4940", + "RequiresApproval": false, + "Connected": true, + "ExtraDNSLabels": [ + "extra-peer-3" + ], + "Meta": { + "WtVersion": "0.76.2", + "GoOS": "linux", + "OSVersion": "26.4.3", + "KernelVersion": "6.8.0-136-generic", + "NetworkAddresses": [ + { + "NetIP": "fe82::8b4c:973f:a76b:3771/64" + }, + { + "NetIP": "192.168.18.1/20" + } + ], + "Files": [ + { + "Path": "/usr/bin/netbird", + "ProcessIsRunning": false + } + ], + "Capabilities": [ + 1, + 2 + ], + "Flags": { + "ServerSSHAllowed": true, + "DisableIPv6": false + }, + "SyncMessageVersion": 1 + }, + "ProxyMeta": { + "Embedded": true, + "Cluster": "cluster-3.netbird.services" + }, + "Location": { + "CountryCode": "DE", + "CityName": "Berlin", + "ConnectionIP": "46.201.150.187" + } + } + }, + "Groups": { + "33-group-no-resources-id": { + "ID": "33-group-no-resources-id", + "Name": "group-3-name", + "PublicID": "33-group-no-resources-id-public", + "Peers": null, + "Resources": null + }, + "33-group-one-resource-id": { + "ID": "33-group-one-resource-id", + "Name": "group-1-name", + "PublicID": "group-one-resource-id-public", + "Peers": [ + "peer-id-331" + ], + "Resources": [ + { + "ID": "host-id-1", + "Type": "host" + } + ] + }, + "33-group-two-resources-id": { + "ID": "33-group-two-resources-id", + "Name": "group-2-name", + "PublicID": "33-group-two-resources-id-public", + "Peers": [ + "peer-id-332", + "peer-id-333" + ], + "Resources": [ + { + "ID": "subnet-id-1", + "Type": "subnet" + }, + { + "ID": "host-id-2", + "Type": "host" + } + ] + } + }, + "Policies": [ + { + "ID": "policy-331", + "PublicID": "policy-1-public", + "Enabled": true, + "SourcePostureChecks": [ + "posture-checks-1", + "posture-checks-2" + ], + "Rules": [ + { + "ID": "policy-331", + "PolicyID": "policy-331", + "Enabled": true, + "Action": "accept", + "Protocol": "tcp", + "Bidirectional": true, + "Sources": [ + "33-group-one-resource-id", + "33-group-two-resources-id" + ], + "Destinations": [ + "33-group-one-resource-id", + "33-group-two-resources-id" + ], + "SourceResource": { + "ID": "host-id-1", + "Type": "host" + }, + "DestinationResource": { + "ID": "domain-331", + "Type": "domain" + }, + "Ports": [ + "8080", + "8443" + ], + "PortRanges": [ + { + "Start": 8080, + "End": 8090 + } + ], + "AuthorizedGroups": { + "33-group-one-resource-id": [ + "user-1", + "user-2" + ] + }, + "AuthorizedUser": "user-3" + } + ] + } + ], + "Routes": [ + { + "ID": "route-331", + "AccountID": "account-33", + "PublicID": "route-1-public", + "Network": "172.0.0.0/16", + "Domains": [ + "test-1.com" + ], + "KeepRoute": true, + "NetID": "route-331-net-id", + "Description": "route-1", + "Peer": "peer-id-331", + "PeerID": "peer-id-331", + "PeerGroups": [ + "33-group-one-resource-id" + ], + "NetworkType": 1, + "Masquerade": true, + "Metric": 9999, + "Enabled": true, + "Groups": [ + "33-group-one-resource-id" + ], + "AccessControlGroups": [ + "33-group-one-resource-id" + ], + "SkipAutoApply": false + } + ], + "NameServerGroups": [ + { + "ID": "nsgroup-331", + "PublicID": "nsgroup-1-public", + "Name": "nsgroup-1", + "Description": "nsgroup-1", + "NameServers": [ + { + "IP": "192.168.31.2", + "NSType": 1, + "Port": 53 + } + ], + "Groups": [ + "33-group-one-resource-id" + ], + "Primary": true, + "Domains": [ + "test-1.com" + ], + "Enabled": true, + "SearchDomainsEnabled": false + }, + { + "ID": "nsgroup-332", + "PublicID": "nsgroup-2-public", + "Name": "nsgroup-2", + "Description": "nsgroup-2", + "NameServers": [ + { + "IP": "192.168.32.3", + "NSType": 1, + "Port": 53 + } + ], + "Groups": [ + "33-group-one-resource-id", + "33-group-no-resources-id" + ], + "Primary": true, + "Domains": [ + "test-1.com", + "test-2.com" + ], + "Enabled": true, + "SearchDomainsEnabled": false + } + ], + "NetworkResources": [ + { + "ID": "net-resource-331", + "NetworkID": "network-331", + "AccountID": "account-33", + "PublicID": "net-resource-public-1", + "Name": "network-resource-1", + "Description": "network-resource-1", + "Type": "subnet", + "Address": "", + "Domain": "", + "Prefix": "10.0.0.0/16", + "Enabled": true + }, + { + "ID": "net-resource-332", + "NetworkID": "network-332", + "AccountID": "account-33", + "PublicID": "net-resource-public-2", + "Name": "network-resource-2", + "Description": "network-resource-2", + "Type": "domain", + "Address": "", + "Domain": "test.com", + "Prefix": "", + "Enabled": true + } + ], + "Network": { + "Identifier": "network-331", + "Net": { + "IP": "100.103.0.0", + "Mask": "//8AAA==" + }, + "NetV6": { + "IP": "fdde:e995:fd38:a465::", + "Mask": "//////////8AAAAAAAAAAA==" + }, + "Dns": "", + "Serial": 1 + }, + "DNSSettings": { + "DisabledManagementGroups": [ + "disabled-group-1", + "disabled-group-2" + ] + }, + "AccountSettings": { + "PeerLoginExpirationEnabled": true, + "PeerLoginExpiration": 86400000000000, + "PeerInactivityExpirationEnabled": false, + "PeerInactivityExpiration": 86400000000000, + "DNSDomain": "", + "IPv6EnabledGroups": [ + "33-group-one-resource-id" + ], + "RoutingPeerDNSResolutionEnabled": false, + "LazyConnectionEnabled": false, + "AutoUpdateVersion": "disabled", + "AutoUpdateAlways": false, + "MetricsPushEnabled": false + }, + "PostureChecks": { + "posturecheck-331": { + "ID": "posturecheck-331", + "Checks": { + "NBVersionCheck": { + "MinVersion": "0.25.0" + }, + "OSVersionCheck": { + "Android": null, + "Darwin": { + "MinVersion": "12.0" + }, + "Ios": null, + "Linux": null, + "Windows": null + }, + "GeoLocationCheck": { + "Locations": [ + { + "CountryCode": "FI", + "CityName": "" + } + ], + "Action": "allow" + }, + "PeerNetworkRangeCheck": { + "Action": "deny", + "Ranges": [ + "192.168.0.1/24" + ] + }, + "ProcessCheck": null + } + } + }, + "PostureValidation": null, + "AllowedUserIDs": {}, + "NetworkXIDToPublicID": { + "network-331": "network-1-public", + "network-332": "network-2-public" + }, + "PostureCheckXIDToPublicID": { + "posturecheck-331": "posturecheck-1-public" + }, + "ValidatedPeers": { + "peer-id-1": {}, + "peer-id-2": {}, + "peer-id-3": {} + }, + "ResourcePolicies": {}, + "Routers": { + "network-id-1": { + "peer-id-331": { + "PublicID": "public-id-1", + "PeerGroups": [ + "33-group-one-resource-id" + ], + "Masquerade": true, + "Metric": 999, + "Enabled": true + } + }, + "network-id-2": { + "peer-id-332": { + "PublicID": "public-id-2", + "PeerGroups": [ + "33-group-two-resources-id", + "33-group-no-resources-id" + ], + "Masquerade": true, + "Metric": 333, + "Enabled": true + }, + "peer-id-333": { + "PublicID": "public-id-2", + "PeerGroups": [ + "33-group-two-resources-id", + "33-group-no-resources-id" + ], + "Masquerade": true, + "Metric": 333, + "Enabled": true + } + } + }, + "GroupIDToUserIDs": {}, + "DNSDomain": "", + "ProxyTargetedDomainResourceIDs": {}, + "AppliedZoneCandidates": [ + { + "DistributionGroups": [ + "33-group-one-resource-id" + ], + "Zone": { + "Domain": "test-331.com.", + "Records": [ + { + "Name": "test.test-331.com.", + "Type": 1, + "Class": "IN", + "TTL": 1800, + "RData": "1.1.1.1" + }, + { + "Name": "test2.test-331.com.", + "Type": 1, + "Class": "IN", + "TTL": 1800, + "RData": "1.1.1.2" + } + ], + "SearchDomainDisabled": false, + "NonAuthoritative": true + } + }, + { + "DistributionGroups": [ + "33-group-two-resources-id" + ], + "Zone": { + "Domain": "search-off-331.com.", + "Records": [ + { + "Name": "test.search-off-331.com.", + "Type": 1, + "Class": "IN", + "TTL": 1800, + "RData": "1.1.1.3" + }, + { + "Name": "alias.search-off-331.com.", + "Type": 5, + "Class": "IN", + "TTL": 1800, + "RData": "test.search-off-331.com." + } + ], + "SearchDomainDisabled": true, + "NonAuthoritative": true + } + } + ], + "PrivateServiceCandidates": null, + "Services": null, + "Domains": [ + { + "Domain": "test-331.com", + "TargetCluster": "target-1.cluster.local" + } + ] +} \ No newline at end of file diff --git a/integration_tests/management/network_map_db/network_map_data_test.go b/integration_tests/management/network_map_db/network_map_data_test.go new file mode 100644 index 000000000..00c0ec03f --- /dev/null +++ b/integration_tests/management/network_map_db/network_map_data_test.go @@ -0,0 +1,74 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + _ "embed" + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + "github.com/netbirdio/netbird/management/server/integrations/integrated_validator" + "github.com/netbirdio/netbird/management/server/settings" + "github.com/netbirdio/netbird/management/server/types" + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +//go:embed network_map_data.sql +var nmapData string + +//go:embed network_map_data_golden.json +var goldenNMap string + +const EnvUpdateGoldenData = "NMAP_UPDATE_GOLDEN_DATA" + +func TestGetNetworkMapData(t *testing.T) { + ctx := context.TODO() + + // The two mocks are generated by different mock frameworks, so each needs a + // controller of its own kind. + extraSettingsManager := settings.NewMockManager(gomock.NewController(t)) + extraSettingsManager.EXPECT().GetExtraSettings(gomock.Any(), gomock.Any()).Return(&types.ExtraSettings{}, nil) + + peerValidators := integrated_validator.NewMockIntegratedValidator(gomock.NewController(t)) + peerValidators.EXPECT().GetValidatedPeers(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return( + map[string]struct{}{ + "peer-id-1": {}, + "peer-id-2": {}, + "peer-id-3": {}, + }, nil) + + storeImpl := networkmapdb.NetworkMapDBStoreImpl{ + Store: store(t), + ExtraSettingsManager: extraSettingsManager, + IntegratedPeerValidator: peerValidators, + } + + for _, query := range strings.Split(nmapData, ";") { + if err := store(t).Exec(ctx, query); err != nil { + log.Fatalf("error initializing nmap test: %s", err.Error()) + } + } + + nmap, err := storeImpl.GetNetworkMapData(ctx, "account-33") + assert.NoError(t, err) + + serializedNMap, err := json.MarshalIndent(nmap, "", " ") + assert.NoError(t, err) + + if _, ok := os.LookupEnv(EnvUpdateGoldenData); ok { + _, filename, _, _ := runtime.Caller(0) + tosavepath := filepath.Join(filepath.Dir(filename), "network_map_data_golden.json") + err = os.WriteFile(tosavepath, serializedNMap, 0644) + assert.NoError(t, err) + goldenNMap = string(serializedNMap) + } + assert.Equal(t, goldenNMap, string(serializedNMap)) +} diff --git a/integration_tests/management/network_map_db/network_resource_test.go b/integration_tests/management/network_map_db/network_resource_test.go new file mode 100644 index 000000000..4325ed3ba --- /dev/null +++ b/integration_tests/management/network_map_db/network_resource_test.go @@ -0,0 +1,65 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "net/netip" + "testing" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetNetworkResources(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into network_resources (id, account_id, network_id, public_id, name, description, type, domain, prefix, enabled) + VALUES('net-resource-1','account-1','network-1','net-resource-public-1','network-resource-1','network-resource-1','subnet','','"10.0.0.0/16"',TRUE)`) + execQuery(t, ctx, + `insert into network_resources (id, account_id, network_id, public_id, name, description, type, domain, prefix, enabled) + VALUES('net-resource-2','account-1','network-2','net-resource-public-2','network-resource-2','network-resource-2','domain','test.com','',TRUE)`) + execQuery(t, ctx, + `insert into network_resources (id, account_id, network_id, public_id, name, description, type, domain, prefix, enabled) + VALUES('net-resource-3','account-1','network-3','net-resource-public-3','network-resource-3','network-resource-3','host','','"10.0.0.1/32"',TRUE)`) + + resources, err := conn(t, ctx).GetNetworkResources(ctx, "account-1") + assert.NoError(t, err) + + assert.Contains(t, resources, nmdata.NetworkResource{ + ID: "net-resource-1", + AccountID: "account-1", + NetworkID: "network-1", + PublicID: "net-resource-public-1", + Name: "network-resource-1", + Description: "network-resource-1", + Type: "subnet", + Domain: "", + Prefix: netip.MustParsePrefix("10.0.0.0/16"), + Enabled: true, + }) + assert.Contains(t, resources, nmdata.NetworkResource{ + ID: "net-resource-2", + AccountID: "account-1", + NetworkID: "network-2", + PublicID: "net-resource-public-2", + Name: "network-resource-2", + Description: "network-resource-2", + Type: "domain", + Domain: "test.com", + Enabled: true, + }) + assert.Contains(t, resources, nmdata.NetworkResource{ + ID: "net-resource-3", + AccountID: "account-1", + NetworkID: "network-3", + PublicID: "net-resource-public-3", + Name: "network-resource-3", + Description: "network-resource-3", + Type: "host", + Domain: "", + Prefix: netip.MustParsePrefix("10.0.0.1/32"), + Enabled: true, + }) +} diff --git a/integration_tests/management/network_map_db/network_router_test.go b/integration_tests/management/network_map_db/network_router_test.go new file mode 100644 index 000000000..2baf146a7 --- /dev/null +++ b/integration_tests/management/network_map_db/network_router_test.go @@ -0,0 +1,45 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "testing" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetNetworkRouters(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into network_routers (id, account_id, public_id, peer, network_id, masquerade, metric, enabled, peer_groups) + VALUES('test-nr-id-1','account-1','public-id-1','peer-id-1','network-id-1',TRUE,999,TRUE,'["group-one-resource-id"]')`) + execQuery(t, ctx, + `insert into network_routers (id, account_id, public_id, peer, network_id, masquerade, metric, enabled, peer_groups) + VALUES('test-nr-id-2','account-1','public-id-2','','network-id-2',TRUE,333,TRUE,'["group-two-resources-id","group-no-resources-id"]')`) + // empty peer_groups + execQuery(t, ctx, + `insert into network_routers (id, account_id, public_id, peer, network_id, masquerade, metric, enabled, peer_groups) + VALUES('test-nr-id-3','account-1','public-id-3','peer-id-3','network-id-3',TRUE,999,TRUE,'[]')`) + // nil peer_groups + execQuery(t, ctx, + `insert into network_routers (id, account_id, public_id, peer, network_id, masquerade, metric, enabled, peer_groups) + VALUES('test-nr-id-4','account-1','public-id-4','peer-id-4','network-id-4',TRUE,999,TRUE,null)`) + + routers, err := conn(t, ctx).GetNetworkRouters(ctx, "account-1") + assert.NoError(t, err) + assert.NotEmpty(t, routers) + + assert.Equal(t, routers["network-id-1"], + map[string]*nmdata.NetworkRouter{"peer-id-1": {PublicID: "public-id-1", Masquerade: true, Metric: 999, Enabled: true, PeerGroups: []string{"group-one-resource-id"}}}) + assert.Equal(t, routers["network-id-2"], + map[string]*nmdata.NetworkRouter{ + "peer-id-2": {PublicID: "public-id-2", Masquerade: true, Metric: 333, Enabled: true, PeerGroups: []string{"group-two-resources-id", "group-no-resources-id"}}, + "peer-id-3": {PublicID: "public-id-2", Masquerade: true, Metric: 333, Enabled: true, PeerGroups: []string{"group-two-resources-id", "group-no-resources-id"}}}) + assert.Equal(t, routers["network-id-3"], + map[string]*nmdata.NetworkRouter{"peer-id-3": {PublicID: "public-id-3", Masquerade: true, Metric: 999, Enabled: true, PeerGroups: []string{}}}) + assert.Equal(t, routers["network-id-4"], + map[string]*nmdata.NetworkRouter{"peer-id-4": {PublicID: "public-id-4", Masquerade: true, Metric: 999, Enabled: true, PeerGroups: nil}}) +} diff --git a/integration_tests/management/network_map_db/network_test.go b/integration_tests/management/network_map_db/network_test.go new file mode 100644 index 000000000..fbccee504 --- /dev/null +++ b/integration_tests/management/network_map_db/network_test.go @@ -0,0 +1,56 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "encoding/json" + "net" + "testing" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetNetwork(t *testing.T) { + ctx := context.TODO() + + network, err := conn(t, ctx).GetNetwork(ctx, "account-1") + assert.NoError(t, err) + assert.Equal(t, network, nmdata.Network{ + Identifier: "network-1", + Net: mustParseCIDR("100.103.0.0/16"), + NetV6: mustParseCIDR("fdde:e995:fd38:a465::/64"), + Serial: 1, + }) + + network, err = conn(t, ctx).GetNetwork(ctx, "account-2") + assert.NoError(t, err) + assert.Equal(t, network, nmdata.Network{ + Identifier: "network-2", + Net: mustParseCIDR("110.0.0.0/16"), + NetV6: mustParseCIDR("fddf:e995:fd38:a465::/64"), + Serial: 2, + }) +} + +func mustParseCIDR(s string) net.IPNet { + var toret net.IPNet + + _, net, err := net.ParseCIDR(s) + if err != nil { + panic(err) + } + + jn, err := json.Marshal(net) + if err != nil { + panic(err) + } + + err = json.Unmarshal(jn, &toret) + if err != nil { + panic(err) + } + + return toret +} diff --git a/integration_tests/management/network_map_db/networks_test.go b/integration_tests/management/network_map_db/networks_test.go new file mode 100644 index 000000000..5af771522 --- /dev/null +++ b/integration_tests/management/network_map_db/networks_test.go @@ -0,0 +1,26 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetNetworks(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into networks (id, account_id, public_id) VALUES('network-1','account-1','network-1-public')`) + execQuery(t, ctx, + `insert into networks (id, account_id, public_id) VALUES('network-2','account-1','network-2-public')`) + + networksIdx, err := conn(t, ctx).GetNetworkXIDToPublicIdMap(ctx, "account-1") + assert.NoError(t, err) + assert.Equal(t, networksIdx, map[string]string{ + "network-1": "network-1-public", + "network-2": "network-2-public", + }) +} diff --git a/integration_tests/management/network_map_db/peer_test.go b/integration_tests/management/network_map_db/peer_test.go new file mode 100644 index 000000000..cf2d7928f --- /dev/null +++ b/integration_tests/management/network_map_db/peer_test.go @@ -0,0 +1,169 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "net" + "net/netip" + "testing" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetPeers(t *testing.T) { + ctx := context.TODO() + + peers, clusterToPeersIdx, err := conn(t, ctx).GetPeers(ctx, "account-1") + assert.NoError(t, err) + + // shouldn't be returned in the index, as it's not connected + execQuery(t, ctx, + `insert into peers (id,account_id,"key",ssh_key,proxy_meta_embedded,peer_status_connected) + values('peer-4','account-1','key-4','ssh-key-4',true,false)`) + // shouldn't be returned in the index as it doesn't have cluster set + execQuery(t, ctx, + `insert into peers (id,account_id,"key",ssh_key,proxy_meta_embedded,peer_status_connected) + values('peer-5','account-1','key-5','ssh-key-5',false,true)`) + + peer1 := nmdata.Peer{ + ID: "peer-id-1", + Key: "key-1", + SSHKey: "ssh-key-1", + DNSLabel: "peer-1", + ExtraDNSLabels: []string{"extra-peer-1"}, + UserID: "user-id-1", + SSHEnabled: true, + LoginExpirationEnabled: true, + LastLogin: mustParseTime("2026-08-06T13:25:59.12999+00:00"), + IP: netip.MustParseAddr("10.10.10.1"), + IPv6: netip.MustParseAddr("fdf4:ba80:6aa5:89f1:44d7:8701:8699:4940"), + RequiresApproval: false, + Connected: true, + Meta: nmdata.PeerSystemMeta{ + WtVersion: "0.76.0", + GoOS: "linux", + OSVersion: "26.4.1", + KernelVersion: "6.8.0-134-generic", + NetworkAddresses: []nmdata.NetworkAddress{ + {NetIP: netip.MustParsePrefix("fe80::8b4c:973f:a76b:3771/64")}, + {NetIP: netip.MustParsePrefix("192.168.16.1/20")}, + }, + Files: []nmdata.File{ + {Path: "/usr/bin/netbird", ProcessIsRunning: false}, + }, + Capabilities: []int32{1, 2}, + Flags: nmdata.Flags{ + ServerSSHAllowed: true, + DisableIPv6: false, + }, + SyncMessageVersion: 1, + }, + ProxyMeta: nmdata.ProxyMeta{ + Embedded: true, + Cluster: "cluster-1.netbird.services", + }, + Location: nmdata.PeerLocation{ + CountryCode: "DE", + CityName: "Berlin", + ConnectionIP: net.ParseIP("46.201.148.187"), + }, + } + peer2 := nmdata.Peer{ + ID: "peer-id-2", + Key: "key-2", + SSHKey: "ssh-key-2", + DNSLabel: "peer-2", + ExtraDNSLabels: []string{"extra-peer-2"}, + UserID: "user-id-2", + SSHEnabled: true, + LoginExpirationEnabled: true, + LastLogin: mustParseTime("2026-08-06T14:25:59.12999+00:00"), + IP: netip.MustParseAddr("10.10.100.1"), + IPv6: netip.MustParseAddr("fdf5:ba80:6aa5:89f1:44d7:8701:8699:4940"), + RequiresApproval: false, + Connected: true, + Meta: nmdata.PeerSystemMeta{ + WtVersion: "0.76.1", + GoOS: "linux", + OSVersion: "26.4.2", + KernelVersion: "6.8.0-135-generic", + NetworkAddresses: []nmdata.NetworkAddress{ + {NetIP: netip.MustParsePrefix("fe81::8b4c:973f:a76b:3771/64")}, + {NetIP: netip.MustParsePrefix("192.168.17.1/20")}, + }, + Files: []nmdata.File{ + {Path: "/usr/bin/netbird", ProcessIsRunning: false}, + }, + Capabilities: []int32{1, 2}, + Flags: nmdata.Flags{ + ServerSSHAllowed: true, + DisableIPv6: false, + }, + SyncMessageVersion: 0, + }, + ProxyMeta: nmdata.ProxyMeta{ + Embedded: true, + Cluster: "cluster-2.netbird.services", + }, + Location: nmdata.PeerLocation{ + CountryCode: "DE", + CityName: "Berlin", + ConnectionIP: net.ParseIP("46.201.149.187"), + }, + } + peer3 := nmdata.Peer{ + ID: "peer-id-3", + Key: "key-3", + SSHKey: "ssh-key-3", + DNSLabel: "peer-3", + ExtraDNSLabels: []string{"extra-peer-3"}, + UserID: "user-id-3", + SSHEnabled: true, + LoginExpirationEnabled: true, + LastLogin: mustParseTime("2026-08-06T12:25:59.12999+00:00"), + IP: netip.MustParseAddr("10.10.200.1"), + IPv6: netip.MustParseAddr("fdf6:ba80:6aa5:89f1:44d7:8701:8699:4940"), + RequiresApproval: false, + Connected: true, + Meta: nmdata.PeerSystemMeta{ + WtVersion: "0.76.2", + GoOS: "linux", + OSVersion: "26.4.3", + KernelVersion: "6.8.0-136-generic", + NetworkAddresses: []nmdata.NetworkAddress{ + {NetIP: netip.MustParsePrefix("fe82::8b4c:973f:a76b:3771/64")}, + {NetIP: netip.MustParsePrefix("192.168.18.1/20")}, + }, + Files: []nmdata.File{ + {Path: "/usr/bin/netbird", ProcessIsRunning: false}, + }, + Capabilities: []int32{1, 2}, + Flags: nmdata.Flags{ + ServerSSHAllowed: true, + DisableIPv6: false, + }, + SyncMessageVersion: 1, + }, + ProxyMeta: nmdata.ProxyMeta{ + Embedded: true, + Cluster: "cluster-3.netbird.services", + }, + Location: nmdata.PeerLocation{ + CountryCode: "DE", + CityName: "Berlin", + ConnectionIP: net.ParseIP("46.201.150.187"), + }, + } + + assert.Contains(t, peers, peer1) + assert.Contains(t, peers, peer2) + assert.Contains(t, peers, peer3) + + assert.Equal(t, clusterToPeersIdx, map[string][]*nmdata.Peer{ + "cluster-1.netbird.services": {&peer1}, + "cluster-2.netbird.services": {&peer2}, + "cluster-3.netbird.services": {&peer3}, + }) +} diff --git a/integration_tests/management/network_map_db/pg_test_store.go b/integration_tests/management/network_map_db/pg_test_store.go new file mode 100644 index 000000000..1710747b5 --- /dev/null +++ b/integration_tests/management/network_map_db/pg_test_store.go @@ -0,0 +1,121 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "fmt" + "regexp" + "strings" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/google/uuid" + networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql" + gormstore "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/testutil" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func createPGTestStore(baseData string) (*networkmap_pgsql.PgStore, func()) { + _, tmpdsn, err := testutil.CreatePostgresTestContainer() + if err != nil { + log.Fatalf("error starting postres container %v", err) + } + + var db *gorm.DB + for i := range 5 { + db, err = gorm.Open(postgres.Open(tmpdsn), &gorm.Config{}) + + if err == nil { + break + } + + if i < 5 { + waitTime := time.Duration(100*(i+1)) * time.Millisecond + time.Sleep(waitTime) + continue + } + + log.Fatalf("error connecting to postres db %v", err) + } + + var cleanup func() + dsn, cleanup, err := createRandomDB(tmpdsn, db) + sqlDB, _ := db.DB() + if sqlDB != nil { + sqlDB.Close() + } + if err != nil { + log.Fatalf("error creating postres db %v", err) + } + + _, err = gormstore.NewPostgresqlStoreForTests(context.TODO(), dsn, nil, false) + if err != nil { + log.Fatalf("error running migrations %v", err) + } + + ctx := context.TODO() + pgstore, err := networkmap_pgsql.NewPostgresqlStore(ctx, dsn) + if err != nil { + log.Fatal("error creating postgres store %w", err) + } + + for _, query := range strings.Split(baseData, ";") { + if _, err := pgstore.Pool.Exec(ctx, query); err != nil { + log.Fatalf("error initializing db: %s", err.Error()) + } + } + + return pgstore, cleanup +} + +func createRandomDB(dsn string, db *gorm.DB) (string, func(), error) { + dbName := fmt.Sprintf("test_db_%s", strings.ReplaceAll(uuid.New().String(), "-", "_")) + + if err := db.Exec(fmt.Sprintf("CREATE DATABASE %s", dbName)).Error; err != nil { + return "", nil, fmt.Errorf("failed to create database: %v", err) + } + + originalDSN := dsn + + cleanup := func() { + var dropDB *gorm.DB + var err error + + dropDB, err = gorm.Open(postgres.Open(originalDSN), &gorm.Config{ + SkipDefaultTransaction: true, + PrepareStmt: false, + }) + if err != nil { + log.Errorf("failed to connect for dropping database %s: %v", dbName, err) + return + } + defer func() { + if sqlDB, _ := dropDB.DB(); sqlDB != nil { + sqlDB.Close() + } + }() + + if sqlDB, _ := dropDB.DB(); sqlDB != nil { + sqlDB.SetMaxOpenConns(1) + sqlDB.SetMaxIdleConns(0) + sqlDB.SetConnMaxLifetime(time.Second) + } + + err = dropDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s WITH (FORCE)", dbName)).Error + + if err != nil { + log.Errorf("failed to drop database %s: %v", dbName, err) + } + } + + return replaceDBName(dsn, dbName), cleanup, nil +} + +func replaceDBName(dsn, newDBName string) string { + re := regexp.MustCompile(`(?P
                                                [:/@])(?P[^/?]+)(?P\?|$)`)
                                                +	return re.ReplaceAllString(dsn, `${pre}`+newDBName+`${post}`)
                                                +}
                                                diff --git a/integration_tests/management/network_map_db/policy_test.go b/integration_tests/management/network_map_db/policy_test.go
                                                new file mode 100644
                                                index 000000000..1f4c543da
                                                --- /dev/null
                                                +++ b/integration_tests/management/network_map_db/policy_test.go
                                                @@ -0,0 +1,146 @@
                                                +//go:build integration
                                                +
                                                +package networkmap_pgsql
                                                +
                                                +import (
                                                +	"context"
                                                +	"testing"
                                                +
                                                +	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
                                                +	"github.com/stretchr/testify/assert"
                                                +)
                                                +
                                                +func TestGetPolicies(t *testing.T) {
                                                +	ctx := context.TODO()
                                                +
                                                +	execQuery(t, ctx,
                                                +		`insert into policies (id, public_id, account_id, enabled, source_posture_checks)
                                                +		 values('policy-1','policy-1-public','account-1',true,'["posture-checks-1","posture-checks-2"]')`)
                                                +	execQuery(t, ctx,
                                                +		`insert into policy_rules (id, policy_id, enabled, action, protocol, bidirectional, sources, destinations,
                                                +		                           source_resource, destination_resource, ports, port_ranges,
                                                +								   authorized_groups, authorized_user)
                                                +		 values('policy-1-rule-1','policy-1',true,'accept','tcp',true,'["group-one-resource-id","group-two-resources-id"]','["group-one-resource-id","group-two-resources-id"]',
                                                +		        '{"ID":"host-id-1","Type":"host"}','{"ID":"domain-1","Type":"domain"}','["8080","8443"]', '[{"Start":8080,"End":8090}]',
                                                +				'{"group-one-resource-id":["user-1", "user-2"]}','user-3')`)
                                                +	execQuery(t, ctx,
                                                +		`insert into policies (id, public_id, account_id, enabled, source_posture_checks)
                                                +		 values('policy-2','policy-2-public','account-1',true,'["posture-checks-3","posture-checks-4"]')`)
                                                +	execQuery(t, ctx,
                                                +		`insert into policy_rules (id, policy_id, enabled, action, protocol, bidirectional, sources, destinations,
                                                +		                           source_resource, destination_resource, ports, port_ranges,
                                                +								   authorized_groups, authorized_user)
                                                +		 values('policy-2-rule-1','policy-2',true,'accept','tcp',true,'["group-one-resource-id"]','["group-two-resources-id"]',
                                                +		        '{"ID":"host-id-3","Type":"host"}','{"ID":"domain-3","Type":"domain"}','["8080","8443"]', '[{"Start":8080,"End":8090}]',
                                                +				'{"group-one-resource-id":["user-6", "user-7"]}','user-8')`)
                                                +	// policy with a rule with null fields
                                                +	execQuery(t, ctx,
                                                +		`insert into policies (id, public_id, account_id, enabled, source_posture_checks)
                                                +		 values('policy-3','policy-3-public','account-1',true,null)`)
                                                +	execQuery(t, ctx,
                                                +		`insert into policy_rules (id, policy_id, enabled, action, protocol, bidirectional, sources, destinations,
                                                +		                           source_resource, destination_resource, ports, port_ranges,
                                                +								   authorized_groups, authorized_user)
                                                +		 values('policy-3-rule-1','policy-3',true,null,null,null,null,null,null,null,null,null,null,null)`)
                                                +	// policy with a disabled rule, destination resource and groups should not be in indexes
                                                +	execQuery(t, ctx,
                                                +		`insert into policies (id, public_id, account_id, enabled, source_posture_checks)
                                                +		 values('policy-4','policy-4-public','account-1',true,null)`)
                                                +	execQuery(t, ctx,
                                                +		`insert into policy_rules (id, policy_id, enabled, action, protocol, bidirectional, sources, destinations,
                                                +		                           source_resource, destination_resource, ports, port_ranges,
                                                +								   authorized_groups, authorized_user)
                                                +		 values('policy-4-rule-1','policy-4',false,null,null,null,null,'["group-two-resources-id"]',
                                                +		        null,'{"ID":"domain-3","Type":"domain"}',null,null,null,null)`)
                                                +
                                                +	policies, policyToDestinationResourceIdx, policyToDestinationGroupIdx, err := conn(t, ctx).GetPolicies(ctx, "account-1")
                                                +	assert.NoError(t, err)
                                                +
                                                +	assert.Contains(t, policies, nmdata.Policy{
                                                +		ID:                  "policy-1",
                                                +		PublicID:            "policy-1-public",
                                                +		Enabled:             true,
                                                +		SourcePostureChecks: []string{"posture-checks-1", "posture-checks-2"},
                                                +		Rules: []*nmdata.PolicyRule{
                                                +			{
                                                +				ID:                  "policy-1",
                                                +				PolicyID:            "policy-1",
                                                +				Enabled:             true,
                                                +				Action:              "accept",
                                                +				Protocol:            "tcp",
                                                +				Bidirectional:       true,
                                                +				Sources:             []string{"group-one-resource-id", "group-two-resources-id"},
                                                +				Destinations:        []string{"group-one-resource-id", "group-two-resources-id"},
                                                +				SourceResource:      nmdata.Resource{ID: "host-id-1", Type: "host"},
                                                +				DestinationResource: nmdata.Resource{ID: "domain-1", Type: "domain"},
                                                +				Ports:               []string{"8080", "8443"},
                                                +				PortRanges:          []nmdata.RulePortRange{{Start: 8080, End: 8090}},
                                                +				AuthorizedGroups:    map[string][]string{"group-one-resource-id": {"user-1", "user-2"}},
                                                +				AuthorizedUser:      "user-3",
                                                +			},
                                                +		},
                                                +	})
                                                +
                                                +	assert.Contains(t, policies, nmdata.Policy{
                                                +		ID:                  "policy-2",
                                                +		PublicID:            "policy-2-public",
                                                +		Enabled:             true,
                                                +		SourcePostureChecks: []string{"posture-checks-3", "posture-checks-4"},
                                                +		Rules: []*nmdata.PolicyRule{
                                                +			{
                                                +				ID:                  "policy-2",
                                                +				PolicyID:            "policy-2",
                                                +				Enabled:             true,
                                                +				Action:              "accept",
                                                +				Protocol:            "tcp",
                                                +				Bidirectional:       true,
                                                +				Sources:             []string{"group-one-resource-id"},
                                                +				Destinations:        []string{"group-two-resources-id"},
                                                +				SourceResource:      nmdata.Resource{ID: "host-id-3", Type: "host"},
                                                +				DestinationResource: nmdata.Resource{ID: "domain-3", Type: "domain"},
                                                +				Ports:               []string{"8080", "8443"},
                                                +				PortRanges:          []nmdata.RulePortRange{{Start: 8080, End: 8090}},
                                                +				AuthorizedGroups:    map[string][]string{"group-one-resource-id": {"user-6", "user-7"}},
                                                +				AuthorizedUser:      "user-8",
                                                +			},
                                                +		},
                                                +	})
                                                +
                                                +	assert.Contains(t, policies, nmdata.Policy{
                                                +		ID:                  "policy-3",
                                                +		PublicID:            "policy-3-public",
                                                +		Enabled:             true,
                                                +		SourcePostureChecks: nil,
                                                +		Rules: []*nmdata.PolicyRule{
                                                +			{
                                                +				ID:       "policy-3",
                                                +				PolicyID: "policy-3",
                                                +				Enabled:  true,
                                                +			},
                                                +		},
                                                +	})
                                                +	assert.Contains(t, policies, nmdata.Policy{
                                                +		ID:                  "policy-4",
                                                +		PublicID:            "policy-4-public",
                                                +		Enabled:             true,
                                                +		SourcePostureChecks: nil,
                                                +		Rules: []*nmdata.PolicyRule{
                                                +			{
                                                +				ID:                  "policy-4",
                                                +				PolicyID:            "policy-4",
                                                +				Enabled:             false,
                                                +				Destinations:        []string{"group-two-resources-id"},
                                                +				DestinationResource: nmdata.Resource{ID: "domain-3", Type: "domain"},
                                                +			},
                                                +		},
                                                +	})
                                                +
                                                +	assert.Equal(t, policyToDestinationGroupIdx, map[string]map[string]any{
                                                +		"policy-1": {"group-one-resource-id": struct{}{}, "group-two-resources-id": struct{}{}},
                                                +		"policy-2": {"group-two-resources-id": struct{}{}},
                                                +	})
                                                +	assert.Equal(t, policyToDestinationResourceIdx, map[string]map[string]any{
                                                +		"policy-1": {"domain-1": struct{}{}},
                                                +		"policy-2": {"domain-3": struct{}{}},
                                                +	})
                                                +}
                                                diff --git a/integration_tests/management/network_map_db/posture_test.go b/integration_tests/management/network_map_db/posture_test.go
                                                new file mode 100644
                                                index 000000000..2b4bb3f3d
                                                --- /dev/null
                                                +++ b/integration_tests/management/network_map_db/posture_test.go
                                                @@ -0,0 +1,61 @@
                                                +//go:build integration
                                                +
                                                +package networkmap_pgsql
                                                +
                                                +import (
                                                +	"context"
                                                +	"net/netip"
                                                +	"testing"
                                                +
                                                +	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
                                                +	"github.com/stretchr/testify/assert"
                                                +)
                                                +
                                                +func TestGetPostureChecks(t *testing.T) {
                                                +	ctx := context.TODO()
                                                +
                                                +	execQuery(t, ctx,
                                                +		`insert into posture_checks (id, account_id, public_id, checks)
                                                +		VALUES('posturecheck-1','account-1','posturecheck-1-public',
                                                +		'{"NBVersionCheck":{"MinVersion":"0.25.0"},
                                                +		  "OSVersionCheck":{"Darwin":{"MinVersion":"12.0"}},
                                                +		  "GeoLocationCheck":{"Locations":[{"CountryCode":"FI","CityName":""}],"Action":"allow"},
                                                +		  "PeerNetworkRangeCheck":{"Action":"deny","Ranges":["192.168.0.1/24"]}}')`)
                                                +
                                                +	execQuery(t, ctx,
                                                +		`insert into posture_checks (id, account_id, public_id, checks)
                                                +		VALUES('posturecheck-2','account-1','posturecheck-2-public',
                                                +		'{"NBVersionCheck":{"MinVersion":"0.25.0"},
                                                +		  "OSVersionCheck":{"Android":{"MinVersion":"0"}},
                                                +		  "GeoLocationCheck":{"Locations":[{"CountryCode":"US","CityName":"Harker Heights"}],"Action":"allow"},
                                                +		  "PeerNetworkRangeCheck":{"Action":"allow","Ranges":["0.0.0.0/0"]}}')`)
                                                +	execQuery(t, ctx,
                                                +		`insert into posture_checks (id, account_id, public_id, checks)
                                                +		VALUES('posturecheck-3','account-1','posturecheck-3-public', null)`)
                                                +
                                                +	postureChecks, idToPublicIDIdx, err := conn(t, ctx).GetPostureChecks(ctx, "account-1")
                                                +	assert.NoError(t, err)
                                                +	assert.Equal(t, idToPublicIDIdx, map[string]string{
                                                +		"posturecheck-1": "posturecheck-1-public",
                                                +		"posturecheck-2": "posturecheck-2-public",
                                                +		"posturecheck-3": "posturecheck-3-public",
                                                +	})
                                                +	assert.Contains(t, postureChecks, nmdata.PostureChecks{
                                                +		ID: "posturecheck-1",
                                                +		Checks: nmdata.ChecksDefinition{
                                                +			NBVersionCheck:        &nmdata.NBVersionCheck{MinVersion: "0.25.0"},
                                                +			OSVersionCheck:        &nmdata.OSVersionCheck{Darwin: &nmdata.MinVersionCheck{MinVersion: "12.0"}},
                                                +			GeoLocationCheck:      &nmdata.GeoLocationCheck{Locations: []nmdata.GeoLocation{{CountryCode: "FI"}}, Action: "allow"},
                                                +			PeerNetworkRangeCheck: &nmdata.PeerNetworkRangeCheck{Action: "deny", Ranges: []netip.Prefix{netip.MustParsePrefix("192.168.0.1/24")}},
                                                +		}})
                                                +	assert.Contains(t, postureChecks, nmdata.PostureChecks{
                                                +		ID: "posturecheck-2",
                                                +		Checks: nmdata.ChecksDefinition{
                                                +			NBVersionCheck:        &nmdata.NBVersionCheck{MinVersion: "0.25.0"},
                                                +			OSVersionCheck:        &nmdata.OSVersionCheck{Android: &nmdata.MinVersionCheck{MinVersion: "0"}},
                                                +			GeoLocationCheck:      &nmdata.GeoLocationCheck{Locations: []nmdata.GeoLocation{{CountryCode: "US", CityName: "Harker Heights"}}, Action: "allow"},
                                                +			PeerNetworkRangeCheck: &nmdata.PeerNetworkRangeCheck{Action: "allow", Ranges: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")}},
                                                +		}})
                                                +	assert.Contains(t, postureChecks, nmdata.PostureChecks{
                                                +		ID: "posturecheck-3"})
                                                +}
                                                diff --git a/integration_tests/management/network_map_db/route_test.go b/integration_tests/management/network_map_db/route_test.go
                                                new file mode 100644
                                                index 000000000..12e9302f9
                                                --- /dev/null
                                                +++ b/integration_tests/management/network_map_db/route_test.go
                                                @@ -0,0 +1,87 @@
                                                +//go:build integration
                                                +
                                                +package networkmap_pgsql
                                                +
                                                +import (
                                                +	"context"
                                                +	"net/netip"
                                                +	"testing"
                                                +
                                                +	"github.com/netbirdio/netbird/shared/management/domain"
                                                +	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
                                                +	"github.com/stretchr/testify/assert"
                                                +)
                                                +
                                                +func TestGetRoutes(t *testing.T) {
                                                +	ctx := context.TODO()
                                                +
                                                +	execQuery(t, ctx,
                                                +		`insert into routes (id, account_id, public_id, network, domains, keep_route, net_id, description,
                                                +	                         peer, peer_groups, network_type, masquerade, metric, enabled, 
                                                +	                         groups, access_control_groups, skip_auto_apply)
                                                +		VALUES('route-1','account-1','route-1-public','"172.0.0.0/16"','["test-1.com"]',true,'route-1-net-id','route-1',
                                                +		        'peer-id-1','["group-one-resource-id"]',1,true,9999,true,
                                                +				'["group-one-resource-id"]','["group-one-resource-id"]',false)`)
                                                +	execQuery(t, ctx,
                                                +		`insert into routes (id, account_id, public_id, network, domains, keep_route, net_id, description,
                                                +	                         peer, peer_groups, network_type, masquerade, metric, enabled, 
                                                +	                         groups, access_control_groups, skip_auto_apply)
                                                +		VALUES('route-2','account-1','route-2-public','"172.10.0.0/16"','["test-1.com","test-2.com"]',true,'route-2-net-id','route-2',
                                                +		        'peer-id-2','["group-two-resources-id"]',1,true,9999,true,
                                                +				'["group-two-resources-id"]','["group-two-resources-id"]',false)`)
                                                +	execQuery(t, ctx,
                                                +		`insert into routes (id, account_id, public_id, network, domains, keep_route, net_id, description,
                                                +	                         peer, peer_groups, network_type, masquerade, metric, enabled, 
                                                +	                         groups, access_control_groups, skip_auto_apply)
                                                +		VALUES('route-3','account-1','route-3-public',null,null,null,null,'route-3',
                                                +		        null,null,null,null,null,null,null,null,null)`)
                                                +
                                                +	routes, err := conn(t, ctx).GetRoutes(ctx, "account-1")
                                                +	assert.NoError(t, err)
                                                +	assert.Contains(t, routes, nmdata.Route{
                                                +		ID:                  "route-1",
                                                +		AccountID:           "account-1",
                                                +		PublicID:            "route-1-public",
                                                +		Network:             netip.MustParsePrefix("172.0.0.0/16"),
                                                +		Domains:             domain.List{"test-1.com"},
                                                +		KeepRoute:           true,
                                                +		NetID:               "route-1-net-id",
                                                +		Description:         "route-1",
                                                +		Peer:                "peer-id-1",
                                                +		PeerID:              "peer-id-1",
                                                +		PeerGroups:          []string{"group-one-resource-id"},
                                                +		NetworkType:         1,
                                                +		Masquerade:          true,
                                                +		Metric:              9999,
                                                +		Enabled:             true,
                                                +		Groups:              []string{"group-one-resource-id"},
                                                +		AccessControlGroups: []string{"group-one-resource-id"},
                                                +		SkipAutoApply:       false,
                                                +	})
                                                +	assert.Contains(t, routes, nmdata.Route{
                                                +		ID:                  "route-2",
                                                +		AccountID:           "account-1",
                                                +		PublicID:            "route-2-public",
                                                +		Network:             netip.MustParsePrefix("172.10.0.0/16"),
                                                +		Domains:             domain.List{"test-1.com", "test-2.com"},
                                                +		KeepRoute:           true,
                                                +		NetID:               "route-2-net-id",
                                                +		Description:         "route-2",
                                                +		Peer:                "peer-id-2",
                                                +		PeerID:              "peer-id-2",
                                                +		PeerGroups:          []string{"group-two-resources-id"},
                                                +		NetworkType:         1,
                                                +		Masquerade:          true,
                                                +		Metric:              9999,
                                                +		Enabled:             true,
                                                +		Groups:              []string{"group-two-resources-id"},
                                                +		AccessControlGroups: []string{"group-two-resources-id"},
                                                +		SkipAutoApply:       false,
                                                +	})
                                                +	assert.Contains(t, routes, nmdata.Route{
                                                +		ID:          "route-3",
                                                +		AccountID:   "account-1",
                                                +		PublicID:    "route-3-public",
                                                +		Description: "route-3",
                                                +	})
                                                +}
                                                diff --git a/integration_tests/management/network_map_db/service_test.go b/integration_tests/management/network_map_db/service_test.go
                                                new file mode 100644
                                                index 000000000..effc7a707
                                                --- /dev/null
                                                +++ b/integration_tests/management/network_map_db/service_test.go
                                                @@ -0,0 +1,109 @@
                                                +//go:build integration
                                                +
                                                +package networkmap_pgsql
                                                +
                                                +import (
                                                +	"context"
                                                +	"database/sql"
                                                +	"testing"
                                                +
                                                +	"github.com/stretchr/testify/assert"
                                                +
                                                +	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
                                                +)
                                                +
                                                +func TestGetPrivateServices(t *testing.T) {
                                                +	ctx := context.TODO()
                                                +
                                                +	execQuery(t, ctx,
                                                +		`insert into services (id, account_id, enabled, private, access_groups, proxy_cluster, domain)
                                                +		 values('service-1','account-1',true,true,'["group-one-resource-id"]','test-1.com','test-2.com')`)
                                                +	execQuery(t, ctx,
                                                +		`insert into services (id, account_id, enabled, private, access_groups, proxy_cluster, domain)
                                                +		 values('service-2','account-1',true,true,'["group-one-resource-id","group-two-resources-id"]','test-3.com','test-4.com')`)
                                                +	execQuery(t, ctx,
                                                +		`insert into services (id, account_id, enabled, private, access_groups, proxy_cluster, domain)
                                                +		 values('service-3','account-1',null,null,null,null,null)`)
                                                +
                                                +	services, err := conn(t, ctx).GetPrivateServices(ctx, "account-1")
                                                +	assert.NoError(t, err)
                                                +	assert.Contains(t, services, networkmapdb.Service{
                                                +		Enabled:      sql.NullBool{Bool: true, Valid: true},
                                                +		Private:      sql.NullBool{Bool: true, Valid: true},
                                                +		AccessGroups: []string{"group-one-resource-id"},
                                                +		ProxyCluster: sql.NullString{String: "test-1.com", Valid: true},
                                                +		Domain:       sql.NullString{String: "test-2.com", Valid: true},
                                                +	})
                                                +	assert.Contains(t, services, networkmapdb.Service{
                                                +		Enabled:      sql.NullBool{Bool: true, Valid: true},
                                                +		Private:      sql.NullBool{Bool: true, Valid: true},
                                                +		AccessGroups: []string{"group-one-resource-id", "group-two-resources-id"},
                                                +		ProxyCluster: sql.NullString{String: "test-3.com", Valid: true},
                                                +		Domain:       sql.NullString{String: "test-4.com", Valid: true},
                                                +	})
                                                +	assert.Contains(t, services, networkmapdb.Service{
                                                +		Enabled:      sql.NullBool{Bool: false, Valid: false},
                                                +		Private:      sql.NullBool{Bool: false, Valid: false},
                                                +		AccessGroups: []string{},
                                                +		ProxyCluster: sql.NullString{String: "", Valid: false},
                                                +		Domain:       sql.NullString{String: "", Valid: false},
                                                +	})
                                                +}
                                                +
                                                +func TestGetProxyTargetedDomainResourceIDs(t *testing.T) {
                                                +	ctx := context.TODO()
                                                +
                                                +	execQuery(t, ctx,
                                                +		`insert into services (id, account_id, enabled, terminated)
                                                +		 values('service-4','account-1',true,false)`)
                                                +	execQuery(t, ctx,
                                                +		`insert into targets (target_id, account_id, service_id, enabled, target_type)
                                                +		 values('target-1','account-1','service-4',true,'domain')`)
                                                +	// id shouldn't be returned as the taget_type is not "domain"
                                                +	execQuery(t, ctx,
                                                +		`insert into targets (target_id, account_id, service_id, enabled, target_type)
                                                +		 values('target-2','account-1','service-4',true,'cluster')`)
                                                +	// id shouldn't be included as the target is disabled
                                                +	execQuery(t, ctx,
                                                +		`insert into targets (target_id, account_id, service_id, enabled, target_type)
                                                +		 values('target-3','account-1','service-4',false,'domain')`)
                                                +	// id shouldn't be included as the service is disabled
                                                +	execQuery(t, ctx,
                                                +		`insert into services (id, account_id, enabled, terminated)
                                                +		 values('service-5','account-1',false,false)`)
                                                +	execQuery(t, ctx,
                                                +		`insert into targets (target_id, account_id, service_id, enabled, target_type)
                                                +		 values('target-4','account-1','service-5',false,'domain')`)
                                                +	// id shouldn't be included as the service is terminated (explicitly)
                                                +	execQuery(t, ctx,
                                                +		`insert into services (id, account_id, enabled, terminated)
                                                +		 values('service-6','account-1',true,true)`)
                                                +	execQuery(t, ctx,
                                                +		`insert into targets (target_id, account_id, service_id, enabled, target_type)
                                                +		 values('target-5','account-1','service-6',true,'domain')`)
                                                +	// id shouldn't be included as the service is terminated (implicitly)
                                                +	execQuery(t, ctx,
                                                +		`insert into services (id, account_id, enabled, terminated)
                                                +		 values('service-7','account-1',true,null)`)
                                                +	execQuery(t, ctx,
                                                +		`insert into targets (target_id, account_id, service_id, enabled, target_type)
                                                +		 values('target-6','account-1','service-7',true,'domain')`)
                                                +	execQuery(t, ctx,
                                                +		`insert into services (id, account_id, enabled, terminated)
                                                +		 values('service-8','account-1',true,false)`)
                                                +	execQuery(t, ctx,
                                                +		`insert into targets (target_id, account_id, service_id, enabled, target_type)
                                                +		 values('target-7','account-1','service-8',true,'domain')`)
                                                +	// id shouldn't be returned as the taget_id is null
                                                +	execQuery(t, ctx,
                                                +		`insert into targets (target_id, account_id, service_id, enabled, target_type)
                                                +		 values(null,'account-1','service-4',true,'cluster')`)
                                                +
                                                +	servtargetedDomains, err := conn(t, ctx).GetProxyTargetedDomainResourceIDs(ctx, "account-1")
                                                +	assert.NoError(t, err)
                                                +	assert.Equal(t, servtargetedDomains, map[string]struct{}{
                                                +		"target-1": {},
                                                +		"target-6": {},
                                                +		"target-7": {},
                                                +	})
                                                +}
                                                diff --git a/integration_tests/management/network_map_db/sqlite_test_store.go b/integration_tests/management/network_map_db/sqlite_test_store.go
                                                new file mode 100644
                                                index 000000000..1c70c93d4
                                                --- /dev/null
                                                +++ b/integration_tests/management/network_map_db/sqlite_test_store.go
                                                @@ -0,0 +1,48 @@
                                                +//go:build integration
                                                +
                                                +package networkmap_pgsql
                                                +
                                                +import (
                                                +	"context"
                                                +	"fmt"
                                                +	"runtime"
                                                +	"strings"
                                                +
                                                +	networkmap_sqlite "github.com/netbirdio/netbird/management/internals/network_map_db/sqlite"
                                                +	gormstore "github.com/netbirdio/netbird/management/server/store"
                                                +	"github.com/netbirdio/netbird/management/server/types"
                                                +	log "github.com/sirupsen/logrus"
                                                +	"gorm.io/driver/sqlite"
                                                +	"gorm.io/gorm"
                                                +)
                                                +
                                                +func createSqliteTestStore(baseData string) (*networkmap_sqlite.SqliteStore, func()) {
                                                +	storeSqliteFileName := ":memory:"
                                                +	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
                                                +	}
                                                +
                                                +	db, err := gorm.Open(sqlite.Open(storeStr), &gorm.Config{})
                                                +	if err != nil {
                                                +		log.Fatalf("error initializing db: %s", err.Error())
                                                +	}
                                                +	_, err = gormstore.NewSqlStore(context.TODO(), db, types.SqliteStoreEngine, nil, false)
                                                +	if err != nil {
                                                +		log.Fatalf("error initializing db: %s", err.Error())
                                                +	}
                                                +
                                                +	sqldb, err := db.DB()
                                                +	if err != nil {
                                                +		log.Fatalf("error initializing db: %s", err.Error())
                                                +
                                                +	}
                                                +	for _, query := range strings.Split(baseData, ";") {
                                                +		if _, err := sqldb.Exec(query); err != nil {
                                                +			log.Fatalf("error initializing db: %s", err.Error())
                                                +		}
                                                +	}
                                                +
                                                +	return &networkmap_sqlite.SqliteStore{Db: sqldb}, func() {}
                                                +}
                                                diff --git a/integration_tests/management/network_map_db/user_test.go b/integration_tests/management/network_map_db/user_test.go
                                                new file mode 100644
                                                index 000000000..fce1833d3
                                                --- /dev/null
                                                +++ b/integration_tests/management/network_map_db/user_test.go
                                                @@ -0,0 +1,67 @@
                                                +//go:build integration
                                                +
                                                +package networkmap_pgsql
                                                +
                                                +import (
                                                +	"context"
                                                +	"testing"
                                                +
                                                +	"github.com/stretchr/testify/assert"
                                                +)
                                                +
                                                +func TestGetAllowedUsers(t *testing.T) {
                                                +	ctx := context.TODO()
                                                +
                                                +	execQuery(t, ctx,
                                                +		`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
                                                +		VALUES('user-1','user-1','account-1','["group-one-resource-id"]',false,false)`)
                                                +	execQuery(t, ctx,
                                                +		`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
                                                +		VALUES('user-2','user-2','account-1','["group-one-resource-id","group-two-resources-id"]',false,false)`)
                                                +	execQuery(t, ctx,
                                                +		`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
                                                +		VALUES('user-3','user-3','account-1','["group-two-resources-id"]',false,false)`)
                                                +	// empty auto_groups; shouldn't error out
                                                +	execQuery(t, ctx,
                                                +		`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
                                                +		VALUES('user-31','user-31','account-1','[]',false,false)`)
                                                +	// null auto_groups; shouldn't error out
                                                +	execQuery(t, ctx,
                                                +		`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
                                                +		VALUES('user-32','user-32','account-1',null,false,false)`)
                                                +	// shouldn't be included as it's blocked
                                                +	execQuery(t, ctx,
                                                +		`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
                                                +		VALUES('user-4','user-4','account-1','["group-two-resources-id"]',true,false)`)
                                                +	// shouldn't be included as it's a service_user
                                                +	execQuery(t, ctx,
                                                +		`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
                                                +		VALUES('user-5','user-5','account-1','["group-two-resources-id"]',false,true)`)
                                                +	execQuery(t, ctx,
                                                +		`insert into groups (id, name, account_id)
                                                +		VALUES('all-group-1','All','account-1')`)
                                                +	execQuery(t, ctx,
                                                +		`insert into groups (id, name, account_id)
                                                +		VALUES('all-group-2','All','account-1')`)
                                                +	execQuery(t, ctx,
                                                +		`insert into groups (id, name, account_id)
                                                +		VALUES('all-group-3','All','account-1')`)
                                                +
                                                +	userIdx, groupIdToUserIds, err := conn(t, ctx).GetAllowedUsers(ctx, "account-1")
                                                +	assert.NoError(t, err)
                                                +
                                                +	assert.Equal(t, userIdx, map[string]struct{}{
                                                +		"user-1":  {},
                                                +		"user-2":  {},
                                                +		"user-3":  {},
                                                +		"user-31": {},
                                                +		"user-32": {},
                                                +	})
                                                +	assert.Equal(t, groupIdToUserIds, map[string][]string{
                                                +		"group-one-resource-id":  {"user-1", "user-2"},
                                                +		"group-two-resources-id": {"user-2", "user-3"},
                                                +		"all-group-1":            {"user-1", "user-2", "user-3", "user-31", "user-32"},
                                                +		"all-group-2":            {"user-1", "user-2", "user-3", "user-31", "user-32"},
                                                +		"all-group-3":            {"user-1", "user-2", "user-3", "user-31", "user-32"},
                                                +	})
                                                +}
                                                diff --git a/magefiles/magefile.go b/magefiles/magefile.go
                                                new file mode 100644
                                                index 000000000..34ab3c08f
                                                --- /dev/null
                                                +++ b/magefiles/magefile.go
                                                @@ -0,0 +1,10 @@
                                                +//mage:multiline
                                                +
                                                +// Set the general description you want to have displayed with mage -l here.
                                                +package main
                                                +
                                                +// mg contains helpful utility functions, like Deps
                                                +
                                                +// Default target to run when none is specified
                                                +// If not set, running mage will list available targets
                                                +//var Default = Integrationtest.All
                                                diff --git a/magefiles/test.go b/magefiles/test.go
                                                new file mode 100644
                                                index 000000000..2d08e8e01
                                                --- /dev/null
                                                +++ b/magefiles/test.go
                                                @@ -0,0 +1,74 @@
                                                +package main
                                                +
                                                +import (
                                                +	"errors"
                                                +	"strings"
                                                +
                                                +	"github.com/magefile/mage/mg"
                                                +	"github.com/magefile/mage/sh"
                                                +)
                                                +
                                                +var defaultcli = []string{"test", "-tags=integration", "-timeout=20m"}
                                                +
                                                +type Integrationtest mg.Namespace
                                                +
                                                +func (i Integrationtest) All(gotestflags *string) error {
                                                +	var errs []error
                                                +	if err := i.Api(gotestflags); err != nil {
                                                +		errs = append(errs, err)
                                                +	}
                                                +	if err := i.NmapDb(gotestflags); err != nil {
                                                +		errs = append(errs, err)
                                                +	}
                                                +	if len(errs) > 0 {
                                                +		return errors.Join(errs...)
                                                +	}
                                                +	return nil
                                                +}
                                                +
                                                +func (Integrationtest) NmapDb(gotestflags *string) error {
                                                +	cli := defaultcli
                                                +	if gotestflags != nil {
                                                +		cli = append(cli, strings.Split(*gotestflags, " ")...)
                                                +	}
                                                +	cli = append(cli, "./integration_tests/management/network_map_db/...")
                                                +
                                                +	return sh.RunV("go", cli...)
                                                +}
                                                +
                                                +func (Integrationtest) NmapDbPostgres(gotestflags *string) error {
                                                +	cli := defaultcli
                                                +	if gotestflags != nil {
                                                +		cli = append(cli, strings.Split(*gotestflags, " ")...)
                                                +	}
                                                +	cli = append(cli, "./integration_tests/management/network_map_db/...")
                                                +
                                                +	return sh.RunWithV(map[string]string{"NETBIRD_STORE_ENGINE": "postgres"}, "go", cli...)
                                                +}
                                                +
                                                +func (Integrationtest) NmapDbSqlite(gotestflags *string) error {
                                                +	cli := defaultcli
                                                +	if gotestflags != nil {
                                                +		cli = append(cli, strings.Split(*gotestflags, " ")...)
                                                +	}
                                                +	cli = append(cli, "./integration_tests/management/network_map_db/...")
                                                +	return sh.RunWithV(map[string]string{"NETBIRD_STORE_ENGINE": "sqlite"}, "go", cli...)
                                                +}
                                                +
                                                +func (Integrationtest) RegenerateNmapGoldenData(gotestflags *string) error {
                                                +	cli := defaultcli
                                                +	if gotestflags != nil {
                                                +		cli = append(cli, strings.Split(*gotestflags, " ")...)
                                                +	}
                                                +	cli = append(cli, "./integration_tests/management/network_map_db/...")
                                                +	return sh.RunWithV(map[string]string{"NMAP_UPDATE_GOLDEN_DATA": "true", "NETBIRD_STORE_ENGINE": "sqlite"}, "go", cli...)
                                                +}
                                                +
                                                +func (Integrationtest) Api(gotestflags *string) error {
                                                +	cli := defaultcli
                                                +	if gotestflags != nil {
                                                +		cli = append(cli, strings.Split(*gotestflags, " ")...)
                                                +	}
                                                +	cli = append(cli, "./management/server/http/...")
                                                +	return sh.RunV("go", cli...)
                                                +}
                                                diff --git a/management/Dockerfile b/management/Dockerfile
                                                index 3b2df2623..fe414158c 100644
                                                --- a/management/Dockerfile
                                                +++ b/management/Dockerfile
                                                @@ -2,4 +2,5 @@ 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 netbird-mgmt /go/bin/netbird-mgmt
                                                +ARG TARGETPLATFORM
                                                +COPY ${TARGETPLATFORM}/netbird-mgmt /go/bin/netbird-mgmt
                                                diff --git a/management/Dockerfile.debug b/management/Dockerfile.debug
                                                deleted file mode 100644
                                                index 4d9730bd7..000000000
                                                --- a/management/Dockerfile.debug
                                                +++ /dev/null
                                                @@ -1,5 +0,0 @@
                                                -FROM ubuntu:24.04
                                                -RUN apt update && apt install -y ca-certificates && rm -fr /var/cache/apt
                                                -ENTRYPOINT [ "/go/bin/netbird-mgmt","management","--log-level","debug"]
                                                -CMD ["--log-file", "console"]
                                                -COPY netbird-mgmt /go/bin/netbird-mgmt
                                                diff --git a/management/Dockerfile.multistage b/management/Dockerfile.multistage
                                                index 619f84615..5d037f1b1 100644
                                                --- a/management/Dockerfile.multistage
                                                +++ b/management/Dockerfile.multistage
                                                @@ -1,4 +1,4 @@
                                                -FROM golang:1.25-bookworm AS builder
                                                +FROM golang:1.26.7-bookworm AS builder
                                                 WORKDIR /app
                                                 
                                                 # Install build dependencies
                                                diff --git a/management/cmd/admin.go b/management/cmd/admin.go
                                                new file mode 100644
                                                index 000000000..e5c0f6ac9
                                                --- /dev/null
                                                +++ b/management/cmd/admin.go
                                                @@ -0,0 +1,177 @@
                                                +package cmd
                                                +
                                                +import (
                                                +	"context"
                                                +	"fmt"
                                                +	"path"
                                                +	"path/filepath"
                                                +
                                                +	"github.com/dexidp/dex/storage"
                                                +	log "github.com/sirupsen/logrus"
                                                +	"github.com/spf13/cobra"
                                                +
                                                +	"github.com/netbirdio/netbird/formatter/hook"
                                                +	admincmd "github.com/netbirdio/netbird/management/cmd/admin"
                                                +	tokencmd "github.com/netbirdio/netbird/management/cmd/token"
                                                +	nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
                                                +	"github.com/netbirdio/netbird/management/server/activity"
                                                +	activitystore "github.com/netbirdio/netbird/management/server/activity/store"
                                                +	"github.com/netbirdio/netbird/management/server/store"
                                                +	"github.com/netbirdio/netbird/util"
                                                +)
                                                +
                                                +var adminDatadir string
                                                +
                                                +// newAdminCommands creates the admin command tree with management-specific resource openers.
                                                +func newAdminCommands() *cobra.Command {
                                                +	cmd := admincmd.NewCommands(admincmd.Openers{
                                                +		Resources: withAdminResources,
                                                +		Store:     withAdminStoreOnly,
                                                +		IDP:       withAdminIDPOnly,
                                                +	})
                                                +	cmd.PersistentFlags().StringVar(&adminDatadir, "datadir", "", "Override the data directory from config (used for store.db and the default idp.db)")
                                                +	return cmd
                                                +}
                                                +
                                                +func newLegacyTokenCommand() *cobra.Command {
                                                +	cmd := tokencmd.NewCommands(tokencmd.StoreOpener(withAdminStoreOnly))
                                                +	cmd.Deprecated = "use 'admin token' instead"
                                                +	cmd.PersistentFlags().StringVar(&nbconfig.MgmtConfigPath, "config", defaultMgmtConfig, "Netbird config file location")
                                                +	return cmd
                                                +}
                                                +
                                                +// withAdminResources initializes logging, loads config, opens the management store
                                                +// and embedded IdP storage, and calls fn.
                                                +func withAdminResources(cmd *cobra.Command, fn func(ctx context.Context, resources admincmd.Resources) error) error {
                                                +	return withAdminConfig(cmd, true, func(ctx context.Context, config *nbconfig.Config, datadir string) error {
                                                +		managementStore, err := openAdminStore(ctx, config, datadir)
                                                +		if err != nil {
                                                +			return err
                                                +		}
                                                +		defer admincmd.CloseStore(ctx, managementStore)
                                                +
                                                +		idpStorage, idpStorageFile, err := admincmd.OpenIDPStorage(config)
                                                +		if err != nil {
                                                +			return err
                                                +		}
                                                +		defer admincmd.CloseIDPStorage(idpStorage)
                                                +
                                                +		eventStore, esErr := openAdminEventStore(ctx, config, datadir)
                                                +		if esErr != nil {
                                                +			_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: audit events will not be recorded: %v\n", esErr)
                                                +		}
                                                +		if eventStore != nil {
                                                +			defer func() {
                                                +				if err := eventStore.Close(ctx); err != nil {
                                                +					log.Debugf("close activity event store: %v", err)
                                                +				}
                                                +			}()
                                                +		}
                                                +
                                                +		return fn(ctx, admincmd.Resources{Store: managementStore, IDPStorage: idpStorage, IDPStorageFile: idpStorageFile, EventStore: eventStore})
                                                +	})
                                                +}
                                                +
                                                +// withAdminStoreOnly opens only the management store for admin subcommands that do not
                                                +// need embedded IdP storage.
                                                +func withAdminStoreOnly(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error {
                                                +	return withAdminConfig(cmd, false, func(ctx context.Context, config *nbconfig.Config, datadir string) error {
                                                +		managementStore, err := openAdminStore(ctx, config, datadir)
                                                +		if err != nil {
                                                +			return err
                                                +		}
                                                +		defer admincmd.CloseStore(ctx, managementStore)
                                                +
                                                +		return fn(ctx, managementStore)
                                                +	})
                                                +}
                                                +
                                                +func withAdminIDPOnly(cmd *cobra.Command, fn func(ctx context.Context, idpStorage storage.Storage, storageFile string) error) error {
                                                +	return withAdminConfig(cmd, true, func(ctx context.Context, config *nbconfig.Config, _ string) error {
                                                +		idpStorage, idpStorageFile, err := admincmd.OpenIDPStorage(config)
                                                +		if err != nil {
                                                +			return err
                                                +		}
                                                +		defer admincmd.CloseIDPStorage(idpStorage)
                                                +
                                                +		return fn(ctx, idpStorage, idpStorageFile)
                                                +	})
                                                +}
                                                +
                                                +func withAdminConfig(cmd *cobra.Command, applyIDPDefaults bool, fn func(ctx context.Context, config *nbconfig.Config, datadir string) 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, datadir, err := loadAdminMgmtConfig(ctx, applyIDPDefaults)
                                                +	if err != nil {
                                                +		return fmt.Errorf("load config: %w", err)
                                                +	}
                                                +
                                                +	return fn(ctx, config, datadir)
                                                +}
                                                +
                                                +func loadAdminMgmtConfig(ctx context.Context, applyIDPDefaults bool) (*nbconfig.Config, string, error) {
                                                +	config := &nbconfig.Config{}
                                                +	if _, err := util.ReadJsonWithEnvSub(nbconfig.MgmtConfigPath, config); err != nil {
                                                +		return nil, "", err
                                                +	}
                                                +
                                                +	if applyIDPDefaults {
                                                +		if err := ApplyEmbeddedIdPConfig(ctx, config); err != nil {
                                                +			return nil, "", err
                                                +		}
                                                +	}
                                                +
                                                +	datadir := config.Datadir
                                                +	applyAdminDatadirOverride(config, &datadir)
                                                +	return config, datadir, nil
                                                +}
                                                +
                                                +func applyAdminDatadirOverride(config *nbconfig.Config, datadir *string) {
                                                +	if adminDatadir == "" {
                                                +		return
                                                +	}
                                                +
                                                +	oldDatadir := *datadir
                                                +	*datadir = adminDatadir
                                                +	if config.EmbeddedIdP != nil && config.EmbeddedIdP.Storage.Type == "sqlite3" && isDefaultIDPStorageFile(config.EmbeddedIdP.Storage.Config.File, oldDatadir) {
                                                +		config.EmbeddedIdP.Storage.Config.File = filepath.Join(*datadir, "idp.db")
                                                +	}
                                                +}
                                                +
                                                +func isDefaultIDPStorageFile(file, datadir string) bool {
                                                +	if file == "" {
                                                +		return true
                                                +	}
                                                +	defaultFile := filepath.Join(datadir, "idp.db")
                                                +	legacyDefaultFile := path.Join(datadir, "idp.db")
                                                +	legacySlashDefaultFile := path.Join(filepath.ToSlash(datadir), "idp.db")
                                                +	return filepath.Clean(file) == filepath.Clean(defaultFile) ||
                                                +		file == legacyDefaultFile ||
                                                +		filepath.ToSlash(file) == legacySlashDefaultFile
                                                +}
                                                +
                                                +func openAdminStore(ctx context.Context, config *nbconfig.Config, datadir string) (store.Store, error) {
                                                +	managementStore, err := store.NewStore(ctx, config.StoreConfig.Engine, datadir, nil, true)
                                                +	if err != nil {
                                                +		return nil, fmt.Errorf("create store: %w", err)
                                                +	}
                                                +	return managementStore, nil
                                                +}
                                                +
                                                +func openAdminEventStore(ctx context.Context, config *nbconfig.Config, datadir string) (activity.Store, error) {
                                                +	if config.DataStoreEncryptionKey == "" {
                                                +		return nil, fmt.Errorf("data store encryption key is not configured")
                                                +	}
                                                +	eventStore, err := activitystore.NewSqlStore(ctx, datadir, config.DataStoreEncryptionKey)
                                                +	if err != nil {
                                                +		return nil, fmt.Errorf("open activity event store: %w", err)
                                                +	}
                                                +	if eventStore == nil {
                                                +		return nil, fmt.Errorf("open activity event store: returned nil store")
                                                +	}
                                                +	return eventStore, nil
                                                +}
                                                diff --git a/management/cmd/admin/admin.go b/management/cmd/admin/admin.go
                                                new file mode 100644
                                                index 000000000..bd56af39b
                                                --- /dev/null
                                                +++ b/management/cmd/admin/admin.go
                                                @@ -0,0 +1,577 @@
                                                +// Package admincmd provides reusable cobra commands for self-hosted administrator helpers.
                                                +// Both the management and combined binaries use these commands, each providing
                                                +// their own opener to handle config loading and storage initialization.
                                                +package admincmd
                                                +
                                                +import (
                                                +	"context"
                                                +	"errors"
                                                +	"fmt"
                                                +	"io"
                                                +	"log/slog"
                                                +	"os"
                                                +	"strings"
                                                +	"time"
                                                +
                                                +	"github.com/dexidp/dex/storage"
                                                +	log "github.com/sirupsen/logrus"
                                                +	"github.com/spf13/cobra"
                                                +	"golang.org/x/crypto/bcrypt"
                                                +
                                                +	"github.com/netbirdio/netbird/formatter/hook"
                                                +	nbdex "github.com/netbirdio/netbird/idp/dex"
                                                +	"github.com/netbirdio/netbird/management/cmd/proxy"
                                                +	"github.com/netbirdio/netbird/management/cmd/token"
                                                +	nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
                                                +	"github.com/netbirdio/netbird/management/server"
                                                +	"github.com/netbirdio/netbird/management/server/activity"
                                                +	"github.com/netbirdio/netbird/management/server/idp"
                                                +	"github.com/netbirdio/netbird/management/server/store"
                                                +	"github.com/netbirdio/netbird/management/server/types"
                                                +)
                                                +
                                                +// Resources contains the storages required by the admin commands.
                                                +type Resources struct {
                                                +	Store          store.Store
                                                +	IDPStorage     storage.Storage
                                                +	IDPStorageFile string
                                                +	EventStore     activity.Store
                                                +}
                                                +
                                                +// Opener initializes command resources from the command context and calls fn.
                                                +type Opener func(cmd *cobra.Command, fn func(ctx context.Context, resources Resources) error) error
                                                +
                                                +// StoreOpener initializes only the management store from the command context and calls fn.
                                                +type StoreOpener func(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error
                                                +
                                                +// IDPOpener initializes only the embedded IdP storage from the command context and calls fn.
                                                +type IDPOpener func(cmd *cobra.Command, fn func(ctx context.Context, idpStorage storage.Storage, storageFile string) error) error
                                                +
                                                +// Openers contains the resource openers needed by the admin command tree.
                                                +type Openers struct {
                                                +	Resources Opener
                                                +	Store     StoreOpener
                                                +	IDP       IDPOpener
                                                +}
                                                +
                                                +type userSelector struct {
                                                +	email  string
                                                +	userID string
                                                +}
                                                +
                                                +func (s userSelector) normalized() userSelector {
                                                +	return userSelector{
                                                +		email:  strings.TrimSpace(s.email),
                                                +		userID: strings.TrimSpace(s.userID),
                                                +	}
                                                +}
                                                +
                                                +func (s userSelector) validate() error {
                                                +	s = s.normalized()
                                                +	if (s.email == "") == (s.userID == "") {
                                                +		return fmt.Errorf("provide exactly one of --email or --user-id")
                                                +	}
                                                +	return nil
                                                +}
                                                +
                                                +// NewCommands creates the admin command tree with the given resource openers.
                                                +func NewCommands(openers Openers) *cobra.Command {
                                                +	adminCmd := &cobra.Command{
                                                +		Use:   "admin",
                                                +		Short: "Self-hosted administrator helpers",
                                                +		Long:  "Administrative helpers for self-hosted deployments using the embedded identity provider.",
                                                +	}
                                                +
                                                +	userCmd := &cobra.Command{
                                                +		Use:   "user",
                                                +		Short: "Manage local embedded IdP users",
                                                +	}
                                                +
                                                +	var passwordSelector userSelector
                                                +	var password string
                                                +	var passwordFile string
                                                +	passwordCmd := &cobra.Command{
                                                +		Use:     "change-password (--email email | --user-id id) (--password password | --password-file path)",
                                                +		Aliases: []string{"set-password"},
                                                +		Short:   "Change a local user's password",
                                                +		Args:    cobra.NoArgs,
                                                +		RunE: func(cmd *cobra.Command, _ []string) error {
                                                +			if err := passwordSelector.validate(); err != nil {
                                                +				return err
                                                +			}
                                                +			newPassword, err := resolvePasswordInput(cmd, password, passwordFile)
                                                +			if err != nil {
                                                +				return err
                                                +			}
                                                +			return openers.IDP(cmd, func(ctx context.Context, idpStorage storage.Storage, storageFile string) error {
                                                +				return runChangePassword(ctx, idpStorage, cmd.OutOrStdout(), passwordSelector, newPassword, storageFile)
                                                +			})
                                                +		},
                                                +	}
                                                +	addUserSelectorFlags(passwordCmd, &passwordSelector)
                                                +	passwordCmd.Flags().StringVar(&password, "password", "", "New password for the user")
                                                +	passwordCmd.Flags().StringVar(&passwordFile, "password-file", "", "Read new password from file ('-' for stdin)")
                                                +
                                                +	var resetSelector userSelector
                                                +	resetMFACmd := &cobra.Command{
                                                +		Use:   "reset-mfa (--email email | --user-id id)",
                                                +		Short: "Reset a local user's MFA enrollment",
                                                +		Args:  cobra.NoArgs,
                                                +		RunE: func(cmd *cobra.Command, _ []string) error {
                                                +			if err := resetSelector.validate(); err != nil {
                                                +				return err
                                                +			}
                                                +			return openers.IDP(cmd, func(ctx context.Context, idpStorage storage.Storage, storageFile string) error {
                                                +				return runResetMFA(ctx, idpStorage, cmd.OutOrStdout(), resetSelector, storageFile)
                                                +			})
                                                +		},
                                                +	}
                                                +	addUserSelectorFlags(resetMFACmd, &resetSelector)
                                                +
                                                +	userCmd.AddCommand(passwordCmd, resetMFACmd)
                                                +
                                                +	mfaCmd := &cobra.Command{
                                                +		Use:   "mfa",
                                                +		Short: "Manage local MFA for embedded IdP users",
                                                +	}
                                                +
                                                +	enableCmd := &cobra.Command{
                                                +		Use:   "enable",
                                                +		Short: "Enable MFA for local embedded IdP users",
                                                +		Args:  cobra.NoArgs,
                                                +		RunE: func(cmd *cobra.Command, _ []string) error {
                                                +			return openers.Resources(cmd, func(ctx context.Context, resources Resources) error {
                                                +				return runSetMFAEnabled(ctx, resources, cmd.OutOrStdout(), true)
                                                +			})
                                                +		},
                                                +	}
                                                +
                                                +	disableCmd := &cobra.Command{
                                                +		Use:   "disable",
                                                +		Short: "Disable MFA for local embedded IdP users",
                                                +		Args:  cobra.NoArgs,
                                                +		RunE: func(cmd *cobra.Command, _ []string) error {
                                                +			return openers.Resources(cmd, func(ctx context.Context, resources Resources) error {
                                                +				return runSetMFAEnabled(ctx, resources, cmd.OutOrStdout(), false)
                                                +			})
                                                +		},
                                                +	}
                                                +
                                                +	statusCmd := &cobra.Command{
                                                +		Use:   "status",
                                                +		Short: "Show local MFA status",
                                                +		Args:  cobra.NoArgs,
                                                +		RunE: func(cmd *cobra.Command, _ []string) error {
                                                +			return openers.Resources(cmd, func(ctx context.Context, resources Resources) error {
                                                +				return runMFAStatus(ctx, resources, cmd.OutOrStdout())
                                                +			})
                                                +		},
                                                +	}
                                                +
                                                +	mfaCmd.AddCommand(enableCmd, disableCmd, statusCmd)
                                                +	adminCmd.AddCommand(userCmd, mfaCmd)
                                                +	if openers.Store != nil {
                                                +		adminCmd.AddCommand(tokencmd.NewCommands(tokencmd.StoreOpener(openers.Store)))
                                                +		adminCmd.AddCommand(proxycmd.NewCommands(proxycmd.StoreOpener(openers.Store)))
                                                +	}
                                                +	return adminCmd
                                                +}
                                                +
                                                +// OpenEmbeddedIDPStorage opens the Dex storage configured for the embedded IdP.
                                                +func OpenEmbeddedIDPStorage(cfg *idp.EmbeddedIdPConfig) (storage.Storage, error) {
                                                +	if cfg == nil || !cfg.Enabled {
                                                +		return nil, fmt.Errorf("admin commands require the embedded IdP to be enabled")
                                                +	}
                                                +
                                                +	yamlConfig, err := cfg.ToYAMLConfig()
                                                +	if err != nil {
                                                +		return nil, fmt.Errorf("build embedded IdP config: %w", err)
                                                +	}
                                                +
                                                +	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
                                                +	st, err := yamlConfig.Storage.OpenStorage(logger)
                                                +	if err != nil {
                                                +		return nil, fmt.Errorf("open embedded IdP storage: %w", err)
                                                +	}
                                                +	return st, nil
                                                +}
                                                +
                                                +// CloseStore closes the management store and logs cleanup errors at debug level.
                                                +func CloseStore(ctx context.Context, s store.Store) {
                                                +	if s == nil {
                                                +		return
                                                +	}
                                                +	if err := s.Close(ctx); err != nil {
                                                +		log.Debugf("close store: %v", err)
                                                +	}
                                                +}
                                                +
                                                +// OpenIDPStorage opens embedded IdP storage and returns its sqlite file path when applicable.
                                                +func OpenIDPStorage(config *nbconfig.Config) (storage.Storage, string, error) {
                                                +	if config == nil {
                                                +		return nil, "", fmt.Errorf("management config is required")
                                                +	}
                                                +	idpStorage, err := OpenEmbeddedIDPStorage(config.EmbeddedIdP)
                                                +	if err != nil {
                                                +		return nil, "", err
                                                +	}
                                                +	return idpStorage, embeddedIDPStorageFile(config), nil
                                                +}
                                                +
                                                +func embeddedIDPStorageFile(config *nbconfig.Config) string {
                                                +	if config.EmbeddedIdP == nil || config.EmbeddedIdP.Storage.Type != "sqlite3" {
                                                +		return ""
                                                +	}
                                                +	return config.EmbeddedIdP.Storage.Config.File
                                                +}
                                                +
                                                +// CloseIDPStorage closes embedded IdP storage and logs cleanup errors at debug level.
                                                +func CloseIDPStorage(s storage.Storage) {
                                                +	if s == nil {
                                                +		return
                                                +	}
                                                +	if err := s.Close(); err != nil {
                                                +		log.Debugf("close embedded IdP storage: %v", err)
                                                +	}
                                                +}
                                                +
                                                +func addUserSelectorFlags(cmd *cobra.Command, selector *userSelector) {
                                                +	cmd.Flags().StringVar(&selector.email, "email", "", "User email")
                                                +	cmd.Flags().StringVar(&selector.userID, "user-id", "", "User ID")
                                                +}
                                                +
                                                +func resolvePasswordInput(cmd *cobra.Command, password, passwordFile string) (string, error) {
                                                +	if password != "" && passwordFile != "" {
                                                +		return "", fmt.Errorf("provide only one of --password or --password-file")
                                                +	}
                                                +	if passwordFile == "" {
                                                +		return password, nil
                                                +	}
                                                +
                                                +	var data []byte
                                                +	var err error
                                                +	if passwordFile == "-" {
                                                +		data, err = io.ReadAll(cmd.InOrStdin())
                                                +	} else {
                                                +		data, err = os.ReadFile(passwordFile)
                                                +	}
                                                +	if err != nil {
                                                +		return "", fmt.Errorf("read password: %w", err)
                                                +	}
                                                +	return strings.TrimRight(string(data), "\r\n"), nil
                                                +}
                                                +
                                                +func runChangePassword(ctx context.Context, idpStorage storage.Storage, w io.Writer, selector userSelector, password string, idpStorageFile string) error {
                                                +	if idpStorage == nil {
                                                +		return fmt.Errorf("embedded IdP storage is required")
                                                +	}
                                                +	selector = selector.normalized()
                                                +	if err := selector.validate(); err != nil {
                                                +		return err
                                                +	}
                                                +	if password == "" {
                                                +		return fmt.Errorf("password is required")
                                                +	}
                                                +	if err := server.ValidatePassword(password); err != nil {
                                                +		return fmt.Errorf("invalid password: %w", err)
                                                +	}
                                                +
                                                +	user, err := findLocalUser(ctx, idpStorage, selector, idpStorageFile)
                                                +	if err != nil {
                                                +		return err
                                                +	}
                                                +
                                                +	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
                                                +	if err != nil {
                                                +		return fmt.Errorf("hash password: %w", err)
                                                +	}
                                                +
                                                +	if err := idpStorage.UpdatePassword(ctx, user.Email, func(old storage.Password) (storage.Password, error) {
                                                +		old.Hash = hash
                                                +		return old, nil
                                                +	}); err != nil {
                                                +		return fmt.Errorf("update password for %s: %w", user.Email, err)
                                                +	}
                                                +
                                                +	if err := deleteLocalAuthSession(ctx, idpStorage, user.UserID); err != nil {
                                                +		return err
                                                +	}
                                                +
                                                +	_, _ = fmt.Fprintf(w, "Password updated for %s.\n", user.Email)
                                                +	return nil
                                                +}
                                                +
                                                +func runResetMFA(ctx context.Context, idpStorage storage.Storage, w io.Writer, selector userSelector, idpStorageFile string) error {
                                                +	if idpStorage == nil {
                                                +		return fmt.Errorf("embedded IdP storage is required")
                                                +	}
                                                +	selector = selector.normalized()
                                                +	if err := selector.validate(); err != nil {
                                                +		return err
                                                +	}
                                                +
                                                +	user, err := findLocalUser(ctx, idpStorage, selector, idpStorageFile)
                                                +	if err != nil {
                                                +		return err
                                                +	}
                                                +
                                                +	reset := false
                                                +	err = idpStorage.UpdateUserIdentity(ctx, user.UserID, idp.LocalConnectorID, func(old storage.UserIdentity) (storage.UserIdentity, error) {
                                                +		reset = reset || len(old.MFASecrets) > 0 || len(old.WebAuthnCredentials) > 0
                                                +		old.MFASecrets = map[string]*storage.MFASecret{}
                                                +		old.WebAuthnCredentials = map[string][]storage.WebAuthnCredential{}
                                                +		return old, nil
                                                +	})
                                                +	if errors.Is(err, storage.ErrNotFound) {
                                                +		if err := deleteLocalAuthSession(ctx, idpStorage, user.UserID); err != nil {
                                                +			return err
                                                +		}
                                                +		_, _ = fmt.Fprintf(w, "No MFA enrollment found for %s.\n", user.Email)
                                                +		return nil
                                                +	}
                                                +	if err != nil {
                                                +		return fmt.Errorf("reset MFA for %s: %w", user.Email, err)
                                                +	}
                                                +
                                                +	if err := deleteLocalAuthSession(ctx, idpStorage, user.UserID); err != nil {
                                                +		return err
                                                +	}
                                                +
                                                +	if reset {
                                                +		_, _ = fmt.Fprintf(w, "MFA reset for %s. The user will re-enroll at next login.\n", user.Email)
                                                +	} else {
                                                +		_, _ = fmt.Fprintf(w, "No MFA enrollment found for %s.\n", user.Email)
                                                +	}
                                                +	return nil
                                                +}
                                                +
                                                +func runSetMFAEnabled(ctx context.Context, resources Resources, w io.Writer, enabled bool) error {
                                                +	if resources.Store == nil {
                                                +		return fmt.Errorf("management store is required")
                                                +	}
                                                +	if resources.IDPStorage == nil {
                                                +		return fmt.Errorf("embedded IdP storage is required")
                                                +	}
                                                +
                                                +	accountID, settings, err := getSingleAccountSettings(ctx, resources.Store)
                                                +	if err != nil {
                                                +		return err
                                                +	}
                                                +
                                                +	oldEnabled := settings.LocalMfaEnabled
                                                +	newSettings := settings.Copy()
                                                +	newSettings.LocalMfaEnabled = enabled
                                                +
                                                +	if err := setIDPClientsMFA(ctx, resources.IDPStorage, enabled); err != nil {
                                                +		return err
                                                +	}
                                                +
                                                +	if err := resources.Store.SaveAccountSettings(ctx, accountID, newSettings); err != nil {
                                                +		if rollbackErr := setIDPClientsMFA(ctx, resources.IDPStorage, oldEnabled); rollbackErr != nil {
                                                +			return fmt.Errorf("save local MFA account setting: %w (also failed to roll back embedded IdP MFA state: %v)", err, rollbackErr)
                                                +		}
                                                +		return fmt.Errorf("save local MFA account setting: %w", err)
                                                +	}
                                                +
                                                +	if err := storeMFAActivity(ctx, resources.EventStore, accountID, enabled); err != nil {
                                                +		_, _ = fmt.Fprintf(w, "Warning: failed to record audit event: %v\n", err)
                                                +	}
                                                +
                                                +	state := "disabled"
                                                +	if enabled {
                                                +		state = "enabled"
                                                +	}
                                                +	_, _ = fmt.Fprintf(w, "Local MFA %s.\n", state)
                                                +	return nil
                                                +}
                                                +
                                                +func runMFAStatus(ctx context.Context, resources Resources, w io.Writer) error {
                                                +	if resources.Store == nil {
                                                +		return fmt.Errorf("management store is required")
                                                +	}
                                                +	if resources.IDPStorage == nil {
                                                +		return fmt.Errorf("embedded IdP storage is required")
                                                +	}
                                                +
                                                +	_, settings, err := getSingleAccountSettings(ctx, resources.Store)
                                                +	if err != nil {
                                                +		return err
                                                +	}
                                                +	accountStatus := "disabled"
                                                +	if settings.LocalMfaEnabled {
                                                +		accountStatus = "enabled"
                                                +	}
                                                +
                                                +	clientStatus, err := idpClientsMFAStatus(ctx, resources.IDPStorage)
                                                +	if err != nil {
                                                +		return err
                                                +	}
                                                +
                                                +	_, _ = fmt.Fprintf(w, "Account setting: %s\n", accountStatus)
                                                +	_, _ = fmt.Fprintf(w, "Embedded IdP clients: %s\n", clientStatus)
                                                +	return nil
                                                +}
                                                +
                                                +func getSingleAccountSettings(ctx context.Context, s store.Store) (string, *types.Settings, error) {
                                                +	count, err := s.GetAccountsCounter(ctx)
                                                +	if err != nil {
                                                +		return "", nil, fmt.Errorf("count accounts: %w", err)
                                                +	}
                                                +	if count != 1 {
                                                +		return "", nil, fmt.Errorf("expected exactly one account, got %d; local MFA is supported only in single-account embedded IdP deployments", count)
                                                +	}
                                                +
                                                +	accountID, err := s.GetAnyAccountID(ctx)
                                                +	if err != nil {
                                                +		return "", nil, fmt.Errorf("get account ID: %w", err)
                                                +	}
                                                +
                                                +	settings, err := s.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
                                                +	if err != nil {
                                                +		return "", nil, fmt.Errorf("get account settings: %w", err)
                                                +	}
                                                +	if settings == nil {
                                                +		settings = &types.Settings{}
                                                +	}
                                                +	return accountID, settings, nil
                                                +}
                                                +
                                                +func storeMFAActivity(ctx context.Context, eventStore activity.Store, accountID string, enabled bool) error {
                                                +	if eventStore == nil {
                                                +		return nil
                                                +	}
                                                +	event := activity.AccountLocalMfaDisabled
                                                +	if enabled {
                                                +		event = activity.AccountLocalMfaEnabled
                                                +	}
                                                +	_, err := eventStore.Save(ctx, &activity.Event{
                                                +		Timestamp:   time.Now().UTC(),
                                                +		Activity:    event,
                                                +		InitiatorID: string(hook.SystemSource),
                                                +		TargetID:    accountID,
                                                +		AccountID:   accountID,
                                                +	})
                                                +	if err != nil {
                                                +		return fmt.Errorf("save local MFA audit event: %w", err)
                                                +	}
                                                +	return nil
                                                +}
                                                +
                                                +func findLocalUser(ctx context.Context, idpStorage storage.Storage, selector userSelector, idpStorageFile string) (storage.Password, error) {
                                                +	selector = selector.normalized()
                                                +	if err := selector.validate(); err != nil {
                                                +		return storage.Password{}, err
                                                +	}
                                                +
                                                +	if selector.email != "" {
                                                +		user, err := idpStorage.GetPassword(ctx, selector.email)
                                                +		if errors.Is(err, storage.ErrNotFound) {
                                                +			if empty, listErr := localUsersEmpty(ctx, idpStorage); listErr != nil {
                                                +				return storage.Password{}, listErr
                                                +			} else if empty {
                                                +				return storage.Password{}, noLocalUsersError(idpStorageFile)
                                                +			}
                                                +			return storage.Password{}, fmt.Errorf("local user with email %q not found", selector.email)
                                                +		}
                                                +		if err != nil {
                                                +			return storage.Password{}, fmt.Errorf("get local user by email %q: %w", selector.email, err)
                                                +		}
                                                +		return user, nil
                                                +	}
                                                +
                                                +	rawUserID := selector.userID
                                                +	if decodedUserID, _, err := nbdex.DecodeDexUserID(selector.userID); err == nil && decodedUserID != "" {
                                                +		rawUserID = decodedUserID
                                                +	}
                                                +
                                                +	users, err := idpStorage.ListPasswords(ctx)
                                                +	if err != nil {
                                                +		return storage.Password{}, fmt.Errorf("list local users: %w", err)
                                                +	}
                                                +	for _, user := range users {
                                                +		if user.UserID == rawUserID || user.UserID == selector.userID {
                                                +			return user, nil
                                                +		}
                                                +	}
                                                +
                                                +	if len(users) == 0 {
                                                +		return storage.Password{}, noLocalUsersError(idpStorageFile)
                                                +	}
                                                +
                                                +	return storage.Password{}, fmt.Errorf("local user with ID %q not found", selector.userID)
                                                +}
                                                +
                                                +func localUsersEmpty(ctx context.Context, idpStorage storage.Storage) (bool, error) {
                                                +	users, err := idpStorage.ListPasswords(ctx)
                                                +	if err != nil {
                                                +		return false, fmt.Errorf("list local users: %w", err)
                                                +	}
                                                +	return len(users) == 0, nil
                                                +}
                                                +
                                                +func noLocalUsersError(idpStorageFile string) error {
                                                +	location := ""
                                                +	if idpStorageFile != "" {
                                                +		location = fmt.Sprintf(" (%s)", idpStorageFile)
                                                +	}
                                                +	return fmt.Errorf("no local users exist in the embedded IdP storage%s; the management server may never have started with this config, or --datadir points at the wrong location", location)
                                                +}
                                                +
                                                +func deleteLocalAuthSession(ctx context.Context, idpStorage storage.Storage, userID string) error {
                                                +	err := idpStorage.DeleteAuthSession(ctx, userID, idp.LocalConnectorID)
                                                +	if err == nil || errors.Is(err, storage.ErrNotFound) {
                                                +		return nil
                                                +	}
                                                +	return fmt.Errorf("delete local auth session for user %s: %w", userID, err)
                                                +}
                                                +
                                                +func setIDPClientsMFA(ctx context.Context, idpStorage storage.Storage, enabled bool) error {
                                                +	var mfaChain []string
                                                +	if enabled {
                                                +		mfaChain = []string{idp.DefaultTOTPAuthenticatorID}
                                                +	}
                                                +
                                                +	clientIDs := []string{idp.StaticClientCLI, idp.StaticClientDashboard}
                                                +	if err := nbdex.SetClientsMFAChain(ctx, idpStorage, clientIDs, mfaChain); err != nil {
                                                +		if errors.Is(err, storage.ErrNotFound) {
                                                +			return fmt.Errorf("embedded IdP client not found; start the management server once before toggling MFA: %w", err)
                                                +		}
                                                +		return fmt.Errorf("update MFA chain on embedded IdP clients: %w", err)
                                                +	}
                                                +	return nil
                                                +}
                                                +
                                                +func idpClientsMFAStatus(ctx context.Context, idpStorage storage.Storage) (string, error) {
                                                +	clientIDs := []string{idp.StaticClientCLI, idp.StaticClientDashboard}
                                                +	enabledCount := 0
                                                +	for _, clientID := range clientIDs {
                                                +		client, err := idpStorage.GetClient(ctx, clientID)
                                                +		if errors.Is(err, storage.ErrNotFound) {
                                                +			return "unknown", fmt.Errorf("embedded IdP client %q not found", clientID)
                                                +		}
                                                +		if err != nil {
                                                +			return "unknown", fmt.Errorf("get embedded IdP client %q: %w", clientID, err)
                                                +		}
                                                +		if hasAuthenticator(client.MFAChain, idp.DefaultTOTPAuthenticatorID) {
                                                +			enabledCount++
                                                +		}
                                                +	}
                                                +
                                                +	switch enabledCount {
                                                +	case 0:
                                                +		return "disabled", nil
                                                +	case len(clientIDs):
                                                +		return "enabled", nil
                                                +	default:
                                                +		return "partially enabled", nil
                                                +	}
                                                +}
                                                +
                                                +func hasAuthenticator(chain []string, authenticatorID string) bool {
                                                +	for _, id := range chain {
                                                +		if id == authenticatorID {
                                                +			return true
                                                +		}
                                                +	}
                                                +	return false
                                                +}
                                                diff --git a/management/cmd/admin/admin_test.go b/management/cmd/admin/admin_test.go
                                                new file mode 100644
                                                index 000000000..dd1b8ed06
                                                --- /dev/null
                                                +++ b/management/cmd/admin/admin_test.go
                                                @@ -0,0 +1,250 @@
                                                +package admincmd
                                                +
                                                +import (
                                                +	"bytes"
                                                +	"context"
                                                +	"io"
                                                +	"log/slog"
                                                +	"strings"
                                                +	"testing"
                                                +	"time"
                                                +
                                                +	"github.com/dexidp/dex/storage"
                                                +	"github.com/dexidp/dex/storage/memory"
                                                +	"github.com/spf13/cobra"
                                                +	"github.com/stretchr/testify/require"
                                                +	"golang.org/x/crypto/bcrypt"
                                                +
                                                +	nbdex "github.com/netbirdio/netbird/idp/dex"
                                                +	"github.com/netbirdio/netbird/management/server/idp"
                                                +	mgmtstore "github.com/netbirdio/netbird/management/server/store"
                                                +	"github.com/netbirdio/netbird/management/server/types"
                                                +)
                                                +
                                                +func newTestIDPStorage(t *testing.T) storage.Storage {
                                                +	t.Helper()
                                                +
                                                +	st := memory.New(slog.New(slog.NewTextHandler(io.Discard, nil)))
                                                +	hash, err := bcrypt.GenerateFromPassword([]byte("OldPass1!"), bcrypt.DefaultCost)
                                                +	require.NoError(t, err)
                                                +
                                                +	require.NoError(t, st.CreatePassword(context.Background(), storage.Password{
                                                +		Email:    "user@example.com",
                                                +		Username: "User",
                                                +		UserID:   "user-1",
                                                +		Hash:     hash,
                                                +	}))
                                                +	require.NoError(t, st.CreateUserIdentity(context.Background(), storage.UserIdentity{
                                                +		UserID:      "user-1",
                                                +		ConnectorID: idp.LocalConnectorID,
                                                +		MFASecrets: map[string]*storage.MFASecret{
                                                +			idp.DefaultTOTPAuthenticatorID: {
                                                +				AuthenticatorID: idp.DefaultTOTPAuthenticatorID,
                                                +				Type:            "TOTP",
                                                +				Secret:          "otpauth://totp/NetBird:user@example.com?secret=ABC",
                                                +				Confirmed:       true,
                                                +				CreatedAt:       time.Now(),
                                                +			},
                                                +		},
                                                +		WebAuthnCredentials: map[string][]storage.WebAuthnCredential{
                                                +			"webauthn": {{CredentialID: []byte("credential")}},
                                                +		},
                                                +	}))
                                                +	require.NoError(t, st.CreateAuthSession(context.Background(), storage.AuthSession{
                                                +		UserID:      "user-1",
                                                +		ConnectorID: idp.LocalConnectorID,
                                                +		Nonce:       "nonce",
                                                +	}))
                                                +	require.NoError(t, st.CreateClient(context.Background(), storage.Client{ID: idp.StaticClientCLI, Name: "CLI"}))
                                                +	require.NoError(t, st.CreateClient(context.Background(), storage.Client{ID: idp.StaticClientDashboard, Name: "Dashboard"}))
                                                +
                                                +	return st
                                                +}
                                                +
                                                +func TestRunChangePassword(t *testing.T) {
                                                +	ctx := context.Background()
                                                +	st := newTestIDPStorage(t)
                                                +	var out bytes.Buffer
                                                +
                                                +	err := runChangePassword(ctx, st, &out, userSelector{email: "user@example.com"}, "NewPass1!", "")
                                                +	require.NoError(t, err)
                                                +	require.Contains(t, out.String(), "Password updated")
                                                +
                                                +	user, err := st.GetPassword(ctx, "user@example.com")
                                                +	require.NoError(t, err)
                                                +	require.NoError(t, bcrypt.CompareHashAndPassword(user.Hash, []byte("NewPass1!")))
                                                +
                                                +	_, err = st.GetAuthSession(ctx, "user-1", idp.LocalConnectorID)
                                                +	require.ErrorIs(t, err, storage.ErrNotFound)
                                                +}
                                                +
                                                +func TestRunChangePasswordValidatesPassword(t *testing.T) {
                                                +	st := newTestIDPStorage(t)
                                                +	err := runChangePassword(context.Background(), st, io.Discard, userSelector{email: "user@example.com"}, "short", "")
                                                +	require.Error(t, err)
                                                +	require.Contains(t, err.Error(), "invalid password")
                                                +}
                                                +
                                                +func TestRunResetMFA(t *testing.T) {
                                                +	ctx := context.Background()
                                                +	st := newTestIDPStorage(t)
                                                +	var out bytes.Buffer
                                                +
                                                +	encodedUserID := nbdex.EncodeDexUserID("user-1", idp.LocalConnectorID)
                                                +	err := runResetMFA(ctx, st, &out, userSelector{userID: encodedUserID}, "")
                                                +	require.NoError(t, err)
                                                +	require.Contains(t, out.String(), "MFA reset")
                                                +
                                                +	identity, err := st.GetUserIdentity(ctx, "user-1", idp.LocalConnectorID)
                                                +	require.NoError(t, err)
                                                +	require.Empty(t, identity.MFASecrets)
                                                +	require.Empty(t, identity.WebAuthnCredentials)
                                                +
                                                +	_, err = st.GetAuthSession(ctx, "user-1", idp.LocalConnectorID)
                                                +	require.ErrorIs(t, err, storage.ErrNotFound)
                                                +}
                                                +
                                                +func TestRunResetMFAWithoutEnrollment(t *testing.T) {
                                                +	ctx := context.Background()
                                                +	st := newTestIDPStorage(t)
                                                +	require.NoError(t, st.UpdateUserIdentity(ctx, "user-1", idp.LocalConnectorID, func(old storage.UserIdentity) (storage.UserIdentity, error) {
                                                +		old.MFASecrets = nil
                                                +		old.WebAuthnCredentials = nil
                                                +		return old, nil
                                                +	}))
                                                +
                                                +	var out bytes.Buffer
                                                +	err := runResetMFA(ctx, st, &out, userSelector{email: "user@example.com"}, "")
                                                +	require.NoError(t, err)
                                                +	require.Contains(t, out.String(), "No MFA enrollment found")
                                                +}
                                                +
                                                +func TestSetIDPClientsMFA(t *testing.T) {
                                                +	ctx := context.Background()
                                                +	st := newTestIDPStorage(t)
                                                +
                                                +	require.NoError(t, setIDPClientsMFA(ctx, st, true))
                                                +	status, err := idpClientsMFAStatus(ctx, st)
                                                +	require.NoError(t, err)
                                                +	require.Equal(t, "enabled", status)
                                                +
                                                +	require.NoError(t, setIDPClientsMFA(ctx, st, false))
                                                +	status, err = idpClientsMFAStatus(ctx, st)
                                                +	require.NoError(t, err)
                                                +	require.Equal(t, "disabled", status)
                                                +}
                                                +
                                                +func newTestManagementStore(t *testing.T, localMFAEnabled bool) mgmtstore.Store {
                                                +	t.Helper()
                                                +	ctx := context.Background()
                                                +	st, err := mgmtstore.NewStore(ctx, types.SqliteStoreEngine, t.TempDir(), nil, false)
                                                +	require.NoError(t, err)
                                                +	t.Cleanup(func() { require.NoError(t, st.Close(ctx)) })
                                                +	require.NoError(t, st.SaveAccount(ctx, &types.Account{
                                                +		Id:       "account-1",
                                                +		Settings: &types.Settings{LocalMfaEnabled: localMFAEnabled},
                                                +	}))
                                                +	return st
                                                +}
                                                +
                                                +func TestRunSetMFAEnabledDoesNotSaveWhenIDPUpdateFails(t *testing.T) {
                                                +	ctx := context.Background()
                                                +	managementStore := newTestManagementStore(t, false)
                                                +	idpStorage := memory.New(slog.New(slog.NewTextHandler(io.Discard, nil)))
                                                +
                                                +	err := runSetMFAEnabled(ctx, Resources{Store: managementStore, IDPStorage: idpStorage}, io.Discard, true)
                                                +	require.Error(t, err)
                                                +	require.Contains(t, err.Error(), "embedded IdP client")
                                                +
                                                +	settings, err := managementStore.GetAccountSettings(ctx, mgmtstore.LockingStrengthNone, "account-1")
                                                +	require.NoError(t, err)
                                                +	require.False(t, settings.LocalMfaEnabled)
                                                +}
                                                +
                                                +func TestRunSetMFAEnabledUpdatesSettingsAfterIDP(t *testing.T) {
                                                +	ctx := context.Background()
                                                +	managementStore := newTestManagementStore(t, false)
                                                +	idpStorage := newTestIDPStorage(t)
                                                +
                                                +	err := runSetMFAEnabled(ctx, Resources{Store: managementStore, IDPStorage: idpStorage}, io.Discard, true)
                                                +	require.NoError(t, err)
                                                +
                                                +	settings, err := managementStore.GetAccountSettings(ctx, mgmtstore.LockingStrengthNone, "account-1")
                                                +	require.NoError(t, err)
                                                +	require.True(t, settings.LocalMfaEnabled)
                                                +	clientStatus, err := idpClientsMFAStatus(ctx, idpStorage)
                                                +	require.NoError(t, err)
                                                +	require.Equal(t, "enabled", clientStatus)
                                                +}
                                                +
                                                +func TestRunSetMFAEnabledSucceedsWithNilEventStore(t *testing.T) {
                                                +	ctx := context.Background()
                                                +	managementStore := newTestManagementStore(t, false)
                                                +	idpStorage := newTestIDPStorage(t)
                                                +	var out bytes.Buffer
                                                +	var err error
                                                +
                                                +	require.NotPanics(t, func() {
                                                +		err = runSetMFAEnabled(ctx, Resources{Store: managementStore, IDPStorage: idpStorage, EventStore: nil}, &out, true)
                                                +	})
                                                +	require.NoError(t, err)
                                                +	require.Contains(t, out.String(), "Local MFA enabled")
                                                +
                                                +	settings, err := managementStore.GetAccountSettings(ctx, mgmtstore.LockingStrengthNone, "account-1")
                                                +	require.NoError(t, err)
                                                +	require.True(t, settings.LocalMfaEnabled)
                                                +}
                                                +
                                                +func TestUserSelectorValidate(t *testing.T) {
                                                +	require.NoError(t, userSelector{email: " user@example.com "}.validate())
                                                +	require.NoError(t, userSelector{userID: "user-1"}.validate())
                                                +	require.Error(t, userSelector{}.validate())
                                                +	require.Error(t, userSelector{email: "user@example.com", userID: "user-1"}.validate())
                                                +}
                                                +
                                                +func TestFindLocalUserNotFound(t *testing.T) {
                                                +	st := newTestIDPStorage(t)
                                                +	_, err := findLocalUser(context.Background(), st, userSelector{email: "missing@example.com"}, "")
                                                +	require.Error(t, err)
                                                +	require.True(t, strings.Contains(err.Error(), "not found"))
                                                +}
                                                +
                                                +func TestFindLocalUserZeroUsersIncludesStoragePath(t *testing.T) {
                                                +	st := memory.New(slog.New(slog.NewTextHandler(io.Discard, nil)))
                                                +	_, err := findLocalUser(context.Background(), st, userSelector{email: "missing@example.com"}, "/var/lib/netbird/idp.db")
                                                +	require.Error(t, err)
                                                +	require.Contains(t, err.Error(), "no local users exist")
                                                +	require.Contains(t, err.Error(), "/var/lib/netbird/idp.db")
                                                +}
                                                +
                                                +func TestUserCommandValidatesSelectorBeforeOpeningStorage(t *testing.T) {
                                                +	opened := false
                                                +	cmd := NewCommands(Openers{
                                                +		IDP: func(cmd *cobra.Command, fn func(ctx context.Context, idpStorage storage.Storage, storageFile string) error) error {
                                                +			opened = true
                                                +			return nil
                                                +		},
                                                +	})
                                                +	cmd.SetArgs([]string{"user", "change-password", "--password", "NewPass1!"})
                                                +	cmd.SetOut(io.Discard)
                                                +	cmd.SetErr(io.Discard)
                                                +
                                                +	err := cmd.Execute()
                                                +	require.Error(t, err)
                                                +	require.Contains(t, err.Error(), "provide exactly one")
                                                +	require.False(t, opened)
                                                +}
                                                +
                                                +func TestResolvePasswordInputFromStdin(t *testing.T) {
                                                +	cmd := &cobra.Command{}
                                                +	cmd.SetIn(strings.NewReader("NewPass1!\n"))
                                                +
                                                +	password, err := resolvePasswordInput(cmd, "", "-")
                                                +	require.NoError(t, err)
                                                +	require.Equal(t, "NewPass1!", password)
                                                +}
                                                +
                                                +func TestResolvePasswordInputRejectsMultipleSources(t *testing.T) {
                                                +	_, err := resolvePasswordInput(&cobra.Command{}, "NewPass1!", "-")
                                                +	require.Error(t, err)
                                                +}
                                                diff --git a/management/cmd/admin_config_test.go b/management/cmd/admin_config_test.go
                                                new file mode 100644
                                                index 000000000..6da8580a8
                                                --- /dev/null
                                                +++ b/management/cmd/admin_config_test.go
                                                @@ -0,0 +1,80 @@
                                                +package cmd
                                                +
                                                +import (
                                                +	"context"
                                                +	"path"
                                                +	"path/filepath"
                                                +	"testing"
                                                +
                                                +	"github.com/stretchr/testify/require"
                                                +
                                                +	nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
                                                +	"github.com/netbirdio/netbird/management/server/idp"
                                                +)
                                                +
                                                +func TestApplyAdminDatadirOverrideRelocatesDefaultIDPStorage(t *testing.T) {
                                                +	oldDatadir := filepath.Join(t.TempDir(), "old")
                                                +	newDatadir := filepath.Join(t.TempDir(), "new")
                                                +
                                                +	for _, defaultFile := range []string{
                                                +		"",
                                                +		filepath.Join(oldDatadir, "idp.db"),
                                                +		path.Join(oldDatadir, "idp.db"),
                                                +	} {
                                                +		t.Run(defaultFile, func(t *testing.T) {
                                                +			cfg := &nbconfig.Config{
                                                +				EmbeddedIdP: &idp.EmbeddedIdPConfig{
                                                +					Enabled: true,
                                                +					Storage: idp.EmbeddedStorageConfig{
                                                +						Type: "sqlite3",
                                                +						Config: idp.EmbeddedStorageTypeConfig{
                                                +							File: defaultFile,
                                                +						},
                                                +					},
                                                +				},
                                                +			}
                                                +			datadir := oldDatadir
                                                +			oldAdminDatadir := adminDatadir
                                                +			adminDatadir = newDatadir
                                                +			t.Cleanup(func() { adminDatadir = oldAdminDatadir })
                                                +
                                                +			applyAdminDatadirOverride(cfg, &datadir)
                                                +
                                                +			require.Equal(t, newDatadir, datadir)
                                                +			require.Equal(t, filepath.Join(newDatadir, "idp.db"), cfg.EmbeddedIdP.Storage.Config.File)
                                                +		})
                                                +	}
                                                +}
                                                +
                                                +func TestOpenAdminEventStoreMissingEncryptionKeyReturnsNilInterface(t *testing.T) {
                                                +	eventStore, err := openAdminEventStore(context.Background(), &nbconfig.Config{}, t.TempDir())
                                                +	require.Error(t, err)
                                                +	require.Contains(t, err.Error(), "encryption key")
                                                +	require.Nil(t, eventStore)
                                                +}
                                                +
                                                +func TestApplyAdminDatadirOverrideKeepsExplicitIDPStorage(t *testing.T) {
                                                +	oldDatadir := filepath.Join(t.TempDir(), "old")
                                                +	newDatadir := filepath.Join(t.TempDir(), "new")
                                                +	explicitFile := filepath.Join(t.TempDir(), "custom-idp.db")
                                                +	cfg := &nbconfig.Config{
                                                +		EmbeddedIdP: &idp.EmbeddedIdPConfig{
                                                +			Enabled: true,
                                                +			Storage: idp.EmbeddedStorageConfig{
                                                +				Type: "sqlite3",
                                                +				Config: idp.EmbeddedStorageTypeConfig{
                                                +					File: explicitFile,
                                                +				},
                                                +			},
                                                +		},
                                                +	}
                                                +	datadir := oldDatadir
                                                +	oldAdminDatadir := adminDatadir
                                                +	adminDatadir = newDatadir
                                                +	t.Cleanup(func() { adminDatadir = oldAdminDatadir })
                                                +
                                                +	applyAdminDatadirOverride(cfg, &datadir)
                                                +
                                                +	require.Equal(t, newDatadir, datadir)
                                                +	require.Equal(t, explicitFile, cfg.EmbeddedIdP.Storage.Config.File)
                                                +}
                                                diff --git a/management/cmd/management.go b/management/cmd/management.go
                                                index 27d8055e7..fc6bd0a46 100644
                                                --- a/management/cmd/management.go
                                                +++ b/management/cmd/management.go
                                                @@ -13,6 +13,7 @@ import (
                                                 	"os"
                                                 	"os/signal"
                                                 	"path"
                                                +	"path/filepath"
                                                 	"strings"
                                                 	"syscall"
                                                 
                                                @@ -22,9 +23,11 @@ import (
                                                 	"github.com/netbirdio/netbird/management/server/types"
                                                 
                                                 	"github.com/netbirdio/netbird/formatter/hook"
                                                +	agentnetworkpricing "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing"
                                                 	"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/shared/management/grpc"
                                                 	"github.com/netbirdio/netbird/util"
                                                 	"github.com/netbirdio/netbird/util/crypt"
                                                 )
                                                @@ -110,6 +113,29 @@ var (
                                                 				mgmtSingleAccModeDomain = ""
                                                 			}
                                                 
                                                +			// Load the management-side LLM pricing defaults file: an
                                                +			// explicitly configured path is required to load (a typo must
                                                +			// fail startup — the operator believes those rates are live);
                                                +			// otherwise /defaults_llm_pricing.yaml is probed and
                                                +			// may be absent (compiled-in defaults serve). A relative path
                                                +			// is resolved against the datadir so a bare filename lands
                                                +			// alongside the store. Either way the path stays watched: the
                                                +			// reloader picks up edits — and the file appearing later —
                                                +			// without a restart.
                                                +			pricingPath := config.AgentNetwork.PricingDefaultsFile
                                                +			pricingRequired := pricingPath != ""
                                                +			if !pricingRequired {
                                                +				pricingPath = agentnetworkpricing.DefaultFileName
                                                +			}
                                                +			if !filepath.IsAbs(pricingPath) {
                                                +				pricingPath = filepath.Join(config.Datadir, pricingPath)
                                                +			}
                                                +			log.Infof("loading agent-network pricing defaults from %s (required: %v)", pricingPath, pricingRequired)
                                                +			if err := agentnetworkpricing.LoadFile(pricingPath, pricingRequired); err != nil {
                                                +				return fmt.Errorf("load agent-network pricing defaults: %v", err)
                                                +			}
                                                +			agentnetworkpricing.StartReloader(ctx, agentnetworkpricing.ReloadInterval)
                                                +
                                                 			srv := newServer(&server.Config{
                                                 				NbConfig:                    config,
                                                 				DNSDomain:                   dnsDomain,
                                                @@ -153,8 +179,20 @@ func LoadMgmtConfig(ctx context.Context, mgmtConfigPath string) (*nbconfig.Confi
                                                 
                                                 	ApplyCommandLineOverrides(loadedConfig)
                                                 
                                                +	err := grpc.ValidateSyncMessageVersion(loadedConfig.HighestSupportedSyncMessageVersion)
                                                +	if err != nil {
                                                +		return nil, err
                                                +	}
                                                +
                                                +	for account, version := range loadedConfig.PerAccountHighestSupportedSyncMessageVersion {
                                                +		err := grpc.ValidateSyncMessageVersion(&version)
                                                +		if err != nil {
                                                +			return nil, fmt.Errorf("unrecognized sync message version for account %s, %w", account, err)
                                                +		}
                                                +	}
                                                +
                                                 	// Apply EmbeddedIdP config to HttpConfig if embedded IdP is enabled
                                                -	err := ApplyEmbeddedIdPConfig(ctx, loadedConfig)
                                                +	err = ApplyEmbeddedIdPConfig(ctx, loadedConfig)
                                                 	if err != nil {
                                                 		return nil, err
                                                 	}
                                                @@ -198,6 +236,9 @@ func ApplyEmbeddedIdPConfig(ctx context.Context, cfg *nbconfig.Config) error {
                                                 		// Embedded IdP requires single account mode - multiple account mode is not supported
                                                 		return fmt.Errorf("embedded IdP requires single account mode; multiple account mode is not supported with embedded IdP. Please remove --disable-single-account-mode flag")
                                                 	}
                                                +	if mgmtSingleAccModeDomain == "" {
                                                +		return fmt.Errorf("embedded IdP requires single account mode; --single-account-mode-domain must not be empty")
                                                +	}
                                                 	// Enable user deletion from IDP by default if EmbeddedIdP is enabled
                                                 	userDeleteFromIDPEnabled = true
                                                 
                                                @@ -209,7 +250,7 @@ func ApplyEmbeddedIdPConfig(ctx context.Context, cfg *nbconfig.Config) error {
                                                 		cfg.EmbeddedIdP.Storage.Type = "sqlite3"
                                                 	}
                                                 	if cfg.EmbeddedIdP.Storage.Config.File == "" && cfg.Datadir != "" {
                                                -		cfg.EmbeddedIdP.Storage.Config.File = path.Join(cfg.Datadir, "idp.db")
                                                +		cfg.EmbeddedIdP.Storage.Config.File = filepath.Join(cfg.Datadir, "idp.db")
                                                 	}
                                                 
                                                 	issuer := cfg.EmbeddedIdP.Issuer
                                                diff --git a/management/cmd/management_test.go b/management/cmd/management_test.go
                                                index f0c89dd3f..e34e1975e 100644
                                                --- a/management/cmd/management_test.go
                                                +++ b/management/cmd/management_test.go
                                                @@ -4,6 +4,13 @@ import (
                                                 	"context"
                                                 	"os"
                                                 	"testing"
                                                +
                                                +	"github.com/stretchr/testify/assert"
                                                +	"github.com/stretchr/testify/require"
                                                +
                                                +	nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
                                                +	"github.com/netbirdio/netbird/management/server/idp"
                                                +	"github.com/netbirdio/netbird/shared/management/grpc"
                                                 )
                                                 
                                                 const (
                                                @@ -20,34 +27,65 @@ const (
                                                 		"AuthAudience": "https://stageapp/",
                                                 		"AuthIssuer": "https://something.eu.auth0.com/",
                                                 		"OIDCConfigEndpoint": "https://something.eu.auth0.com/.well-known/openid-configuration"
                                                +	  },
                                                +	  "HighestSupportedSyncMessageVersion": 1,
                                                +	  "PerAccountHighestSupportedSyncMessageVersion": {
                                                +	    "1": 0,
                                                +		"2": 1
                                                 	  }
                                                 	}`
                                                 )
                                                 
                                                -func Test_loadMgmtConfig(t *testing.T) {
                                                -	tmpFile, err := createConfig()
                                                -	if err != nil {
                                                -		t.Fatalf("failed to create config: %s", err)
                                                -	}
                                                +func Test_LoadMgmtConfig(t *testing.T) {
                                                +	tmpFile, err := createConfig(exampleConfig)
                                                +	assert.NoError(t, err)
                                                 
                                                 	cfg, err := LoadMgmtConfig(context.Background(), tmpFile)
                                                -	if err != nil {
                                                -		t.Fatalf("failed to load management config: %s", err)
                                                -	}
                                                -	if cfg.Relay == nil {
                                                -		t.Fatalf("config is nil")
                                                -	}
                                                -	if len(cfg.Relay.Addresses) == 0 {
                                                -		t.Fatalf("relay address is empty")
                                                -	}
                                                +	assert.NoError(t, err)
                                                +	assert.NotEmpty(t, cfg.Relay)
                                                +	assert.NotEmpty(t, cfg.Relay.Addresses)
                                                +	assert.Equal(t, int(grpc.ComponentNetworkMap), *cfg.HighestSupportedSyncMessageVersion)
                                                +	assert.Equal(t, map[string]int{"1": int(grpc.Base), "2": int(grpc.ComponentNetworkMap)}, cfg.PerAccountHighestSupportedSyncMessageVersion)
                                                 }
                                                 
                                                -func createConfig() (string, error) {
                                                +func Test_LoadMgmtConfig_Empty(t *testing.T) {
                                                +	tmpFile, err := createConfig(`{
                                                +		"HttpConfig": {
                                                +			"AuthAudience": "https://stageapp/",
                                                +			"AuthIssuer": "https://something.eu.auth0.com/",
                                                +			"OIDCConfigEndpoint": "https://something.eu.auth0.com/.well-known/openid-configuration"
                                                +		}
                                                +	}`)
                                                +	assert.NoError(t, err)
                                                +
                                                +	cfg, err := LoadMgmtConfig(context.Background(), tmpFile)
                                                +	assert.NoError(t, err)
                                                +	assert.Nil(t, cfg.HighestSupportedSyncMessageVersion)
                                                +	assert.Nil(t, cfg.PerAccountHighestSupportedSyncMessageVersion)
                                                +}
                                                +
                                                +func TestApplyEmbeddedIdPConfigRequiresSingleAccountDomain(t *testing.T) {
                                                +	previousDomain := mgmtSingleAccModeDomain
                                                +	previousDisabled := disableSingleAccMode
                                                +	t.Cleanup(func() {
                                                +		mgmtSingleAccModeDomain = previousDomain
                                                +		disableSingleAccMode = previousDisabled
                                                +	})
                                                +
                                                +	mgmtSingleAccModeDomain = ""
                                                +	disableSingleAccMode = false
                                                +	cfg := &nbconfig.Config{
                                                +		EmbeddedIdP: &idp.EmbeddedIdPConfig{Enabled: true},
                                                +	}
                                                +	require.ErrorContains(t, ApplyEmbeddedIdPConfig(context.Background(), cfg), "embedded IdP requires single account mode")
                                                +}
                                                +
                                                +func createConfig(config string) (string, error) {
                                                 	tmpfile, err := os.CreateTemp("", "config.json")
                                                 	if err != nil {
                                                 		return "", err
                                                 	}
                                                -	_, err = tmpfile.Write([]byte(exampleConfig))
                                                +	_, err = tmpfile.Write([]byte(config))
                                                 	if err != nil {
                                                 		return "", err
                                                 	}
                                                diff --git a/management/cmd/proxy/proxy.go b/management/cmd/proxy/proxy.go
                                                new file mode 100644
                                                index 000000000..73f83b3d6
                                                --- /dev/null
                                                +++ b/management/cmd/proxy/proxy.go
                                                @@ -0,0 +1,141 @@
                                                +// Package proxycmd provides reusable cobra commands for managing reverse proxy instances.
                                                +// Both the management and combined binaries use these commands, each providing
                                                +// their own StoreOpener to handle config loading and store initialization.
                                                +package proxycmd
                                                +
                                                +import (
                                                +	"bufio"
                                                +	"context"
                                                +	"fmt"
                                                +	"io"
                                                +	"strings"
                                                +	"text/tabwriter"
                                                +
                                                +	"github.com/spf13/cobra"
                                                +
                                                +	rpproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
                                                +	"github.com/netbirdio/netbird/management/server/store"
                                                +)
                                                +
                                                +// 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
                                                +
                                                +const disconnectAllConfirmation = "disconnect all proxies"
                                                +
                                                +// NewCommands creates the proxy command tree with the given store opener.
                                                +// Returns the parent "proxy" command with the disconnect-all subcommand.
                                                +func NewCommands(opener StoreOpener) *cobra.Command {
                                                +	var dryRun bool
                                                +	var force bool
                                                +
                                                +	proxyCmd := &cobra.Command{
                                                +		Use:   "proxy",
                                                +		Short: "Manage reverse proxy instances",
                                                +		Long:  "Commands for inspecting and repairing the reverse proxy instances registered with the management server.",
                                                +	}
                                                +
                                                +	disconnectAllCmd := &cobra.Command{
                                                +		Use:   "disconnect-all",
                                                +		Short: "Force-mark all reverse proxy instances as disconnected",
                                                +		Long: "Lists all reverse proxy instances and force-marks them as disconnected, regardless of their session state. " +
                                                +			"Use this to repair stale connection state, e.g. after an unclean management server shutdown. " +
                                                +			"By default, it asks for manual confirmation before changing state. Use --dry-run to preview without changing state, or --force to skip confirmation. " +
                                                +			"Run during a maintenance window; affected live proxies may stay hidden until their next heartbeat or reconnect/re-register.",
                                                +		Args: cobra.NoArgs,
                                                +		RunE: func(cmd *cobra.Command, _ []string) error {
                                                +			return opener(cmd, func(ctx context.Context, s store.Store) error {
                                                +				return runDisconnectAll(ctx, s, cmd.OutOrStdout(), cmd.InOrStdin(), dryRun, force)
                                                +			})
                                                +		},
                                                +	}
                                                +	disconnectAllCmd.Flags().BoolVar(&dryRun, "dry-run", false, "List reverse proxy instances that would be disconnected without changing state")
                                                +	disconnectAllCmd.Flags().BoolVar(&force, "force", false, "Skip the confirmation prompt and apply the repair")
                                                +
                                                +	proxyCmd.AddCommand(disconnectAllCmd)
                                                +	return proxyCmd
                                                +}
                                                +
                                                +func runDisconnectAll(ctx context.Context, s store.Store, out io.Writer, in io.Reader, dryRun, force bool) error {
                                                +	proxies, err := s.GetAllProxies(ctx)
                                                +	if err != nil {
                                                +		return fmt.Errorf("list proxies: %w", err)
                                                +	}
                                                +
                                                +	if len(proxies) == 0 {
                                                +		_, _ = fmt.Fprintln(out, "No reverse proxy instances found.")
                                                +		return nil
                                                +	}
                                                +
                                                +	toDisconnect := 0
                                                +	w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
                                                +	_, _ = fmt.Fprintln(w, "ID\tCLUSTER\tIP\tACCOUNT\tSTATUS\tLAST SEEN")
                                                +	_, _ = fmt.Fprintln(w, "--\t-------\t--\t-------\t------\t---------")
                                                +
                                                +	for _, p := range proxies {
                                                +		if p.Status != rpproxy.StatusDisconnected {
                                                +			toDisconnect++
                                                +		}
                                                +
                                                +		account := "-"
                                                +		if p.AccountID != nil {
                                                +			account = *p.AccountID
                                                +		}
                                                +
                                                +		_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
                                                +			p.ID,
                                                +			p.ClusterAddress,
                                                +			p.IPAddress,
                                                +			account,
                                                +			p.Status,
                                                +			p.LastSeen.Format("2006-01-02 15:04:05"),
                                                +		)
                                                +	}
                                                +	if err := w.Flush(); err != nil {
                                                +		return fmt.Errorf("write proxy list: %w", err)
                                                +	}
                                                +
                                                +	if dryRun {
                                                +		_, _ = fmt.Fprintf(out, "\nDry run: would force-mark %d of %d reverse proxy instance(s) as disconnected.\n", toDisconnect, len(proxies))
                                                +		return nil
                                                +	}
                                                +
                                                +	if !force {
                                                +		confirmed, err := confirmDisconnectAll(out, in)
                                                +		if err != nil {
                                                +			return err
                                                +		}
                                                +		if !confirmed {
                                                +			_, _ = fmt.Fprintln(out, "Aborted. No reverse proxy instances were changed.")
                                                +			return nil
                                                +		}
                                                +	}
                                                +
                                                +	disconnected, err := s.DisconnectAllProxies(ctx)
                                                +	if err != nil {
                                                +		return fmt.Errorf("disconnect proxies: %w", err)
                                                +	}
                                                +
                                                +	_, _ = fmt.Fprintf(out, "\nForce-marked %d of %d reverse proxy instance(s) as disconnected.\n", disconnected, len(proxies))
                                                +	return nil
                                                +}
                                                +
                                                +func confirmDisconnectAll(out io.Writer, in io.Reader) (bool, error) {
                                                +	if in == nil {
                                                +		in = strings.NewReader("")
                                                +	}
                                                +
                                                +	_, _ = fmt.Fprintln(out, "\nWARNING: This command changes stored reverse proxy state for every non-disconnected instance.")
                                                +	_, _ = fmt.Fprintln(out, "Run it during a maintenance window; affected live proxies may stay hidden until "+
                                                +		"their next heartbeat or reconnect/re-register.")
                                                +	_, _ = fmt.Fprintf(out, "Type %q to continue: ", disconnectAllConfirmation)
                                                +
                                                +	scanner := bufio.NewScanner(in)
                                                +	if !scanner.Scan() {
                                                +		if err := scanner.Err(); err != nil {
                                                +			return false, fmt.Errorf("read confirmation: %w", err)
                                                +		}
                                                +		return false, nil
                                                +	}
                                                +
                                                +	return strings.EqualFold(strings.TrimSpace(scanner.Text()), disconnectAllConfirmation), nil
                                                +}
                                                diff --git a/management/cmd/proxy/proxy_test.go b/management/cmd/proxy/proxy_test.go
                                                new file mode 100644
                                                index 000000000..ff0dc8119
                                                --- /dev/null
                                                +++ b/management/cmd/proxy/proxy_test.go
                                                @@ -0,0 +1,180 @@
                                                +package proxycmd
                                                +
                                                +import (
                                                +	"bytes"
                                                +	"context"
                                                +	"strings"
                                                +	"testing"
                                                +	"time"
                                                +
                                                +	"github.com/spf13/cobra"
                                                +	"github.com/stretchr/testify/require"
                                                +
                                                +	rpproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
                                                +	"github.com/netbirdio/netbird/management/server/store"
                                                +)
                                                +
                                                +func newTestStore(t *testing.T) store.Store {
                                                +	t.Helper()
                                                +
                                                +	s, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir())
                                                +	require.NoError(t, err)
                                                +	t.Cleanup(cleanup)
                                                +
                                                +	return s
                                                +}
                                                +
                                                +func seedProxies(t *testing.T, ctx context.Context, s store.Store) {
                                                +	t.Helper()
                                                +
                                                +	accountID := "account-1"
                                                +	alreadyDisconnectedAt := time.Now().Add(-time.Hour)
                                                +	seed := []*rpproxy.Proxy{
                                                +		{
                                                +			ID:             "proxy-1",
                                                +			SessionID:      "session-1",
                                                +			ClusterAddress: "cluster-a.example.com",
                                                +			IPAddress:      "10.0.0.1",
                                                +			LastSeen:       time.Now(),
                                                +			Status:         rpproxy.StatusConnected,
                                                +		},
                                                +		{
                                                +			ID:             "proxy-2",
                                                +			SessionID:      "session-2",
                                                +			ClusterAddress: "cluster-b.example.com",
                                                +			IPAddress:      "10.0.0.2",
                                                +			AccountID:      &accountID,
                                                +			LastSeen:       time.Now(),
                                                +			Status:         rpproxy.StatusConnected,
                                                +		},
                                                +		{
                                                +			ID:             "proxy-3",
                                                +			SessionID:      "session-3",
                                                +			ClusterAddress: "cluster-a.example.com",
                                                +			IPAddress:      "10.0.0.3",
                                                +			LastSeen:       time.Now().Add(-time.Hour),
                                                +			Status:         rpproxy.StatusDisconnected,
                                                +			DisconnectedAt: &alreadyDisconnectedAt,
                                                +		},
                                                +	}
                                                +	for _, p := range seed {
                                                +		require.NoError(t, s.SaveProxy(ctx, p))
                                                +	}
                                                +}
                                                +
                                                +func proxiesByID(t *testing.T, ctx context.Context, s store.Store) map[string]*rpproxy.Proxy {
                                                +	t.Helper()
                                                +
                                                +	proxies, err := s.GetAllProxies(ctx)
                                                +	require.NoError(t, err)
                                                +	require.Len(t, proxies, 3)
                                                +
                                                +	byID := make(map[string]*rpproxy.Proxy, len(proxies))
                                                +	for _, p := range proxies {
                                                +		byID[p.ID] = p
                                                +	}
                                                +	return byID
                                                +}
                                                +
                                                +func TestRunDisconnectAllWithConfirmation(t *testing.T) {
                                                +	ctx := context.Background()
                                                +	s := newTestStore(t)
                                                +	seedProxies(t, ctx, s)
                                                +
                                                +	var out bytes.Buffer
                                                +	require.NoError(t, runDisconnectAll(ctx, s, &out, strings.NewReader(disconnectAllConfirmation+"\n"), false, false))
                                                +
                                                +	output := out.String()
                                                +	require.Contains(t, output, "proxy-1")
                                                +	require.Contains(t, output, "proxy-2")
                                                +	require.Contains(t, output, "proxy-3")
                                                +	require.Contains(t, output, "cluster-a.example.com")
                                                +	require.Contains(t, output, "account-1")
                                                +	require.Contains(t, output, "Type \"disconnect all proxies\" to continue")
                                                +	require.Contains(t, output, "Force-marked 2 of 3 reverse proxy instance(s) as disconnected.")
                                                +
                                                +	for _, p := range proxiesByID(t, ctx, s) {
                                                +		require.Equal(t, rpproxy.StatusDisconnected, p.Status, "proxy %s should be disconnected", p.ID)
                                                +		require.NotNil(t, p.DisconnectedAt, "proxy %s should have a disconnected timestamp", p.ID)
                                                +	}
                                                +}
                                                +
                                                +func TestRunDisconnectAllForceSkipsConfirmation(t *testing.T) {
                                                +	ctx := context.Background()
                                                +	s := newTestStore(t)
                                                +	seedProxies(t, ctx, s)
                                                +
                                                +	var out bytes.Buffer
                                                +	require.NoError(t, runDisconnectAll(ctx, s, &out, strings.NewReader(""), false, true))
                                                +
                                                +	output := out.String()
                                                +	require.NotContains(t, output, "Type \"disconnect all proxies\" to continue")
                                                +	require.Contains(t, output, "Force-marked 2 of 3 reverse proxy instance(s) as disconnected.")
                                                +}
                                                +
                                                +func TestRunDisconnectAllAbortLeavesProxiesUnchanged(t *testing.T) {
                                                +	ctx := context.Background()
                                                +	s := newTestStore(t)
                                                +	seedProxies(t, ctx, s)
                                                +
                                                +	var out bytes.Buffer
                                                +	require.NoError(t, runDisconnectAll(ctx, s, &out, strings.NewReader("no\n"), false, false))
                                                +
                                                +	output := out.String()
                                                +	require.Contains(t, output, "Type \"disconnect all proxies\" to continue")
                                                +	require.Contains(t, output, "Aborted. No reverse proxy instances were changed.")
                                                +
                                                +	byID := proxiesByID(t, ctx, s)
                                                +	require.Equal(t, rpproxy.StatusConnected, byID["proxy-1"].Status)
                                                +	require.Equal(t, rpproxy.StatusConnected, byID["proxy-2"].Status)
                                                +	require.Equal(t, rpproxy.StatusDisconnected, byID["proxy-3"].Status)
                                                +}
                                                +
                                                +func TestRunDisconnectAllDryRunLeavesProxiesUnchanged(t *testing.T) {
                                                +	ctx := context.Background()
                                                +	s := newTestStore(t)
                                                +	seedProxies(t, ctx, s)
                                                +
                                                +	var out bytes.Buffer
                                                +	require.NoError(t, runDisconnectAll(ctx, s, &out, strings.NewReader(""), true, false))
                                                +
                                                +	output := out.String()
                                                +	require.Contains(t, output, "Dry run: would force-mark 2 of 3 reverse proxy instance(s) as disconnected.")
                                                +	require.NotContains(t, output, "Type \"disconnect all proxies\" to continue")
                                                +
                                                +	byID := proxiesByID(t, ctx, s)
                                                +	require.Equal(t, rpproxy.StatusConnected, byID["proxy-1"].Status)
                                                +	require.Equal(t, rpproxy.StatusConnected, byID["proxy-2"].Status)
                                                +	require.Equal(t, rpproxy.StatusDisconnected, byID["proxy-3"].Status)
                                                +}
                                                +
                                                +func TestNewCommandsDisconnectAllDryRun(t *testing.T) {
                                                +	ctx := context.Background()
                                                +	s := newTestStore(t)
                                                +	seedProxies(t, ctx, s)
                                                +
                                                +	opened := false
                                                +	cmd := NewCommands(func(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error {
                                                +		opened = true
                                                +		return fn(cmd.Context(), s)
                                                +	})
                                                +
                                                +	var out bytes.Buffer
                                                +	cmd.SetOut(&out)
                                                +	cmd.SetErr(&out)
                                                +	cmd.SetIn(strings.NewReader(""))
                                                +	cmd.SetArgs([]string{"disconnect-all", "--dry-run"})
                                                +
                                                +	require.NoError(t, cmd.ExecuteContext(ctx))
                                                +	require.True(t, opened)
                                                +	require.Contains(t, out.String(), "Dry run: would force-mark 2 of 3 reverse proxy instance(s) as disconnected.")
                                                +}
                                                +
                                                +func TestRunDisconnectAllEmpty(t *testing.T) {
                                                +	ctx := context.Background()
                                                +	s := newTestStore(t)
                                                +
                                                +	var out bytes.Buffer
                                                +	require.NoError(t, runDisconnectAll(ctx, s, &out, strings.NewReader(""), false, false))
                                                +	require.Contains(t, out.String(), "No reverse proxy instances found.")
                                                +}
                                                diff --git a/management/cmd/root.go b/management/cmd/root.go
                                                index fc43d315d..ae03a09e8 100644
                                                --- a/management/cmd/root.go
                                                +++ b/management/cmd/root.go
                                                @@ -54,6 +54,15 @@ func Execute() error {
                                                 	return rootCmd.Execute()
                                                 }
                                                 
                                                +// Customize hands the fully built root command to fn so an embedding binary
                                                +// can extend or adjust the command tree — most commonly attaching its own
                                                +// subcommands next to (or under) the built-in ones — before calling Execute.
                                                +// The root command is constructed in this package's init, so Customize may be
                                                +// called from the embedding binary's main at any point before Execute.
                                                +func Customize(fn func(root *cobra.Command)) {
                                                +	fn(rootCmd)
                                                +}
                                                +
                                                 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)")
                                                @@ -83,7 +92,8 @@ func init() {
                                                 
                                                 	rootCmd.AddCommand(migrationCmd)
                                                 
                                                -	tc := newTokenCommands()
                                                -	tc.PersistentFlags().StringVar(&nbconfig.MgmtConfigPath, "config", defaultMgmtConfig, "Netbird config file location")
                                                -	rootCmd.AddCommand(tc)
                                                +	ac := newAdminCommands()
                                                +	ac.PersistentFlags().StringVar(&nbconfig.MgmtConfigPath, "config", defaultMgmtConfig, "Netbird config file location")
                                                +	rootCmd.AddCommand(ac)
                                                +	rootCmd.AddCommand(newLegacyTokenCommand())
                                                 }
                                                diff --git a/management/cmd/root_test.go b/management/cmd/root_test.go
                                                new file mode 100644
                                                index 000000000..826fd2d50
                                                --- /dev/null
                                                +++ b/management/cmd/root_test.go
                                                @@ -0,0 +1,42 @@
                                                +package cmd
                                                +
                                                +import (
                                                +	"testing"
                                                +
                                                +	"github.com/spf13/cobra"
                                                +)
                                                +
                                                +// TestCustomize verifies an embedding binary can extend the command tree: a
                                                +// top-level command attached through the hook, and a subcommand attached under
                                                +// the built-in admin group, are both resolvable exactly as Execute would
                                                +// resolve them.
                                                +func TestCustomize(t *testing.T) {
                                                +	topLevel := &cobra.Command{Use: "some-extra", RunE: func(*cobra.Command, []string) error { return nil }}
                                                +	nested := &cobra.Command{Use: "cluster", RunE: func(*cobra.Command, []string) error { return nil }}
                                                +
                                                +	Customize(func(root *cobra.Command) {
                                                +		root.AddCommand(topLevel)
                                                +		for _, c := range root.Commands() {
                                                +			if c.Name() == "admin" {
                                                +				c.AddCommand(nested)
                                                +				return
                                                +			}
                                                +		}
                                                +		t.Fatal("admin command not found in the root tree")
                                                +	})
                                                +	t.Cleanup(func() {
                                                +		rootCmd.RemoveCommand(topLevel)
                                                +		for _, c := range rootCmd.Commands() {
                                                +			if c.Name() == "admin" {
                                                +				c.RemoveCommand(nested)
                                                +			}
                                                +		}
                                                +	})
                                                +
                                                +	if found, _, err := rootCmd.Find([]string{"some-extra"}); err != nil || found != topLevel {
                                                +		t.Fatalf("top-level command not resolvable: found=%v err=%v", found, err)
                                                +	}
                                                +	if found, _, err := rootCmd.Find([]string{"admin", "cluster"}); err != nil || found != nested {
                                                +		t.Fatalf("nested admin subcommand not resolvable: found=%v err=%v", found, err)
                                                +	}
                                                +}
                                                diff --git a/management/cmd/token.go b/management/cmd/token.go
                                                deleted file mode 100644
                                                index 67af1a5f5..000000000
                                                --- a/management/cmd/token.go
                                                +++ /dev/null
                                                @@ -1,55 +0,0 @@
                                                -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/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go
                                                index 4b414df6f..9727ff958 100644
                                                --- a/management/internals/controllers/network_map/controller/controller.go
                                                +++ b/management/internals/controllers/network_map/controller/controller.go
                                                @@ -7,7 +7,6 @@ import (
                                                 	"os"
                                                 	"slices"
                                                 	"strconv"
                                                -	"strings"
                                                 	"sync"
                                                 	"sync/atomic"
                                                 	"time"
                                                @@ -16,13 +15,13 @@ import (
                                                 	"golang.org/x/exp/maps"
                                                 	"golang.org/x/mod/semver"
                                                 
                                                -	nbdns "github.com/netbirdio/netbird/dns"
                                                 	"github.com/netbirdio/netbird/management/internals/controllers/network_map"
                                                 	"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache"
                                                 	"github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral"
                                                -	"github.com/netbirdio/netbird/management/internals/modules/zones"
                                                +	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
                                                 	"github.com/netbirdio/netbird/management/internals/server/config"
                                                 	"github.com/netbirdio/netbird/management/internals/shared/grpc"
                                                +	"github.com/netbirdio/netbird/management/internals/shared/requestbuffer"
                                                 	"github.com/netbirdio/netbird/management/server/account"
                                                 	"github.com/netbirdio/netbird/management/server/integrations/integrated_validator"
                                                 	"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
                                                @@ -32,11 +31,17 @@ import (
                                                 	"github.com/netbirdio/netbird/management/server/store"
                                                 	"github.com/netbirdio/netbird/management/server/telemetry"
                                                 	"github.com/netbirdio/netbird/management/server/types"
                                                +	sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc"
                                                +	"github.com/netbirdio/netbird/shared/management/networkmap"
                                                +	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
                                                 	"github.com/netbirdio/netbird/shared/management/proto"
                                                 	"github.com/netbirdio/netbird/shared/management/status"
                                                 	"github.com/netbirdio/netbird/util"
                                                +	"github.com/netbirdio/netbird/version"
                                                 )
                                                 
                                                +const defaultNetworkMapDataBufferInterval = 100 * time.Millisecond
                                                +
                                                 type Controller struct {
                                                 	repo    Repository
                                                 	metrics *metrics
                                                @@ -47,7 +52,7 @@ type Controller struct {
                                                 	EphemeralPeersManager ephemeral.Manager
                                                 
                                                 	accountUpdateLocks               sync.Map
                                                -	sendAccountUpdateLocks           sync.Map
                                                +	affectedPeerUpdateLocks          sync.Map
                                                 	updateAccountPeersBufferInterval atomic.Int64
                                                 	// dnsDomain is used for peer resolution. This is appended to the peer's name
                                                 	dnsDomain string
                                                @@ -59,12 +64,12 @@ type Controller struct {
                                                 
                                                 	integratedPeerValidator integrated_validator.IntegratedValidator
                                                 
                                                -	holder *types.Holder
                                                +	serverSupportedSyncMessageVersion sharedgrpc.SyncMessageVersion
                                                 
                                                -	expNewNetworkMap     bool
                                                -	expNewNetworkMapAIDs map[string]struct{}
                                                +	perAccountServerSupportedSyncMessageVersions map[string]sharedgrpc.SyncMessageVersion
                                                 
                                                -	compactedNetworkMap bool
                                                +	nmdataStore  *networkmapdb.NetworkMapDBStoreImpl
                                                +	nmdataBuffer *requestbuffer.Buffer[*networkmap.NetworkMapData]
                                                 }
                                                 
                                                 type bufferUpdate struct {
                                                @@ -73,38 +78,22 @@ type bufferUpdate struct {
                                                 	update atomic.Bool
                                                 }
                                                 
                                                +type bufferAffectedUpdate struct {
                                                +	sendMu  sync.Mutex
                                                +	dataMu  sync.Mutex
                                                +	next    *time.Timer
                                                +	peerIDs map[string]struct{}
                                                +}
                                                +
                                                 var _ network_map.Controller = (*Controller)(nil)
                                                 
                                                -func NewController(ctx context.Context, store store.Store, metrics telemetry.AppMetrics, peersUpdateManager network_map.PeersUpdateManager, requestBuffer account.RequestBuffer, integratedPeerValidator integrated_validator.IntegratedValidator, settingsManager settings.Manager, dnsDomain string, proxyController port_forwarding.Controller, ephemeralPeersManager ephemeral.Manager, config *config.Config) *Controller {
                                                +func NewController(ctx context.Context, store store.Store, metrics telemetry.AppMetrics, peersUpdateManager network_map.PeersUpdateManager, requestBuffer account.RequestBuffer, integratedPeerValidator integrated_validator.IntegratedValidator, settingsManager settings.Manager, dnsDomain string, proxyController port_forwarding.Controller, ephemeralPeersManager ephemeral.Manager, config *config.Config, nmdataStore *networkmapdb.NetworkMapDBStoreImpl) *Controller {
                                                 	nMetrics, err := newMetrics(metrics.UpdateChannelMetrics())
                                                 	if err != nil {
                                                 		log.Fatal(fmt.Errorf("error creating metrics: %w", err))
                                                 	}
                                                 
                                                -	newNetworkMapBuilder, err := strconv.ParseBool(os.Getenv(network_map.EnvNewNetworkMapBuilder))
                                                -	if err != nil {
                                                -		log.WithContext(ctx).Warnf("failed to parse %s, using default value false: %v", network_map.EnvNewNetworkMapBuilder, err)
                                                -		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 {
                                                -		expIDs[id] = struct{}{}
                                                -	}
                                                -
                                                -	return &Controller{
                                                +	c := &Controller{
                                                 		repo:                    newRepository(store),
                                                 		metrics:                 nMetrics,
                                                 		accountManagerMetrics:   metrics.AccountManagerMetrics(),
                                                @@ -115,15 +104,20 @@ func NewController(ctx context.Context, store store.Store, metrics telemetry.App
                                                 		dnsDomain:               dnsDomain,
                                                 		config:                  config,
                                                 
                                                -		proxyController:       proxyController,
                                                -		EphemeralPeersManager: ephemeralPeersManager,
                                                -
                                                -		holder:               types.NewHolder(),
                                                -		expNewNetworkMap:     newNetworkMapBuilder,
                                                -		expNewNetworkMapAIDs: expIDs,
                                                -
                                                -		compactedNetworkMap: compactedNetworkMap,
                                                +		proxyController:                              proxyController,
                                                +		EphemeralPeersManager:                        ephemeralPeersManager,
                                                +		serverSupportedSyncMessageVersion:            sharedgrpc.SyncMessageVersionFromConfig(config.HighestSupportedSyncMessageVersion),
                                                +		perAccountServerSupportedSyncMessageVersions: sharedgrpc.SyncMessageVersionsFromMap(config.PerAccountHighestSupportedSyncMessageVersion),
                                                +		nmdataStore:                                  nmdataStore,
                                                 	}
                                                +
                                                +	if nmdataStore != nil {
                                                +		interval := requestbuffer.Interval(ctx, "NB_NETWORK_MAP_DATA_BUFFER_INTERVAL", defaultNetworkMapDataBufferInterval)
                                                +		log.WithContext(ctx).Infof("set network map data request buffer interval to %s", interval)
                                                +		c.nmdataBuffer = requestbuffer.New(ctx, "network map data request buffer", interval, c.fetchNetworkMapData)
                                                +	}
                                                +
                                                +	return c
                                                 }
                                                 
                                                 func (c *Controller) OnPeerConnected(ctx context.Context, accountID string, peerID string) (chan *network_map.UpdateMessage, error) {
                                                @@ -147,23 +141,57 @@ func (c *Controller) OnPeerDisconnected(ctx context.Context, accountID string, p
                                                 	c.EphemeralPeersManager.OnPeerDisconnected(ctx, peer)
                                                 }
                                                 
                                                +// injectAllProxyPolicies prepares an account for the per-peer network-map
                                                +// computation. It prepends the in-memory agent-network services synthesised
                                                +// from the account's current provider/policy state to account.Services, so the
                                                +// twin store built from the account carries them alongside the persisted
                                                +// reverse-proxy services and synthesises their ACLs. Synthesised services are
                                                +// never persisted; the account is loaded fresh per cycle so re-prepending is
                                                +// safe and idempotent. Accounts without agent-network providers get an empty
                                                +// synth slice — no behaviour change.
                                                +func (c *Controller) injectAllProxyPolicies(ctx context.Context, account *types.Account) {
                                                +	synth, err := c.repo.SynthesizeAgentNetworkServices(ctx, account.Id)
                                                +	if err != nil {
                                                +		log.WithContext(ctx).Warnf("synthesise agent-network services for account %s: %v", account.Id, err)
                                                +	} else if len(synth) > 0 {
                                                +		account.Services = append(synth, account.Services...)
                                                +	}
                                                +}
                                                +
                                                +// proxyServicesFromRepo is the store-path counterpart of
                                                +// injectAllProxyPolicies: the network-map store reads the policies table, which
                                                +// never holds the proxy ACLs, so the twin gets the services they are
                                                +// synthesised from — the synthesised agent-network ones first, exactly as the
                                                +// account path orders them.
                                                +func (c *Controller) proxyServicesFromRepo(ctx context.Context, accountID string) []*nmdata.Service {
                                                +	persisted, err := c.repo.GetAccountServices(ctx, accountID)
                                                +	if err != nil {
                                                +		log.WithContext(ctx).Errorf("failed to get services for account %s: %v", accountID, err)
                                                +		return nil
                                                +	}
                                                +
                                                +	synth, err := c.repo.SynthesizeAgentNetworkServices(ctx, accountID)
                                                +	if err != nil {
                                                +		log.WithContext(ctx).Warnf("synthesise agent-network services for account %s: %v", accountID, err)
                                                +	}
                                                +
                                                +	return types.TwinServices(append(synth, persisted...))
                                                +}
                                                +
                                                 func (c *Controller) CountStreams() int {
                                                 	return c.peersUpdateManager.CountStreams()
                                                 }
                                                 
                                                -func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID string) error {
                                                +func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error {
                                                 	log.WithContext(ctx).Tracef("updating peers for account %s from %s", accountID, util.GetCallerName())
                                                -	var (
                                                -		account *types.Account
                                                -		err     error
                                                -	)
                                                -	if c.experimentalNetworkMap(accountID) {
                                                -		account = c.getAccountFromHolderOrInit(ctx, accountID)
                                                -	} else {
                                                -		account, err = c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
                                                -		if err != nil {
                                                -			return fmt.Errorf("failed to get account: %v", err)
                                                -		}
                                                +
                                                +	if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil {
                                                +		return c.sendUpdateAccountPeersFromData(ctx, accountID, reason, nmData)
                                                +	}
                                                +
                                                +	account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
                                                +	if err != nil {
                                                +		return fmt.Errorf("failed to get account: %v", err)
                                                 	}
                                                 
                                                 	globalStart := time.Now()
                                                @@ -181,7 +209,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
                                                 		return nil
                                                 	}
                                                 
                                                -	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
                                                +	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
                                                 	if err != nil {
                                                 		return fmt.Errorf("failed to get validate peers: %v", err)
                                                 	}
                                                @@ -189,7 +217,8 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
                                                 	var wg sync.WaitGroup
                                                 	semaphore := make(chan struct{}, 10)
                                                 
                                                -	account.InjectProxyPolicies(ctx)
                                                +	c.injectAllProxyPolicies(ctx, account)
                                                +	account.PrecomputePostureValidation(ctx)
                                                 	dnsCache := &cache.DNSConfigCache{}
                                                 	dnsDomain := c.GetDNSDomain(account.Settings)
                                                 	peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain)
                                                @@ -197,10 +226,6 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
                                                 	routers := account.GetResourceRoutersMap()
                                                 	groupIDToUserIDs := account.GetActiveGroupUsers()
                                                 
                                                -	if c.experimentalNetworkMap(accountID) {
                                                -		c.initNetworkMapBuilderIfNeeded(account, approvedPeersMap)
                                                -	}
                                                -
                                                 	proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMapsAll(ctx, accountID, account.Peers)
                                                 	if err != nil {
                                                 		log.WithContext(ctx).Errorf("failed to get proxy network maps: %v", err)
                                                @@ -226,6 +251,10 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
                                                 			continue
                                                 		}
                                                 
                                                +		if c.accountManagerMetrics != nil {
                                                +			c.accountManagerMetrics.CountNmapTriggered(string(reason.Resource), string(reason.Operation))
                                                +		}
                                                +
                                                 		wg.Add(1)
                                                 		semaphore <- struct{}{}
                                                 		go func(p *nbpeer.Peer) {
                                                @@ -243,27 +272,53 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
                                                 			c.metrics.CountCalcPostureChecksDuration(time.Since(start))
                                                 			start = time.Now()
                                                 
                                                -			var remotePeerNetworkMap *types.NetworkMap
                                                +			peerGroups := account.GetPeerGroups(p.ID)
                                                +			proxyNetworkMap := proxyNetworkMaps[p.ID]
                                                +			var update *proto.SyncResponse
                                                 
                                                -			switch {
                                                -			case c.experimentalNetworkMap(accountID):
                                                -				remotePeerNetworkMap = c.getPeerNetworkMapExp(ctx, p.AccountID, p.ID, approvedPeersMap, peersCustomZone, accountZones, c.accountManagerMetrics)
                                                -			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)
                                                +			commonSyncMessageVersion := sharedgrpc.HighestCommonSyncMessageVersion(
                                                +				c.perAccountOrGlobalSupportedSyncMessageVersions(accountID),
                                                +				sharedgrpc.SyncMessageVersionFromConfig(&peer.Meta.SyncMessageVersion))
                                                +
                                                +			log.WithContext(ctx).
                                                +				WithFields(log.Fields{
                                                +					"sync_message_version":        commonSyncMessageVersion,
                                                +					"server_sync_message_version": c.perAccountOrGlobalSupportedSyncMessageVersions(peer.AccountID),
                                                +					"peer_sync_message_version":   sharedgrpc.SyncMessageVersionFromConfig(&peer.Meta.SyncMessageVersion),
                                                +				}).Debug("common highest sync message version")
                                                +
                                                +			if commonSyncMessageVersion == sharedgrpc.ComponentNetworkMap {
                                                +				components := account.GetPeerNetworkMapComponents(
                                                +					ctx, p.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, groupIDToUserIDs)
                                                +
                                                +				c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start))
                                                +
                                                +				start = time.Now()
                                                +				// proxyNetworkMap rides the envelope as a ProxyPatch sidecar;
                                                +				// the client merges it into Calculate()'s output the same
                                                +				// way the legacy server did via NetworkMap.Merge.
                                                +				update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
                                                +				c.metrics.CountToComponentSyncResponseDuration(time.Since(start))
                                                +
                                                +				c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
                                                +					Update:      update,
                                                +					MessageType: network_map.MessageTypeNetworkMap,
                                                +				})
                                                +
                                                +				return
                                                 			}
                                                 
                                                +			nmap := account.GetPeerNetworkMapFromComponents(
                                                +				ctx, p.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs)
                                                +
                                                 			c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start))
                                                 
                                                -			proxyNetworkMap, ok := proxyNetworkMaps[peer.ID]
                                                -			if ok {
                                                -				remotePeerNetworkMap.Merge(proxyNetworkMap)
                                                +			if proxyNetworkMap != nil {
                                                +				nmap.Merge(proxyNetworkMap)
                                                 			}
                                                 
                                                -			peerGroups := account.GetPeerGroups(p.ID)
                                                 			start = time.Now()
                                                -			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)
                                                +			update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
                                                 			c.metrics.CountToSyncResponseDuration(time.Since(start))
                                                 
                                                 			c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
                                                @@ -281,48 +336,477 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
                                                 	return nil
                                                 }
                                                 
                                                -func (c *Controller) bufferSendUpdateAccountPeers(ctx context.Context, accountID string) error {
                                                -	log.WithContext(ctx).Tracef("buffer sending update peers for account %s from %s", accountID, util.GetCallerName())
                                                +// sendUpdateAccountPeersFromData is the account-free variant of
                                                +// sendUpdateAccountPeers: everything is computed from the network-map DB
                                                +// store's twin data; only extra settings and validated peers are resolved at
                                                +// runtime. Proxy network maps and policy injection, private-service zones,
                                                +// group-to-user SSH mappings and forced routing-peer DNS resolution have no
                                                +// DB-backed source yet and are omitted.
                                                +func (c *Controller) sendUpdateAccountPeersFromData(ctx context.Context, accountID string, reason types.UpdateReason, nmData *networkmap.NetworkMapData) error {
                                                +	peersToUpdate := c.connectedPeersFromData(nmData, nil)
                                                +	if len(peersToUpdate) == 0 {
                                                +		return nil
                                                +	}
                                                +	return c.sendUpdatesFromData(ctx, accountID, nmData, peersToUpdate, &reason)
                                                +}
                                                 
                                                -	bufUpd, _ := c.sendAccountUpdateLocks.LoadOrStore(accountID, &bufferUpdate{})
                                                -	b := bufUpd.(*bufferUpdate)
                                                -
                                                -	if !b.mu.TryLock() {
                                                -		b.update.Store(true)
                                                +// sendUpdateForAffectedPeersFromData is the account-free variant of
                                                +// sendUpdateForAffectedPeers.
                                                +func (c *Controller) sendUpdateForAffectedPeersFromData(ctx context.Context, accountID string, peerIDs []string, nmData *networkmap.NetworkMapData) error {
                                                +	if len(peerIDs) == 0 {
                                                +		log.WithContext(ctx).Tracef("sendUpdateForAffectedPeersFromData: no affected peers")
                                                 		return nil
                                                 	}
                                                 
                                                -	if b.next != nil {
                                                -		b.next.Stop()
                                                +	peersToUpdate := c.connectedPeersFromData(nmData, peerIDs)
                                                +	if len(peersToUpdate) == 0 {
                                                +		log.WithContext(ctx).Tracef("sendUpdateForAffectedPeersFromData: no peers to update (affected peers not found in data or no channels)")
                                                +		return nil
                                                 	}
                                                 
                                                -	go func() {
                                                -		defer b.mu.Unlock()
                                                -		_ = c.sendUpdateAccountPeers(ctx, accountID)
                                                -		if !b.update.Load() {
                                                -			return
                                                +	log.WithContext(ctx).Tracef("sendUpdateForAffectedPeersFromData: sending network map to %d connected peers", len(peersToUpdate))
                                                +
                                                +	return c.sendUpdatesFromData(ctx, accountID, nmData, peersToUpdate, nil)
                                                +}
                                                +
                                                +// connectedPeersFromData returns the peers with an open update channel. An
                                                +// empty affected list means all peers; a non-empty list restricts the result
                                                +// to those peer IDs.
                                                +func (c *Controller) connectedPeersFromData(nmData *networkmap.NetworkMapData, affected []string) []*nmdata.Peer {
                                                +	if len(affected) == 0 {
                                                +		result := make([]*nmdata.Peer, 0, len(nmData.Peers))
                                                +		for _, peer := range nmData.Peers {
                                                +			if c.peersUpdateManager.HasChannel(peer.ID) {
                                                +				result = append(result, peer)
                                                +			}
                                                 		}
                                                -		b.update.Store(false)
                                                -		if b.next == nil {
                                                -			b.next = time.AfterFunc(time.Duration(c.updateAccountPeersBufferInterval.Load()), func() {
                                                -				_ = c.sendUpdateAccountPeers(ctx, accountID)
                                                +		return result
                                                +	}
                                                +
                                                +	result := make([]*nmdata.Peer, 0, len(affected))
                                                +	for _, peerID := range affected {
                                                +		peer := nmData.Peers[peerID]
                                                +		if peer == nil {
                                                +			continue
                                                +		}
                                                +		if c.peersUpdateManager.HasChannel(peerID) {
                                                +			result = append(result, peer)
                                                +		}
                                                +	}
                                                +	return result
                                                +}
                                                +
                                                +func (c *Controller) sendUpdatesFromData(ctx context.Context, accountID string, nmData *networkmap.NetworkMapData, peersToUpdate []*nmdata.Peer, reason *types.UpdateReason) error {
                                                +	globalStart := time.Now()
                                                +
                                                +	extraSettings, err := c.settingsManager.GetExtraSettings(ctx, accountID)
                                                +	if err != nil {
                                                +		return fmt.Errorf("failed to get flow enabled status: %v", err)
                                                +	}
                                                +
                                                +	dnsCache := &cache.DNSConfigCache{}
                                                +	dnsDomain := c.getDNSDomainFromData(nmData.AccountSettings)
                                                +	peersCustomZone := networkmap.PeersCustomZone(ctx, accountID, dnsDomain, nmData.Peers, IPv6AllowedPeersFromData(nmData))
                                                +
                                                +	dnsFwdPort := ComputeForwarderPortFromData(nmData.Peers, network_map.DnsForwarderPortMinVersion)
                                                +
                                                +	var wg sync.WaitGroup
                                                +	semaphore := make(chan struct{}, 10)
                                                +
                                                +	for _, peer := range peersToUpdate {
                                                +		if reason != nil && c.accountManagerMetrics != nil {
                                                +			c.accountManagerMetrics.CountNmapTriggered(string(reason.Resource), string(reason.Operation))
                                                +		}
                                                +
                                                +		wg.Add(1)
                                                +		semaphore <- struct{}{}
                                                +		go func(p *nmdata.Peer) {
                                                +			defer wg.Done()
                                                +			defer func() { <-semaphore }()
                                                +
                                                +			start := time.Now()
                                                +
                                                +			postureChecks := peerPostureChecksFromData(nmData, p.ID)
                                                +
                                                +			c.metrics.CountCalcPostureChecksDuration(time.Since(start))
                                                +			start = time.Now()
                                                +
                                                +			peerGroups := maps.Keys(nmData.GetPeerGroups(p.ID))
                                                +			var update *proto.SyncResponse
                                                +
                                                +			commonSyncMessageVersion := sharedgrpc.HighestCommonSyncMessageVersion(
                                                +				c.perAccountOrGlobalSupportedSyncMessageVersions(accountID),
                                                +				sharedgrpc.SyncMessageVersionFromConfig(&p.Meta.SyncMessageVersion))
                                                +
                                                +			log.WithContext(ctx).
                                                +				WithFields(log.Fields{
                                                +					"sync_message_version":        commonSyncMessageVersion,
                                                +					"server_sync_message_version": c.perAccountOrGlobalSupportedSyncMessageVersions(accountID),
                                                +					"peer_sync_message_version":   sharedgrpc.SyncMessageVersionFromConfig(&p.Meta.SyncMessageVersion),
                                                +				}).Debug("common highest sync message version")
                                                +
                                                +			if commonSyncMessageVersion == sharedgrpc.ComponentNetworkMap {
                                                +				components := nmData.GetPeerNetworkMapComponents(p.ID, peersCustomZone)
                                                +
                                                +				c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start))
                                                +
                                                +				start = time.Now()
                                                +				update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, components, nil, dnsDomain, postureChecks, nmData.AccountSettings, extraSettings, peerGroups, dnsFwdPort)
                                                +				c.metrics.CountToComponentSyncResponseDuration(time.Since(start))
                                                +
                                                +				c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
                                                +					Update:      update,
                                                +					MessageType: network_map.MessageTypeNetworkMap,
                                                +				})
                                                +
                                                +				return
                                                +			}
                                                +
                                                +			nmap := NetworkMapFromData(ctx, nmData, p.ID, peersCustomZone, c.accountManagerMetrics)
                                                +
                                                +			c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start))
                                                +
                                                +			start = time.Now()
                                                +			update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, nmData.AccountSettings, extraSettings, peerGroups, dnsFwdPort)
                                                +			c.metrics.CountToSyncResponseDuration(time.Since(start))
                                                +
                                                +			c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
                                                +				Update:      update,
                                                +				MessageType: network_map.MessageTypeNetworkMap,
                                                 			})
                                                -			return
                                                -		}
                                                -		b.next.Reset(time.Duration(c.updateAccountPeersBufferInterval.Load()))
                                                -	}()
                                                +		}(peer)
                                                +	}
                                                +
                                                +	wg.Wait()
                                                +	if c.accountManagerMetrics != nil {
                                                +		c.accountManagerMetrics.CountUpdateAccountPeersDuration(time.Since(globalStart))
                                                +	}
                                                 
                                                 	return nil
                                                 }
                                                 
                                                -// UpdatePeers updates all peers that belong to an account.
                                                -// Should be called when changes have to be synced to peers.
                                                -func (c *Controller) UpdateAccountPeers(ctx context.Context, accountID string) error {
                                                -	if err := c.RecalculateNetworkMapCache(ctx, accountID); err != nil {
                                                -		return fmt.Errorf("recalculate network map cache: %v", err)
                                                +func (c *Controller) getNetworkMapData(ctx context.Context, accountID string) *networkmap.NetworkMapData {
                                                +	if c.nmdataBuffer == nil {
                                                +		return nil
                                                 	}
                                                 
                                                -	return c.sendUpdateAccountPeers(ctx, accountID)
                                                +	nmData, err := c.nmdataBuffer.Get(ctx, accountID)
                                                +	if err != nil {
                                                +		log.WithContext(ctx).Errorf("failed to get network map data for account %s, falling back to account-based computation: %v", accountID, err)
                                                +		return nil
                                                +	}
                                                +
                                                +	return nmData
                                                +}
                                                +
                                                +// fetchNetworkMapData reads the twin once per buffer window. Its result is
                                                +// shared by every waiter of that window, so the mutating steps run here, before
                                                +// it is handed out: the twin the callers see is read-only. Injected proxy
                                                +// policies carry no posture checks, so precomputing after the injection yields
                                                +// the same validation as precomputing before it.
                                                +func (c *Controller) fetchNetworkMapData(ctx context.Context, accountID string) (*networkmap.NetworkMapData, error) {
                                                +	nmData, err := c.nmdataStore.GetNetworkMapData(ctx, accountID)
                                                +	if err != nil {
                                                +		return nil, err
                                                +	}
                                                +
                                                +	nmData.Services = c.proxyServicesFromRepo(ctx, accountID)
                                                +	nmData.BuildPrivateServiceCandidates()
                                                +	nmData.InjectProxyPolicies()
                                                +	nmData.PrecomputePostureValidation()
                                                +
                                                +	return nmData, nil
                                                +}
                                                +
                                                +func (c *Controller) getDNSDomainFromData(settings *nmdata.AccountSettingsInfo) string {
                                                +	if settings == nil || settings.DNSDomain == "" {
                                                +		return c.dnsDomain
                                                +	}
                                                +	return settings.DNSDomain
                                                +}
                                                +
                                                +func IPv6AllowedPeersFromData(nmData *networkmap.NetworkMapData) map[string]struct{} {
                                                +	result := make(map[string]struct{})
                                                +	// An account with no IPv6-enabled group runs no overlay at all, so the
                                                +	// embedded-proxy carve-out below has nothing to reach and stays shut.
                                                +	if nmData.AccountSettings == nil || len(nmData.AccountSettings.IPv6EnabledGroups) == 0 {
                                                +		return result
                                                +	}
                                                +	for _, groupID := range nmData.AccountSettings.IPv6EnabledGroups {
                                                +		group := nmData.Groups[groupID]
                                                +		if group == nil {
                                                +			continue
                                                +		}
                                                +		for _, peerID := range group.Peers {
                                                +			result[peerID] = struct{}{}
                                                +		}
                                                +	}
                                                +	for id, p := range nmData.Peers {
                                                +		if p != nil && p.ProxyMeta.Embedded {
                                                +			result[id] = struct{}{}
                                                +		}
                                                +	}
                                                +	return result
                                                +}
                                                +
                                                +func NetworkMapFromData(ctx context.Context, nmData *networkmap.NetworkMapData, peerID string, peersCustomZone nmdata.CustomZone, metrics *telemetry.AccountManagerMetrics) *types.NetworkMap {
                                                +	start := time.Now()
                                                +
                                                +	components := nmData.GetPeerNetworkMapComponents(peerID, peersCustomZone)
                                                +	if components.IsEmpty() {
                                                +		return &types.NetworkMap{Network: components.Network}
                                                +	}
                                                +	nm := types.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))
                                                +	}
                                                +
                                                +	return nm
                                                +}
                                                +
                                                +// peerPostureChecksFromData mirrors getPeerPostureChecks on the twin store.
                                                +func peerPostureChecksFromData(nmData *networkmap.NetworkMapData, peerID string) []*nmdata.PostureChecks {
                                                +	if len(nmData.PostureChecks) == 0 {
                                                +		return nil
                                                +	}
                                                +
                                                +	peerPostureChecks := make(map[string]*nmdata.PostureChecks)
                                                +	for _, policy := range nmData.Policies {
                                                +		if policy == nil || !policy.Enabled || len(policy.SourcePostureChecks) == 0 {
                                                +			continue
                                                +		}
                                                +		if !isPeerInPolicySourcesFromData(nmData, peerID, policy) {
                                                +			continue
                                                +		}
                                                +		for _, checkID := range policy.SourcePostureChecks {
                                                +			if twin := nmData.PostureChecks[checkID]; twin != nil {
                                                +				peerPostureChecks[checkID] = twin
                                                +			}
                                                +		}
                                                +	}
                                                +
                                                +	return maps.Values(peerPostureChecks)
                                                +}
                                                +
                                                +func isPeerInPolicySourcesFromData(nmData *networkmap.NetworkMapData, peerID string, policy *nmdata.Policy) bool {
                                                +	for _, rule := range policy.Rules {
                                                +		if rule == nil || !rule.Enabled {
                                                +			continue
                                                +		}
                                                +		if rule.SourceResource.Type == string(types.ResourceTypePeer) && rule.SourceResource.ID == peerID {
                                                +			return true
                                                +		}
                                                +		for _, groupID := range rule.Sources {
                                                +			if group := nmData.Groups[groupID]; group != nil && slices.Contains(group.Peers, peerID) {
                                                +				return true
                                                +			}
                                                +		}
                                                +	}
                                                +	return false
                                                +}
                                                +
                                                +func (c *Controller) perAccountOrGlobalSupportedSyncMessageVersions(accountId string) sharedgrpc.SyncMessageVersion {
                                                +	if perAccount, ok := c.perAccountServerSupportedSyncMessageVersions[accountId]; ok {
                                                +		return perAccount
                                                +	}
                                                +	return c.serverSupportedSyncMessageVersion
                                                +}
                                                +
                                                +// UpdatePeers updates all peers that belong to an account.
                                                +// Should be called when changes have to be synced to peers.
                                                +func (c *Controller) UpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error {
                                                +	if c.accountManagerMetrics != nil {
                                                +		c.accountManagerMetrics.CountUpdateAccountPeersTriggered(string(reason.Resource), string(reason.Operation))
                                                +	}
                                                +	return c.sendUpdateAccountPeers(ctx, accountID, reason)
                                                +}
                                                +
                                                +// UpdateAffectedPeers updates only the specified peers that belong to an account.
                                                +func (c *Controller) UpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string) error {
                                                +	if len(peerIDs) == 0 {
                                                +		return nil
                                                +	}
                                                +	return c.sendUpdateForAffectedPeers(ctx, accountID, peerIDs)
                                                +}
                                                +
                                                +func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID string, peerIDs []string) error {
                                                +	log.WithContext(ctx).Tracef("sendUpdateForAffectedPeers: account %s, %d affected peers: %v (caller: %s)", accountID, len(peerIDs), peerIDs, util.GetCallerName())
                                                +
                                                +	if !c.hasConnectedPeers(peerIDs) {
                                                +		log.WithContext(ctx).Tracef("sendUpdateForAffectedPeers: no connected peers among %v, skipping", peerIDs)
                                                +		return nil
                                                +	}
                                                +
                                                +	if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil {
                                                +		return c.sendUpdateForAffectedPeersFromData(ctx, accountID, peerIDs, nmData)
                                                +	}
                                                +
                                                +	account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
                                                +	if err != nil {
                                                +		return fmt.Errorf("failed to get account: %v", err)
                                                +	}
                                                +
                                                +	globalStart := time.Now()
                                                +
                                                +	peersToUpdate := c.filterConnectedAffectedPeers(account, peerIDs)
                                                +	if len(peersToUpdate) == 0 {
                                                +		log.WithContext(ctx).Tracef("sendUpdateForAffectedPeers: no peers to update (affected peers not found in account or no channels)")
                                                +		return nil
                                                +	}
                                                +
                                                +	log.WithContext(ctx).Tracef("sendUpdateForAffectedPeers: sending network map to %d connected peers", len(peersToUpdate))
                                                +
                                                +	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
                                                +	if err != nil {
                                                +		return fmt.Errorf("failed to get validate peers: %v", err)
                                                +	}
                                                +
                                                +	var wg sync.WaitGroup
                                                +	semaphore := make(chan struct{}, 10)
                                                +
                                                +	// The affected-peer path MUST mirror sendUpdateAccountPeers (line 171)
                                                +	// here: injectAllProxyPolicies prepends the synthesised agent-network
                                                +	// services BEFORE InjectProxyPolicies + private-service policies run.
                                                +	// Previously this path called only account.InjectProxyPolicies, which
                                                +	// skipped the synth-services prepend — so peer-level changes
                                                +	// (proxy restart, embedded peer connect/disconnect) propagated a
                                                +	// network map that omitted the synth DNS zone, and the agent kept
                                                +	// resolving against the stale or absent record.
                                                +	c.injectAllProxyPolicies(ctx, account)
                                                +	account.PrecomputePostureValidation(ctx)
                                                +	dnsCache := &cache.DNSConfigCache{}
                                                +	dnsDomain := c.GetDNSDomain(account.Settings)
                                                +	peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain)
                                                +	resourcePolicies := account.GetResourcePoliciesMap()
                                                +	routers := account.GetResourceRoutersMap()
                                                +	groupIDToUserIDs := account.GetActiveGroupUsers()
                                                +
                                                +	proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMapsAll(ctx, accountID, account.Peers)
                                                +	if err != nil {
                                                +		log.WithContext(ctx).Errorf("failed to get proxy network maps: %v", err)
                                                +		return fmt.Errorf("failed to get proxy network maps: %v", err)
                                                +	}
                                                +
                                                +	extraSetting, err := c.settingsManager.GetExtraSettings(ctx, accountID)
                                                +	if err != nil {
                                                +		return fmt.Errorf("failed to get flow enabled status: %v", err)
                                                +	}
                                                +
                                                +	dnsFwdPort := computeForwarderPort(maps.Values(account.Peers), network_map.DnsForwarderPortMinVersion)
                                                +
                                                +	accountZones, err := c.repo.GetAccountZones(ctx, account.Id)
                                                +	if err != nil {
                                                +		log.WithContext(ctx).Errorf("failed to get account zones: %v", err)
                                                +		return fmt.Errorf("failed to get account zones: %v", err)
                                                +	}
                                                +
                                                +	for _, peer := range peersToUpdate {
                                                +		wg.Add(1)
                                                +		semaphore <- struct{}{}
                                                +		go func(p *nbpeer.Peer) {
                                                +			defer wg.Done()
                                                +			defer func() { <-semaphore }()
                                                +
                                                +			start := time.Now()
                                                +
                                                +			postureChecks, err := c.getPeerPostureChecks(account, p.ID)
                                                +			if err != nil {
                                                +				log.WithContext(ctx).Debugf("failed to get posture checks for peer %s: %v", p.ID, err)
                                                +				return
                                                +			}
                                                +
                                                +			c.metrics.CountCalcPostureChecksDuration(time.Since(start))
                                                +			start = time.Now()
                                                +
                                                +			peerGroups := account.GetPeerGroups(p.ID)
                                                +			proxyNetworkMap := proxyNetworkMaps[p.ID]
                                                +			var update *proto.SyncResponse
                                                +
                                                +			commonSyncMessageVersion := sharedgrpc.HighestCommonSyncMessageVersion(
                                                +				c.perAccountOrGlobalSupportedSyncMessageVersions(accountID),
                                                +				sharedgrpc.SyncMessageVersionFromConfig(&peer.Meta.SyncMessageVersion))
                                                +
                                                +			log.WithContext(ctx).
                                                +				WithFields(log.Fields{
                                                +					"sync_message_version":        commonSyncMessageVersion,
                                                +					"server_sync_message_version": c.perAccountOrGlobalSupportedSyncMessageVersions(peer.AccountID),
                                                +					"peer_sync_message_version":   sharedgrpc.SyncMessageVersionFromConfig(&peer.Meta.SyncMessageVersion),
                                                +				}).Debug("common highest sync message version")
                                                +
                                                +			if commonSyncMessageVersion == sharedgrpc.ComponentNetworkMap {
                                                +				components := account.GetPeerNetworkMapComponents(
                                                +					ctx, p.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, groupIDToUserIDs)
                                                +
                                                +				c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start))
                                                +
                                                +				start = time.Now()
                                                +				// proxyNetworkMap rides the envelope as a ProxyPatch sidecar;
                                                +				// the client merges it into Calculate()'s output the same
                                                +				// way the legacy server did via NetworkMap.Merge.
                                                +				update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
                                                +				c.metrics.CountToComponentSyncResponseDuration(time.Since(start))
                                                +
                                                +				c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
                                                +					Update:      update,
                                                +					MessageType: network_map.MessageTypeNetworkMap,
                                                +				})
                                                +
                                                +				return
                                                +			}
                                                +
                                                +			nmap := account.GetPeerNetworkMapFromComponents(
                                                +				ctx, p.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs)
                                                +
                                                +			c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start))
                                                +
                                                +			if proxyNetworkMap != nil {
                                                +				nmap.Merge(proxyNetworkMap)
                                                +			}
                                                +
                                                +			start = time.Now()
                                                +			update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
                                                +			c.metrics.CountToSyncResponseDuration(time.Since(start))
                                                +
                                                +			c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
                                                +				Update:      update,
                                                +				MessageType: network_map.MessageTypeNetworkMap,
                                                +			})
                                                +		}(peer)
                                                +	}
                                                +
                                                +	wg.Wait()
                                                +	if c.accountManagerMetrics != nil {
                                                +		c.accountManagerMetrics.CountUpdateAccountPeersDuration(time.Since(globalStart))
                                                +	}
                                                +
                                                +	return nil
                                                +}
                                                +
                                                +func (c *Controller) hasConnectedPeers(peerIDs []string) bool {
                                                +	for _, id := range peerIDs {
                                                +		if c.peersUpdateManager.HasChannel(id) {
                                                +			return true
                                                +		}
                                                +	}
                                                +	return false
                                                +}
                                                +
                                                +func (c *Controller) filterConnectedAffectedPeers(account *types.Account, peerIDs []string) []*nbpeer.Peer {
                                                +	affected := make(map[string]struct{}, len(peerIDs))
                                                +	for _, id := range peerIDs {
                                                +		affected[id] = struct{}{}
                                                +	}
                                                +
                                                +	var result []*nbpeer.Peer
                                                +	for _, peer := range account.Peers {
                                                +		if _, ok := affected[peer.ID]; ok && c.peersUpdateManager.HasChannel(peer.ID) {
                                                +			result = append(result, peer)
                                                +		}
                                                +	}
                                                +	return result
                                                 }
                                                 
                                                 func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, peerId string) error {
                                                @@ -340,12 +824,12 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe
                                                 		return fmt.Errorf("peer %s doesn't exists in account %s", peerId, accountId)
                                                 	}
                                                 
                                                -	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
                                                +	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
                                                 	if err != nil {
                                                 		return fmt.Errorf("failed to get validated peers: %v", err)
                                                 	}
                                                 
                                                -	account.InjectProxyPolicies(ctx)
                                                +	c.injectAllProxyPolicies(ctx, account)
                                                 	dnsCache := &cache.DNSConfigCache{}
                                                 	dnsDomain := c.GetDNSDomain(account.Settings)
                                                 	peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain)
                                                @@ -371,22 +855,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe
                                                 		return err
                                                 	}
                                                 
                                                -	var remotePeerNetworkMap *types.NetworkMap
                                                -
                                                -	switch {
                                                -	case c.experimentalNetworkMap(accountId):
                                                -		remotePeerNetworkMap = c.getPeerNetworkMapExp(ctx, peer.AccountID, peer.ID, approvedPeersMap, peersCustomZone, accountZones, c.accountManagerMetrics)
                                                -	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)
                                                -	}
                                                -
                                                -	proxyNetworkMap, ok := proxyNetworkMaps[peer.ID]
                                                -	if ok {
                                                -		remotePeerNetworkMap.Merge(proxyNetworkMap)
                                                -	}
                                                -
                                                +	proxyNetworkMap := proxyNetworkMaps[peer.ID]
                                                 	extraSettings, err := c.settingsManager.GetExtraSettings(ctx, peer.AccountID)
                                                 	if err != nil {
                                                 		return fmt.Errorf("failed to get extra settings: %v", err)
                                                @@ -395,7 +864,45 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe
                                                 	peerGroups := account.GetPeerGroups(peerId)
                                                 	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)
                                                +	var update *proto.SyncResponse
                                                +
                                                +	commonSyncMessageVersion := sharedgrpc.HighestCommonSyncMessageVersion(
                                                +		c.perAccountOrGlobalSupportedSyncMessageVersions(accountId),
                                                +		sharedgrpc.SyncMessageVersionFromConfig(&peer.Meta.SyncMessageVersion))
                                                +
                                                +	log.WithContext(ctx).
                                                +		WithFields(log.Fields{
                                                +			"sync_message_version":        commonSyncMessageVersion,
                                                +			"server_sync_message_version": c.perAccountOrGlobalSupportedSyncMessageVersions(peer.AccountID),
                                                +			"peer_sync_message_version":   sharedgrpc.SyncMessageVersionFromConfig(&peer.Meta.SyncMessageVersion),
                                                +		}).Debug("common highest sync message version")
                                                +
                                                +	if commonSyncMessageVersion == sharedgrpc.ComponentNetworkMap {
                                                +		components := account.GetPeerNetworkMapComponents(
                                                +			ctx, peer.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, groupIDToUserIDs)
                                                +
                                                +		// proxyNetworkMap rides the envelope as a ProxyPatch sidecar;
                                                +		// the client merges it into Calculate()'s output the same
                                                +		// way the legacy server did via NetworkMap.Merge.
                                                +		update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(peer), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSettings, maps.Keys(peerGroups), dnsFwdPort)
                                                +
                                                +		c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{
                                                +			Update:      update,
                                                +			MessageType: network_map.MessageTypeNetworkMap,
                                                +		})
                                                +
                                                +		return nil
                                                +	}
                                                +
                                                +	nmap := account.GetPeerNetworkMapFromComponents(
                                                +		ctx, peer.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs)
                                                +
                                                +	if proxyNetworkMap != nil {
                                                +		nmap.Merge(proxyNetworkMap)
                                                +	}
                                                +
                                                +	update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(peer), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSettings, maps.Keys(peerGroups), dnsFwdPort)
                                                +
                                                 	c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{
                                                 		Update:      update,
                                                 		MessageType: network_map.MessageTypeNetworkMap,
                                                @@ -404,9 +911,13 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe
                                                 	return nil
                                                 }
                                                 
                                                -func (c *Controller) BufferUpdateAccountPeers(ctx context.Context, accountID string) error {
                                                +func (c *Controller) BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error {
                                                 	log.WithContext(ctx).Tracef("buffer updating peers for account %s from %s", accountID, util.GetCallerName())
                                                 
                                                +	if c.accountManagerMetrics != nil {
                                                +		c.accountManagerMetrics.CountUpdateAccountPeersTriggered(string(reason.Resource), string(reason.Operation))
                                                +	}
                                                +
                                                 	bufUpd, _ := c.accountUpdateLocks.LoadOrStore(accountID, &bufferUpdate{})
                                                 	b := bufUpd.(*bufferUpdate)
                                                 
                                                @@ -421,14 +932,14 @@ func (c *Controller) BufferUpdateAccountPeers(ctx context.Context, accountID str
                                                 
                                                 	go func() {
                                                 		defer b.mu.Unlock()
                                                -		_ = c.UpdateAccountPeers(ctx, accountID)
                                                +		_ = c.sendUpdateAccountPeers(ctx, accountID, reason)
                                                 		if !b.update.Load() {
                                                 			return
                                                 		}
                                                 		b.update.Store(false)
                                                 		if b.next == nil {
                                                 			b.next = time.AfterFunc(time.Duration(c.updateAccountPeersBufferInterval.Load()), func() {
                                                -				_ = c.UpdateAccountPeers(ctx, accountID)
                                                +				_ = c.sendUpdateAccountPeers(ctx, accountID, reason)
                                                 			})
                                                 			return
                                                 		}
                                                @@ -438,186 +949,264 @@ func (c *Controller) BufferUpdateAccountPeers(ctx context.Context, accountID str
                                                 	return nil
                                                 }
                                                 
                                                -func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peer *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
                                                +// GetValidatedPeerWithComponents is the components-format counterpart of
                                                +// GetValidatedPeerWithMap. It returns raw NetworkMapComponents for capable
                                                +// peers along with the proxy NetworkMap fragment (BYOP / port-forwarding
                                                +// data the legacy server folds in via NetworkMap.Merge). The gRPC layer
                                                +// encodes both into the wire envelope. Callers must gate on capability
                                                +// themselves before dispatching here — this method does NOT branch on it.
                                                +func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, peer *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
                                                 	if isRequiresApproval {
                                                 		network, err := c.repo.GetAccountNetwork(ctx, accountID)
                                                 		if err != nil {
                                                -			return nil, nil, nil, 0, err
                                                +			return nil, nil, nil, nil, 0, err
                                                 		}
                                                -
                                                -		emptyMap := &types.NetworkMap{
                                                -			Network: network.Copy(),
                                                -		}
                                                -		return peer, emptyMap, nil, 0, nil
                                                +		return peer, &types.NetworkMapComponents{Network: types.TwinNetwork(network)}, nil, nil, 0, nil
                                                 	}
                                                 
                                                -	var (
                                                -		account *types.Account
                                                -		err     error
                                                -	)
                                                -	if c.experimentalNetworkMap(accountID) {
                                                -		account = c.getAccountFromHolderOrInit(ctx, accountID)
                                                -	} else {
                                                -		account, err = c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
                                                -		if err != nil {
                                                -			return nil, nil, nil, 0, err
                                                -		}
                                                +	if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil {
                                                +		return c.getValidatedPeerWithComponentsFromData(ctx, accountID, peer, nmData)
                                                 	}
                                                 
                                                -	account.InjectProxyPolicies(ctx)
                                                -
                                                -	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
                                                +	account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
                                                 	if err != nil {
                                                -		return nil, nil, nil, 0, err
                                                +		return nil, nil, nil, nil, 0, err
                                                +	}
                                                +
                                                +	// it's possible that the peer gets deleted between the call to "sendInitialSync()" and here, bail out in this case
                                                +	if _, ok := account.Peers[peer.ID]; !ok {
                                                +		return nil, nil, nil, nil, 0, fmt.Errorf("peer '%s' no longer exists", peer.ID)
                                                +	}
                                                +
                                                +	c.injectAllProxyPolicies(ctx, account)
                                                +
                                                +	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
                                                +	if err != nil {
                                                +		return nil, nil, nil, nil, 0, err
                                                 	}
                                                 
                                                -	startPosture := time.Now()
                                                 	postureChecks, err := c.getPeerPostureChecks(account, peer.ID)
                                                 	if err != nil {
                                                -		return nil, nil, nil, 0, err
                                                +		return nil, nil, nil, nil, 0, err
                                                 	}
                                                -	log.WithContext(ctx).Debugf("getPeerPostureChecks took %s", time.Since(startPosture))
                                                 
                                                 	accountZones, err := c.repo.GetAccountZones(ctx, account.Id)
                                                 	if err != nil {
                                                -		log.WithContext(ctx).Errorf("failed to get account zones: %v", err)
                                                -		return nil, nil, nil, 0, err
                                                +		return nil, nil, nil, nil, 0, err
                                                +	}
                                                +
                                                +	// Fetch the proxy network map fragment for this peer alongside the
                                                +	// components — same single-account-load path the streaming controller
                                                +	// uses, so initial-sync delivers BYOP/forwarding patches synchronously
                                                +	// instead of waiting for the next streaming push.
                                                +	proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMaps(ctx, account.Id, peer.ID, account.Peers)
                                                +	if err != nil {
                                                +		log.WithContext(ctx).Errorf("failed to get proxy network maps: %v", err)
                                                +		return nil, nil, nil, nil, 0, err
                                                 	}
                                                 
                                                 	dnsDomain := c.GetDNSDomain(account.Settings)
                                                 	peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain)
                                                 
                                                -	proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMaps(ctx, account.Id, peer.ID, account.Peers)
                                                +	resourcePolicies := account.GetResourcePoliciesMap()
                                                +	routers := account.GetResourceRoutersMap()
                                                +	groupIDToUserIDs := account.GetActiveGroupUsers()
                                                +	components := account.GetPeerNetworkMapComponents(ctx, peer.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, groupIDToUserIDs)
                                                +	dnsFwdPort := computeForwarderPort(maps.Values(account.Peers), network_map.DnsForwarderPortMinVersion)
                                                +
                                                +	return peer, components, proxyNetworkMaps[peer.ID], postureChecks, dnsFwdPort, nil
                                                +}
                                                +
                                                +// getValidatedPeerWithComponentsFromData is the account-free variant of
                                                +// GetValidatedPeerWithComponents. The proxy network map fragment is omitted
                                                +// like on the other nmdata paths.
                                                +func (c *Controller) getValidatedPeerWithComponentsFromData(ctx context.Context, accountID string, peer *nbpeer.Peer, nmData *networkmap.NetworkMapData) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
                                                +	postureChecks := peerPostureChecksFromData(nmData, peer.ID)
                                                +
                                                +	dnsDomain := c.getDNSDomainFromData(nmData.AccountSettings)
                                                +	peersCustomZone := networkmap.PeersCustomZone(ctx, accountID, dnsDomain, nmData.Peers, IPv6AllowedPeersFromData(nmData))
                                                +
                                                +	components := nmData.GetPeerNetworkMapComponents(peer.ID, peersCustomZone)
                                                +	dnsFwdPort := ComputeForwarderPortFromData(nmData.Peers, network_map.DnsForwarderPortMinVersion)
                                                +
                                                +	return peer, components, nil, postureChecks, dnsFwdPort, nil
                                                +}
                                                +
                                                +// BufferUpdateAffectedPeers accumulates peer IDs and flushes them after the buffer interval.
                                                +func (c *Controller) BufferUpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string, reason types.UpdateReason) error {
                                                +	if len(peerIDs) == 0 {
                                                +		return nil
                                                +	}
                                                +
                                                +	if c.accountManagerMetrics != nil {
                                                +		c.accountManagerMetrics.CountUpdateAccountPeersTriggered(string(reason.Resource), string(reason.Operation))
                                                +	}
                                                +
                                                +	log.WithContext(ctx).Tracef("buffer updating %d affected peers for account %s from %s with reason %s/%s", len(peerIDs), accountID, util.GetCallerName(), reason.Operation, reason.Resource)
                                                +
                                                +	bufUpd, _ := c.affectedPeerUpdateLocks.LoadOrStore(accountID, &bufferAffectedUpdate{
                                                +		peerIDs: make(map[string]struct{}),
                                                +	})
                                                +	b := bufUpd.(*bufferAffectedUpdate)
                                                +
                                                +	b.addPeerIDs(peerIDs)
                                                +
                                                +	if !b.sendMu.TryLock() {
                                                +		// Another goroutine is already sending; it will pick up our IDs on its next drain.
                                                +		return nil
                                                +	}
                                                +
                                                +	b.stopTimer()
                                                +
                                                +	// The send and the debounced timer outlive the calling request, so detach from
                                                +	// its context to avoid sending with a cancelled context once the handler returns.
                                                +	bgCtx := context.WithoutCancel(ctx)
                                                +
                                                +	collected := b.drainPeerIDs()
                                                +	go func() {
                                                +		defer b.sendMu.Unlock()
                                                +		_ = c.sendUpdateForAffectedPeers(bgCtx, accountID, collected)
                                                +
                                                +		// Check if more peer IDs accumulated while we were sending.
                                                +		if !b.hasPending() {
                                                +			return
                                                +		}
                                                +
                                                +		// Schedule a debounced flush for the newly accumulated IDs.
                                                +		b.setTimer(time.Duration(c.updateAccountPeersBufferInterval.Load()), func() {
                                                +			ids := b.drainPeerIDs()
                                                +			if len(ids) > 0 {
                                                +				_ = c.sendUpdateForAffectedPeers(bgCtx, accountID, ids)
                                                +			}
                                                +		})
                                                +	}()
                                                +
                                                +	return nil
                                                +}
                                                +
                                                +func (b *bufferAffectedUpdate) addPeerIDs(ids []string) {
                                                +	b.dataMu.Lock()
                                                +	for _, id := range ids {
                                                +		b.peerIDs[id] = struct{}{}
                                                +	}
                                                +	b.dataMu.Unlock()
                                                +}
                                                +
                                                +func (b *bufferAffectedUpdate) drainPeerIDs() []string {
                                                +	b.dataMu.Lock()
                                                +	defer b.dataMu.Unlock()
                                                +	if len(b.peerIDs) == 0 {
                                                +		return nil
                                                +	}
                                                +	ids := make([]string, 0, len(b.peerIDs))
                                                +	for id := range b.peerIDs {
                                                +		ids = append(ids, id)
                                                +	}
                                                +	b.peerIDs = make(map[string]struct{})
                                                +	return ids
                                                +}
                                                +
                                                +func (b *bufferAffectedUpdate) hasPending() bool {
                                                +	b.dataMu.Lock()
                                                +	defer b.dataMu.Unlock()
                                                +	return len(b.peerIDs) > 0
                                                +}
                                                +
                                                +func (b *bufferAffectedUpdate) stopTimer() {
                                                +	b.dataMu.Lock()
                                                +	defer b.dataMu.Unlock()
                                                +	if b.next != nil {
                                                +		b.next.Stop()
                                                +	}
                                                +}
                                                +
                                                +func (b *bufferAffectedUpdate) setTimer(d time.Duration, f func()) {
                                                +	b.dataMu.Lock()
                                                +	defer b.dataMu.Unlock()
                                                +	if b.next == nil {
                                                +		b.next = time.AfterFunc(d, f)
                                                +		return
                                                +	}
                                                +	b.next.Reset(d)
                                                +}
                                                +
                                                +func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peerID string) (*types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
                                                +	if isRequiresApproval {
                                                +		network, err := c.repo.GetAccountNetwork(ctx, accountID)
                                                +		if err != nil {
                                                +			return nil, nil, 0, err
                                                +		}
                                                +
                                                +		emptyMap := &types.NetworkMap{
                                                +			Network: types.TwinNetwork(network),
                                                +		}
                                                +		return emptyMap, nil, 0, nil
                                                +	}
                                                +
                                                +	if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil {
                                                +		return c.getValidatedPeerWithMapFromData(ctx, accountID, peerID, nmData)
                                                +	}
                                                +
                                                +	account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
                                                +	if err != nil {
                                                +		return nil, nil, 0, err
                                                +	}
                                                +
                                                +	c.injectAllProxyPolicies(ctx, account)
                                                +
                                                +	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
                                                +	if err != nil {
                                                +		return nil, nil, 0, err
                                                +	}
                                                +
                                                +	postureChecks, err := c.getPeerPostureChecks(account, peerID)
                                                +	if err != nil {
                                                +		return nil, nil, 0, err
                                                +	}
                                                +
                                                +	accountZones, err := c.repo.GetAccountZones(ctx, account.Id)
                                                +	if err != nil {
                                                +		log.WithContext(ctx).Errorf("failed to get account zones: %v", err)
                                                +		return nil, nil, 0, err
                                                +	}
                                                +
                                                +	dnsDomain := c.GetDNSDomain(account.Settings)
                                                +	peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain)
                                                +
                                                +	proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMaps(ctx, account.Id, peerID, account.Peers)
                                                 	if err != nil {
                                                 		log.WithContext(ctx).Errorf("failed to get proxy network maps: %v", err)
                                                -		return nil, nil, nil, 0, err
                                                +		return nil, nil, 0, err
                                                 	}
                                                 
                                                -	var networkMap *types.NetworkMap
                                                +	resourcePolicies := account.GetResourcePoliciesMap()
                                                +	routers := account.GetResourceRoutersMap()
                                                +	groupIDToUserIDs := account.GetActiveGroupUsers()
                                                +	networkMap := account.GetPeerNetworkMapFromComponents(ctx, peerID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs)
                                                 
                                                -	if c.experimentalNetworkMap(accountID) {
                                                -		networkMap = c.getPeerNetworkMapExp(ctx, peer.AccountID, peer.ID, approvedPeersMap, peersCustomZone, accountZones, c.accountManagerMetrics)
                                                -	} else {
                                                -		resourcePolicies := account.GetResourcePoliciesMap()
                                                -		routers := account.GetResourceRoutersMap()
                                                -		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]
                                                +	proxyNetworkMap, ok := proxyNetworkMaps[peerID]
                                                 	if ok {
                                                 		networkMap.Merge(proxyNetworkMap)
                                                 	}
                                                 
                                                 	dnsFwdPort := computeForwarderPort(maps.Values(account.Peers), network_map.DnsForwarderPortMinVersion)
                                                 
                                                -	return peer, networkMap, postureChecks, dnsFwdPort, nil
                                                +	return networkMap, postureChecks, dnsFwdPort, nil
                                                 }
                                                 
                                                -func (c *Controller) initNetworkMapBuilderIfNeeded(account *types.Account, validatedPeers map[string]struct{}) {
                                                -	c.enrichAccountFromHolder(account)
                                                -	account.InitNetworkMapBuilderIfNeeded(validatedPeers)
                                                -}
                                                +// getValidatedPeerWithMapFromData is the account-free variant of
                                                +// GetValidatedPeerWithMap. The proxy network map fragment is omitted like on
                                                +// the other nmdata paths.
                                                +func (c *Controller) getValidatedPeerWithMapFromData(ctx context.Context, accountID string, peerID string, nmData *networkmap.NetworkMapData) (*types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
                                                +	postureChecks := peerPostureChecksFromData(nmData, peerID)
                                                 
                                                -func (c *Controller) getPeerNetworkMapExp(
                                                -	ctx context.Context,
                                                -	accountId string,
                                                -	peerId string,
                                                -	validatedPeers map[string]struct{},
                                                -	peersCustomZone nbdns.CustomZone,
                                                -	accountZones []*zones.Zone,
                                                -	metrics *telemetry.AccountManagerMetrics,
                                                -) *types.NetworkMap {
                                                -	account := c.getAccountFromHolderOrInit(ctx, accountId)
                                                -	if account == nil {
                                                -		log.WithContext(ctx).Warnf("account %s not found in holder when getting peer network map", accountId)
                                                -		return &types.NetworkMap{
                                                -			Network: &types.Network{},
                                                -		}
                                                -	}
                                                +	dnsDomain := c.getDNSDomainFromData(nmData.AccountSettings)
                                                +	peersCustomZone := networkmap.PeersCustomZone(ctx, accountID, dnsDomain, nmData.Peers, IPv6AllowedPeersFromData(nmData))
                                                 
                                                -	return account.GetPeerNetworkMapExp(ctx, peerId, peersCustomZone, accountZones, validatedPeers, metrics)
                                                -}
                                                +	networkMap := NetworkMapFromData(ctx, nmData, peerID, peersCustomZone, c.accountManagerMetrics)
                                                +	dnsFwdPort := ComputeForwarderPortFromData(nmData.Peers, network_map.DnsForwarderPortMinVersion)
                                                 
                                                -func (c *Controller) onPeersAddedUpdNetworkMapCache(account *types.Account, peerIds ...string) {
                                                -	c.enrichAccountFromHolder(account)
                                                -	account.OnPeersAddedUpdNetworkMapCache(peerIds...)
                                                -}
                                                -
                                                -func (c *Controller) onPeerDeletedUpdNetworkMapCache(account *types.Account, peerId string) error {
                                                -	c.enrichAccountFromHolder(account)
                                                -	return account.OnPeerDeletedUpdNetworkMapCache(peerId)
                                                -}
                                                -
                                                -func (c *Controller) UpdatePeerInNetworkMapCache(accountId string, peer *nbpeer.Peer) {
                                                -	account := c.getAccountFromHolder(accountId)
                                                -	if account == nil {
                                                -		return
                                                -	}
                                                -	account.UpdatePeerInNetworkMapCache(peer)
                                                -}
                                                -
                                                -func (c *Controller) recalculateNetworkMapCache(account *types.Account, validatedPeers map[string]struct{}) {
                                                -	account.RecalculateNetworkMapCache(validatedPeers)
                                                -	c.updateAccountInHolder(account)
                                                -}
                                                -
                                                -func (c *Controller) RecalculateNetworkMapCache(ctx context.Context, accountId string) error {
                                                -	if c.experimentalNetworkMap(accountId) {
                                                -		account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountId)
                                                -		if err != nil {
                                                -			return err
                                                -		}
                                                -		validatedPeers, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
                                                -		if err != nil {
                                                -			log.WithContext(ctx).Errorf("failed to get validate peers: %v", err)
                                                -			return err
                                                -		}
                                                -		c.recalculateNetworkMapCache(account, validatedPeers)
                                                -	}
                                                -	return nil
                                                -}
                                                -
                                                -func (c *Controller) experimentalNetworkMap(accountId string) bool {
                                                -	_, ok := c.expNewNetworkMapAIDs[accountId]
                                                -	return c.expNewNetworkMap || ok
                                                -}
                                                -
                                                -func (c *Controller) enrichAccountFromHolder(account *types.Account) {
                                                -	a := c.holder.GetAccount(account.Id)
                                                -	if a == nil {
                                                -		c.holder.AddAccount(account)
                                                -		return
                                                -	}
                                                -	account.NetworkMapCache = a.NetworkMapCache
                                                -	if account.NetworkMapCache == nil {
                                                -		return
                                                -	}
                                                -	c.holder.AddAccount(account)
                                                -}
                                                -
                                                -func (c *Controller) getAccountFromHolder(accountID string) *types.Account {
                                                -	return c.holder.GetAccount(accountID)
                                                -}
                                                -
                                                -func (c *Controller) getAccountFromHolderOrInit(ctx context.Context, accountID string) *types.Account {
                                                -	a := c.holder.GetAccount(accountID)
                                                -	if a != nil {
                                                -		return a
                                                -	}
                                                -	account, err := c.holder.LoadOrStoreFunc(ctx, accountID, c.requestBuffer.GetAccountWithBackpressure)
                                                -	if err != nil {
                                                -		return nil
                                                -	}
                                                -	return account
                                                -}
                                                -
                                                -func (c *Controller) updateAccountInHolder(account *types.Account) {
                                                -	c.holder.AddAccount(account)
                                                +	return networkMap, postureChecks, dnsFwdPort, nil
                                                 }
                                                 
                                                 // GetDNSDomain returns the configured dnsDomain
                                                @@ -633,7 +1222,7 @@ func (c *Controller) GetDNSDomain(settings *types.Settings) string {
                                                 }
                                                 
                                                 // getPeerPostureChecks returns the posture checks applied for a given peer.
                                                -func (c *Controller) getPeerPostureChecks(account *types.Account, peerID string) ([]*posture.Checks, error) {
                                                +func (c *Controller) getPeerPostureChecks(account *types.Account, peerID string) ([]*nmdata.PostureChecks, error) {
                                                 	peerPostureChecks := make(map[string]*posture.Checks)
                                                 
                                                 	if len(account.PostureChecks) == 0 {
                                                @@ -650,7 +1239,7 @@ func (c *Controller) getPeerPostureChecks(account *types.Account, peerID string)
                                                 		}
                                                 	}
                                                 
                                                -	return maps.Values(peerPostureChecks), nil
                                                +	return types.TwinPostureChecksList(maps.Values(peerPostureChecks)), nil
                                                 }
                                                 
                                                 func (c *Controller) StartWarmup(ctx context.Context) {
                                                @@ -682,20 +1271,36 @@ func (c *Controller) StartWarmup(ctx context.Context) {
                                                 // computeForwarderPort checks if all peers in the account have updated to a specific version or newer.
                                                 // If all peers have the required version, it returns the new well-known port (22054), otherwise returns 0.
                                                 func computeForwarderPort(peers []*nbpeer.Peer, requiredVersion string) int64 {
                                                -	if len(peers) == 0 {
                                                +	versions := make([]string, 0, len(peers))
                                                +	for _, peer := range peers {
                                                +		versions = append(versions, peer.Meta.WtVersion)
                                                +	}
                                                +	return computeForwarderPortFromVersions(versions, requiredVersion)
                                                +}
                                                +
                                                +func ComputeForwarderPortFromData(peers map[string]*nmdata.Peer, requiredVersion string) int64 {
                                                +	versions := make([]string, 0, len(peers))
                                                +	for _, peer := range peers {
                                                +		versions = append(versions, peer.Meta.WtVersion)
                                                +	}
                                                +	return computeForwarderPortFromVersions(versions, requiredVersion)
                                                +}
                                                +
                                                +func computeForwarderPortFromVersions(wtVersions []string, requiredVersion string) int64 {
                                                +	if len(wtVersions) == 0 {
                                                 		return int64(network_map.OldForwarderPort)
                                                 	}
                                                 
                                                 	reqVer := semver.Canonical(requiredVersion)
                                                 
                                                 	// Check if all peers have the required version or newer
                                                -	for _, peer := range peers {
                                                +	for _, wtVersion := range wtVersions {
                                                 
                                                 		// Development version is always supported
                                                -		if peer.Meta.WtVersion == "development" {
                                                +		if version.IsDevelopmentVersion(wtVersion) {
                                                 			continue
                                                 		}
                                                -		peerVersion := semver.Canonical("v" + peer.Meta.WtVersion)
                                                +		peerVersion := semver.Canonical("v" + wtVersion)
                                                 		if peerVersion == "" {
                                                 			// If any peer doesn't have version info, return 0
                                                 			return int64(network_map.OldForwarderPort)
                                                @@ -713,7 +1318,7 @@ func computeForwarderPort(peers []*nbpeer.Peer, requiredVersion string) int64 {
                                                 
                                                 // addPolicyPostureChecks adds posture checks from a policy to the peer posture checks map if the peer is in the policy's source groups.
                                                 func addPolicyPostureChecks(account *types.Account, peerID string, policy *types.Policy, peerPostureChecks map[string]*posture.Checks) error {
                                                -	isInGroup, err := isPeerInPolicySourceGroups(account, peerID, policy)
                                                +	isInGroup, err := isPeerInPolicySources(account, peerID, policy)
                                                 	if err != nil {
                                                 		return err
                                                 	}
                                                @@ -733,13 +1338,17 @@ func addPolicyPostureChecks(account *types.Account, peerID string, policy *types
                                                 	return nil
                                                 }
                                                 
                                                -// isPeerInPolicySourceGroups checks if a peer is present in any of the policy rule source groups.
                                                -func isPeerInPolicySourceGroups(account *types.Account, peerID string, policy *types.Policy) (bool, error) {
                                                +// isPeerInPolicySources checks if a peer is a source of the policy, directly or through a source group.
                                                +func isPeerInPolicySources(account *types.Account, peerID string, policy *types.Policy) (bool, error) {
                                                 	for _, rule := range policy.Rules {
                                                 		if !rule.Enabled {
                                                 			continue
                                                 		}
                                                 
                                                +		if rule.SourceResource.Type == types.ResourceTypePeer && rule.SourceResource.ID == peerID {
                                                +			return true, nil
                                                +		}
                                                +
                                                 		for _, sourceGroup := range rule.Sources {
                                                 			group := account.GetGroup(sourceGroup)
                                                 			if group == nil {
                                                @@ -755,38 +1364,24 @@ func isPeerInPolicySourceGroups(account *types.Account, peerID string, policy *t
                                                 	return false, nil
                                                 }
                                                 
                                                -func (c *Controller) OnPeersUpdated(ctx context.Context, accountID string, peerIDs []string) error {
                                                -	peers, err := c.repo.GetPeersByIDs(ctx, accountID, peerIDs)
                                                -	if err != nil {
                                                -		return fmt.Errorf("failed to get peers by ids: %w", err)
                                                +func (c *Controller) OnPeersUpdated(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error {
                                                +	if len(affectedPeerIDs) == 0 {
                                                +		log.WithContext(ctx).Tracef("no affected peers for peer update in account %s, skipping", accountID)
                                                +		return nil
                                                 	}
                                                -
                                                -	for _, peer := range peers {
                                                -		c.UpdatePeerInNetworkMapCache(accountID, peer)
                                                -	}
                                                -
                                                -	err = c.bufferSendUpdateAccountPeers(ctx, accountID)
                                                -	if err != nil {
                                                -		log.WithContext(ctx).Errorf("failed to buffer update account peers for peer update in account %s: %v", accountID, err)
                                                -	}
                                                -
                                                -	return nil
                                                +	return c.BufferUpdateAffectedPeers(ctx, accountID, affectedPeerIDs, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationUpdate})
                                                 }
                                                 
                                                -func (c *Controller) OnPeersAdded(ctx context.Context, accountID string, peerIDs []string) error {
                                                +func (c *Controller) OnPeersAdded(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error {
                                                 	log.WithContext(ctx).Debugf("OnPeersAdded call to add peers: %v", peerIDs)
                                                -	if c.experimentalNetworkMap(accountID) {
                                                -		account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
                                                -		if err != nil {
                                                -			return err
                                                -		}
                                                -		log.WithContext(ctx).Debugf("peers are ready to be added to networkmap cache: %v", peerIDs)
                                                -		c.onPeersAddedUpdNetworkMapCache(account, peerIDs...)
                                                +	if len(affectedPeerIDs) == 0 {
                                                +		log.WithContext(ctx).Tracef("no affected peers for peer add in account %s, skipping", accountID)
                                                +		return nil
                                                 	}
                                                -	return c.bufferSendUpdateAccountPeers(ctx, accountID)
                                                +	return c.BufferUpdateAffectedPeers(ctx, accountID, affectedPeerIDs, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationCreate})
                                                 }
                                                 
                                                -func (c *Controller) OnPeersDeleted(ctx context.Context, accountID string, peerIDs []string) error {
                                                +func (c *Controller) OnPeersDeleted(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error {
                                                 	network, err := c.repo.GetAccountNetwork(ctx, accountID)
                                                 	if err != nil {
                                                 		return err
                                                @@ -810,29 +1405,20 @@ func (c *Controller) OnPeersDeleted(ctx context.Context, accountID string, peerI
                                                 					FirewallRules:        []*proto.FirewallRule{},
                                                 					FirewallRulesIsEmpty: true,
                                                 					DNSConfig: &proto.DNSConfig{
                                                -						ForwarderPort: dnsFwdPort,
                                                +						ForwarderPort: dnsFwdPort, //nolint:staticcheck
                                                 					},
                                                 				},
                                                 			},
                                                 			MessageType: network_map.MessageTypeNetworkMap,
                                                 		})
                                                 		c.peersUpdateManager.CloseChannel(ctx, peerID)
                                                -
                                                -		if c.experimentalNetworkMap(accountID) {
                                                -			account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
                                                -			if err != nil {
                                                -				log.WithContext(ctx).Errorf("failed to get account %s: %v", accountID, err)
                                                -				continue
                                                -			}
                                                -			err = c.onPeerDeletedUpdNetworkMapCache(account, peerID)
                                                -			if err != nil {
                                                -				log.WithContext(ctx).Errorf("failed to update network map cache for deleted peer %s in account %s: %v", peerID, accountID, err)
                                                -				continue
                                                -			}
                                                -		}
                                                 	}
                                                 
                                                -	return c.bufferSendUpdateAccountPeers(ctx, accountID)
                                                +	if len(affectedPeerIDs) == 0 {
                                                +		log.WithContext(ctx).Tracef("no affected peers for peer delete in account %s, skipping", accountID)
                                                +		return nil
                                                +	}
                                                +	return c.BufferUpdateAffectedPeers(ctx, accountID, affectedPeerIDs, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationDelete})
                                                 }
                                                 
                                                 // GetNetworkMap returns Network map for a given peer (omits original peer from the Peers result)
                                                @@ -852,7 +1438,12 @@ func (c *Controller) GetNetworkMap(ctx context.Context, peerID string) (*types.N
                                                 		groups[groupID] = group.Peers
                                                 	}
                                                 
                                                -	validatedPeers, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
                                                +	extraSettings, err := c.settingsManager.GetExtraSettings(ctx, account.Id)
                                                +	if err != nil {
                                                +		return nil, err
                                                +	}
                                                +
                                                +	validatedPeers, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), extraSettings)
                                                 	if err != nil {
                                                 		return nil, err
                                                 	}
                                                @@ -872,21 +1463,11 @@ func (c *Controller) GetNetworkMap(ctx context.Context, peerID string) (*types.N
                                                 		return nil, err
                                                 	}
                                                 
                                                -	var networkMap *types.NetworkMap
                                                -
                                                -	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()
                                                -		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)
                                                -		}
                                                -	}
                                                +	c.injectAllProxyPolicies(ctx, account)
                                                +	resourcePolicies := account.GetResourcePoliciesMap()
                                                +	routers := account.GetResourceRoutersMap()
                                                +	groupIDToUserIDs := account.GetActiveGroupUsers()
                                                +	networkMap := account.GetPeerNetworkMapFromComponents(ctx, peer.ID, peersCustomZone, accountZones, validatedPeers, resourcePolicies, routers, nil, groupIDToUserIDs)
                                                 
                                                 	proxyNetworkMap, ok := proxyNetworkMaps[peer.ID]
                                                 	if ok {
                                                diff --git a/management/internals/controllers/network_map/controller/controller_test.go b/management/internals/controllers/network_map/controller/controller_test.go
                                                index 90e7b6e18..dfbbb2915 100644
                                                --- a/management/internals/controllers/network_map/controller/controller_test.go
                                                +++ b/management/internals/controllers/network_map/controller/controller_test.go
                                                @@ -1,10 +1,15 @@
                                                 package controller
                                                 
                                                 import (
                                                +	"context"
                                                 	"testing"
                                                 
                                                 	"github.com/netbirdio/netbird/management/internals/controllers/network_map"
                                                +	"github.com/netbirdio/netbird/management/server/account"
                                                 	nbpeer "github.com/netbirdio/netbird/management/server/peer"
                                                +	"github.com/netbirdio/netbird/management/server/types"
                                                +	"github.com/stretchr/testify/assert"
                                                +	"go.uber.org/mock/gomock"
                                                 )
                                                 
                                                 func TestComputeForwarderPort(t *testing.T) {
                                                @@ -107,3 +112,22 @@ func TestComputeForwarderPort(t *testing.T) {
                                                 		t.Errorf("Expected %d for peers with unknown version, got %d", network_map.OldForwarderPort, result)
                                                 	}
                                                 }
                                                +
                                                +func TestGetValidatedPeerWithComponents_DeletedPeer(t *testing.T) {
                                                +	ctrl := gomock.NewController(t)
                                                +	mockrequestBuffer := account.NewMockRequestBuffer(ctrl)
                                                +
                                                +	c := Controller{
                                                +		requestBuffer: mockrequestBuffer,
                                                +	}
                                                +
                                                +	mockrequestBuffer.EXPECT().GetAccountWithBackpressure(gomock.Any(), gomock.Any()).Return(&types.Account{}, nil)
                                                +	peer, components, netmap, posturechecks, dnsforwardPort, err := c.GetValidatedPeerWithComponents(context.TODO(), false, "test-account-id", &nbpeer.Peer{ID: "test-peer-id"})
                                                +
                                                +	assert.Nil(t, peer)
                                                +	assert.Nil(t, components)
                                                +	assert.Nil(t, netmap)
                                                +	assert.Nil(t, posturechecks)
                                                +	assert.Equal(t, int64(0), dnsforwardPort)
                                                +	assert.NotNil(t, err)
                                                +}
                                                diff --git a/management/internals/controllers/network_map/controller/ipv6_allowed_test.go b/management/internals/controllers/network_map/controller/ipv6_allowed_test.go
                                                new file mode 100644
                                                index 000000000..c80f3b734
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/controller/ipv6_allowed_test.go
                                                @@ -0,0 +1,47 @@
                                                +package controller
                                                +
                                                +import (
                                                +	"testing"
                                                +
                                                +	"github.com/stretchr/testify/assert"
                                                +
                                                +	"github.com/netbirdio/netbird/shared/management/networkmap"
                                                +	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
                                                +)
                                                +
                                                +// The account-side builder (types.Account.peerIPv6AllowedSet) is the reference:
                                                +// an account with no IPv6-enabled group runs no IPv6 overlay at all, embedded
                                                +// proxy peers included — see TestPeerIPv6AllowedEmbeddedProxy. Both builders
                                                +// gate the same AAAA records, so the store-backed one has to agree.
                                                +func TestIPv6AllowedPeersFromData(t *testing.T) {
                                                +	data := func(enabledGroups []string) *networkmap.NetworkMapData {
                                                +		return &networkmap.NetworkMapData{
                                                +			AccountSettings: &nmdata.AccountSettingsInfo{IPv6EnabledGroups: enabledGroups},
                                                +			Peers: map[string]*nmdata.Peer{
                                                +				"peer1":  {ID: "peer1"},
                                                +				"lonely": {ID: "lonely"},
                                                +				"proxy":  {ID: "proxy", ProxyMeta: nmdata.ProxyMeta{Embedded: true, Cluster: "netbird.test"}},
                                                +			},
                                                +			Groups: map[string]*nmdata.Group{
                                                +				"group-devs": {ID: "group-devs", Peers: []string{"peer1"}},
                                                +			},
                                                +		}
                                                +	}
                                                +
                                                +	t.Run("embedded proxy allowed when any v6 group exists, without group membership", func(t *testing.T) {
                                                +		allowed := IPv6AllowedPeersFromData(data([]string{"group-devs"}))
                                                +		assert.Contains(t, allowed, "proxy", "embedded proxy participates in v6 overlay")
                                                +		assert.Contains(t, allowed, "peer1", "regular peer in enabled group still allowed")
                                                +	})
                                                +
                                                +	t.Run("embedded proxy denied when no v6 group enabled", func(t *testing.T) {
                                                +		allowed := IPv6AllowedPeersFromData(data(nil))
                                                +		assert.NotContains(t, allowed, "proxy", "v6 disabled account-wide denies embedded proxies too")
                                                +		assert.Empty(t, allowed, "no peer participates in the v6 overlay")
                                                +	})
                                                +
                                                +	t.Run("non-embedded peer outside any enabled group is not pulled in", func(t *testing.T) {
                                                +		allowed := IPv6AllowedPeersFromData(data([]string{"group-devs"}))
                                                +		assert.NotContains(t, allowed, "lonely", "embedded-proxy bypass must not leak to regular peers")
                                                +	})
                                                +}
                                                diff --git a/management/internals/controllers/network_map/controller/posture_twin_test.go b/management/internals/controllers/network_map/controller/posture_twin_test.go
                                                new file mode 100644
                                                index 000000000..98e0991d0
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/controller/posture_twin_test.go
                                                @@ -0,0 +1,68 @@
                                                +package controller
                                                +
                                                +import (
                                                +	"testing"
                                                +
                                                +	"github.com/stretchr/testify/assert"
                                                +
                                                +	"github.com/netbirdio/netbird/shared/management/networkmap"
                                                +	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
                                                +	"github.com/netbirdio/netbird/shared/management/types"
                                                +)
                                                +
                                                +func postureSelectionData(policies ...*nmdata.Policy) *networkmap.NetworkMapData {
                                                +	return &networkmap.NetworkMapData{
                                                +		Groups:   map[string]*nmdata.Group{"g-src": {ID: "g-src", Peers: []string{"peer-group"}}},
                                                +		Policies: policies,
                                                +		PostureChecks: map[string]*nmdata.PostureChecks{
                                                +			"pc1": {ID: "pc1", Checks: nmdata.ChecksDefinition{NBVersionCheck: &nmdata.NBVersionCheck{MinVersion: "0.30.0"}}},
                                                +		},
                                                +	}
                                                +}
                                                +
                                                +func gatedPolicy(id string, rule *nmdata.PolicyRule, checkIDs ...string) *nmdata.Policy {
                                                +	return &nmdata.Policy{ID: id, Enabled: true, SourcePostureChecks: checkIDs, Rules: []*nmdata.PolicyRule{rule}}
                                                +}
                                                +
                                                +func checkIDs(checks []*nmdata.PostureChecks) []string {
                                                +	ids := make([]string, 0, len(checks))
                                                +	for _, c := range checks {
                                                +		ids = append(ids, c.ID)
                                                +	}
                                                +	return ids
                                                +}
                                                +
                                                +func TestPeerPostureChecksFromData_SelectsPolicySourcePeers(t *testing.T) {
                                                +	groupRule := &nmdata.PolicyRule{ID: "r-group", Enabled: true, Sources: []string{"g-src"}, Destinations: []string{"g-dst"}}
                                                +	directRule := &nmdata.PolicyRule{ID: "r-direct", Enabled: true, SourceResource: nmdata.Resource{ID: "peer-direct", Type: string(types.ResourceTypePeer)}, Destinations: []string{"g-dst"}}
                                                +
                                                +	t.Run("source group member and direct source peer both get the checks", func(t *testing.T) {
                                                +		nmData := postureSelectionData(gatedPolicy("p1", groupRule, "pc1"), gatedPolicy("p2", directRule, "pc1"))
                                                +
                                                +		assert.Equal(t, []string{"pc1"}, checkIDs(peerPostureChecksFromData(nmData, "peer-group")))
                                                +		assert.Equal(t, []string{"pc1"}, checkIDs(peerPostureChecksFromData(nmData, "peer-direct")))
                                                +		assert.Empty(t, peerPostureChecksFromData(nmData, "peer-elsewhere"))
                                                +	})
                                                +
                                                +	t.Run("source resource of a non-peer type never matches a peer", func(t *testing.T) {
                                                +		hostRule := &nmdata.PolicyRule{ID: "r-host", Enabled: true, SourceResource: nmdata.Resource{ID: "peer-direct", Type: string(types.ResourceTypeHost)}, Destinations: []string{"g-dst"}}
                                                +		nmData := postureSelectionData(gatedPolicy("p1", hostRule, "pc1"))
                                                +
                                                +		assert.Empty(t, peerPostureChecksFromData(nmData, "peer-direct"))
                                                +	})
                                                +
                                                +	t.Run("same check through two policies is returned once", func(t *testing.T) {
                                                +		nmData := postureSelectionData(gatedPolicy("p1", groupRule, "pc1"), gatedPolicy("p2", groupRule, "pc1"))
                                                +
                                                +		assert.Equal(t, []string{"pc1"}, checkIDs(peerPostureChecksFromData(nmData, "peer-group")))
                                                +	})
                                                +
                                                +	t.Run("disabled policy, disabled rule and dangling check are ignored", func(t *testing.T) {
                                                +		disabledPolicy := gatedPolicy("p-off", groupRule, "pc1")
                                                +		disabledPolicy.Enabled = false
                                                +		disabledRule := &nmdata.PolicyRule{ID: "r-off", Enabled: false, Sources: []string{"g-src"}}
                                                +		nmData := postureSelectionData(disabledPolicy, gatedPolicy("p-rule-off", disabledRule, "pc1"), gatedPolicy("p-dangling", groupRule, "pc-missing"))
                                                +
                                                +		assert.Empty(t, peerPostureChecksFromData(nmData, "peer-group"))
                                                +	})
                                                +}
                                                diff --git a/management/internals/controllers/network_map/controller/repository.go b/management/internals/controllers/network_map/controller/repository.go
                                                index caef362cb..5c3195f16 100644
                                                --- a/management/internals/controllers/network_map/controller/repository.go
                                                +++ b/management/internals/controllers/network_map/controller/repository.go
                                                @@ -3,12 +3,16 @@ package controller
                                                 import (
                                                 	"context"
                                                 
                                                +	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
                                                +	"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
                                                 	"github.com/netbirdio/netbird/management/internals/modules/zones"
                                                 	"github.com/netbirdio/netbird/management/server/peer"
                                                 	"github.com/netbirdio/netbird/management/server/store"
                                                 	"github.com/netbirdio/netbird/management/server/types"
                                                 )
                                                 
                                                +//go:generate go tool mockgen -source=./repository.go -package=controller -destination=repository_mock.go
                                                +
                                                 type Repository interface {
                                                 	GetAccountNetwork(ctx context.Context, accountID string) (*types.Network, error)
                                                 	GetAccountPeers(ctx context.Context, accountID string) ([]*peer.Peer, error)
                                                @@ -16,6 +20,11 @@ type Repository interface {
                                                 	GetPeersByIDs(ctx context.Context, accountID string, peerIDs []string) (map[string]*peer.Peer, error)
                                                 	GetPeerByID(ctx context.Context, accountID string, peerID string) (*peer.Peer, error)
                                                 	GetAccountZones(ctx context.Context, accountID string) ([]*zones.Zone, error)
                                                +	// SynthesizeAgentNetworkServices returns the in-memory reverse-proxy
                                                +	// services synthesised from the account's agent-network provider/policy
                                                +	// state. Empty for accounts without agent-network providers.
                                                +	SynthesizeAgentNetworkServices(ctx context.Context, accountID string) ([]*service.Service, error)
                                                +	GetAccountServices(ctx context.Context, accountID string) ([]*service.Service, error)
                                                 }
                                                 
                                                 type repository struct {
                                                @@ -50,6 +59,14 @@ func (r *repository) GetPeerByID(ctx context.Context, accountID string, peerID s
                                                 	return r.store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID)
                                                 }
                                                 
                                                +func (r *repository) SynthesizeAgentNetworkServices(ctx context.Context, accountID string) ([]*service.Service, error) {
                                                +	return agentnetwork.SynthesizeServices(ctx, r.store, accountID)
                                                +}
                                                +
                                                +func (r *repository) GetAccountServices(ctx context.Context, accountID string) ([]*service.Service, error) {
                                                +	return r.store.GetAccountServices(ctx, store.LockingStrengthNone, accountID)
                                                +}
                                                +
                                                 func (r *repository) GetAccountZones(ctx context.Context, accountID string) ([]*zones.Zone, error) {
                                                 	return r.store.GetAccountZones(ctx, store.LockingStrengthNone, accountID)
                                                 }
                                                diff --git a/management/internals/controllers/network_map/controller/repository_mock.go b/management/internals/controllers/network_map/controller/repository_mock.go
                                                new file mode 100644
                                                index 000000000..5246eef4b
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/controller/repository_mock.go
                                                @@ -0,0 +1,150 @@
                                                +// Code generated by MockGen. DO NOT EDIT.
                                                +// Source: ./repository.go
                                                +//
                                                +// Generated by this command:
                                                +//
                                                +//	mockgen -source=./repository.go -package=controller -destination=repository_mock.go
                                                +//
                                                +
                                                +// Package controller is a generated GoMock package.
                                                +package controller
                                                +
                                                +import (
                                                +	context "context"
                                                +	reflect "reflect"
                                                +
                                                +	service "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
                                                +	zones "github.com/netbirdio/netbird/management/internals/modules/zones"
                                                +	peer "github.com/netbirdio/netbird/management/server/peer"
                                                +	types "github.com/netbirdio/netbird/management/server/types"
                                                +	gomock "go.uber.org/mock/gomock"
                                                +)
                                                +
                                                +// MockRepository is a mock of Repository interface.
                                                +type MockRepository struct {
                                                +	ctrl     *gomock.Controller
                                                +	recorder *MockRepositoryMockRecorder
                                                +	isgomock struct{}
                                                +}
                                                +
                                                +// MockRepositoryMockRecorder is the mock recorder for MockRepository.
                                                +type MockRepositoryMockRecorder struct {
                                                +	mock *MockRepository
                                                +}
                                                +
                                                +// NewMockRepository creates a new mock instance.
                                                +func NewMockRepository(ctrl *gomock.Controller) *MockRepository {
                                                +	mock := &MockRepository{ctrl: ctrl}
                                                +	mock.recorder = &MockRepositoryMockRecorder{mock}
                                                +	return mock
                                                +}
                                                +
                                                +// EXPECT returns an object that allows the caller to indicate expected use.
                                                +func (m *MockRepository) EXPECT() *MockRepositoryMockRecorder {
                                                +	return m.recorder
                                                +}
                                                +
                                                +// GetAccountByPeerID mocks base method.
                                                +func (m *MockRepository) GetAccountByPeerID(ctx context.Context, peerID string) (*types.Account, error) {
                                                +	m.ctrl.T.Helper()
                                                +	ret := m.ctrl.Call(m, "GetAccountByPeerID", ctx, peerID)
                                                +	ret0, _ := ret[0].(*types.Account)
                                                +	ret1, _ := ret[1].(error)
                                                +	return ret0, ret1
                                                +}
                                                +
                                                +// GetAccountByPeerID indicates an expected call of GetAccountByPeerID.
                                                +func (mr *MockRepositoryMockRecorder) GetAccountByPeerID(ctx, peerID any) *gomock.Call {
                                                +	mr.mock.ctrl.T.Helper()
                                                +	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountByPeerID", reflect.TypeOf((*MockRepository)(nil).GetAccountByPeerID), ctx, peerID)
                                                +}
                                                +
                                                +// GetAccountNetwork mocks base method.
                                                +func (m *MockRepository) GetAccountNetwork(ctx context.Context, accountID string) (*types.Network, error) {
                                                +	m.ctrl.T.Helper()
                                                +	ret := m.ctrl.Call(m, "GetAccountNetwork", ctx, accountID)
                                                +	ret0, _ := ret[0].(*types.Network)
                                                +	ret1, _ := ret[1].(error)
                                                +	return ret0, ret1
                                                +}
                                                +
                                                +// GetAccountNetwork indicates an expected call of GetAccountNetwork.
                                                +func (mr *MockRepositoryMockRecorder) GetAccountNetwork(ctx, accountID any) *gomock.Call {
                                                +	mr.mock.ctrl.T.Helper()
                                                +	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountNetwork", reflect.TypeOf((*MockRepository)(nil).GetAccountNetwork), ctx, accountID)
                                                +}
                                                +
                                                +// GetAccountPeers mocks base method.
                                                +func (m *MockRepository) GetAccountPeers(ctx context.Context, accountID string) ([]*peer.Peer, error) {
                                                +	m.ctrl.T.Helper()
                                                +	ret := m.ctrl.Call(m, "GetAccountPeers", ctx, accountID)
                                                +	ret0, _ := ret[0].([]*peer.Peer)
                                                +	ret1, _ := ret[1].(error)
                                                +	return ret0, ret1
                                                +}
                                                +
                                                +// GetAccountPeers indicates an expected call of GetAccountPeers.
                                                +func (mr *MockRepositoryMockRecorder) GetAccountPeers(ctx, accountID any) *gomock.Call {
                                                +	mr.mock.ctrl.T.Helper()
                                                +	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPeers", reflect.TypeOf((*MockRepository)(nil).GetAccountPeers), ctx, accountID)
                                                +}
                                                +
                                                +// GetAccountZones mocks base method.
                                                +func (m *MockRepository) GetAccountZones(ctx context.Context, accountID string) ([]*zones.Zone, error) {
                                                +	m.ctrl.T.Helper()
                                                +	ret := m.ctrl.Call(m, "GetAccountZones", ctx, accountID)
                                                +	ret0, _ := ret[0].([]*zones.Zone)
                                                +	ret1, _ := ret[1].(error)
                                                +	return ret0, ret1
                                                +}
                                                +
                                                +// GetAccountZones indicates an expected call of GetAccountZones.
                                                +func (mr *MockRepositoryMockRecorder) GetAccountZones(ctx, accountID any) *gomock.Call {
                                                +	mr.mock.ctrl.T.Helper()
                                                +	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountZones", reflect.TypeOf((*MockRepository)(nil).GetAccountZones), ctx, accountID)
                                                +}
                                                +
                                                +// GetPeerByID mocks base method.
                                                +func (m *MockRepository) GetPeerByID(ctx context.Context, accountID, peerID string) (*peer.Peer, error) {
                                                +	m.ctrl.T.Helper()
                                                +	ret := m.ctrl.Call(m, "GetPeerByID", ctx, accountID, peerID)
                                                +	ret0, _ := ret[0].(*peer.Peer)
                                                +	ret1, _ := ret[1].(error)
                                                +	return ret0, ret1
                                                +}
                                                +
                                                +// GetPeerByID indicates an expected call of GetPeerByID.
                                                +func (mr *MockRepositoryMockRecorder) GetPeerByID(ctx, accountID, peerID any) *gomock.Call {
                                                +	mr.mock.ctrl.T.Helper()
                                                +	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerByID", reflect.TypeOf((*MockRepository)(nil).GetPeerByID), ctx, accountID, peerID)
                                                +}
                                                +
                                                +// GetPeersByIDs mocks base method.
                                                +func (m *MockRepository) GetPeersByIDs(ctx context.Context, accountID string, peerIDs []string) (map[string]*peer.Peer, error) {
                                                +	m.ctrl.T.Helper()
                                                +	ret := m.ctrl.Call(m, "GetPeersByIDs", ctx, 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 *MockRepositoryMockRecorder) GetPeersByIDs(ctx, accountID, peerIDs any) *gomock.Call {
                                                +	mr.mock.ctrl.T.Helper()
                                                +	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeersByIDs", reflect.TypeOf((*MockRepository)(nil).GetPeersByIDs), ctx, accountID, peerIDs)
                                                +}
                                                +
                                                +// SynthesizeAgentNetworkServices mocks base method.
                                                +func (m *MockRepository) SynthesizeAgentNetworkServices(ctx context.Context, accountID string) ([]*service.Service, error) {
                                                +	m.ctrl.T.Helper()
                                                +	ret := m.ctrl.Call(m, "SynthesizeAgentNetworkServices", ctx, accountID)
                                                +	ret0, _ := ret[0].([]*service.Service)
                                                +	ret1, _ := ret[1].(error)
                                                +	return ret0, ret1
                                                +}
                                                +
                                                +// SynthesizeAgentNetworkServices indicates an expected call of SynthesizeAgentNetworkServices.
                                                +func (mr *MockRepositoryMockRecorder) SynthesizeAgentNetworkServices(ctx, accountID any) *gomock.Call {
                                                +	mr.mock.ctrl.T.Helper()
                                                +	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SynthesizeAgentNetworkServices", reflect.TypeOf((*MockRepository)(nil).SynthesizeAgentNetworkServices), ctx, accountID)
                                                +}
                                                diff --git a/management/internals/controllers/network_map/interface.go b/management/internals/controllers/network_map/interface.go
                                                index 64caac861..1e8c219b3 100644
                                                --- a/management/internals/controllers/network_map/interface.go
                                                +++ b/management/internals/controllers/network_map/interface.go
                                                @@ -1,38 +1,38 @@
                                                 package network_map
                                                 
                                                -//go:generate go run go.uber.org/mock/mockgen -package network_map -destination=interface_mock.go -source=./interface.go -build_flags=-mod=mod
                                                +//go:generate go tool mockgen -package network_map -destination=interface_mock.go -source=./interface.go -build_flags=-mod=mod
                                                 
                                                 import (
                                                 	"context"
                                                 
                                                 	nbdns "github.com/netbirdio/netbird/dns"
                                                 	nbpeer "github.com/netbirdio/netbird/management/server/peer"
                                                -	"github.com/netbirdio/netbird/management/server/posture"
                                                 	"github.com/netbirdio/netbird/management/server/types"
                                                +	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
                                                 )
                                                 
                                                 const (
                                                -	EnvNewNetworkMapBuilder  = "NB_EXPERIMENT_NETWORK_MAP"
                                                -	EnvNewNetworkMapAccounts = "NB_EXPERIMENT_NETWORK_MAP_ACCOUNTS"
                                                -
                                                 	DnsForwarderPort           = nbdns.ForwarderServerPort
                                                 	OldForwarderPort           = nbdns.ForwarderClientPort
                                                 	DnsForwarderPortMinVersion = "v0.59.0"
                                                 )
                                                 
                                                 type Controller interface {
                                                -	UpdateAccountPeers(ctx context.Context, accountID string) error
                                                +	UpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error
                                                +	UpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string) error
                                                +	BufferUpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string, reason types.UpdateReason) error
                                                 	UpdateAccountPeer(ctx context.Context, accountId string, peerId string) error
                                                -	BufferUpdateAccountPeers(ctx context.Context, accountID string) error
                                                -	GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, p *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error)
                                                +	BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error
                                                +	GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peerID string) (*types.NetworkMap, []*nmdata.PostureChecks, int64, error)
                                                +	GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, p *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*nmdata.PostureChecks, int64, error)
                                                 	GetDNSDomain(settings *types.Settings) string
                                                 	StartWarmup(context.Context)
                                                 	GetNetworkMap(ctx context.Context, peerID string) (*types.NetworkMap, error)
                                                 	CountStreams() int
                                                 
                                                -	OnPeersUpdated(ctx context.Context, accountId string, peerIDs []string) error
                                                -	OnPeersAdded(ctx context.Context, accountID string, peerIDs []string) error
                                                -	OnPeersDeleted(ctx context.Context, accountID string, peerIDs []string) error
                                                +	OnPeersUpdated(ctx context.Context, accountId string, peerIDs []string, affectedPeerIDs []string) error
                                                +	OnPeersAdded(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error
                                                +	OnPeersDeleted(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error
                                                 	DisconnectPeers(ctx context.Context, accountId string, peerIDs []string)
                                                 	OnPeerConnected(ctx context.Context, accountID string, peerID string) (chan *UpdateMessage, error)
                                                 	OnPeerDisconnected(ctx context.Context, accountID string, peerID string)
                                                diff --git a/management/internals/controllers/network_map/interface_mock.go b/management/internals/controllers/network_map/interface_mock.go
                                                index 4e86d2973..8b104dfa0 100644
                                                --- a/management/internals/controllers/network_map/interface_mock.go
                                                +++ b/management/internals/controllers/network_map/interface_mock.go
                                                @@ -1,9 +1,9 @@
                                                 // Code generated by MockGen. DO NOT EDIT.
                                                -// Source: management/internals/controllers/network_map/interface.go
                                                +// Source: ./interface.go
                                                 //
                                                 // Generated by this command:
                                                 //
                                                -//	mockgen -package network_map -destination=management/internals/controllers/network_map/interface_mock.go -source=management/internals/controllers/network_map/interface.go -build_flags=-mod=mod
                                                +//	mockgen -package network_map -destination=interface_mock.go -source=./interface.go -build_flags=-mod=mod
                                                 //
                                                 
                                                 // Package network_map is a generated GoMock package.
                                                @@ -14,8 +14,8 @@ import (
                                                 	reflect "reflect"
                                                 
                                                 	peer "github.com/netbirdio/netbird/management/server/peer"
                                                -	posture "github.com/netbirdio/netbird/management/server/posture"
                                                 	types "github.com/netbirdio/netbird/management/server/types"
                                                +	nmdata "github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
                                                 	gomock "go.uber.org/mock/gomock"
                                                 )
                                                 
                                                @@ -44,17 +44,31 @@ func (m *MockController) EXPECT() *MockControllerMockRecorder {
                                                 }
                                                 
                                                 // BufferUpdateAccountPeers mocks base method.
                                                -func (m *MockController) BufferUpdateAccountPeers(ctx context.Context, accountID string) error {
                                                +func (m *MockController) BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error {
                                                 	m.ctrl.T.Helper()
                                                -	ret := m.ctrl.Call(m, "BufferUpdateAccountPeers", ctx, accountID)
                                                +	ret := m.ctrl.Call(m, "BufferUpdateAccountPeers", ctx, accountID, reason)
                                                 	ret0, _ := ret[0].(error)
                                                 	return ret0
                                                 }
                                                 
                                                 // BufferUpdateAccountPeers indicates an expected call of BufferUpdateAccountPeers.
                                                -func (mr *MockControllerMockRecorder) BufferUpdateAccountPeers(ctx, accountID any) *gomock.Call {
                                                +func (mr *MockControllerMockRecorder) BufferUpdateAccountPeers(ctx, accountID, reason any) *gomock.Call {
                                                 	mr.mock.ctrl.T.Helper()
                                                -	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BufferUpdateAccountPeers", reflect.TypeOf((*MockController)(nil).BufferUpdateAccountPeers), ctx, accountID)
                                                +	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BufferUpdateAccountPeers", reflect.TypeOf((*MockController)(nil).BufferUpdateAccountPeers), ctx, accountID, reason)
                                                +}
                                                +
                                                +// BufferUpdateAffectedPeers mocks base method.
                                                +func (m *MockController) BufferUpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string, reason types.UpdateReason) error {
                                                +	m.ctrl.T.Helper()
                                                +	ret := m.ctrl.Call(m, "BufferUpdateAffectedPeers", ctx, accountID, peerIDs, reason)
                                                +	ret0, _ := ret[0].(error)
                                                +	return ret0
                                                +}
                                                +
                                                +// BufferUpdateAffectedPeers indicates an expected call of BufferUpdateAffectedPeers.
                                                +func (mr *MockControllerMockRecorder) BufferUpdateAffectedPeers(ctx, accountID, peerIDs, reason any) *gomock.Call {
                                                +	mr.mock.ctrl.T.Helper()
                                                +	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BufferUpdateAffectedPeers", reflect.TypeOf((*MockController)(nil).BufferUpdateAffectedPeers), ctx, accountID, peerIDs, reason)
                                                 }
                                                 
                                                 // CountStreams mocks base method.
                                                @@ -112,22 +126,40 @@ func (mr *MockControllerMockRecorder) GetNetworkMap(ctx, peerID any) *gomock.Cal
                                                 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkMap", reflect.TypeOf((*MockController)(nil).GetNetworkMap), ctx, peerID)
                                                 }
                                                 
                                                -// GetValidatedPeerWithMap mocks base method.
                                                -func (m *MockController) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, p *peer.Peer) (*peer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
                                                +// GetValidatedPeerWithComponents mocks base method.
                                                +func (m *MockController) GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, p *peer.Peer) (*peer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
                                                 	m.ctrl.T.Helper()
                                                -	ret := m.ctrl.Call(m, "GetValidatedPeerWithMap", ctx, isRequiresApproval, accountID, p)
                                                +	ret := m.ctrl.Call(m, "GetValidatedPeerWithComponents", ctx, isRequiresApproval, accountID, p)
                                                 	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
                                                +	ret1, _ := ret[1].(*types.NetworkMapComponents)
                                                +	ret2, _ := ret[2].(*types.NetworkMap)
                                                +	ret3, _ := ret[3].([]*nmdata.PostureChecks)
                                                +	ret4, _ := ret[4].(int64)
                                                +	ret5, _ := ret[5].(error)
                                                +	return ret0, ret1, ret2, ret3, ret4, ret5
                                                +}
                                                +
                                                +// GetValidatedPeerWithComponents indicates an expected call of GetValidatedPeerWithComponents.
                                                +func (mr *MockControllerMockRecorder) GetValidatedPeerWithComponents(ctx, isRequiresApproval, accountID, p any) *gomock.Call {
                                                +	mr.mock.ctrl.T.Helper()
                                                +	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetValidatedPeerWithComponents", reflect.TypeOf((*MockController)(nil).GetValidatedPeerWithComponents), ctx, isRequiresApproval, accountID, p)
                                                +}
                                                +
                                                +// GetValidatedPeerWithMap mocks base method.
                                                +func (m *MockController) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID, peerID string) (*types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
                                                +	m.ctrl.T.Helper()
                                                +	ret := m.ctrl.Call(m, "GetValidatedPeerWithMap", ctx, isRequiresApproval, accountID, peerID)
                                                +	ret0, _ := ret[0].(*types.NetworkMap)
                                                +	ret1, _ := ret[1].([]*nmdata.PostureChecks)
                                                +	ret2, _ := ret[2].(int64)
                                                +	ret3, _ := ret[3].(error)
                                                +	return ret0, ret1, ret2, ret3
                                                 }
                                                 
                                                 // GetValidatedPeerWithMap indicates an expected call of GetValidatedPeerWithMap.
                                                -func (mr *MockControllerMockRecorder) GetValidatedPeerWithMap(ctx, isRequiresApproval, accountID, p any) *gomock.Call {
                                                +func (mr *MockControllerMockRecorder) GetValidatedPeerWithMap(ctx, isRequiresApproval, accountID, peerID any) *gomock.Call {
                                                 	mr.mock.ctrl.T.Helper()
                                                -	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetValidatedPeerWithMap", reflect.TypeOf((*MockController)(nil).GetValidatedPeerWithMap), ctx, isRequiresApproval, accountID, p)
                                                +	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetValidatedPeerWithMap", reflect.TypeOf((*MockController)(nil).GetValidatedPeerWithMap), ctx, isRequiresApproval, accountID, peerID)
                                                 }
                                                 
                                                 // OnPeerConnected mocks base method.
                                                @@ -158,45 +190,45 @@ func (mr *MockControllerMockRecorder) OnPeerDisconnected(ctx, accountID, peerID
                                                 }
                                                 
                                                 // OnPeersAdded mocks base method.
                                                -func (m *MockController) OnPeersAdded(ctx context.Context, accountID string, peerIDs []string) error {
                                                +func (m *MockController) OnPeersAdded(ctx context.Context, accountID string, peerIDs, affectedPeerIDs []string) error {
                                                 	m.ctrl.T.Helper()
                                                -	ret := m.ctrl.Call(m, "OnPeersAdded", ctx, accountID, peerIDs)
                                                +	ret := m.ctrl.Call(m, "OnPeersAdded", ctx, accountID, peerIDs, affectedPeerIDs)
                                                 	ret0, _ := ret[0].(error)
                                                 	return ret0
                                                 }
                                                 
                                                 // OnPeersAdded indicates an expected call of OnPeersAdded.
                                                -func (mr *MockControllerMockRecorder) OnPeersAdded(ctx, accountID, peerIDs any) *gomock.Call {
                                                +func (mr *MockControllerMockRecorder) OnPeersAdded(ctx, accountID, peerIDs, affectedPeerIDs any) *gomock.Call {
                                                 	mr.mock.ctrl.T.Helper()
                                                -	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeersAdded", reflect.TypeOf((*MockController)(nil).OnPeersAdded), ctx, accountID, peerIDs)
                                                +	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeersAdded", reflect.TypeOf((*MockController)(nil).OnPeersAdded), ctx, accountID, peerIDs, affectedPeerIDs)
                                                 }
                                                 
                                                 // OnPeersDeleted mocks base method.
                                                -func (m *MockController) OnPeersDeleted(ctx context.Context, accountID string, peerIDs []string) error {
                                                +func (m *MockController) OnPeersDeleted(ctx context.Context, accountID string, peerIDs, affectedPeerIDs []string) error {
                                                 	m.ctrl.T.Helper()
                                                -	ret := m.ctrl.Call(m, "OnPeersDeleted", ctx, accountID, peerIDs)
                                                +	ret := m.ctrl.Call(m, "OnPeersDeleted", ctx, accountID, peerIDs, affectedPeerIDs)
                                                 	ret0, _ := ret[0].(error)
                                                 	return ret0
                                                 }
                                                 
                                                 // OnPeersDeleted indicates an expected call of OnPeersDeleted.
                                                -func (mr *MockControllerMockRecorder) OnPeersDeleted(ctx, accountID, peerIDs any) *gomock.Call {
                                                +func (mr *MockControllerMockRecorder) OnPeersDeleted(ctx, accountID, peerIDs, affectedPeerIDs any) *gomock.Call {
                                                 	mr.mock.ctrl.T.Helper()
                                                -	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeersDeleted", reflect.TypeOf((*MockController)(nil).OnPeersDeleted), ctx, accountID, peerIDs)
                                                +	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeersDeleted", reflect.TypeOf((*MockController)(nil).OnPeersDeleted), ctx, accountID, peerIDs, affectedPeerIDs)
                                                 }
                                                 
                                                 // OnPeersUpdated mocks base method.
                                                -func (m *MockController) OnPeersUpdated(ctx context.Context, accountId string, peerIDs []string) error {
                                                +func (m *MockController) OnPeersUpdated(ctx context.Context, accountId string, peerIDs, affectedPeerIDs []string) error {
                                                 	m.ctrl.T.Helper()
                                                -	ret := m.ctrl.Call(m, "OnPeersUpdated", ctx, accountId, peerIDs)
                                                +	ret := m.ctrl.Call(m, "OnPeersUpdated", ctx, accountId, peerIDs, affectedPeerIDs)
                                                 	ret0, _ := ret[0].(error)
                                                 	return ret0
                                                 }
                                                 
                                                 // OnPeersUpdated indicates an expected call of OnPeersUpdated.
                                                -func (mr *MockControllerMockRecorder) OnPeersUpdated(ctx, accountId, peerIDs any) *gomock.Call {
                                                +func (mr *MockControllerMockRecorder) OnPeersUpdated(ctx, accountId, peerIDs, affectedPeerIDs any) *gomock.Call {
                                                 	mr.mock.ctrl.T.Helper()
                                                -	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeersUpdated", reflect.TypeOf((*MockController)(nil).OnPeersUpdated), ctx, accountId, peerIDs)
                                                +	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeersUpdated", reflect.TypeOf((*MockController)(nil).OnPeersUpdated), ctx, accountId, peerIDs, affectedPeerIDs)
                                                 }
                                                 
                                                 // StartWarmup mocks base method.
                                                @@ -238,15 +270,29 @@ func (mr *MockControllerMockRecorder) UpdateAccountPeer(ctx, accountId, peerId a
                                                 }
                                                 
                                                 // UpdateAccountPeers mocks base method.
                                                -func (m *MockController) UpdateAccountPeers(ctx context.Context, accountID string) error {
                                                +func (m *MockController) UpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error {
                                                 	m.ctrl.T.Helper()
                                                -	ret := m.ctrl.Call(m, "UpdateAccountPeers", ctx, accountID)
                                                +	ret := m.ctrl.Call(m, "UpdateAccountPeers", ctx, accountID, reason)
                                                 	ret0, _ := ret[0].(error)
                                                 	return ret0
                                                 }
                                                 
                                                 // UpdateAccountPeers indicates an expected call of UpdateAccountPeers.
                                                -func (mr *MockControllerMockRecorder) UpdateAccountPeers(ctx, accountID any) *gomock.Call {
                                                +func (mr *MockControllerMockRecorder) UpdateAccountPeers(ctx, accountID, reason any) *gomock.Call {
                                                 	mr.mock.ctrl.T.Helper()
                                                -	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountPeers", reflect.TypeOf((*MockController)(nil).UpdateAccountPeers), ctx, accountID)
                                                +	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountPeers", reflect.TypeOf((*MockController)(nil).UpdateAccountPeers), ctx, accountID, reason)
                                                +}
                                                +
                                                +// UpdateAffectedPeers mocks base method.
                                                +func (m *MockController) UpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string) error {
                                                +	m.ctrl.T.Helper()
                                                +	ret := m.ctrl.Call(m, "UpdateAffectedPeers", ctx, accountID, peerIDs)
                                                +	ret0, _ := ret[0].(error)
                                                +	return ret0
                                                +}
                                                +
                                                +// UpdateAffectedPeers indicates an expected call of UpdateAffectedPeers.
                                                +func (mr *MockControllerMockRecorder) UpdateAffectedPeers(ctx, accountID, peerIDs any) *gomock.Call {
                                                +	mr.mock.ctrl.T.Helper()
                                                +	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAffectedPeers", reflect.TypeOf((*MockController)(nil).UpdateAffectedPeers), ctx, accountID, peerIDs)
                                                 }
                                                diff --git a/management/internals/controllers/network_map/nmaptest/canonicalize.go b/management/internals/controllers/network_map/nmaptest/canonicalize.go
                                                new file mode 100644
                                                index 000000000..ec6614d81
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/canonicalize.go
                                                @@ -0,0 +1,380 @@
                                                +package nmaptest
                                                +
                                                +import (
                                                +	"bytes"
                                                +	"cmp"
                                                +	"fmt"
                                                +	"slices"
                                                +	"sort"
                                                +	"strconv"
                                                +	"strings"
                                                +
                                                +	"github.com/netbirdio/netbird/shared/management/proto"
                                                +)
                                                +
                                                +// normalizeIDSpace replaces policy and route identifiers with positional
                                                +// placeholders so a comparison can reach everything else.
                                                +//
                                                +// This exists only because the envelope round-trip currently substitutes each
                                                +// internal xid with the object's public id, which is a tracked defect and not a
                                                +// licence to differ: those identifiers reach the server again inside flow
                                                +// events, which resolve them by internal id, so the substitution silently
                                                +// breaks flow attribution for component-format peers. TestIDSpaceMatches
                                                +// asserts the equality that must eventually hold; this erasure keeps the other
                                                +// 40-odd cases reporting on semantics meanwhile. When the id space is unified,
                                                +// delete this and the calls to it — every case should still pass.
                                                +//
                                                +// Cardinality and cross-references survive the erasure: two rules under one
                                                +// policy still share a token and a route firewall rule still points at its
                                                +// route, so a path that drops a policy, merges two policies, or misattributes a
                                                +// rule to the wrong route still fails.
                                                +func normalizeIDSpace(nm *proto.NetworkMap) {
                                                +	if nm == nil {
                                                +		return
                                                +	}
                                                +	policies := newTokenizer("policy")
                                                +	routes := newTokenizer("route")
                                                +
                                                +	for _, i := range orderBy(nm.Routes, routeKeyWithoutID) {
                                                +		nm.Routes[i].ID = routes.get(nm.Routes[i].ID)
                                                +	}
                                                +	for _, i := range orderBy(nm.FirewallRules, firewallKeyWithoutPolicy) {
                                                +		r := nm.FirewallRules[i]
                                                +		if len(r.PolicyID) > 0 {
                                                +			r.PolicyID = []byte(policies.get(string(r.PolicyID)))
                                                +		}
                                                +	}
                                                +	for _, i := range orderBy(nm.RoutesFirewallRules, routeFirewallKeyWithoutIDs) {
                                                +		r := nm.RoutesFirewallRules[i]
                                                +		if len(r.PolicyID) > 0 {
                                                +			r.PolicyID = []byte(policies.get(string(r.PolicyID)))
                                                +		}
                                                +		r.RouteID = routes.get(r.RouteID)
                                                +	}
                                                +}
                                                +
                                                +// tokenizer maps identifiers to positional placeholders in order of first use.
                                                +type tokenizer struct {
                                                +	prefix string
                                                +	seen   map[string]string
                                                +}
                                                +
                                                +func newTokenizer(prefix string) *tokenizer {
                                                +	return &tokenizer{prefix: prefix, seen: make(map[string]string)}
                                                +}
                                                +
                                                +func (t *tokenizer) get(id string) string {
                                                +	if id == "" {
                                                +		return ""
                                                +	}
                                                +	if tok, ok := t.seen[id]; ok {
                                                +		return tok
                                                +	}
                                                +	tok := fmt.Sprintf("%s#%d", t.prefix, len(t.seen))
                                                +	t.seen[id] = tok
                                                +	return tok
                                                +}
                                                +
                                                +// orderBy returns indices sorted by key, so placeholder numbering does not
                                                +// depend on the identifiers being erased.
                                                +func orderBy[T any](items []T, key func(T) string) []int {
                                                +	idx := make([]int, len(items))
                                                +	for i := range idx {
                                                +		idx[i] = i
                                                +	}
                                                +	sort.SliceStable(idx, func(a, b int) bool { return key(items[idx[a]]) < key(items[idx[b]]) })
                                                +	return idx
                                                +}
                                                +
                                                +func routeKeyWithoutID(r *proto.Route) string {
                                                +	if r == nil {
                                                +		return ""
                                                +	}
                                                +	return fmt.Sprintf("%s|%s|%s|%d|%d|%t|%t|%v",
                                                +		r.Network, r.NetID, r.Peer, r.Metric, r.NetworkType, r.Masquerade, r.KeepRoute, r.Domains)
                                                +}
                                                +
                                                +func firewallKeyWithoutPolicy(r *proto.FirewallRule) string {
                                                +	if r == nil {
                                                +		return ""
                                                +	}
                                                +	return fmt.Sprintf("%s|%d|%d|%d|%s|%s|%v",
                                                +		r.PeerIP, r.Direction, r.Action, r.Protocol, r.Port, portInfoKey(r.PortInfo), r.SourcePrefixes) //nolint:staticcheck
                                                +}
                                                +
                                                +func routeFirewallKeyWithoutIDs(r *proto.RouteFirewallRule) string {
                                                +	if r == nil {
                                                +		return ""
                                                +	}
                                                +	return fmt.Sprintf("%s|%d|%d|%s|%v|%v|%t|%d",
                                                +		r.Destination, r.Protocol, r.Action, portInfoKey(r.PortInfo), r.Domains, r.SourceRanges, r.IsDynamic, r.CustomProtocol)
                                                +}
                                                +
                                                +// canonicalize sorts every repeated field of the NetworkMap by a stable key.
                                                +// The producing paths iterate Go maps while building these slices, so order
                                                +// can differ between runs even when the content is identical; comparing
                                                +// without this reports noise.
                                                +func canonicalize(nm *proto.NetworkMap) {
                                                +	if nm == nil {
                                                +		return
                                                +	}
                                                +	slices.SortFunc(nm.RemotePeers, cmpRemotePeer)
                                                +	slices.SortFunc(nm.OfflinePeers, cmpRemotePeer)
                                                +	slices.SortFunc(nm.Routes, cmpRoute)
                                                +	slices.SortFunc(nm.FirewallRules, cmpFirewallRule)
                                                +	slices.SortFunc(nm.RoutesFirewallRules, cmpRouteFirewallRule)
                                                +	slices.SortFunc(nm.ForwardingRules, cmpForwardingRule)
                                                +
                                                +	for _, r := range nm.FirewallRules {
                                                +		slices.SortFunc(r.SourcePrefixes, bytes.Compare)
                                                +	}
                                                +	for _, r := range nm.RoutesFirewallRules {
                                                +		slices.Sort(r.SourceRanges)
                                                +	}
                                                +	canonicalizeDNSConfig(nm.DNSConfig)
                                                +	canonicalizeSSHAuth(nm.SshAuth)
                                                +}
                                                +
                                                +func canonicalizeDNSConfig(d *proto.DNSConfig) {
                                                +	if d == nil {
                                                +		return
                                                +	}
                                                +	for _, g := range d.NameServerGroups {
                                                +		if g == nil {
                                                +			continue
                                                +		}
                                                +		slices.Sort(g.Domains)
                                                +		slices.SortFunc(g.NameServers, func(a, b *proto.NameServer) int {
                                                +			if a == nil || b == nil {
                                                +				return boolCmp(a == nil, b == nil)
                                                +			}
                                                +			if c := cmp.Compare(a.IP, b.IP); c != 0 {
                                                +				return c
                                                +			}
                                                +			if c := cmp.Compare(a.Port, b.Port); c != 0 {
                                                +				return c
                                                +			}
                                                +			return cmp.Compare(a.NSType, b.NSType)
                                                +		})
                                                +	}
                                                +	slices.SortFunc(d.NameServerGroups, func(a, b *proto.NameServerGroup) int {
                                                +		return cmp.Compare(nsgKey(a), nsgKey(b))
                                                +	})
                                                +	for _, z := range d.CustomZones {
                                                +		if z == nil {
                                                +			continue
                                                +		}
                                                +		slices.SortFunc(z.Records, cmpSimpleRecord)
                                                +	}
                                                +	slices.SortFunc(d.CustomZones, func(a, b *proto.CustomZone) int {
                                                +		if a == nil || b == nil {
                                                +			return boolCmp(a == nil, b == nil)
                                                +		}
                                                +		return cmp.Compare(a.Domain, b.Domain)
                                                +	})
                                                +}
                                                +
                                                +// canonicalizeSSHAuth sorts AuthorizedUsers and re-keys MachineUsers.Indexes
                                                +// against the new ordering, preserving which machine user maps to which hashes.
                                                +func canonicalizeSSHAuth(s *proto.SSHAuth) {
                                                +	if s == nil || len(s.AuthorizedUsers) == 0 {
                                                +		return
                                                +	}
                                                +	type hashed struct {
                                                +		bytes []byte
                                                +		old   uint32
                                                +	}
                                                +	entries := make([]hashed, len(s.AuthorizedUsers))
                                                +	for i, b := range s.AuthorizedUsers {
                                                +		entries[i] = hashed{bytes: b, old: uint32(i)}
                                                +	}
                                                +	slices.SortFunc(entries, func(a, b hashed) int { return bytes.Compare(a.bytes, b.bytes) })
                                                +
                                                +	remap := make(map[uint32]uint32, len(entries))
                                                +	sorted := make([][]byte, len(entries))
                                                +	for newIdx, e := range entries {
                                                +		remap[e.old] = uint32(newIdx)
                                                +		sorted[newIdx] = e.bytes
                                                +	}
                                                +	s.AuthorizedUsers = sorted
                                                +
                                                +	for _, mu := range s.MachineUsers {
                                                +		if mu == nil {
                                                +			continue
                                                +		}
                                                +		for i, oldIdx := range mu.Indexes {
                                                +			if newIdx, ok := remap[oldIdx]; ok {
                                                +				mu.Indexes[i] = newIdx
                                                +			}
                                                +		}
                                                +		slices.Sort(mu.Indexes)
                                                +	}
                                                +}
                                                +
                                                +func boolCmp(a, b bool) int {
                                                +	if a == b {
                                                +		return 0
                                                +	}
                                                +	if a {
                                                +		return 1
                                                +	}
                                                +	return -1
                                                +}
                                                +
                                                +func nsgKey(g *proto.NameServerGroup) string {
                                                +	if g == nil {
                                                +		return ""
                                                +	}
                                                +	var parts []string
                                                +	for _, ns := range g.NameServers {
                                                +		if ns == nil {
                                                +			continue
                                                +		}
                                                +		parts = append(parts, ns.IP+":"+strconv.FormatInt(ns.Port, 10)+":"+strconv.FormatInt(ns.NSType, 10))
                                                +	}
                                                +	slices.Sort(parts)
                                                +	key := strings.Join(parts, ",")
                                                +	domains := append([]string(nil), g.Domains...)
                                                +	slices.Sort(domains)
                                                +	key += "|" + strings.Join(domains, "|")
                                                +	if g.Primary {
                                                +		key += "|P"
                                                +	}
                                                +	if g.SearchDomainsEnabled {
                                                +		key += "|S"
                                                +	}
                                                +	return key
                                                +}
                                                +
                                                +func cmpSimpleRecord(a, b *proto.SimpleRecord) int {
                                                +	if a == nil || b == nil {
                                                +		return boolCmp(a == nil, b == nil)
                                                +	}
                                                +	if c := cmp.Compare(a.Name, b.Name); c != 0 {
                                                +		return c
                                                +	}
                                                +	if c := cmp.Compare(a.Type, b.Type); c != 0 {
                                                +		return c
                                                +	}
                                                +	if c := cmp.Compare(a.Class, b.Class); c != 0 {
                                                +		return c
                                                +	}
                                                +	if c := cmp.Compare(a.RData, b.RData); c != 0 {
                                                +		return c
                                                +	}
                                                +	return cmp.Compare(a.TTL, b.TTL)
                                                +}
                                                +
                                                +func cmpRemotePeer(a, b *proto.RemotePeerConfig) int {
                                                +	if a == nil || b == nil {
                                                +		return boolCmp(a == nil, b == nil)
                                                +	}
                                                +	return cmp.Compare(a.WgPubKey, b.WgPubKey)
                                                +}
                                                +
                                                +func cmpRoute(a, b *proto.Route) int {
                                                +	if a == nil || b == nil {
                                                +		return boolCmp(a == nil, b == nil)
                                                +	}
                                                +	if c := cmp.Compare(a.ID, b.ID); c != 0 {
                                                +		return c
                                                +	}
                                                +	if c := cmp.Compare(a.NetID, b.NetID); c != 0 {
                                                +		return c
                                                +	}
                                                +	if c := cmp.Compare(a.Network, b.Network); c != 0 {
                                                +		return c
                                                +	}
                                                +	if c := cmp.Compare(a.Peer, b.Peer); c != 0 {
                                                +		return c
                                                +	}
                                                +	if c := cmp.Compare(a.Metric, b.Metric); c != 0 {
                                                +		return c
                                                +	}
                                                +	return slices.Compare(a.Domains, b.Domains)
                                                +}
                                                +
                                                +func cmpFirewallRule(a, b *proto.FirewallRule) int {
                                                +	if a == nil || b == nil {
                                                +		return boolCmp(a == nil, b == nil)
                                                +	}
                                                +	if c := bytes.Compare(a.PolicyID, b.PolicyID); c != 0 {
                                                +		return c
                                                +	}
                                                +	if c := cmp.Compare(a.PeerIP, b.PeerIP); c != 0 { //nolint:staticcheck
                                                +		return c
                                                +	}
                                                +	if c := cmp.Compare(int32(a.Direction), int32(b.Direction)); c != 0 {
                                                +		return c
                                                +	}
                                                +	if c := cmp.Compare(int32(a.Action), int32(b.Action)); c != 0 {
                                                +		return c
                                                +	}
                                                +	if c := cmp.Compare(int32(a.Protocol), int32(b.Protocol)); c != 0 {
                                                +		return c
                                                +	}
                                                +	if c := cmp.Compare(a.Port, b.Port); c != 0 {
                                                +		return c
                                                +	}
                                                +	return cmp.Compare(portInfoKey(a.PortInfo), portInfoKey(b.PortInfo))
                                                +}
                                                +
                                                +func cmpRouteFirewallRule(a, b *proto.RouteFirewallRule) int {
                                                +	if a == nil || b == nil {
                                                +		return boolCmp(a == nil, b == nil)
                                                +	}
                                                +	if c := bytes.Compare(a.PolicyID, b.PolicyID); c != 0 {
                                                +		return c
                                                +	}
                                                +	if c := cmp.Compare(a.RouteID, b.RouteID); c != 0 {
                                                +		return c
                                                +	}
                                                +	if c := cmp.Compare(a.Destination, b.Destination); c != 0 {
                                                +		return c
                                                +	}
                                                +	if c := cmp.Compare(int32(a.Protocol), int32(b.Protocol)); c != 0 {
                                                +		return c
                                                +	}
                                                +	if c := cmp.Compare(portInfoKey(a.PortInfo), portInfoKey(b.PortInfo)); c != 0 {
                                                +		return c
                                                +	}
                                                +	if c := cmp.Compare(int32(a.Action), int32(b.Action)); c != 0 {
                                                +		return c
                                                +	}
                                                +	if c := slices.Compare(a.Domains, b.Domains); c != 0 {
                                                +		return c
                                                +	}
                                                +	if c := slices.Compare(a.SourceRanges, b.SourceRanges); c != 0 {
                                                +		return c
                                                +	}
                                                +	if c := cmp.Compare(a.CustomProtocol, b.CustomProtocol); c != 0 {
                                                +		return c
                                                +	}
                                                +	return boolCmp(a.IsDynamic, b.IsDynamic)
                                                +}
                                                +
                                                +func cmpForwardingRule(a, b *proto.ForwardingRule) int {
                                                +	if a == nil || b == nil {
                                                +		return boolCmp(a == nil, b == nil)
                                                +	}
                                                +	if c := cmp.Compare(int32(a.Protocol), int32(b.Protocol)); c != 0 {
                                                +		return c
                                                +	}
                                                +	return bytes.Compare(a.TranslatedAddress, b.TranslatedAddress)
                                                +}
                                                +
                                                +func portInfoKey(pi *proto.PortInfo) string {
                                                +	if pi == nil {
                                                +		return ""
                                                +	}
                                                +	switch sel := pi.PortSelection.(type) {
                                                +	case *proto.PortInfo_Port:
                                                +		return "P" + strconv.FormatUint(uint64(sel.Port), 10)
                                                +	case *proto.PortInfo_Range_:
                                                +		if sel.Range == nil {
                                                +			return "R"
                                                +		}
                                                +		return "R" + strconv.FormatUint(uint64(sel.Range.Start), 10) + "-" + strconv.FormatUint(uint64(sel.Range.End), 10)
                                                +	}
                                                +	return ""
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/fixture.go b/management/internals/controllers/network_map/nmaptest/fixture.go
                                                new file mode 100644
                                                index 000000000..d56285f95
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/fixture.go
                                                @@ -0,0 +1,218 @@
                                                +package nmaptest
                                                +
                                                +import (
                                                +	"crypto/sha256"
                                                +	"encoding/base64"
                                                +	"encoding/json"
                                                +	"fmt"
                                                +	"net"
                                                +	"os"
                                                +
                                                +	"github.com/netbirdio/netbird/shared/management/networkmap"
                                                +	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
                                                +)
                                                +
                                                +// LoadNetworkMapData reads a fixture holding the NetworkMapData the store
                                                +// would return for one account. Unknown fields are rejected so fixture typos
                                                +// fail loudly instead of silently testing a default.
                                                +func LoadNetworkMapData(path string) (*networkmap.NetworkMapData, error) {
                                                +	f, err := os.Open(path)
                                                +	if err != nil {
                                                +		return nil, fmt.Errorf("open fixture: %w", err)
                                                +	}
                                                +	defer f.Close()
                                                +
                                                +	dec := json.NewDecoder(f)
                                                +	dec.DisallowUnknownFields()
                                                +	var nmData networkmap.NetworkMapData
                                                +	if err := dec.Decode(&nmData); err != nil {
                                                +		return nil, fmt.Errorf("decode fixture %s: %w", path, err)
                                                +	}
                                                +	return &nmData, nil
                                                +}
                                                +
                                                +var defaultNetworkNet = func() net.IPNet {
                                                +	_, ipnet, err := net.ParseCIDR("100.64.0.0/10")
                                                +	if err != nil {
                                                +		panic(err)
                                                +	}
                                                +	return *ipnet
                                                +}()
                                                +
                                                +// applyFixtureDefaults fills the boilerplate a fixture may omit. Map-keyed
                                                +// objects inherit their key as ID, peers get a deterministic WG-shaped key
                                                +// and their ID as DNS label, PublicIDs default to the internal ID (the
                                                +// envelope encoder puts public IDs on the wire and silently degrades on
                                                +// empty ones), and a nil ValidatedPeers validates every peer — production
                                                +// fills it through the integrated validator, not the store.
                                                +func applyFixtureDefaults(nmData *networkmap.NetworkMapData) {
                                                +	if nmData.Network == nil {
                                                +		nmData.Network = &nmdata.Network{}
                                                +	}
                                                +	if nmData.Network.Identifier == "" {
                                                +		nmData.Network.Identifier = "network"
                                                +	}
                                                +	if nmData.Network.Net.IP == nil {
                                                +		nmData.Network.Net = defaultNetworkNet
                                                +	}
                                                +	if nmData.AccountSettings == nil {
                                                +		nmData.AccountSettings = &nmdata.AccountSettingsInfo{}
                                                +	}
                                                +	if nmData.DNSSettings == nil {
                                                +		nmData.DNSSettings = &nmdata.DNSSettings{}
                                                +	}
                                                +
                                                +	for id, p := range nmData.Peers {
                                                +		if p == nil {
                                                +			continue
                                                +		}
                                                +		if p.ID == "" {
                                                +			p.ID = id
                                                +		}
                                                +		if p.Key == "" {
                                                +			p.Key = derivedWgKey(p.ID)
                                                +		}
                                                +		if p.DNSLabel == "" {
                                                +			p.DNSLabel = p.ID
                                                +		}
                                                +	}
                                                +
                                                +	for id, g := range nmData.Groups {
                                                +		if g == nil {
                                                +			continue
                                                +		}
                                                +		if g.ID == "" {
                                                +			g.ID = id
                                                +		}
                                                +		if g.Name == "" {
                                                +			g.Name = g.ID
                                                +		}
                                                +		if g.PublicID == "" {
                                                +			g.PublicID = g.ID
                                                +		}
                                                +	}
                                                +
                                                +	for _, policy := range nmData.Policies {
                                                +		defaultPolicyIDs(policy)
                                                +	}
                                                +	resolveResourcePolicyRefs(nmData)
                                                +
                                                +	for _, r := range nmData.Routes {
                                                +		if r != nil && r.PublicID == "" {
                                                +			r.PublicID = r.ID
                                                +		}
                                                +	}
                                                +	for _, nsg := range nmData.NameServerGroups {
                                                +		if nsg != nil && nsg.PublicID == "" {
                                                +			nsg.PublicID = nsg.ID
                                                +		}
                                                +	}
                                                +	for _, res := range nmData.NetworkResources {
                                                +		if res == nil {
                                                +			continue
                                                +		}
                                                +		if res.PublicID == "" {
                                                +			res.PublicID = res.ID
                                                +		}
                                                +		defaultXIDMapping(&nmData.NetworkXIDToPublicID, res.NetworkID)
                                                +	}
                                                +	for networkID, routers := range nmData.Routers {
                                                +		defaultXIDMapping(&nmData.NetworkXIDToPublicID, networkID)
                                                +		for _, router := range routers {
                                                +			if router != nil && router.PublicID == "" {
                                                +				router.PublicID = networkID
                                                +			}
                                                +		}
                                                +	}
                                                +
                                                +	for id, pc := range nmData.PostureChecks {
                                                +		if pc == nil {
                                                +			continue
                                                +		}
                                                +		if pc.ID == "" {
                                                +			pc.ID = id
                                                +		}
                                                +		defaultXIDMapping(&nmData.PostureCheckXIDToPublicID, pc.ID)
                                                +	}
                                                +
                                                +	if nmData.ValidatedPeers == nil {
                                                +		nmData.ValidatedPeers = make(map[string]struct{}, len(nmData.Peers))
                                                +		for id := range nmData.Peers {
                                                +			nmData.ValidatedPeers[id] = struct{}{}
                                                +		}
                                                +	}
                                                +}
                                                +
                                                +// resolveResourcePolicyRefs lets a fixture name an account policy by ID in
                                                +// ResourcePolicies — {"ID": "pol-x"} with no rules — instead of repeating it.
                                                +// The real store puts the same policy pointer in both places, which is what
                                                +// resolving the reference reproduces.
                                                +func resolveResourcePolicyRefs(nmData *networkmap.NetworkMapData) {
                                                +	byID := make(map[string]*nmdata.Policy, len(nmData.Policies))
                                                +	for _, policy := range nmData.Policies {
                                                +		if policy != nil && policy.ID != "" {
                                                +			byID[policy.ID] = policy
                                                +		}
                                                +	}
                                                +
                                                +	for _, policies := range nmData.ResourcePolicies {
                                                +		for i, policy := range policies {
                                                +			if policy == nil {
                                                +				continue
                                                +			}
                                                +			if len(policy.Rules) == 0 {
                                                +				if full, ok := byID[policy.ID]; ok {
                                                +					policies[i] = full
                                                +					continue
                                                +				}
                                                +			}
                                                +			defaultPolicyIDs(policy)
                                                +		}
                                                +	}
                                                +}
                                                +
                                                +func defaultPolicyIDs(policy *nmdata.Policy) {
                                                +	if policy == nil {
                                                +		return
                                                +	}
                                                +	if policy.PublicID == "" {
                                                +		policy.PublicID = policy.ID
                                                +	}
                                                +	for i, rule := range policy.Rules {
                                                +		if rule == nil {
                                                +			continue
                                                +		}
                                                +		if rule.PolicyID == "" {
                                                +			rule.PolicyID = policy.ID
                                                +		}
                                                +		if rule.ID == "" {
                                                +			// Production gives a rule its policy's id (management/server/policy.go:205,
                                                +			// "when policy can contain multiple rules, need refactor"), so a
                                                +			// single-rule policy — the only shape the product can create today —
                                                +			// must be modelled that way or the wire ids come out unrealistic.
                                                +			rule.ID = policy.ID
                                                +			if len(policy.Rules) > 1 {
                                                +				rule.ID = fmt.Sprintf("%s-rule-%d", policy.ID, i)
                                                +			}
                                                +		}
                                                +	}
                                                +}
                                                +
                                                +func defaultXIDMapping(m *map[string]string, id string) {
                                                +	if id == "" {
                                                +		return
                                                +	}
                                                +	if *m == nil {
                                                +		*m = make(map[string]string)
                                                +	}
                                                +	if _, ok := (*m)[id]; !ok {
                                                +		(*m)[id] = id
                                                +	}
                                                +}
                                                +
                                                +// derivedWgKey returns a deterministic base64 key of 32 bytes, valid for the
                                                +// envelope decoder's WG-key identity.
                                                +func derivedWgKey(peerID string) string {
                                                +	sum := sha256.Sum256([]byte(peerID))
                                                +	return base64.StdEncoding.EncodeToString(sum[:])
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/golden_test.go b/management/internals/controllers/network_map/nmaptest/golden_test.go
                                                new file mode 100644
                                                index 000000000..75c0d57d2
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/golden_test.go
                                                @@ -0,0 +1,12 @@
                                                +package nmaptest_test
                                                +
                                                +import (
                                                +	"path/filepath"
                                                +	"testing"
                                                +
                                                +	"github.com/netbirdio/netbird/management/internals/controllers/network_map/nmaptest"
                                                +)
                                                +
                                                +func TestNetworkMapGolden(t *testing.T) {
                                                +	nmaptest.RunGoldenDir(t, filepath.Join("testdata", "cases"))
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/legacyaccount.go b/management/internals/controllers/network_map/nmaptest/legacyaccount.go
                                                new file mode 100644
                                                index 000000000..d6a653f7a
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/legacyaccount.go
                                                @@ -0,0 +1,543 @@
                                                +package nmaptest
                                                +
                                                +import (
                                                +	"context"
                                                +	"strings"
                                                +	"testing"
                                                +
                                                +	"github.com/miekg/dns"
                                                +	"github.com/stretchr/testify/require"
                                                +
                                                +	nbdns "github.com/netbirdio/netbird/dns"
                                                +	"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache"
                                                +	"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"
                                                +	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/management/server/types"
                                                +	"github.com/netbirdio/netbird/management/server/types/legacynmap"
                                                +	nbroute "github.com/netbirdio/netbird/route"
                                                +	"github.com/netbirdio/netbird/shared/management/networkmap"
                                                +	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
                                                +	"github.com/netbirdio/netbird/shared/management/proto"
                                                +	sharedtypes "github.com/netbirdio/netbird/shared/management/types"
                                                +)
                                                +
                                                +// legacyInput is the account and the four derived arguments main's computation
                                                +// took alongside it. The controller resolved them from the account before
                                                +// calling; the twin carries them as fields, so the fixture is the source for
                                                +// both halves.
                                                +type legacyInput struct {
                                                +	account          *types.Account
                                                +	accountZones     []*zones.Zone
                                                +	validatedPeers   map[string]struct{}
                                                +	resourcePolicies map[string][]*types.Policy
                                                +	routers          map[string]map[string]*routerTypes.NetworkRouter
                                                +	groupIDToUserIDs map[string][]string
                                                +}
                                                +
                                                +// legacyInputFromData rebuilds the Account the fixture stands for. A fixture is
                                                +// the value the store returns, and the store's twins carry exactly the state
                                                +// the computation reads, so inverting them reproduces the account main would
                                                +// have loaded — which is what lets one expectation measure all three paths.
                                                +//
                                                +// The inverse is only defined for what a twin carries: fields the builders drop
                                                +// (peer names, policy descriptions, user records behind AllowedUserIDs) come
                                                +// back as the zero value or a minimal stand-in, because no path reads them.
                                                +func legacyInputFromData(accountID string, nmData *networkmap.NetworkMapData) legacyInput {
                                                +	account := &types.Account{
                                                +		Id:               accountID,
                                                +		Network:          accountNetwork(nmData.Network),
                                                +		Settings:         accountSettings(nmData.AccountSettings),
                                                +		DNSSettings:      types.DNSSettings{DisabledManagementGroups: nmData.DNSSettings.DisabledManagementGroups},
                                                +		Peers:            make(map[string]*nbpeer.Peer, len(nmData.Peers)),
                                                +		Groups:           make(map[string]*types.Group, len(nmData.Groups)),
                                                +		Policies:         make([]*types.Policy, 0, len(nmData.Policies)),
                                                +		Routes:           make(map[nbroute.ID]*nbroute.Route, len(nmData.Routes)),
                                                +		NameServerGroups: make(map[string]*nbdns.NameServerGroup, len(nmData.NameServerGroups)),
                                                +		NetworkResources: make([]*resourceTypes.NetworkResource, 0, len(nmData.NetworkResources)),
                                                +		PostureChecks:    make([]*posture.Checks, 0, len(nmData.PostureChecks)),
                                                +		Users:            make(map[string]*types.User, len(nmData.AllowedUserIDs)),
                                                +		Services:         accountServices(nmData.Services),
                                                +	}
                                                +
                                                +	for id, p := range nmData.Peers {
                                                +		account.Peers[id] = accountPeer(id, p)
                                                +	}
                                                +	for id, g := range nmData.Groups {
                                                +		account.Groups[id] = accountGroup(id, g)
                                                +	}
                                                +
                                                +	policiesByID := make(map[string]*types.Policy, len(nmData.Policies))
                                                +	for _, p := range nmData.Policies {
                                                +		policy := accountPolicy(p)
                                                +		if policy == nil {
                                                +			continue
                                                +		}
                                                +		account.Policies = append(account.Policies, policy)
                                                +		policiesByID[policy.ID] = policy
                                                +	}
                                                +
                                                +	for _, r := range nmData.Routes {
                                                +		route := accountRoute(r)
                                                +		if route != nil {
                                                +			account.Routes[route.ID] = route
                                                +		}
                                                +	}
                                                +	for _, nsg := range nmData.NameServerGroups {
                                                +		group := accountNSG(nsg)
                                                +		if group != nil {
                                                +			account.NameServerGroups[group.ID] = group
                                                +		}
                                                +	}
                                                +	for _, res := range nmData.NetworkResources {
                                                +		if resource := accountNetworkResource(res); resource != nil {
                                                +			account.NetworkResources = append(account.NetworkResources, resource)
                                                +		}
                                                +	}
                                                +	for id, pc := range nmData.PostureChecks {
                                                +		if check := accountPostureChecks(id, pc, nmData.PostureCheckXIDToPublicID[id]); check != nil {
                                                +			account.PostureChecks = append(account.PostureChecks, check)
                                                +		}
                                                +	}
                                                +	for xid, publicID := range nmData.NetworkXIDToPublicID {
                                                +		account.Networks = append(account.Networks, &networkTypes.Network{ID: xid, PublicID: publicID})
                                                +	}
                                                +	// The twin keeps only the ids of the users a peer may be shared with; the
                                                +	// legacy side derives the same set from the account's user records, so a
                                                +	// bare non-blocked regular user per id is enough.
                                                +	for userID := range nmData.AllowedUserIDs {
                                                +		account.Users[userID] = &types.User{Id: userID}
                                                +	}
                                                +
                                                +	// Main's network-map controller synthesised the reverse-proxy ACLs onto the
                                                +	// account and only then derived the resource-policy map, so the frozen copy
                                                +	// has to be fed in that order to stand for what main produced.
                                                +	account.Policies = append(account.Policies, legacynmap.SynthesizeProxyPolicies(account)...)
                                                +
                                                +	return legacyInput{
                                                +		account:          account,
                                                +		accountZones:     accountZones(nmData.AppliedZoneCandidates),
                                                +		validatedPeers:   nmData.ValidatedPeers,
                                                +		resourcePolicies: account.GetResourcePoliciesMap(),
                                                +		routers:          accountRouters(nmData.Routers),
                                                +		groupIDToUserIDs: nmData.GroupIDToUserIDs,
                                                +	}
                                                +}
                                                +
                                                +// computeLegacy runs the fixture through main's frozen path and its own proto
                                                +// encoder, the one comparison surface the three modes share.
                                                +func computeLegacy(t *testing.T, ctx context.Context, legacy legacyInput, peerID string, zone nmdata.CustomZone, dnsDomain string, dnsFwdPort int64) *proto.NetworkMap {
                                                +	t.Helper()
                                                +
                                                +	require.NotNil(t, legacy.account, "legacy mode needs an account rebuilt from the fixture")
                                                +	peer := legacy.account.Peers[peerID]
                                                +	require.NotNil(t, peer, "target peer %q not in rebuilt account", peerID)
                                                +
                                                +	nm := legacynmap.GetPeerNetworkMapFromComponents(
                                                +		legacy.account, ctx, peerID, legacyCustomZone(zone), legacy.accountZones, legacy.validatedPeers,
                                                +		legacy.resourcePolicies, legacy.routers, nil, legacy.groupIDToUserIDs,
                                                +	)
                                                +	require.NotNil(t, nm, "legacy path returned no network map for peer %q", peerID)
                                                +
                                                +	return legacynmap.ToProtoNetworkMap(
                                                +		ctx, peer, nm, dnsDomain, legacy.account.Settings, nil, &cache.DNSConfigCache{}, dnsFwdPort,
                                                +	)
                                                +}
                                                +
                                                +// legacyCustomZone converts the peers custom zone the runner computes once for
                                                +// every mode into the shape main's path took.
                                                +func legacyCustomZone(z nmdata.CustomZone) nbdns.CustomZone {
                                                +	zoneRecords := make([]nbdns.SimpleRecord, 0, len(z.Records))
                                                +	for _, r := range z.Records {
                                                +		zoneRecords = append(zoneRecords, nbdns.SimpleRecord{
                                                +			Name:  r.Name,
                                                +			Type:  r.Type,
                                                +			Class: r.Class,
                                                +			TTL:   r.TTL,
                                                +			RData: r.RData,
                                                +		})
                                                +	}
                                                +	return nbdns.CustomZone{
                                                +		Domain:               z.Domain,
                                                +		Records:              zoneRecords,
                                                +		SearchDomainDisabled: z.SearchDomainDisabled,
                                                +		NonAuthoritative:     z.NonAuthoritative,
                                                +	}
                                                +}
                                                +
                                                +func accountNetwork(n *nmdata.Network) *types.Network {
                                                +	if n == nil {
                                                +		return nil
                                                +	}
                                                +	return &types.Network{
                                                +		Identifier: n.Identifier,
                                                +		Net:        n.Net,
                                                +		NetV6:      n.NetV6,
                                                +		Dns:        n.Dns,
                                                +		Serial:     uint64(n.Serial),
                                                +	}
                                                +}
                                                +
                                                +func accountSettings(s *nmdata.AccountSettingsInfo) *types.Settings {
                                                +	if s == nil {
                                                +		return nil
                                                +	}
                                                +	return &types.Settings{
                                                +		PeerLoginExpirationEnabled:      s.PeerLoginExpirationEnabled,
                                                +		PeerLoginExpiration:             s.PeerLoginExpiration,
                                                +		PeerInactivityExpirationEnabled: s.PeerInactivityExpirationEnabled,
                                                +		PeerInactivityExpiration:        s.PeerInactivityExpiration,
                                                +		DNSDomain:                       s.DNSDomain,
                                                +		IPv6EnabledGroups:               s.IPv6EnabledGroups,
                                                +		RoutingPeerDNSResolutionEnabled: s.RoutingPeerDNSResolutionEnabled,
                                                +		LazyConnectionEnabled:           s.LazyConnectionEnabled,
                                                +		AutoUpdateVersion:               s.AutoUpdateVersion,
                                                +		AutoUpdateAlways:                s.AutoUpdateAlways,
                                                +		MetricsPushEnabled:              s.MetricsPushEnabled,
                                                +	}
                                                +}
                                                +
                                                +func accountPeer(id string, p *nmdata.Peer) *nbpeer.Peer {
                                                +	if p == nil {
                                                +		return nil
                                                +	}
                                                +	networkAddresses := make([]nbpeer.NetworkAddress, 0, len(p.Meta.NetworkAddresses))
                                                +	for _, na := range p.Meta.NetworkAddresses {
                                                +		networkAddresses = append(networkAddresses, nbpeer.NetworkAddress{NetIP: na.NetIP})
                                                +	}
                                                +	files := make([]nbpeer.File, 0, len(p.Meta.Files))
                                                +	for _, f := range p.Meta.Files {
                                                +		files = append(files, nbpeer.File{Path: f.Path, ProcessIsRunning: f.ProcessIsRunning})
                                                +	}
                                                +	return &nbpeer.Peer{
                                                +		ID:                     id,
                                                +		Key:                    p.Key,
                                                +		SSHKey:                 p.SSHKey,
                                                +		DNSLabel:               p.DNSLabel,
                                                +		UserID:                 p.UserID,
                                                +		SSHEnabled:             p.SSHEnabled,
                                                +		LoginExpirationEnabled: p.LoginExpirationEnabled,
                                                +		LastLogin:              p.LastLogin,
                                                +		IP:                     p.IP,
                                                +		IPv6:                   p.IPv6,
                                                +		ExtraDNSLabels:         p.ExtraDNSLabels,
                                                +		ProxyMeta:              nbpeer.ProxyMeta{Embedded: p.ProxyMeta.Embedded, Cluster: p.ProxyMeta.Cluster},
                                                +		// Connected is what SynthesizePrivateServiceZones gates its records on,
                                                +		// and a fixture peer stands for a peer the store returned, so it is one
                                                +		// the account would have reported connected.
                                                +		Status: &nbpeer.PeerStatus{RequiresApproval: p.RequiresApproval, Connected: true},
                                                +		Meta: nbpeer.PeerSystemMeta{
                                                +			WtVersion:          p.Meta.WtVersion,
                                                +			GoOS:               p.Meta.GoOS,
                                                +			OSVersion:          p.Meta.OSVersion,
                                                +			KernelVersion:      p.Meta.KernelVersion,
                                                +			NetworkAddresses:   networkAddresses,
                                                +			Files:              files,
                                                +			Capabilities:       p.Meta.Capabilities,
                                                +			SyncMessageVersion: p.Meta.SyncMessageVersion,
                                                +			Flags: nbpeer.Flags{
                                                +				ServerSSHAllowed: p.Meta.Flags.ServerSSHAllowed,
                                                +				DisableIPv6:      p.Meta.Flags.DisableIPv6,
                                                +			},
                                                +		},
                                                +		Location: nbpeer.Location{
                                                +			CountryCode:  p.Location.CountryCode,
                                                +			CityName:     p.Location.CityName,
                                                +			ConnectionIP: p.Location.ConnectionIP,
                                                +		},
                                                +	}
                                                +}
                                                +
                                                +func accountGroup(id string, g *nmdata.Group) *types.Group {
                                                +	if g == nil {
                                                +		return nil
                                                +	}
                                                +	return &types.Group{
                                                +		ID:       id,
                                                +		Name:     g.Name,
                                                +		PublicID: g.PublicID,
                                                +		Peers:    g.Peers,
                                                +	}
                                                +}
                                                +
                                                +func accountPolicy(p *nmdata.Policy) *types.Policy {
                                                +	if p == nil {
                                                +		return nil
                                                +	}
                                                +	rules := make([]*types.PolicyRule, 0, len(p.Rules))
                                                +	for _, r := range p.Rules {
                                                +		if r == nil {
                                                +			continue
                                                +		}
                                                +		var portRanges []sharedtypes.RulePortRange
                                                +		if r.PortRanges != nil {
                                                +			portRanges = make([]sharedtypes.RulePortRange, len(r.PortRanges))
                                                +			for i, pr := range r.PortRanges {
                                                +				portRanges[i] = sharedtypes.RulePortRange{Start: pr.Start, End: pr.End}
                                                +			}
                                                +		}
                                                +		rules = append(rules, &types.PolicyRule{
                                                +			ID:                  r.ID,
                                                +			PolicyID:            r.PolicyID,
                                                +			Enabled:             r.Enabled,
                                                +			Action:              sharedtypes.PolicyTrafficActionType(r.Action),
                                                +			Protocol:            sharedtypes.PolicyRuleProtocolType(r.Protocol),
                                                +			Bidirectional:       r.Bidirectional,
                                                +			Sources:             r.Sources,
                                                +			Destinations:        r.Destinations,
                                                +			SourceResource:      types.Resource{ID: r.SourceResource.ID, Type: sharedtypes.ResourceType(r.SourceResource.Type)},
                                                +			DestinationResource: types.Resource{ID: r.DestinationResource.ID, Type: sharedtypes.ResourceType(r.DestinationResource.Type)},
                                                +			Ports:               r.Ports,
                                                +			PortRanges:          portRanges,
                                                +			AuthorizedGroups:    r.AuthorizedGroups,
                                                +			AuthorizedUser:      r.AuthorizedUser,
                                                +		})
                                                +	}
                                                +	return &types.Policy{
                                                +		ID:                  p.ID,
                                                +		PublicID:            p.PublicID,
                                                +		Enabled:             p.Enabled,
                                                +		SourcePostureChecks: p.SourcePostureChecks,
                                                +		Rules:               rules,
                                                +	}
                                                +}
                                                +
                                                +func accountRoute(r *nmdata.Route) *nbroute.Route {
                                                +	if r == nil {
                                                +		return nil
                                                +	}
                                                +	return &nbroute.Route{
                                                +		ID:                  nbroute.ID(r.ID),
                                                +		AccountID:           r.AccountID,
                                                +		PublicID:            r.PublicID,
                                                +		Network:             r.Network,
                                                +		Domains:             r.Domains,
                                                +		KeepRoute:           r.KeepRoute,
                                                +		NetID:               nbroute.NetID(r.NetID),
                                                +		Description:         r.Description,
                                                +		Peer:                r.Peer,
                                                +		PeerID:              r.PeerID,
                                                +		PeerGroups:          r.PeerGroups,
                                                +		NetworkType:         nbroute.NetworkType(r.NetworkType),
                                                +		Masquerade:          r.Masquerade,
                                                +		Metric:              r.Metric,
                                                +		Enabled:             r.Enabled,
                                                +		Groups:              r.Groups,
                                                +		AccessControlGroups: r.AccessControlGroups,
                                                +		SkipAutoApply:       r.SkipAutoApply,
                                                +	}
                                                +}
                                                +
                                                +func accountNSG(n *nmdata.NameServerGroup) *nbdns.NameServerGroup {
                                                +	if n == nil {
                                                +		return nil
                                                +	}
                                                +	nameServers := make([]nbdns.NameServer, 0, len(n.NameServers))
                                                +	for _, ns := range n.NameServers {
                                                +		nameServers = append(nameServers, nbdns.NameServer{
                                                +			IP:     ns.IP,
                                                +			NSType: nbdns.NameServerType(ns.NSType),
                                                +			Port:   ns.Port,
                                                +		})
                                                +	}
                                                +	return &nbdns.NameServerGroup{
                                                +		ID:                   n.ID,
                                                +		PublicID:             n.PublicID,
                                                +		Name:                 n.Name,
                                                +		Description:          n.Description,
                                                +		NameServers:          nameServers,
                                                +		Groups:               n.Groups,
                                                +		Primary:              n.Primary,
                                                +		Domains:              n.Domains,
                                                +		Enabled:              n.Enabled,
                                                +		SearchDomainsEnabled: n.SearchDomainsEnabled,
                                                +	}
                                                +}
                                                +
                                                +func accountNetworkResource(r *nmdata.NetworkResource) *resourceTypes.NetworkResource {
                                                +	if r == nil {
                                                +		return nil
                                                +	}
                                                +	return &resourceTypes.NetworkResource{
                                                +		ID:          r.ID,
                                                +		NetworkID:   r.NetworkID,
                                                +		AccountID:   r.AccountID,
                                                +		PublicID:    r.PublicID,
                                                +		Name:        r.Name,
                                                +		Description: r.Description,
                                                +		Type:        resourceTypes.NetworkResourceType(r.Type),
                                                +		Address:     r.Address,
                                                +		Domain:      r.Domain,
                                                +		Prefix:      r.Prefix,
                                                +		Enabled:     r.Enabled,
                                                +	}
                                                +}
                                                +
                                                +func accountPostureChecks(id string, pc *nmdata.PostureChecks, publicID string) *posture.Checks {
                                                +	if pc == nil {
                                                +		return nil
                                                +	}
                                                +	out := &posture.Checks{ID: id, PublicID: publicID}
                                                +	def := pc.Checks
                                                +	if def.NBVersionCheck != nil {
                                                +		out.Checks.NBVersionCheck = &posture.NBVersionCheck{MinVersion: def.NBVersionCheck.MinVersion}
                                                +	}
                                                +	if def.OSVersionCheck != nil {
                                                +		oc := &posture.OSVersionCheck{}
                                                +		if def.OSVersionCheck.Android != nil {
                                                +			oc.Android = &posture.MinVersionCheck{MinVersion: def.OSVersionCheck.Android.MinVersion}
                                                +		}
                                                +		if def.OSVersionCheck.Darwin != nil {
                                                +			oc.Darwin = &posture.MinVersionCheck{MinVersion: def.OSVersionCheck.Darwin.MinVersion}
                                                +		}
                                                +		if def.OSVersionCheck.Ios != nil {
                                                +			oc.Ios = &posture.MinVersionCheck{MinVersion: def.OSVersionCheck.Ios.MinVersion}
                                                +		}
                                                +		if def.OSVersionCheck.Linux != nil {
                                                +			oc.Linux = &posture.MinKernelVersionCheck{MinKernelVersion: def.OSVersionCheck.Linux.MinKernelVersion}
                                                +		}
                                                +		if def.OSVersionCheck.Windows != nil {
                                                +			oc.Windows = &posture.MinKernelVersionCheck{MinKernelVersion: def.OSVersionCheck.Windows.MinKernelVersion}
                                                +		}
                                                +		out.Checks.OSVersionCheck = oc
                                                +	}
                                                +	if def.GeoLocationCheck != nil {
                                                +		gc := &posture.GeoLocationCheck{Action: def.GeoLocationCheck.Action}
                                                +		for _, loc := range def.GeoLocationCheck.Locations {
                                                +			gc.Locations = append(gc.Locations, posture.Location{CountryCode: loc.CountryCode, CityName: loc.CityName})
                                                +		}
                                                +		out.Checks.GeoLocationCheck = gc
                                                +	}
                                                +	if def.PeerNetworkRangeCheck != nil {
                                                +		out.Checks.PeerNetworkRangeCheck = &posture.PeerNetworkRangeCheck{
                                                +			Action: def.PeerNetworkRangeCheck.Action,
                                                +			Ranges: def.PeerNetworkRangeCheck.Ranges,
                                                +		}
                                                +	}
                                                +	if def.ProcessCheck != nil {
                                                +		procs := make([]posture.Process, 0, len(def.ProcessCheck.Processes))
                                                +		for _, p := range def.ProcessCheck.Processes {
                                                +			procs = append(procs, posture.Process{LinuxPath: p.LinuxPath, MacPath: p.MacPath, WindowsPath: p.WindowsPath})
                                                +		}
                                                +		out.Checks.ProcessCheck = &posture.ProcessCheck{Processes: procs}
                                                +	}
                                                +	return out
                                                +}
                                                +
                                                +func accountServices(services []*nmdata.Service) []*service.Service {
                                                +	if len(services) == 0 {
                                                +		return nil
                                                +	}
                                                +	out := make([]*service.Service, 0, len(services))
                                                +	for _, svc := range services {
                                                +		if svc == nil {
                                                +			continue
                                                +		}
                                                +		targets := make([]*service.Target, 0, len(svc.Targets))
                                                +		for _, t := range svc.Targets {
                                                +			if t == nil {
                                                +				continue
                                                +			}
                                                +			target := &service.Target{
                                                +				Enabled:    t.Enabled,
                                                +				Port:       t.Port,
                                                +				Protocol:   t.Protocol,
                                                +				TargetId:   t.TargetID,
                                                +				TargetType: service.TargetType(t.TargetType),
                                                +			}
                                                +			if t.Path != "" {
                                                +				path := t.Path
                                                +				target.Path = &path
                                                +			}
                                                +			targets = append(targets, target)
                                                +		}
                                                +		out = append(out, &service.Service{
                                                +			ID:           svc.ID,
                                                +			Enabled:      svc.Enabled,
                                                +			Private:      svc.Private,
                                                +			Mode:         svc.Mode,
                                                +			ProxyCluster: svc.ProxyCluster,
                                                +			AccessGroups: svc.AccessGroups,
                                                +			Targets:      targets,
                                                +		})
                                                +	}
                                                +	return out
                                                +}
                                                +
                                                +// accountZones inverts buildAppliedZoneCandidates. Records come back with the
                                                +// record type the builder mapped them from; a candidate only ever carries the
                                                +// three types it converts.
                                                +func accountZones(candidates []networkmap.AppliedZoneCandidate) []*zones.Zone {
                                                +	if len(candidates) == 0 {
                                                +		return nil
                                                +	}
                                                +	out := make([]*zones.Zone, 0, len(candidates))
                                                +	for _, candidate := range candidates {
                                                +		zoneRecords := make([]*records.Record, 0, len(candidate.Zone.Records))
                                                +		for _, r := range candidate.Zone.Records {
                                                +			recordType, ok := zoneRecordType(r.Type)
                                                +			if !ok {
                                                +				continue
                                                +			}
                                                +			zoneRecords = append(zoneRecords, &records.Record{
                                                +				Name:    strings.TrimSuffix(r.Name, "."),
                                                +				Type:    recordType,
                                                +				Content: r.RData,
                                                +				TTL:     r.TTL,
                                                +			})
                                                +		}
                                                +		out = append(out, &zones.Zone{
                                                +			ID:                 candidate.Zone.Domain,
                                                +			Domain:             strings.TrimSuffix(candidate.Zone.Domain, "."),
                                                +			Enabled:            true,
                                                +			EnableSearchDomain: !candidate.Zone.SearchDomainDisabled,
                                                +			DistributionGroups: candidate.DistributionGroups,
                                                +			Records:            zoneRecords,
                                                +		})
                                                +	}
                                                +	return out
                                                +}
                                                +
                                                +func zoneRecordType(recordType int) (records.RecordType, bool) {
                                                +	switch uint16(recordType) {
                                                +	case dns.TypeA:
                                                +		return records.RecordTypeA, true
                                                +	case dns.TypeAAAA:
                                                +		return records.RecordTypeAAAA, true
                                                +	case dns.TypeCNAME:
                                                +		return records.RecordTypeCNAME, true
                                                +	default:
                                                +		return "", false
                                                +	}
                                                +}
                                                +
                                                +func accountRouters(routers map[string]map[string]*nmdata.NetworkRouter) map[string]map[string]*routerTypes.NetworkRouter {
                                                +	if len(routers) == 0 {
                                                +		return nil
                                                +	}
                                                +	out := make(map[string]map[string]*routerTypes.NetworkRouter, len(routers))
                                                +	for networkID, inner := range routers {
                                                +		converted := make(map[string]*routerTypes.NetworkRouter, len(inner))
                                                +		for peerID, router := range inner {
                                                +			if router == nil {
                                                +				continue
                                                +			}
                                                +			converted[peerID] = &routerTypes.NetworkRouter{
                                                +				NetworkID:  networkID,
                                                +				PublicID:   router.PublicID,
                                                +				Peer:       peerID,
                                                +				PeerGroups: router.PeerGroups,
                                                +				Masquerade: router.Masquerade,
                                                +				Metric:     router.Metric,
                                                +				Enabled:    router.Enabled,
                                                +			}
                                                +		}
                                                +		out[networkID] = converted
                                                +	}
                                                +	return out
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/runner.go b/management/internals/controllers/network_map/nmaptest/runner.go
                                                new file mode 100644
                                                index 000000000..0d6ac9c18
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/runner.go
                                                @@ -0,0 +1,333 @@
                                                +// Package nmaptest measures network map generation on the dedicated store
                                                +// path against committed expectations. A case stands in for the store load
                                                +// with a NetworkMapData fixture — the value NetworkMapDBStoreImpl returns for
                                                +// one account — then runs the production per-peer pipeline the controller
                                                +// uses, PeersCustomZone → GetPeerNetworkMapComponents → proto conversion, in
                                                +// both wire shapes: the full map (grpc.ToSyncResponse) and the component
                                                +// envelope expanded client-side (grpc.ToComponentSyncResponse →
                                                +// networkmap.EnvelopeToNetworkMap). A third mode inverts the fixture back into
                                                +// the Account it stands for and runs main's frozen path over it (legacynmap),
                                                +// so every case is pinned to what main shipped as well.
                                                +//
                                                +// The expectation files are the point of the framework. They state what the
                                                +// output should be, so a failing case means the code disagrees with the
                                                +// expectation and the answer is normally to fix the code; an expectation
                                                +// changes only through a deliberate reviewed edit. Nothing in this package
                                                +// writes to testdata — there is no flag that records current behaviour into an
                                                +// expectation, because that is how a defect becomes the baseline. Cases whose
                                                +// expectation encodes correct behaviour the code does not yet deliver stay red
                                                +// on purpose.
                                                +//
                                                +// A case lives in testdata/cases// as case.json (manifest: description,
                                                +// peers, optional accountID, dnsDomain, modes), nmdata.json (the fixture the
                                                +// mocked store returns, using Go field names; zero values may be omitted and
                                                +// applyFixtureDefaults fills the boilerplate) and golden/.json.
                                                +//
                                                +// There is ONE expectation per peer, shared by every mode, because all three
                                                +// must arrive at the same client-facing map. Full and envelope are not even
                                                +// different computations — CalculateNetworkMapFromComponents is
                                                +// components.Calculate and both assemble the proto with the same encode
                                                +// helpers — so the only variable between them is what the envelope round-trip
                                                +// did in transit, and a difference there is a round-trip fidelity defect.
                                                +// Legacy is a different computation, main's, reached from a rebuilt account;
                                                +// a difference there is this tree having drifted from what main shipped.
                                                +// Results are canonicalized before comparison, since repeated proto fields
                                                +// come from map iteration.
                                                +package nmaptest
                                                +
                                                +import (
                                                +	"bytes"
                                                +	"context"
                                                +	"encoding/base64"
                                                +	"encoding/json"
                                                +	"fmt"
                                                +	"os"
                                                +	"path/filepath"
                                                +	"slices"
                                                +	"strings"
                                                +	"testing"
                                                +
                                                +	"github.com/google/go-cmp/cmp"
                                                +	"github.com/stretchr/testify/require"
                                                +	"golang.org/x/exp/maps"
                                                +	"google.golang.org/protobuf/encoding/protojson"
                                                +	"google.golang.org/protobuf/testing/protocmp"
                                                +
                                                +	"github.com/netbirdio/netbird/management/internals/controllers/network_map"
                                                +	"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
                                                +	"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache"
                                                +	mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
                                                +	"github.com/netbirdio/netbird/shared/management/networkmap"
                                                +	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
                                                +	"github.com/netbirdio/netbird/shared/management/proto"
                                                +)
                                                +
                                                +// Mode selects the wire shape a case is verified through. Both end in a
                                                +// *proto.NetworkMap, the one comparison surface shared by every path.
                                                +type Mode string
                                                +
                                                +const (
                                                +	// ModeFull is the legacy wire shape: the server runs Calculate and sends
                                                +	// the expanded map (grpc.ToSyncResponse).
                                                +	ModeFull Mode = "full"
                                                +	// ModeEnvelope is the component wire shape: the server encodes components
                                                +	// into a NetworkMapEnvelope (grpc.ToComponentSyncResponse) and the map is
                                                +	// expanded the way the client engine does (networkmap.EnvelopeToNetworkMap).
                                                +	ModeEnvelope Mode = "envelope"
                                                +	// ModeLegacy is main's frozen path: the fixture is inverted back into the
                                                +	// Account it stands for and run through legacynmap, the copy of what main
                                                +	// shipped. It is the outside measurement — the other two modes share this
                                                +	// tree's computation, so only this one can catch the whole tree drifting.
                                                +	ModeLegacy Mode = "legacy"
                                                +
                                                +	defaultAccountID = "account"
                                                +	defaultDNSDomain = "netbird.test"
                                                +)
                                                +
                                                +var defaultModes = []Mode{ModeFull, ModeEnvelope, ModeLegacy}
                                                +
                                                +// Case is one nmap-generation scenario: store data for a single account, the
                                                +// peers whose network maps are computed, and the directory holding one expected
                                                +// *proto.NetworkMap per peer — shared by every mode.
                                                +type Case struct {
                                                +	Name      string
                                                +	AccountID string
                                                +	DNSDomain string
                                                +	Peers     []string
                                                +	Modes     []Mode
                                                +	Data      *networkmap.NetworkMapData
                                                +	GoldenDir string
                                                +}
                                                +
                                                +type manifest struct {
                                                +	Description string
                                                +	AccountID   string
                                                +	DNSDomain   string
                                                +	Peers       []string
                                                +	Modes       []Mode
                                                +}
                                                +
                                                +// RunGoldenDir discovers and runs every fixture case under dir. A case is a
                                                +// directory containing case.json (manifest), nmdata.json (store fixture) and
                                                +// golden/.json (expected proto.NetworkMap, protojson).
                                                +func RunGoldenDir(t *testing.T, dir string) {
                                                +	t.Helper()
                                                +
                                                +	entries, err := os.ReadDir(dir)
                                                +	require.NoError(t, err, "read cases dir")
                                                +
                                                +	ran := 0
                                                +	for _, entry := range entries {
                                                +		if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
                                                +			continue
                                                +		}
                                                +		caseDir := filepath.Join(dir, entry.Name())
                                                +		c, err := loadCase(caseDir)
                                                +		require.NoError(t, err, "load case %s", entry.Name())
                                                +		ran++
                                                +		t.Run(entry.Name(), func(t *testing.T) {
                                                +			RunCase(t, c)
                                                +		})
                                                +	}
                                                +	require.NotZero(t, ran, "no cases found under %s", dir)
                                                +}
                                                +
                                                +func loadCase(caseDir string) (Case, error) {
                                                +	raw, err := os.ReadFile(filepath.Join(caseDir, "case.json"))
                                                +	if err != nil {
                                                +		return Case{}, fmt.Errorf("read manifest: %w", err)
                                                +	}
                                                +	dec := json.NewDecoder(bytes.NewReader(raw))
                                                +	dec.DisallowUnknownFields()
                                                +	var m manifest
                                                +	if err := dec.Decode(&m); err != nil {
                                                +		return Case{}, fmt.Errorf("decode manifest: %w", err)
                                                +	}
                                                +
                                                +	data, err := LoadNetworkMapData(filepath.Join(caseDir, "nmdata.json"))
                                                +	if err != nil {
                                                +		return Case{}, err
                                                +	}
                                                +
                                                +	return Case{
                                                +		Name:      filepath.Base(caseDir),
                                                +		AccountID: m.AccountID,
                                                +		DNSDomain: m.DNSDomain,
                                                +		Peers:     m.Peers,
                                                +		Modes:     m.Modes,
                                                +		Data:      data,
                                                +		GoldenDir: filepath.Join(caseDir, "golden"),
                                                +	}, nil
                                                +}
                                                +
                                                +// RunCase computes each target peer's network map through every enabled mode
                                                +// and compares the canonicalized result against the peer's expectation file.
                                                +// It mirrors the controller's store path: fill fixture defaults, precompute
                                                +// posture validation once, then run the per-peer pipeline.
                                                +func RunCase(t *testing.T, c Case) {
                                                +	t.Helper()
                                                +
                                                +	require.NotNil(t, c.Data, "case %s: Data is required", c.Name)
                                                +	require.NotEmpty(t, c.Peers, "case %s: Peers is required", c.Name)
                                                +	require.NotEmpty(t, c.GoldenDir, "case %s: GoldenDir is required", c.Name)
                                                +	if c.AccountID == "" {
                                                +		c.AccountID = defaultAccountID
                                                +	}
                                                +	if c.DNSDomain == "" {
                                                +		c.DNSDomain = defaultDNSDomain
                                                +	}
                                                +	if len(c.Modes) == 0 {
                                                +		c.Modes = defaultModes
                                                +	}
                                                +
                                                +	ctx := context.Background()
                                                +	nmData := c.Data
                                                +	applyFixtureDefaults(nmData)
                                                +	nmData.BuildPrivateServiceCandidates()
                                                +	nmData.PrecomputePostureValidation()
                                                +
                                                +	dnsDomain := c.DNSDomain
                                                +	if nmData.AccountSettings.DNSDomain != "" {
                                                +		dnsDomain = nmData.AccountSettings.DNSDomain
                                                +	}
                                                +
                                                +	zone := networkmap.PeersCustomZone(ctx, c.AccountID, dnsDomain, nmData.Peers, controller.IPv6AllowedPeersFromData(nmData))
                                                +	dnsFwdPort := controller.ComputeForwarderPortFromData(nmData.Peers, network_map.DnsForwarderPortMinVersion)
                                                +
                                                +	for _, mode := range c.Modes {
                                                +		if mode == ModeEnvelope {
                                                +			requireEnvelopeSafeKeys(t, nmData, c.Name)
                                                +			break
                                                +		}
                                                +	}
                                                +
                                                +	// Built before any mode runs: the first per-peer computation injects the
                                                +	// synthesised proxy ACLs into the twin's policies, and the legacy side
                                                +	// synthesises its own, so inverting a twin that already carries them would
                                                +	// hand the legacy path each ACL twice.
                                                +	var legacy legacyInput
                                                +	if slices.Contains(c.Modes, ModeLegacy) {
                                                +		legacy = legacyInputFromData(c.AccountID, nmData)
                                                +	}
                                                +
                                                +	for _, peerID := range c.Peers {
                                                +		peer := nmData.Peers[peerID]
                                                +		require.NotNil(t, peer, "case %s: target peer %q not in fixture", c.Name, peerID)
                                                +
                                                +		for _, mode := range c.Modes {
                                                +			t.Run(peerID+"/"+string(mode), func(t *testing.T) {
                                                +				got := computeMode(t, ctx, mode, nmData, peerID, zone, dnsDomain, dnsFwdPort, legacy)
                                                +				canonicalize(got)
                                                +				compareGolden(t, filepath.Join(c.GoldenDir, peerID+".json"), got, mode)
                                                +			})
                                                +		}
                                                +	}
                                                +}
                                                +
                                                +// computeMode produces the peer's proto.NetworkMap the way the controller does
                                                +// for that wire shape.
                                                +func computeMode(t *testing.T, ctx context.Context, mode Mode, nmData *networkmap.NetworkMapData,
                                                +	peerID string, zone nmdata.CustomZone, dnsDomain string, dnsFwdPort int64, legacy legacyInput) *proto.NetworkMap {
                                                +	t.Helper()
                                                +
                                                +	peer := nmData.Peers[peerID]
                                                +	require.NotNil(t, peer, "target peer %q not in fixture", peerID)
                                                +
                                                +	switch mode {
                                                +	case ModeLegacy:
                                                +		return computeLegacy(t, ctx, legacy, peerID, zone, dnsDomain, dnsFwdPort)
                                                +	case ModeFull:
                                                +		nmap := controller.NetworkMapFromData(ctx, nmData, peerID, zone, nil)
                                                +		return mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, nmap, dnsDomain, nil,
                                                +			&cache.DNSConfigCache{}, nmData.AccountSettings, nil, nil, dnsFwdPort).NetworkMap
                                                +	case ModeEnvelope:
                                                +		components := nmData.GetPeerNetworkMapComponents(peerID, zone)
                                                +		peerGroups := maps.Keys(nmData.GetPeerGroups(peerID))
                                                +		resp := mgmtgrpc.ToComponentSyncResponse(ctx, nil, nil, nil, peer, nil, nil, components, nil,
                                                +			dnsDomain, nil, nmData.AccountSettings, nil, peerGroups, dnsFwdPort)
                                                +		res, err := networkmap.EnvelopeToNetworkMap(ctx, resp.NetworkMapEnvelope, peer.Key, dnsDomain)
                                                +		require.NoError(t, err, "expand envelope")
                                                +		return res.NetworkMap
                                                +	default:
                                                +		t.Fatalf("unknown mode %q", mode)
                                                +		return nil
                                                +	}
                                                +}
                                                +
                                                +// requireEnvelopeSafeKeys fails fast on peer keys the envelope decoder would
                                                +// silently drop: it re-keys peers by base64 of the raw 32-byte WG public key.
                                                +func requireEnvelopeSafeKeys(t *testing.T, nmData *networkmap.NetworkMapData, caseName string) {
                                                +	t.Helper()
                                                +	for id, p := range nmData.Peers {
                                                +		if p == nil {
                                                +			continue
                                                +		}
                                                +		raw, err := base64.StdEncoding.DecodeString(p.Key)
                                                +		if err != nil || len(raw) != 32 {
                                                +			t.Fatalf("case %s: peer %q Key must be base64 of 32 bytes for mode %q (the envelope decoder drops it otherwise); use a real WireGuard public key or restrict the case to mode %q",
                                                +				caseName, id, ModeEnvelope, ModeFull)
                                                +		}
                                                +	}
                                                +}
                                                +
                                                +// compareGolden measures got against the committed expectation file. One
                                                +// expectation serves every mode, because the modes run the same computation and
                                                +// must therefore agree. The expectation is the authority: a mismatch means the
                                                +// code does not produce what this case says it should, so it is reported as a
                                                +// failure and not quietly absorbed.
                                                +//
                                                +// The full and legacy modes are compared verbatim, identifiers included, so the
                                                +// expectation pins real ids and stays readable. The envelope mode has
                                                +// identifiers erased on both sides first, because it currently rewrites them —
                                                +// a tracked defect that TestIDSpaceMatches asserts against on its own, so it
                                                +// does not have to drown out every other case here.
                                                +// Nothing here writes to testdata. Expectation files are authored by hand and
                                                +// only ever change through a reviewed edit, so there is no mode in which a run
                                                +// can create or replace one. When a file is missing the computed map is printed
                                                +// for the author to read and, if it is genuinely correct, save deliberately.
                                                +func compareGolden(t *testing.T, path string, got *proto.NetworkMap, mode Mode) {
                                                +	t.Helper()
                                                +
                                                +	if mode == ModeEnvelope {
                                                +		normalizeIDSpace(got)
                                                +		canonicalize(got)
                                                +	}
                                                +
                                                +	raw, err := os.ReadFile(path)
                                                +	if err != nil {
                                                +		rendered, mErr := renderNetworkMap(got)
                                                +		require.NoError(t, mErr)
                                                +		t.Fatalf("no expectation file %s: %v\nThis case has nothing to measure against — write the "+
                                                +			"proto.NetworkMap this peer should receive. Mode %s currently produces:\n%s\nRead it before "+
                                                +			"saving any of it: if the code is wrong, so is this.", path, err, mode, rendered)
                                                +	}
                                                +	want := &proto.NetworkMap{}
                                                +	require.NoError(t, protojson.Unmarshal(raw, want), "parse expectation %s", path)
                                                +	canonicalize(want)
                                                +	if mode == ModeEnvelope {
                                                +		normalizeIDSpace(want)
                                                +		canonicalize(want)
                                                +	}
                                                +
                                                +	if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
                                                +		t.Errorf("mode %s does not produce what %s expects (-want +got):\n%s\n"+
                                                +			"Every mode has to deliver the same client-facing map for the same account state. "+
                                                +			"The expectation file is the committed statement of correct output — fix the code, or change the "+
                                                +			"expectation deliberately if the intended behaviour really moved.", mode, path, diff)
                                                +	}
                                                +}
                                                +
                                                +// renderNetworkMap renders stable protojson: protojson output whitespace is
                                                +// deliberately unstable, so it is reformatted through json.Indent.
                                                +func renderNetworkMap(nm *proto.NetworkMap) ([]byte, error) {
                                                +	raw, err := protojson.Marshal(nm)
                                                +	if err != nil {
                                                +		return nil, err
                                                +	}
                                                +	var buf bytes.Buffer
                                                +	if err := json.Indent(&buf, raw, "", "  "); err != nil {
                                                +		return nil, err
                                                +	}
                                                +	buf.WriteByte('\n')
                                                +	return buf.Bytes(), nil
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/case.json
                                                new file mode 100644
                                                index 000000000..4747e9640
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/case.json
                                                @@ -0,0 +1,7 @@
                                                +{
                                                +  "description": "Two groups joined by one allow-all policy; peer-c has SSH enabled so the legacy-SSH path fills SshAuth from AllowedUserIDs.",
                                                +  "peers": [
                                                +    "peer-a",
                                                +    "peer-c"
                                                +  ]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/golden/peer-a.json
                                                new file mode 100644
                                                index 000000000..e2b69276c
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/golden/peer-a.json
                                                @@ -0,0 +1,65 @@
                                                +{
                                                +  "Serial": "5",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.1/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-a.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "4deEImv8zGvsyBmmfC2G0eQkbyMzyGuz/YK7pcYETwM=",
                                                +      "allowedIps": [
                                                +        "100.64.0.3/32"
                                                +      ],
                                                +      "sshConfig": {
                                                +        "sshPubKey": "c3NoLXBlZXItYw=="
                                                +      },
                                                +      "fqdn": "peer-c.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-a.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-c.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.3"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.3",
                                                +      "Protocol": "ALL",
                                                +      "PolicyID": "cG9sLWFsbA=="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.3",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "ALL",
                                                +      "PolicyID": "cG9sLWFsbA=="
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/golden/peer-c.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/golden/peer-c.json
                                                new file mode 100644
                                                index 000000000..4c358b163
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/golden/peer-c.json
                                                @@ -0,0 +1,102 @@
                                                +{
                                                +  "Serial": "5",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.3/10",
                                                +    "sshConfig": {
                                                +      "sshEnabled": true
                                                +    },
                                                +    "fqdn": "peer-c.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=",
                                                +      "allowedIps": [
                                                +        "100.64.0.2/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-b.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    },
                                                +    {
                                                +      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
                                                +      "allowedIps": [
                                                +        "100.64.0.1/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-a.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-a.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-b.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.2"
                                                +          },
                                                +          {
                                                +            "Name": "peer-c.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.3"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Protocol": "ALL",
                                                +      "PolicyID": "cG9sLWFsbA=="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "ALL",
                                                +      "PolicyID": "cG9sLWFsbA=="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.2",
                                                +      "Protocol": "ALL",
                                                +      "PolicyID": "cG9sLWFsbA=="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.2",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "ALL",
                                                +      "PolicyID": "cG9sLWFsbA=="
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub",
                                                +    "AuthorizedUsers": [
                                                +      "u9dHvAXZJKiXITuwP9jD/A=="
                                                +    ],
                                                +    "machineUsers": {
                                                +      "*": {
                                                +        "indexes": [
                                                +          0
                                                +        ]
                                                +      }
                                                +    }
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/nmdata.json
                                                new file mode 100644
                                                index 000000000..7d78e5c61
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/nmdata.json
                                                @@ -0,0 +1,31 @@
                                                +{
                                                +  "Network": {"Serial": 5},
                                                +  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
                                                +  "Peers": {
                                                +    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-c": {"IP": "100.64.0.3", "SSHEnabled": true, "SSHKey": "ssh-peer-c", "Meta": {"WtVersion": "0.60.0"}}
                                                +  },
                                                +  "Groups": {
                                                +    "grp-dev": {"Peers": ["peer-a", "peer-b"]},
                                                +    "grp-ops": {"Peers": ["peer-c"]}
                                                +  },
                                                +  "Policies": [
                                                +    {
                                                +      "ID": "pol-all",
                                                +      "PublicID": "pol-all-pub",
                                                +      "Enabled": true,
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "all",
                                                +          "Bidirectional": true,
                                                +          "Sources": ["grp-dev"],
                                                +          "Destinations": ["grp-ops"]
                                                +        }
                                                +      ]
                                                +    }
                                                +  ],
                                                +  "AllowedUserIDs": {"user-ops": {}}
                                                +}
                                                \ No newline at end of file
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/case.json
                                                new file mode 100644
                                                index 000000000..e4c46c63d
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/case.json
                                                @@ -0,0 +1,7 @@
                                                +{
                                                +  "description": "Nameserver group and applied custom zones distributed to grp-dev; peer-a (with an extra DNS label) receives them, peer-c is outside that group and receives only the zone distributed to grp-ops. Zone flags travel per zone: both grp-dev zones are match-only (NonAuthoritative), only search-off.internal. disables the search domain, and the built-in peer zone stays authoritative.",
                                                +  "peers": [
                                                +    "peer-a",
                                                +    "peer-c"
                                                +  ]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-a.json
                                                new file mode 100644
                                                index 000000000..f06a19d9d
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-a.json
                                                @@ -0,0 +1,115 @@
                                                +{
                                                +  "Serial": "8",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.1/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-a.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=",
                                                +      "allowedIps": [
                                                +        "100.64.0.2/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-b.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "NameServerGroups": [
                                                +      {
                                                +        "NameServers": [
                                                +          {
                                                +            "IP": "8.8.8.8",
                                                +            "Port": "53"
                                                +          }
                                                +        ],
                                                +        "Primary": true
                                                +      }
                                                +    ],
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "corp.internal.",
                                                +        "NonAuthoritative": true,
                                                +        "Records": [
                                                +          {
                                                +            "Name": "db.corp.internal.",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "10.10.0.5"
                                                +          }
                                                +        ]
                                                +      },
                                                +      {
                                                +        "Domain": "search-off.internal.",
                                                +        "SearchDomainDisabled": true,
                                                +        "NonAuthoritative": true,
                                                +        "Records": [
                                                +          {
                                                +            "Name": "alias.search-off.internal.",
                                                +            "Type": "5",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "app.search-off.internal."
                                                +          },
                                                +          {
                                                +            "Name": "app.search-off.internal.",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "10.10.0.6"
                                                +          }
                                                +        ]
                                                +      },
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-a.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-b.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.2"
                                                +          },
                                                +          {
                                                +            "Name": "www.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.2",
                                                +      "Protocol": "ALL",
                                                +      "PolicyID": "cG9sLW1lc2g="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.2",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "ALL",
                                                +      "PolicyID": "cG9sLW1lc2g="
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-c.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-c.json
                                                new file mode 100644
                                                index 000000000..7e04dca40
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-c.json
                                                @@ -0,0 +1,47 @@
                                                +{
                                                +  "Serial": "8",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.3/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-c.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeersIsEmpty": true,
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-c.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.3"
                                                +          }
                                                +        ]
                                                +      },
                                                +      {
                                                +        "Domain": "ops-only.internal.",
                                                +        "NonAuthoritative": true,
                                                +        "Records": [
                                                +          {
                                                +            "Name": "tool.ops-only.internal.",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "10.10.0.7"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/nmdata.json
                                                new file mode 100644
                                                index 000000000..b9741ef16
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/nmdata.json
                                                @@ -0,0 +1,74 @@
                                                +{
                                                +  "Network": {"Serial": 8},
                                                +  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
                                                +  "Peers": {
                                                +    "peer-a": {"IP": "100.64.0.1", "ExtraDNSLabels": ["www"], "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-c": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}}
                                                +  },
                                                +  "Groups": {
                                                +    "grp-dev": {"Peers": ["peer-a", "peer-b"]},
                                                +    "grp-ops": {"Peers": ["peer-c"]}
                                                +  },
                                                +  "Policies": [
                                                +    {
                                                +      "ID": "pol-mesh",
                                                +      "PublicID": "pol-mesh-pub",
                                                +      "Enabled": true,
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "all",
                                                +          "Bidirectional": true,
                                                +          "Sources": ["grp-dev"],
                                                +          "Destinations": ["grp-dev"]
                                                +        }
                                                +      ]
                                                +    }
                                                +  ],
                                                +  "NameServerGroups": [
                                                +    {
                                                +      "ID": "nsg-1",
                                                +      "Name": "dns-primary",
                                                +      "NameServers": [{"IP": "8.8.8.8", "Port": 53}],
                                                +      "Groups": ["grp-dev"],
                                                +      "Primary": true,
                                                +      "Enabled": true
                                                +    }
                                                +  ],
                                                +  "AppliedZoneCandidates": [
                                                +    {
                                                +      "DistributionGroups": ["grp-dev"],
                                                +      "Zone": {
                                                +        "Domain": "corp.internal.",
                                                +        "NonAuthoritative": true,
                                                +        "Records": [
                                                +          {"Name": "db.corp.internal.", "Type": 1, "Class": "IN", "TTL": 300, "RData": "10.10.0.5"}
                                                +        ]
                                                +      }
                                                +    },
                                                +    {
                                                +      "DistributionGroups": ["grp-dev"],
                                                +      "Zone": {
                                                +        "Domain": "search-off.internal.",
                                                +        "NonAuthoritative": true,
                                                +        "SearchDomainDisabled": true,
                                                +        "Records": [
                                                +          {"Name": "app.search-off.internal.", "Type": 1, "Class": "IN", "TTL": 300, "RData": "10.10.0.6"},
                                                +          {"Name": "alias.search-off.internal.", "Type": 5, "Class": "IN", "TTL": 300, "RData": "app.search-off.internal."}
                                                +        ]
                                                +      }
                                                +    },
                                                +    {
                                                +      "DistributionGroups": ["grp-ops"],
                                                +      "Zone": {
                                                +        "Domain": "ops-only.internal.",
                                                +        "NonAuthoritative": true,
                                                +        "Records": [
                                                +          {"Name": "tool.ops-only.internal.", "Type": 1, "Class": "IN", "TTL": 300, "RData": "10.10.0.7"}
                                                +        ]
                                                +      }
                                                +    }
                                                +  ]
                                                +}
                                                \ No newline at end of file
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/case.json
                                                new file mode 100644
                                                index 000000000..a5776d0e7
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/case.json
                                                @@ -0,0 +1,4 @@
                                                +{
                                                +  "description": "Domain network resource: the route carries the domain list and the 192.0.2.0/32 placeholder network with NetworkType 3 (dynamic), and peer-r's route firewall rules must be marked dynamic and repeat the domain. Two ports on the policy must produce one rule per port. A domain resource contributes no DNS custom zone of its own — resolution happens through the routing peer's forwarder.",
                                                +  "peers": ["peer-a", "peer-r"]
                                                +}
                                                \ No newline at end of file
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/golden/peer-a.json
                                                new file mode 100644
                                                index 000000000..f83e7a2f2
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/golden/peer-a.json
                                                @@ -0,0 +1,59 @@
                                                +{
                                                +  "Serial": "22",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.1/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-a.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
                                                +      "allowedIps": [
                                                +        "100.64.0.9/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-r.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "Routes": [
                                                +    {
                                                +      "ID": "res-domain:peer-r",
                                                +      "Network": "192.0.2.0/32",
                                                +      "NetworkType": "3",
                                                +      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
                                                +      "Metric": "9999",
                                                +      "Masquerade": true,
                                                +      "NetID": "app-domain",
                                                +      "Domains": [
                                                +        "app.internal"
                                                +      ],
                                                +      "keepRoute": true
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-a.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/golden/peer-r.json
                                                new file mode 100644
                                                index 000000000..41ae3dd33
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/golden/peer-r.json
                                                @@ -0,0 +1,92 @@
                                                +{
                                                +  "Serial": "22",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.9/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-r.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
                                                +      "allowedIps": [
                                                +        "100.64.0.1/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-a.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "Routes": [
                                                +    {
                                                +      "ID": "res-domain:peer-r",
                                                +      "Network": "192.0.2.0/32",
                                                +      "NetworkType": "3",
                                                +      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
                                                +      "Metric": "9999",
                                                +      "Masquerade": true,
                                                +      "NetID": "app-domain",
                                                +      "Domains": [
                                                +        "app.internal"
                                                +      ],
                                                +      "keepRoute": true
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-r.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.9"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRules": [
                                                +    {
                                                +      "sourceRanges": [
                                                +        "100.64.0.1/32"
                                                +      ],
                                                +      "destination": "192.0.2.0/32",
                                                +      "protocol": "TCP",
                                                +      "portInfo": {
                                                +        "port": 443
                                                +      },
                                                +      "isDynamic": true,
                                                +      "domains": [
                                                +        "app.internal"
                                                +      ],
                                                +      "PolicyID": "cG9sLWFwcA==",
                                                +      "RouteID": "res-domain:peer-r"
                                                +    },
                                                +    {
                                                +      "sourceRanges": [
                                                +        "100.64.0.1/32"
                                                +      ],
                                                +      "destination": "192.0.2.0/32",
                                                +      "protocol": "TCP",
                                                +      "portInfo": {
                                                +        "port": 80
                                                +      },
                                                +      "isDynamic": true,
                                                +      "domains": [
                                                +        "app.internal"
                                                +      ],
                                                +      "PolicyID": "cG9sLWFwcA==",
                                                +      "RouteID": "res-domain:peer-r"
                                                +    }
                                                +  ],
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/nmdata.json
                                                new file mode 100644
                                                index 000000000..db6dc8eda
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/nmdata.json
                                                @@ -0,0 +1,43 @@
                                                +{
                                                +  "Network": {"Serial": 22},
                                                +  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
                                                +  "Peers": {
                                                +    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
                                                +  },
                                                +  "Groups": {
                                                +    "grp-dev": {"Peers": ["peer-a"]}
                                                +  },
                                                +  "Policies": [
                                                +    {
                                                +      "ID": "pol-app",
                                                +      "Enabled": true,
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "tcp",
                                                +          "Ports": ["80", "443"],
                                                +          "Sources": ["grp-dev"],
                                                +          "DestinationResource": {"ID": "res-domain", "Type": "domain"}
                                                +        }
                                                +      ]
                                                +    }
                                                +  ],
                                                +  "ResourcePolicies": {"res-domain": [{"ID": "pol-app"}]},
                                                +  "NetworkResources": [
                                                +    {
                                                +      "ID": "res-domain",
                                                +      "NetworkID": "net-1",
                                                +      "Name": "app-domain",
                                                +      "Type": "domain",
                                                +      "Domain": "app.internal",
                                                +      "Enabled": true
                                                +    }
                                                +  ],
                                                +  "Routers": {
                                                +    "net-1": {
                                                +      "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
                                                +    }
                                                +  }
                                                +}
                                                \ No newline at end of file
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/case.json
                                                new file mode 100644
                                                index 000000000..fa1e5c24b
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/case.json
                                                @@ -0,0 +1,4 @@
                                                +{
                                                +  "description": "Host network resource (single /32) behind one directly-assigned router. peer-a is in the resource policy's source group and must receive one route to 10.10.0.7/32 via peer-r with KeepRoute set and NetID taken from the resource name; peer-r as the router must receive the same route plus a route firewall rule whose SourceRanges are the policy's source peers. A client never gets route firewall rules.",
                                                +  "peers": ["peer-a", "peer-r"]
                                                +}
                                                \ No newline at end of file
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/golden/peer-a.json
                                                new file mode 100644
                                                index 000000000..8bf83f20e
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/golden/peer-a.json
                                                @@ -0,0 +1,56 @@
                                                +{
                                                +  "Serial": "20",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.1/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-a.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
                                                +      "allowedIps": [
                                                +        "100.64.0.9/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-r.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "Routes": [
                                                +    {
                                                +      "ID": "res-host:peer-r",
                                                +      "Network": "10.10.0.7/32",
                                                +      "NetworkType": "1",
                                                +      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
                                                +      "Metric": "9999",
                                                +      "Masquerade": true,
                                                +      "NetID": "web-host",
                                                +      "keepRoute": true
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-a.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/golden/peer-r.json
                                                new file mode 100644
                                                index 000000000..ef3b5a6c8
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/golden/peer-r.json
                                                @@ -0,0 +1,69 @@
                                                +{
                                                +  "Serial": "20",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.9/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-r.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
                                                +      "allowedIps": [
                                                +        "100.64.0.1/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-a.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "Routes": [
                                                +    {
                                                +      "ID": "res-host:peer-r",
                                                +      "Network": "10.10.0.7/32",
                                                +      "NetworkType": "1",
                                                +      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
                                                +      "Metric": "9999",
                                                +      "Masquerade": true,
                                                +      "NetID": "web-host",
                                                +      "keepRoute": true
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-r.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.9"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRules": [
                                                +    {
                                                +      "sourceRanges": [
                                                +        "100.64.0.1/32"
                                                +      ],
                                                +      "destination": "10.10.0.7/32",
                                                +      "protocol": "TCP",
                                                +      "portInfo": {
                                                +        "port": 443
                                                +      },
                                                +      "PolicyID": "cG9sLXdlYg==",
                                                +      "RouteID": "res-host:peer-r"
                                                +    }
                                                +  ],
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/nmdata.json
                                                new file mode 100644
                                                index 000000000..fdd35a439
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/nmdata.json
                                                @@ -0,0 +1,43 @@
                                                +{
                                                +  "Network": {"Serial": 20},
                                                +  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
                                                +  "Peers": {
                                                +    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
                                                +  },
                                                +  "Groups": {
                                                +    "grp-dev": {"Peers": ["peer-a"]}
                                                +  },
                                                +  "Policies": [
                                                +    {
                                                +      "ID": "pol-web",
                                                +      "Enabled": true,
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "tcp",
                                                +          "Ports": ["443"],
                                                +          "Sources": ["grp-dev"],
                                                +          "DestinationResource": {"ID": "res-host", "Type": "host"}
                                                +        }
                                                +      ]
                                                +    }
                                                +  ],
                                                +  "ResourcePolicies": {"res-host": [{"ID": "pol-web"}]},
                                                +  "NetworkResources": [
                                                +    {
                                                +      "ID": "res-host",
                                                +      "NetworkID": "net-1",
                                                +      "Name": "web-host",
                                                +      "Type": "host",
                                                +      "Prefix": "10.10.0.7/32",
                                                +      "Enabled": true
                                                +    }
                                                +  ],
                                                +  "Routers": {
                                                +    "net-1": {
                                                +      "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
                                                +    }
                                                +  }
                                                +}
                                                \ No newline at end of file
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/case.json
                                                new file mode 100644
                                                index 000000000..ca54a4b81
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/case.json
                                                @@ -0,0 +1,4 @@
                                                +{
                                                +  "description": "A disabled resource with a valid policy and router must leave no trace: no routes and no route firewall rules for either the client or the router. Disabling a resource is the switch that revokes access without deleting the policy.",
                                                +  "peers": ["peer-a", "peer-r"]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/golden/peer-a.json
                                                new file mode 100644
                                                index 000000000..a4f5a92bb
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/golden/peer-a.json
                                                @@ -0,0 +1,34 @@
                                                +{
                                                +  "Serial": "25",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.1/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-a.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeersIsEmpty": true,
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-a.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/golden/peer-r.json
                                                new file mode 100644
                                                index 000000000..b83cfdff6
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/golden/peer-r.json
                                                @@ -0,0 +1,34 @@
                                                +{
                                                +  "Serial": "25",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.9/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-r.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeersIsEmpty": true,
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-r.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.9"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/nmdata.json
                                                new file mode 100644
                                                index 000000000..43e00a2db
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/nmdata.json
                                                @@ -0,0 +1,42 @@
                                                +{
                                                +  "Network": {"Serial": 25},
                                                +  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
                                                +  "Peers": {
                                                +    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
                                                +  },
                                                +  "Groups": {
                                                +    "grp-dev": {"Peers": ["peer-a"]}
                                                +  },
                                                +  "Policies": [
                                                +    {
                                                +      "ID": "pol-off-resource",
                                                +      "Enabled": true,
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "tcp",
                                                +          "Ports": ["443"],
                                                +          "Sources": ["grp-dev"],
                                                +          "DestinationResource": {"ID": "res-disabled", "Type": "subnet"}
                                                +        }
                                                +      ]
                                                +    }
                                                +  ],
                                                +  "ResourcePolicies": {"res-disabled": [{"ID": "pol-off-resource"}]},
                                                +  "NetworkResources": [
                                                +    {
                                                +      "ID": "res-disabled",
                                                +      "NetworkID": "net-1",
                                                +      "Name": "disabled-subnet",
                                                +      "Type": "subnet",
                                                +      "Prefix": "10.50.0.0/24"
                                                +    }
                                                +  ],
                                                +  "Routers": {
                                                +    "net-1": {
                                                +      "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
                                                +    }
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/case.json
                                                new file mode 100644
                                                index 000000000..494ac0fce
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/case.json
                                                @@ -0,0 +1,4 @@
                                                +{
                                                +  "description": "An enabled resource with a healthy router but no policy granting access to it must produce nothing anywhere: no route for the client and none for the router either, since access to a resource is only ever created by a policy. The router also gets no route firewall rules despite being a routing peer for the network.",
                                                +  "peers": ["peer-a", "peer-r"]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/golden/peer-a.json
                                                new file mode 100644
                                                index 000000000..32f9cf35e
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/golden/peer-a.json
                                                @@ -0,0 +1,34 @@
                                                +{
                                                +  "Serial": "24",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.1/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-a.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeersIsEmpty": true,
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-a.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/golden/peer-r.json
                                                new file mode 100644
                                                index 000000000..a97eac9a3
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/golden/peer-r.json
                                                @@ -0,0 +1,34 @@
                                                +{
                                                +  "Serial": "24",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.9/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-r.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeersIsEmpty": true,
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-r.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.9"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/nmdata.json
                                                new file mode 100644
                                                index 000000000..a3bbf299a
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/nmdata.json
                                                @@ -0,0 +1,26 @@
                                                +{
                                                +  "Network": {"Serial": 24},
                                                +  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
                                                +  "Peers": {
                                                +    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
                                                +  },
                                                +  "Groups": {
                                                +    "grp-dev": {"Peers": ["peer-a"]}
                                                +  },
                                                +  "NetworkResources": [
                                                +    {
                                                +      "ID": "res-orphan",
                                                +      "NetworkID": "net-1",
                                                +      "Name": "orphan-subnet",
                                                +      "Type": "subnet",
                                                +      "Prefix": "10.40.0.0/24",
                                                +      "Enabled": true
                                                +    }
                                                +  ],
                                                +  "Routers": {
                                                +    "net-1": {
                                                +      "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
                                                +    }
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/case.json
                                                new file mode 100644
                                                index 000000000..2922a5deb
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/case.json
                                                @@ -0,0 +1,4 @@
                                                +{
                                                +  "description": "A DISABLED policy granting access to a network resource must grant nothing: no route to 10.90.0.0/24 for peer-a and none for the router either, exactly as if the policy were absent. THE FULL EXPECTATION CURRENTLY FAILS, and should: resource-policy selection never checks policy.Enabled (networkmapcompute.go and networkmap_components.go both test only nil/len(Rules)/Rules[0]), so the legacy path still hands out the route — access survives disabling the policy. The envelope path happens to be correct because the encoder drops disabled policies from the wire. Fix the compute path, do not weaken this expectation.",
                                                +  "peers": ["peer-a", "peer-r"]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/golden/peer-a.json
                                                new file mode 100644
                                                index 000000000..92ba75b1e
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/golden/peer-a.json
                                                @@ -0,0 +1,34 @@
                                                +{
                                                +  "Serial": "39",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.1/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-a.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeersIsEmpty": true,
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-a.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/golden/peer-r.json
                                                new file mode 100644
                                                index 000000000..8c27320ee
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/golden/peer-r.json
                                                @@ -0,0 +1,34 @@
                                                +{
                                                +  "Serial": "39",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.9/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-r.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeersIsEmpty": true,
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-r.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.9"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/nmdata.json
                                                new file mode 100644
                                                index 000000000..be7251712
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/nmdata.json
                                                @@ -0,0 +1,42 @@
                                                +{
                                                +  "Network": {"Serial": 39},
                                                +  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
                                                +  "Peers": {
                                                +    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
                                                +  },
                                                +  "Groups": {
                                                +    "grp-dev": {"Peers": ["peer-a"]}
                                                +  },
                                                +  "Policies": [
                                                +    {
                                                +      "ID": "pol-revoked",
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "tcp",
                                                +          "Ports": ["5432"],
                                                +          "Sources": ["grp-dev"],
                                                +          "DestinationResource": {"ID": "res-db", "Type": "subnet"}
                                                +        }
                                                +      ]
                                                +    }
                                                +  ],
                                                +  "ResourcePolicies": {"res-db": [{"ID": "pol-revoked"}]},
                                                +  "NetworkResources": [
                                                +    {
                                                +      "ID": "res-db",
                                                +      "NetworkID": "net-1",
                                                +      "Name": "db-subnet",
                                                +      "Type": "subnet",
                                                +      "Prefix": "10.90.0.0/24",
                                                +      "Enabled": true
                                                +    }
                                                +  ],
                                                +  "Routers": {
                                                +    "net-1": {
                                                +      "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
                                                +    }
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/case.json
                                                new file mode 100644
                                                index 000000000..de1027d48
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/case.json
                                                @@ -0,0 +1,4 @@
                                                +{
                                                +  "description": "The routing peer for the resource is not in ValidatedPeers — an unapproved peer, which the integrated validator withholds. peer-a must therefore receive no route through it and must not see it as a peer at all: traffic may not be routed through a peer the account has not approved. THE ENVELOPE EXPECTATION CURRENTLY FAILS, and should: component selection puts every routing peer into RouterPeers without checking validation, the encoder indexes them into the envelope's peer table, and the client decoder puts every peer it finds back into its peer map, so the unapproved router reappears client-side with a working route. The full path drops it correctly. Fix the component/encoder path, do not weaken this expectation.",
                                                +  "peers": ["peer-a"]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/golden/peer-a.json
                                                new file mode 100644
                                                index 000000000..2554fc08c
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/golden/peer-a.json
                                                @@ -0,0 +1,34 @@
                                                +{
                                                +  "Serial": "40",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.1/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-a.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeersIsEmpty": true,
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-a.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/nmdata.json
                                                new file mode 100644
                                                index 000000000..98ba94471
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/nmdata.json
                                                @@ -0,0 +1,44 @@
                                                +{
                                                +  "Network": {"Serial": 40},
                                                +  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
                                                +  "Peers": {
                                                +    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
                                                +  },
                                                +  "ValidatedPeers": {"peer-a": {}},
                                                +  "Groups": {
                                                +    "grp-dev": {"Peers": ["peer-a"]}
                                                +  },
                                                +  "Policies": [
                                                +    {
                                                +      "ID": "pol-db",
                                                +      "Enabled": true,
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "tcp",
                                                +          "Ports": ["5432"],
                                                +          "Sources": ["grp-dev"],
                                                +          "DestinationResource": {"ID": "res-db", "Type": "subnet"}
                                                +        }
                                                +      ]
                                                +    }
                                                +  ],
                                                +  "ResourcePolicies": {"res-db": [{"ID": "pol-db"}]},
                                                +  "NetworkResources": [
                                                +    {
                                                +      "ID": "res-db",
                                                +      "NetworkID": "net-1",
                                                +      "Name": "db-subnet",
                                                +      "Type": "subnet",
                                                +      "Prefix": "10.100.0.0/24",
                                                +      "Enabled": true
                                                +    }
                                                +  ],
                                                +  "Routers": {
                                                +    "net-1": {
                                                +      "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
                                                +    }
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/case.json
                                                new file mode 100644
                                                index 000000000..8ec63c816
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/case.json
                                                @@ -0,0 +1,4 @@
                                                +{
                                                +  "description": "Routing peer group: one router record assigned to a peer group, which the store expands into one entry per member peer sharing the router's settings. peer-a must receive one route per routing peer — same NetID and destination, different route ID and peer — which is what gives the client an HA pair to choose between. Each router must receive only its own route, never its sibling's, plus its own route firewall rule.",
                                                +  "peers": ["peer-a", "peer-r1", "peer-r2"]
                                                +}
                                                \ No newline at end of file
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-a.json
                                                new file mode 100644
                                                index 000000000..020c34835
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-a.json
                                                @@ -0,0 +1,75 @@
                                                +{
                                                +  "Serial": "23",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.1/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-a.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=",
                                                +      "allowedIps": [
                                                +        "100.64.0.11/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-r1.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    },
                                                +    {
                                                +      "wgPubKey": "YC6sbWtvpB2d6W/XqsNIV9crsrXBoVDdwmhCoszjCps=",
                                                +      "allowedIps": [
                                                +        "100.64.0.12/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-r2.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "Routes": [
                                                +    {
                                                +      "ID": "res-ha:peer-r1",
                                                +      "Network": "10.30.0.0/24",
                                                +      "NetworkType": "1",
                                                +      "Peer": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=",
                                                +      "Metric": "9999",
                                                +      "Masquerade": true,
                                                +      "NetID": "ha-subnet",
                                                +      "keepRoute": true
                                                +    },
                                                +    {
                                                +      "ID": "res-ha:peer-r2",
                                                +      "Network": "10.30.0.0/24",
                                                +      "NetworkType": "1",
                                                +      "Peer": "YC6sbWtvpB2d6W/XqsNIV9crsrXBoVDdwmhCoszjCps=",
                                                +      "Metric": "9999",
                                                +      "Masquerade": true,
                                                +      "NetID": "ha-subnet",
                                                +      "keepRoute": true
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-a.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-r1.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-r1.json
                                                new file mode 100644
                                                index 000000000..e42214ca8
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-r1.json
                                                @@ -0,0 +1,69 @@
                                                +{
                                                +  "Serial": "23",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.11/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-r1.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
                                                +      "allowedIps": [
                                                +        "100.64.0.1/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-a.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "Routes": [
                                                +    {
                                                +      "ID": "res-ha:peer-r1",
                                                +      "Network": "10.30.0.0/24",
                                                +      "NetworkType": "1",
                                                +      "Peer": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=",
                                                +      "Metric": "9999",
                                                +      "Masquerade": true,
                                                +      "NetID": "ha-subnet",
                                                +      "keepRoute": true
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-r1.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.11"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRules": [
                                                +    {
                                                +      "sourceRanges": [
                                                +        "100.64.0.1/32"
                                                +      ],
                                                +      "destination": "10.30.0.0/24",
                                                +      "protocol": "TCP",
                                                +      "portInfo": {
                                                +        "port": 5432
                                                +      },
                                                +      "PolicyID": "cG9sLWhh",
                                                +      "RouteID": "res-ha:peer-r1"
                                                +    }
                                                +  ],
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-r2.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-r2.json
                                                new file mode 100644
                                                index 000000000..2560742fb
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-r2.json
                                                @@ -0,0 +1,69 @@
                                                +{
                                                +  "Serial": "23",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.12/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-r2.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
                                                +      "allowedIps": [
                                                +        "100.64.0.1/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-a.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "Routes": [
                                                +    {
                                                +      "ID": "res-ha:peer-r2",
                                                +      "Network": "10.30.0.0/24",
                                                +      "NetworkType": "1",
                                                +      "Peer": "YC6sbWtvpB2d6W/XqsNIV9crsrXBoVDdwmhCoszjCps=",
                                                +      "Metric": "9999",
                                                +      "Masquerade": true,
                                                +      "NetID": "ha-subnet",
                                                +      "keepRoute": true
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-r2.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.12"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRules": [
                                                +    {
                                                +      "sourceRanges": [
                                                +        "100.64.0.1/32"
                                                +      ],
                                                +      "destination": "10.30.0.0/24",
                                                +      "protocol": "TCP",
                                                +      "portInfo": {
                                                +        "port": 5432
                                                +      },
                                                +      "PolicyID": "cG9sLWhh",
                                                +      "RouteID": "res-ha:peer-r2"
                                                +    }
                                                +  ],
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/nmdata.json
                                                new file mode 100644
                                                index 000000000..03937cb14
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/nmdata.json
                                                @@ -0,0 +1,46 @@
                                                +{
                                                +  "Network": {"Serial": 23},
                                                +  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
                                                +  "Peers": {
                                                +    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-r1": {"IP": "100.64.0.11", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-r2": {"IP": "100.64.0.12", "Meta": {"WtVersion": "0.60.0"}}
                                                +  },
                                                +  "Groups": {
                                                +    "grp-dev": {"Peers": ["peer-a"]},
                                                +    "grp-routers": {"Peers": ["peer-r1", "peer-r2"]}
                                                +  },
                                                +  "Policies": [
                                                +    {
                                                +      "ID": "pol-ha",
                                                +      "Enabled": true,
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "tcp",
                                                +          "Ports": ["5432"],
                                                +          "Sources": ["grp-dev"],
                                                +          "DestinationResource": {"ID": "res-ha", "Type": "subnet"}
                                                +        }
                                                +      ]
                                                +    }
                                                +  ],
                                                +  "ResourcePolicies": {"res-ha": [{"ID": "pol-ha"}]},
                                                +  "NetworkResources": [
                                                +    {
                                                +      "ID": "res-ha",
                                                +      "NetworkID": "net-ha",
                                                +      "Name": "ha-subnet",
                                                +      "Type": "subnet",
                                                +      "Prefix": "10.30.0.0/24",
                                                +      "Enabled": true
                                                +    }
                                                +  ],
                                                +  "Routers": {
                                                +    "net-ha": {
                                                +      "peer-r1": {"PublicID": "router-ha", "PeerGroups": ["grp-routers"], "Masquerade": true, "Metric": 9999, "Enabled": true},
                                                +      "peer-r2": {"PublicID": "router-ha", "PeerGroups": ["grp-routers"], "Masquerade": true, "Metric": 9999, "Enabled": true}
                                                +    }
                                                +  }
                                                +}
                                                \ No newline at end of file
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/case.json
                                                new file mode 100644
                                                index 000000000..16cfedf34
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/case.json
                                                @@ -0,0 +1,4 @@
                                                +{
                                                +  "description": "Subnet network resource behind one directly-assigned router, with masquerade off and a non-default metric so both reach the wire verbatim, and an all-protocol policy from a two-peer source group. peer-r's route firewall rule must list both source peers; peer-b confirms a second client in the same group gets its own identical route.",
                                                +  "peers": ["peer-a", "peer-b", "peer-r"]
                                                +}
                                                \ No newline at end of file
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-a.json
                                                new file mode 100644
                                                index 000000000..a5bc8f880
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-a.json
                                                @@ -0,0 +1,55 @@
                                                +{
                                                +  "Serial": "21",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.1/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-a.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
                                                +      "allowedIps": [
                                                +        "100.64.0.9/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-r.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "Routes": [
                                                +    {
                                                +      "ID": "res-subnet:peer-r",
                                                +      "Network": "10.20.0.0/24",
                                                +      "NetworkType": "1",
                                                +      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
                                                +      "Metric": "500",
                                                +      "NetID": "office-subnet",
                                                +      "keepRoute": true
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-a.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-b.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-b.json
                                                new file mode 100644
                                                index 000000000..01c31edf6
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-b.json
                                                @@ -0,0 +1,55 @@
                                                +{
                                                +  "Serial": "21",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.2/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-b.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
                                                +      "allowedIps": [
                                                +        "100.64.0.9/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-r.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "Routes": [
                                                +    {
                                                +      "ID": "res-subnet:peer-r",
                                                +      "Network": "10.20.0.0/24",
                                                +      "NetworkType": "1",
                                                +      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
                                                +      "Metric": "500",
                                                +      "NetID": "office-subnet",
                                                +      "keepRoute": true
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-b.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.2"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-r.json
                                                new file mode 100644
                                                index 000000000..39a29125d
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-r.json
                                                @@ -0,0 +1,76 @@
                                                +{
                                                +  "Serial": "21",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.9/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-r.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=",
                                                +      "allowedIps": [
                                                +        "100.64.0.2/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-b.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    },
                                                +    {
                                                +      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
                                                +      "allowedIps": [
                                                +        "100.64.0.1/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-a.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "Routes": [
                                                +    {
                                                +      "ID": "res-subnet:peer-r",
                                                +      "Network": "10.20.0.0/24",
                                                +      "NetworkType": "1",
                                                +      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
                                                +      "Metric": "500",
                                                +      "NetID": "office-subnet",
                                                +      "keepRoute": true
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-r.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.9"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRules": [
                                                +    {
                                                +      "sourceRanges": [
                                                +        "100.64.0.1/32",
                                                +        "100.64.0.2/32"
                                                +      ],
                                                +      "destination": "10.20.0.0/24",
                                                +      "protocol": "ALL",
                                                +      "portInfo": {},
                                                +      "PolicyID": "cG9sLXN1Ym5ldA==",
                                                +      "RouteID": "res-subnet:peer-r"
                                                +    }
                                                +  ],
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/nmdata.json
                                                new file mode 100644
                                                index 000000000..ba27f494c
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/nmdata.json
                                                @@ -0,0 +1,43 @@
                                                +{
                                                +  "Network": {"Serial": 21},
                                                +  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
                                                +  "Peers": {
                                                +    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
                                                +  },
                                                +  "Groups": {
                                                +    "grp-dev": {"Peers": ["peer-a", "peer-b"]}
                                                +  },
                                                +  "Policies": [
                                                +    {
                                                +      "ID": "pol-subnet",
                                                +      "Enabled": true,
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "all",
                                                +          "Sources": ["grp-dev"],
                                                +          "DestinationResource": {"ID": "res-subnet", "Type": "subnet"}
                                                +        }
                                                +      ]
                                                +    }
                                                +  ],
                                                +  "ResourcePolicies": {"res-subnet": [{"ID": "pol-subnet"}]},
                                                +  "NetworkResources": [
                                                +    {
                                                +      "ID": "res-subnet",
                                                +      "NetworkID": "net-1",
                                                +      "Name": "office-subnet",
                                                +      "Type": "subnet",
                                                +      "Prefix": "10.20.0.0/24",
                                                +      "Enabled": true
                                                +    }
                                                +  ],
                                                +  "Routers": {
                                                +    "net-1": {
                                                +      "peer-r": {"PublicID": "router-direct", "Metric": 500, "Enabled": true}
                                                +    }
                                                +  }
                                                +}
                                                \ No newline at end of file
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/case.json
                                                new file mode 100644
                                                index 000000000..cdf31c413
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/case.json
                                                @@ -0,0 +1,5 @@
                                                +{
                                                +  "description": "A peer named directly as a rule source or destination is subject to approval exactly like a group member: unvalidated peer-b is neither a source for peer-c nor a destination for peer-a, while the validated direct source peer-a reaches peer-c. Legacy mode is excluded: the frozen legacynmap copy still carries the direct-peer bypass.",
                                                +  "peers": ["peer-a", "peer-c"],
                                                +  "modes": ["full", "envelope"]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/golden/peer-a.json
                                                new file mode 100644
                                                index 000000000..e0605525f
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/golden/peer-a.json
                                                @@ -0,0 +1,64 @@
                                                +{
                                                +  "Serial": "22",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.1/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-a.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "4deEImv8zGvsyBmmfC2G0eQkbyMzyGuz/YK7pcYETwM=",
                                                +      "allowedIps": [
                                                +        "100.64.0.3/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-c.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-a.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-c.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.3"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.3",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLWRpcmVjdC1vaw=="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.3",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLWRpcmVjdC1vaw=="
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/golden/peer-c.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/golden/peer-c.json
                                                new file mode 100644
                                                index 000000000..f2b3e9357
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/golden/peer-c.json
                                                @@ -0,0 +1,64 @@
                                                +{
                                                +  "Serial": "22",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.3/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-c.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
                                                +      "allowedIps": [
                                                +        "100.64.0.1/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-a.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-a.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-c.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.3"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLWRpcmVjdC1vaw=="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLWRpcmVjdC1vaw=="
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/nmdata.json
                                                new file mode 100644
                                                index 000000000..283df304c
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/nmdata.json
                                                @@ -0,0 +1,63 @@
                                                +{
                                                +  "Network": {"Serial": 22},
                                                +  "Peers": {
                                                +    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-c": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}}
                                                +  },
                                                +  "ValidatedPeers": {"peer-a": {}, "peer-c": {}},
                                                +  "Groups": {
                                                +    "grp-dev": {"Peers": ["peer-a"]},
                                                +    "grp-ops": {"Peers": ["peer-c"]}
                                                +  },
                                                +  "Policies": [
                                                +    {
                                                +      "ID": "pol-direct-ok",
                                                +      "PublicID": "pol-direct-ok-pub",
                                                +      "Enabled": true,
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "tcp",
                                                +          "Ports": ["443"],
                                                +          "Bidirectional": true,
                                                +          "SourceResource": {"ID": "peer-a", "Type": "peer"},
                                                +          "Destinations": ["grp-ops"]
                                                +        }
                                                +      ]
                                                +    },
                                                +    {
                                                +      "ID": "pol-src-unval",
                                                +      "PublicID": "pol-src-unval-pub",
                                                +      "Enabled": true,
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "tcp",
                                                +          "Ports": ["8443"],
                                                +          "Bidirectional": true,
                                                +          "SourceResource": {"ID": "peer-b", "Type": "peer"},
                                                +          "Destinations": ["grp-ops"]
                                                +        }
                                                +      ]
                                                +    },
                                                +    {
                                                +      "ID": "pol-dst-unval",
                                                +      "PublicID": "pol-dst-unval-pub",
                                                +      "Enabled": true,
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "tcp",
                                                +          "Ports": ["9443"],
                                                +          "Bidirectional": true,
                                                +          "Sources": ["grp-dev"],
                                                +          "DestinationResource": {"ID": "peer-b", "Type": "peer"}
                                                +        }
                                                +      ]
                                                +    }
                                                +  ]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/case.json
                                                new file mode 100644
                                                index 000000000..39c477b9f
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/case.json
                                                @@ -0,0 +1,4 @@
                                                +{
                                                +  "description": "Direct peer-to-peer policy via Source/DestinationResource of type peer, no groups involved; peer-a and peer-b see each other, bystander peer-c sees nobody.",
                                                +  "peers": ["peer-a", "peer-b", "peer-c"]
                                                +}
                                                \ No newline at end of file
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-a.json
                                                new file mode 100644
                                                index 000000000..4d59c33bb
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-a.json
                                                @@ -0,0 +1,64 @@
                                                +{
                                                +  "Serial": "15",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.1/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-a.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=",
                                                +      "allowedIps": [
                                                +        "100.64.0.2/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-b.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-a.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-b.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.2"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.2",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLWRpcmVjdA=="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.2",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLWRpcmVjdA=="
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-b.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-b.json
                                                new file mode 100644
                                                index 000000000..59b4bd24c
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-b.json
                                                @@ -0,0 +1,64 @@
                                                +{
                                                +  "Serial": "15",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.2/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-b.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
                                                +      "allowedIps": [
                                                +        "100.64.0.1/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-a.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-a.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-b.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.2"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLWRpcmVjdA=="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLWRpcmVjdA=="
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-c.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-c.json
                                                new file mode 100644
                                                index 000000000..9ff24ce1a
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-c.json
                                                @@ -0,0 +1,33 @@
                                                +{
                                                +  "Serial": "15",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.3/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-c.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeersIsEmpty": true,
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-c.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.3"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/nmdata.json
                                                new file mode 100644
                                                index 000000000..f3ee4d163
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/nmdata.json
                                                @@ -0,0 +1,26 @@
                                                +{
                                                +  "Network": {"Serial": 15},
                                                +  "Peers": {
                                                +    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-c": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}}
                                                +  },
                                                +  "Policies": [
                                                +    {
                                                +      "ID": "pol-direct",
                                                +      "PublicID": "pol-direct-pub",
                                                +      "Enabled": true,
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "tcp",
                                                +          "Ports": ["443"],
                                                +          "Bidirectional": true,
                                                +          "SourceResource": {"ID": "peer-a", "Type": "peer"},
                                                +          "DestinationResource": {"ID": "peer-b", "Type": "peer"}
                                                +        }
                                                +      ]
                                                +    }
                                                +  ]
                                                +}
                                                \ No newline at end of file
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/case.json
                                                new file mode 100644
                                                index 000000000..a0eccba20
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/case.json
                                                @@ -0,0 +1,4 @@
                                                +{
                                                +  "description": "One-way udp/514 plus bidirectional tcp port-range 1000-2000 between the same groups; a disabled policy and a policy whose only rule is disabled must leave no trace.",
                                                +  "peers": ["peer-a", "peer-srv"]
                                                +}
                                                \ No newline at end of file
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/golden/peer-a.json
                                                new file mode 100644
                                                index 000000000..5a2276d96
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/golden/peer-a.json
                                                @@ -0,0 +1,81 @@
                                                +{
                                                +  "Serial": "14",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.1/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-a.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=",
                                                +      "allowedIps": [
                                                +        "100.64.0.10/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-srv.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-a.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-srv.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.10"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.10",
                                                +      "Protocol": "TCP",
                                                +      "PortInfo": {
                                                +        "range": {
                                                +          "start": 1000,
                                                +          "end": 2000
                                                +        }
                                                +      },
                                                +      "PolicyID": "cG9sLXJhbmdl"
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.10",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "PortInfo": {
                                                +        "range": {
                                                +          "start": 1000,
                                                +          "end": 2000
                                                +        }
                                                +      },
                                                +      "PolicyID": "cG9sLXJhbmdl"
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.10",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "UDP",
                                                +      "Port": "514",
                                                +      "PolicyID": "cG9sLXN5c2xvZw=="
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/golden/peer-srv.json
                                                new file mode 100644
                                                index 000000000..6a89148f5
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/golden/peer-srv.json
                                                @@ -0,0 +1,80 @@
                                                +{
                                                +  "Serial": "14",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.10/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-srv.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
                                                +      "allowedIps": [
                                                +        "100.64.0.1/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-a.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-a.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-srv.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.10"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Protocol": "TCP",
                                                +      "PortInfo": {
                                                +        "range": {
                                                +          "start": 1000,
                                                +          "end": 2000
                                                +        }
                                                +      },
                                                +      "PolicyID": "cG9sLXJhbmdl"
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "PortInfo": {
                                                +        "range": {
                                                +          "start": 1000,
                                                +          "end": 2000
                                                +        }
                                                +      },
                                                +      "PolicyID": "cG9sLXJhbmdl"
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Protocol": "UDP",
                                                +      "Port": "514",
                                                +      "PolicyID": "cG9sLXN5c2xvZw=="
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/nmdata.json
                                                new file mode 100644
                                                index 000000000..f1262a0d7
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/nmdata.json
                                                @@ -0,0 +1,74 @@
                                                +{
                                                +  "Network": {"Serial": 14},
                                                +  "Peers": {
                                                +    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
                                                +  },
                                                +  "Groups": {
                                                +    "grp-dev": {"Peers": ["peer-a"]},
                                                +    "grp-svc": {"Peers": ["peer-srv"]}
                                                +  },
                                                +  "Policies": [
                                                +    {
                                                +      "ID": "pol-syslog",
                                                +      "PublicID": "pol-syslog-pub",
                                                +      "Enabled": true,
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "udp",
                                                +          "Ports": ["514"],
                                                +          "Sources": ["grp-dev"],
                                                +          "Destinations": ["grp-svc"]
                                                +        }
                                                +      ]
                                                +    },
                                                +    {
                                                +      "ID": "pol-range",
                                                +      "PublicID": "pol-range-pub",
                                                +      "Enabled": true,
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "tcp",
                                                +          "PortRanges": [{"Start": 1000, "End": 2000}],
                                                +          "Bidirectional": true,
                                                +          "Sources": ["grp-dev"],
                                                +          "Destinations": ["grp-svc"]
                                                +        }
                                                +      ]
                                                +    },
                                                +    {
                                                +      "ID": "pol-off",
                                                +      "PublicID": "pol-off-pub",
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "tcp",
                                                +          "Ports": ["9999"],
                                                +          "Bidirectional": true,
                                                +          "Sources": ["grp-dev"],
                                                +          "Destinations": ["grp-svc"]
                                                +        }
                                                +      ]
                                                +    },
                                                +    {
                                                +      "ID": "pol-rule-off",
                                                +      "PublicID": "pol-rule-off-pub",
                                                +      "Enabled": true,
                                                +      "Rules": [
                                                +        {
                                                +          "Action": "accept",
                                                +          "Protocol": "udp",
                                                +          "Ports": ["1111"],
                                                +          "Bidirectional": true,
                                                +          "Sources": ["grp-dev"],
                                                +          "Destinations": ["grp-svc"]
                                                +        }
                                                +      ]
                                                +    }
                                                +  ]
                                                +}
                                                \ No newline at end of file
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/case.json
                                                new file mode 100644
                                                index 000000000..3307e0afe
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/case.json
                                                @@ -0,0 +1,4 @@
                                                +{
                                                +  "description": "Posture checks gate a policy's sources only, never its destinations. peer-srv-old would fail the version check, but it sits in the destination group, so peer-client must still receive it alongside peer-srv-new, and peer-srv-old must still receive peer-client. This asymmetry is deliberate in the compute path — destination peers are resolved with no posture checks passed in — and it is worth pinning because it is easy to assume a posture check protects both ends.",
                                                +  "peers": ["peer-client", "peer-srv-old"]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/golden/peer-client.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/golden/peer-client.json
                                                new file mode 100644
                                                index 000000000..73182932b
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/golden/peer-client.json
                                                @@ -0,0 +1,93 @@
                                                +{
                                                +  "Serial": "37",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.1/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-client.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "MdeD+cDSnurizeZ/Zd7rEdIhs9VZViEnutUwkodqb1s=",
                                                +      "allowedIps": [
                                                +        "100.64.0.12/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-srv-new.netbird.test",
                                                +      "agentVersion": "1.0.0"
                                                +    },
                                                +    {
                                                +      "wgPubKey": "ph1eqUTlSeLQ6V9zLEUpck25m5K5sOQq+AHY879HZME=",
                                                +      "allowedIps": [
                                                +        "100.64.0.11/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-srv-old.netbird.test",
                                                +      "agentVersion": "0.30.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-client.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-srv-new.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.12"
                                                +          },
                                                +          {
                                                +            "Name": "peer-srv-old.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.11"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "5353"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.11",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLWRlc3Q="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.11",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLWRlc3Q="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.12",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLWRlc3Q="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.12",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLWRlc3Q="
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/golden/peer-srv-old.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/golden/peer-srv-old.json
                                                new file mode 100644
                                                index 000000000..8d1ad4feb
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/golden/peer-srv-old.json
                                                @@ -0,0 +1,64 @@
                                                +{
                                                +  "Serial": "37",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.11/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-srv-old.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "tKxuKEYQFPR8lCpcfVWBKVX0vGFKYXtTtFjXhoiu5zc=",
                                                +      "allowedIps": [
                                                +        "100.64.0.1/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-client.netbird.test",
                                                +      "agentVersion": "1.0.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-client.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-srv-old.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.11"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "5353"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLWRlc3Q="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLWRlc3Q="
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/nmdata.json
                                                new file mode 100644
                                                index 000000000..ca5ece21a
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/nmdata.json
                                                @@ -0,0 +1,33 @@
                                                +{
                                                +  "Network": {"Serial": 37},
                                                +  "Peers": {
                                                +    "peer-client": {"IP": "100.64.0.1", "Meta": {"WtVersion": "1.0.0"}},
                                                +    "peer-srv-old": {"IP": "100.64.0.11", "Meta": {"WtVersion": "0.30.0"}},
                                                +    "peer-srv-new": {"IP": "100.64.0.12", "Meta": {"WtVersion": "1.0.0"}}
                                                +  },
                                                +  "Groups": {
                                                +    "grp-clients": {"Peers": ["peer-client"]},
                                                +    "grp-srv": {"Peers": ["peer-srv-old", "peer-srv-new"]}
                                                +  },
                                                +  "PostureChecks": {
                                                +    "chk-version": {"Checks": {"NBVersionCheck": {"MinVersion": "0.45.0"}}}
                                                +  },
                                                +  "Policies": [
                                                +    {
                                                +      "ID": "pol-dest",
                                                +      "Enabled": true,
                                                +      "SourcePostureChecks": ["chk-version"],
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "tcp",
                                                +          "Ports": ["443"],
                                                +          "Bidirectional": true,
                                                +          "Sources": ["grp-clients"],
                                                +          "Destinations": ["grp-srv"]
                                                +        }
                                                +      ]
                                                +    }
                                                +  ]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/case.json
                                                new file mode 100644
                                                index 000000000..8d7460721
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/case.json
                                                @@ -0,0 +1,5 @@
                                                +{
                                                +  "description": "A peer named directly as a rule source is gated by the policy's posture checks exactly like a group member: peer-b (0.40.0) fails the 0.45.0 minimum, so it gets no connectivity and peer-c must not see it, while the compliant direct source peer-a reaches peer-c. Legacy mode is excluded: the frozen legacynmap copy still carries the direct-peer bypass.",
                                                +  "peers": ["peer-b", "peer-c"],
                                                +  "modes": ["full", "envelope"]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/golden/peer-b.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/golden/peer-b.json
                                                new file mode 100644
                                                index 000000000..240358e40
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/golden/peer-b.json
                                                @@ -0,0 +1,33 @@
                                                +{
                                                +  "Serial": "21",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.2/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-b.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeersIsEmpty": true,
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-b.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.2"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "5353"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/golden/peer-c.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/golden/peer-c.json
                                                new file mode 100644
                                                index 000000000..85573ed35
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/golden/peer-c.json
                                                @@ -0,0 +1,64 @@
                                                +{
                                                +  "Serial": "21",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.3/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-c.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
                                                +      "allowedIps": [
                                                +        "100.64.0.1/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-a.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-a.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-c.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.3"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "5353"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLWRpcmVjdC1vaw=="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLWRpcmVjdC1vaw=="
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/nmdata.json
                                                new file mode 100644
                                                index 000000000..e6b99bfdd
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/nmdata.json
                                                @@ -0,0 +1,51 @@
                                                +{
                                                +  "Network": {"Serial": 21},
                                                +  "Peers": {
                                                +    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.40.0"}},
                                                +    "peer-c": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}}
                                                +  },
                                                +  "Groups": {
                                                +    "grp-ops": {"Peers": ["peer-c"]}
                                                +  },
                                                +  "PostureChecks": {
                                                +    "chk-ver": {"Checks": {"NBVersionCheck": {"MinVersion": "0.45.0"}}}
                                                +  },
                                                +  "PostureCheckXIDToPublicID": {"chk-ver": "chk-ver-pub"},
                                                +  "Policies": [
                                                +    {
                                                +      "ID": "pol-direct-ok",
                                                +      "PublicID": "pol-direct-ok-pub",
                                                +      "Enabled": true,
                                                +      "SourcePostureChecks": ["chk-ver"],
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "tcp",
                                                +          "Ports": ["443"],
                                                +          "Bidirectional": true,
                                                +          "SourceResource": {"ID": "peer-a", "Type": "peer"},
                                                +          "Destinations": ["grp-ops"]
                                                +        }
                                                +      ]
                                                +    },
                                                +    {
                                                +      "ID": "pol-direct-denied",
                                                +      "PublicID": "pol-direct-denied-pub",
                                                +      "Enabled": true,
                                                +      "SourcePostureChecks": ["chk-ver"],
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "tcp",
                                                +          "Ports": ["8443"],
                                                +          "Bidirectional": true,
                                                +          "SourceResource": {"ID": "peer-b", "Type": "peer"},
                                                +          "Destinations": ["grp-ops"]
                                                +        }
                                                +      ]
                                                +    }
                                                +  ]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/case.json
                                                new file mode 100644
                                                index 000000000..a661fa53e
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/case.json
                                                @@ -0,0 +1,7 @@
                                                +{
                                                +  "description": "Source-side NB-version posture check: peer-b (0.40.0) fails the 0.45.0 minimum, so peer-c must not see it and peer-b itself gets no policy connectivity.",
                                                +  "peers": [
                                                +    "peer-b",
                                                +    "peer-c"
                                                +  ]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/golden/peer-b.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/golden/peer-b.json
                                                new file mode 100644
                                                index 000000000..201be294f
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/golden/peer-b.json
                                                @@ -0,0 +1,34 @@
                                                +{
                                                +  "Serial": "6",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.2/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-b.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeersIsEmpty": true,
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-b.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.2"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "5353"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/golden/peer-c.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/golden/peer-c.json
                                                new file mode 100644
                                                index 000000000..009d00490
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/golden/peer-c.json
                                                @@ -0,0 +1,65 @@
                                                +{
                                                +  "Serial": "6",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.3/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-c.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
                                                +      "allowedIps": [
                                                +        "100.64.0.1/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-a.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-a.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-c.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.3"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "5353"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLWdhdGVk"
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLWdhdGVk"
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/nmdata.json
                                                new file mode 100644
                                                index 000000000..4962e8b6a
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/nmdata.json
                                                @@ -0,0 +1,36 @@
                                                +{
                                                +  "Network": {"Serial": 6},
                                                +  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
                                                +  "Peers": {
                                                +    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.40.0"}},
                                                +    "peer-c": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}}
                                                +  },
                                                +  "Groups": {
                                                +    "grp-dev": {"Peers": ["peer-a", "peer-b"]},
                                                +    "grp-ops": {"Peers": ["peer-c"]}
                                                +  },
                                                +  "PostureChecks": {
                                                +    "chk-ver": {"Checks": {"NBVersionCheck": {"MinVersion": "0.45.0"}}}
                                                +  },
                                                +  "PostureCheckXIDToPublicID": {"chk-ver": "chk-ver-pub"},
                                                +  "Policies": [
                                                +    {
                                                +      "ID": "pol-gated",
                                                +      "PublicID": "pol-gated-pub",
                                                +      "Enabled": true,
                                                +      "SourcePostureChecks": ["chk-ver"],
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "tcp",
                                                +          "Bidirectional": true,
                                                +          "Ports": ["443"],
                                                +          "Sources": ["grp-dev"],
                                                +          "Destinations": ["grp-ops"]
                                                +        }
                                                +      ]
                                                +    }
                                                +  ]
                                                +}
                                                \ No newline at end of file
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/case.json
                                                new file mode 100644
                                                index 000000000..9f7f86860
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/case.json
                                                @@ -0,0 +1,4 @@
                                                +{
                                                +  "description": "Geo location posture check in allow mode. An entry naming only a country matches the whole country, so peer-de passes; an entry naming a city must match that city exactly, so peer-us-ny passes while peer-us-bos does not. peer-fr matches nothing and fails. peer-nowhere has no location at all, which the check reports as an error, and an errored check denies — so it fails too.",
                                                +  "peers": ["peer-srv", "peer-de", "peer-us-bos", "peer-nowhere"]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-de.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-de.json
                                                new file mode 100644
                                                index 000000000..9a07139fe
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-de.json
                                                @@ -0,0 +1,64 @@
                                                +{
                                                +  "Serial": "31",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.1/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-de.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=",
                                                +      "allowedIps": [
                                                +        "100.64.0.10/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-srv.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-de.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-srv.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.10"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.10",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLWdlbw=="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.10",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLWdlbw=="
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-nowhere.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-nowhere.json
                                                new file mode 100644
                                                index 000000000..aed97a69f
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-nowhere.json
                                                @@ -0,0 +1,33 @@
                                                +{
                                                +  "Serial": "31",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.5/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-nowhere.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeersIsEmpty": true,
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-nowhere.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.5"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-srv.json
                                                new file mode 100644
                                                index 000000000..9f3fcb1ba
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-srv.json
                                                @@ -0,0 +1,93 @@
                                                +{
                                                +  "Serial": "31",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.10/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-srv.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "9nwvdE0wik6Fcs8Tw6WBnmOqGZmzdiTR4VZAdRBOJF4=",
                                                +      "allowedIps": [
                                                +        "100.64.0.2/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-us-ny.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    },
                                                +    {
                                                +      "wgPubKey": "s/f5frZqT3DT1o9QCuhA14Pj5GUa4JUsF4M3twrprmk=",
                                                +      "allowedIps": [
                                                +        "100.64.0.1/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-de.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-de.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-srv.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.10"
                                                +          },
                                                +          {
                                                +            "Name": "peer-us-ny.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.2"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLWdlbw=="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLWdlbw=="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.2",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLWdlbw=="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.2",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLWdlbw=="
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-us-bos.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-us-bos.json
                                                new file mode 100644
                                                index 000000000..624666c36
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-us-bos.json
                                                @@ -0,0 +1,33 @@
                                                +{
                                                +  "Serial": "31",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.3/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-us-bos.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeersIsEmpty": true,
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-us-bos.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.3"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/nmdata.json
                                                new file mode 100644
                                                index 000000000..b92840266
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/nmdata.json
                                                @@ -0,0 +1,46 @@
                                                +{
                                                +  "Network": {"Serial": 31},
                                                +  "Peers": {
                                                +    "peer-de": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}, "Location": {"CountryCode": "DE", "CityName": "Berlin"}},
                                                +    "peer-us-ny": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}, "Location": {"CountryCode": "US", "CityName": "New York"}},
                                                +    "peer-us-bos": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}, "Location": {"CountryCode": "US", "CityName": "Boston"}},
                                                +    "peer-fr": {"IP": "100.64.0.4", "Meta": {"WtVersion": "0.60.0"}, "Location": {"CountryCode": "FR", "CityName": "Paris"}},
                                                +    "peer-nowhere": {"IP": "100.64.0.5", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
                                                +  },
                                                +  "Groups": {
                                                +    "grp-clients": {"Peers": ["peer-de", "peer-us-ny", "peer-us-bos", "peer-fr", "peer-nowhere"]},
                                                +    "grp-srv": {"Peers": ["peer-srv"]}
                                                +  },
                                                +  "PostureChecks": {
                                                +    "chk-geo": {
                                                +      "Checks": {
                                                +        "GeoLocationCheck": {
                                                +          "Action": "allow",
                                                +          "Locations": [
                                                +            {"CountryCode": "DE"},
                                                +            {"CountryCode": "US", "CityName": "New York"}
                                                +          ]
                                                +        }
                                                +      }
                                                +    }
                                                +  },
                                                +  "Policies": [
                                                +    {
                                                +      "ID": "pol-geo",
                                                +      "Enabled": true,
                                                +      "SourcePostureChecks": ["chk-geo"],
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "tcp",
                                                +          "Ports": ["443"],
                                                +          "Bidirectional": true,
                                                +          "Sources": ["grp-clients"],
                                                +          "Destinations": ["grp-srv"]
                                                +        }
                                                +      ]
                                                +    }
                                                +  ]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/case.json
                                                new file mode 100644
                                                index 000000000..f234b5280
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/case.json
                                                @@ -0,0 +1,4 @@
                                                +{
                                                +  "description": "Geo location posture check in deny mode: matching the list rejects, not matching passes, so peer-ru is excluded and peer-de is admitted. peer-nowhere has no location and fails here as well — a missing location is an error and errors deny in both modes, so deny mode is not a way to admit peers whose location is unknown.",
                                                +  "peers": ["peer-srv", "peer-ru", "peer-nowhere"]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-nowhere.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-nowhere.json
                                                new file mode 100644
                                                index 000000000..e73b77d9e
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-nowhere.json
                                                @@ -0,0 +1,33 @@
                                                +{
                                                +  "Serial": "32",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.3/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-nowhere.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeersIsEmpty": true,
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-nowhere.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.3"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-ru.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-ru.json
                                                new file mode 100644
                                                index 000000000..721267f37
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-ru.json
                                                @@ -0,0 +1,33 @@
                                                +{
                                                +  "Serial": "32",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.1/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-ru.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeersIsEmpty": true,
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-ru.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-srv.json
                                                new file mode 100644
                                                index 000000000..93883369e
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-srv.json
                                                @@ -0,0 +1,62 @@
                                                +{
                                                +  "Serial": "32",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.10/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-srv.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "s/f5frZqT3DT1o9QCuhA14Pj5GUa4JUsF4M3twrprmk=",
                                                +      "allowedIps": [
                                                +        "100.64.0.2/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-de.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-de.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.2"
                                                +          },
                                                +          {
                                                +            "Name": "peer-srv.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.10"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.2",
                                                +      "Protocol": "ALL",
                                                +      "PolicyID": "cG9sLWdlby1kZW55"
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.2",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "ALL",
                                                +      "PolicyID": "cG9sLWdlby1kZW55"
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/nmdata.json
                                                new file mode 100644
                                                index 000000000..5edc50a38
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/nmdata.json
                                                @@ -0,0 +1,40 @@
                                                +{
                                                +  "Network": {"Serial": 32},
                                                +  "Peers": {
                                                +    "peer-ru": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}, "Location": {"CountryCode": "RU", "CityName": "Moscow"}},
                                                +    "peer-de": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}, "Location": {"CountryCode": "DE", "CityName": "Berlin"}},
                                                +    "peer-nowhere": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
                                                +  },
                                                +  "Groups": {
                                                +    "grp-clients": {"Peers": ["peer-ru", "peer-de", "peer-nowhere"]},
                                                +    "grp-srv": {"Peers": ["peer-srv"]}
                                                +  },
                                                +  "PostureChecks": {
                                                +    "chk-geo-deny": {
                                                +      "Checks": {
                                                +        "GeoLocationCheck": {
                                                +          "Action": "deny",
                                                +          "Locations": [{"CountryCode": "RU"}]
                                                +        }
                                                +      }
                                                +    }
                                                +  },
                                                +  "Policies": [
                                                +    {
                                                +      "ID": "pol-geo-deny",
                                                +      "Enabled": true,
                                                +      "SourcePostureChecks": ["chk-geo-deny"],
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "all",
                                                +          "Bidirectional": true,
                                                +          "Sources": ["grp-clients"],
                                                +          "Destinations": ["grp-srv"]
                                                +        }
                                                +      ]
                                                +    }
                                                +  ]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/case.json
                                                new file mode 100644
                                                index 000000000..c0eab5408
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/case.json
                                                @@ -0,0 +1,4 @@
                                                +{
                                                +  "description": "One posture check bundle holding two different checks. All checks in a bundle must pass, so only peer-both is admitted: peer-badgeo satisfies the version rule and peer-badversion satisfies the location rule, and each is still rejected on the other. This pins the AND semantics of a bundle rather than any-of.",
                                                +  "peers": ["peer-srv", "peer-both", "peer-badgeo"]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-badgeo.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-badgeo.json
                                                new file mode 100644
                                                index 000000000..6a6272909
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-badgeo.json
                                                @@ -0,0 +1,33 @@
                                                +{
                                                +  "Serial": "35",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.2/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-badgeo.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeersIsEmpty": true,
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-badgeo.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.2"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "5353"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-both.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-both.json
                                                new file mode 100644
                                                index 000000000..7e18e7cb8
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-both.json
                                                @@ -0,0 +1,64 @@
                                                +{
                                                +  "Serial": "35",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.1/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-both.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=",
                                                +      "allowedIps": [
                                                +        "100.64.0.10/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-srv.netbird.test",
                                                +      "agentVersion": "1.0.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-both.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-srv.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.10"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "5353"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.10",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLWNvbWJv"
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.10",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLWNvbWJv"
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-srv.json
                                                new file mode 100644
                                                index 000000000..e91e8f777
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-srv.json
                                                @@ -0,0 +1,64 @@
                                                +{
                                                +  "Serial": "35",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.10/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-srv.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "ilmSCJoVLTTY/Am7or8ES8R0hL/OdfE2FDK197pxc5o=",
                                                +      "allowedIps": [
                                                +        "100.64.0.1/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-both.netbird.test",
                                                +      "agentVersion": "1.0.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-both.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-srv.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.10"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "5353"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLWNvbWJv"
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLWNvbWJv"
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/nmdata.json
                                                new file mode 100644
                                                index 000000000..fbf7a5f1e
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/nmdata.json
                                                @@ -0,0 +1,42 @@
                                                +{
                                                +  "Network": {"Serial": 35},
                                                +  "Peers": {
                                                +    "peer-both": {"IP": "100.64.0.1", "Meta": {"WtVersion": "1.0.0"}, "Location": {"CountryCode": "DE", "CityName": "Berlin"}},
                                                +    "peer-badgeo": {"IP": "100.64.0.2", "Meta": {"WtVersion": "1.0.0"}, "Location": {"CountryCode": "FR", "CityName": "Paris"}},
                                                +    "peer-badversion": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.30.0"}, "Location": {"CountryCode": "DE", "CityName": "Berlin"}},
                                                +    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "1.0.0"}}
                                                +  },
                                                +  "Groups": {
                                                +    "grp-clients": {"Peers": ["peer-both", "peer-badgeo", "peer-badversion"]},
                                                +    "grp-srv": {"Peers": ["peer-srv"]}
                                                +  },
                                                +  "PostureChecks": {
                                                +    "chk-combo": {
                                                +      "Checks": {
                                                +        "NBVersionCheck": {"MinVersion": "0.45.0"},
                                                +        "GeoLocationCheck": {
                                                +          "Action": "allow",
                                                +          "Locations": [{"CountryCode": "DE"}]
                                                +        }
                                                +      }
                                                +    }
                                                +  },
                                                +  "Policies": [
                                                +    {
                                                +      "ID": "pol-combo",
                                                +      "Enabled": true,
                                                +      "SourcePostureChecks": ["chk-combo"],
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "tcp",
                                                +          "Ports": ["443"],
                                                +          "Bidirectional": true,
                                                +          "Sources": ["grp-clients"],
                                                +          "Destinations": ["grp-srv"]
                                                +        }
                                                +      ]
                                                +    }
                                                +  ]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/case.json
                                                new file mode 100644
                                                index 000000000..d10bfc9ae
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/case.json
                                                @@ -0,0 +1,4 @@
                                                +{
                                                +  "description": "Peer network range posture check in allow mode over 192.168.0.0/16. peer-office passes on its reported interface network, and peer-by-connip passes on the address it connected from, which the check folds in as a single-host prefix — so either source of address information can satisfy it. peer-remote is outside the range and peer-noaddr reports no address at all, which errors and therefore denies. Note this policy is tcp/22 without the peer's SSH flag, so no authorized users appear.",
                                                +  "peers": ["peer-srv", "peer-office", "peer-by-connip", "peer-remote"]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-by-connip.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-by-connip.json
                                                new file mode 100644
                                                index 000000000..c9cb3ed0a
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-by-connip.json
                                                @@ -0,0 +1,64 @@
                                                +{
                                                +  "Serial": "33",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.3/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-by-connip.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=",
                                                +      "allowedIps": [
                                                +        "100.64.0.10/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-srv.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-by-connip.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.3"
                                                +          },
                                                +          {
                                                +            "Name": "peer-srv.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.10"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.10",
                                                +      "Protocol": "TCP",
                                                +      "Port": "22",
                                                +      "PolicyID": "cG9sLXJhbmdl"
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.10",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "22",
                                                +      "PolicyID": "cG9sLXJhbmdl"
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-office.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-office.json
                                                new file mode 100644
                                                index 000000000..1b9834d12
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-office.json
                                                @@ -0,0 +1,64 @@
                                                +{
                                                +  "Serial": "33",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.1/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-office.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=",
                                                +      "allowedIps": [
                                                +        "100.64.0.10/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-srv.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-office.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-srv.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.10"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.10",
                                                +      "Protocol": "TCP",
                                                +      "Port": "22",
                                                +      "PolicyID": "cG9sLXJhbmdl"
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.10",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "22",
                                                +      "PolicyID": "cG9sLXJhbmdl"
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-remote.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-remote.json
                                                new file mode 100644
                                                index 000000000..134a0aff2
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-remote.json
                                                @@ -0,0 +1,33 @@
                                                +{
                                                +  "Serial": "33",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.2/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-remote.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeersIsEmpty": true,
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-remote.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.2"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-srv.json
                                                new file mode 100644
                                                index 000000000..a8499e031
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-srv.json
                                                @@ -0,0 +1,93 @@
                                                +{
                                                +  "Serial": "33",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.10/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-srv.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "2Za6YHlJPJPv3hG/vDvdT0emXqIQADX9+wE8F1ZsgHU=",
                                                +      "allowedIps": [
                                                +        "100.64.0.3/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-by-connip.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    },
                                                +    {
                                                +      "wgPubKey": "iVuRGJEIX4iqqF3zV01cxAEgyjXW3X4rrMoal6iA4fc=",
                                                +      "allowedIps": [
                                                +        "100.64.0.1/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-office.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-by-connip.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.3"
                                                +          },
                                                +          {
                                                +            "Name": "peer-office.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-srv.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.10"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Protocol": "TCP",
                                                +      "Port": "22",
                                                +      "PolicyID": "cG9sLXJhbmdl"
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "22",
                                                +      "PolicyID": "cG9sLXJhbmdl"
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.3",
                                                +      "Protocol": "TCP",
                                                +      "Port": "22",
                                                +      "PolicyID": "cG9sLXJhbmdl"
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.3",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "22",
                                                +      "PolicyID": "cG9sLXJhbmdl"
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/nmdata.json
                                                new file mode 100644
                                                index 000000000..a258e855d
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/nmdata.json
                                                @@ -0,0 +1,42 @@
                                                +{
                                                +  "Network": {"Serial": 33},
                                                +  "Peers": {
                                                +    "peer-office": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0", "NetworkAddresses": [{"NetIP": "192.168.1.10/24"}]}},
                                                +    "peer-remote": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0", "NetworkAddresses": [{"NetIP": "10.0.0.5/8"}]}},
                                                +    "peer-by-connip": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}, "Location": {"ConnectionIP": "192.168.5.5"}},
                                                +    "peer-noaddr": {"IP": "100.64.0.4", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
                                                +  },
                                                +  "Groups": {
                                                +    "grp-clients": {"Peers": ["peer-office", "peer-remote", "peer-by-connip", "peer-noaddr"]},
                                                +    "grp-srv": {"Peers": ["peer-srv"]}
                                                +  },
                                                +  "PostureChecks": {
                                                +    "chk-range": {
                                                +      "Checks": {
                                                +        "PeerNetworkRangeCheck": {
                                                +          "Action": "allow",
                                                +          "Ranges": ["192.168.0.0/16"]
                                                +        }
                                                +      }
                                                +    }
                                                +  },
                                                +  "Policies": [
                                                +    {
                                                +      "ID": "pol-range",
                                                +      "Enabled": true,
                                                +      "SourcePostureChecks": ["chk-range"],
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "tcp",
                                                +          "Ports": ["22"],
                                                +          "Bidirectional": true,
                                                +          "Sources": ["grp-clients"],
                                                +          "Destinations": ["grp-srv"]
                                                +        }
                                                +      ]
                                                +    }
                                                +  ]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/case.json
                                                new file mode 100644
                                                index 000000000..b23a187d4
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/case.json
                                                @@ -0,0 +1,4 @@
                                                +{
                                                +  "description": "OS version posture check with per-OS minimums. peer-srv must see only the peers that satisfy their own platform's rule: the Linux peer on kernel 6.1 (the check compares the part before the first dash) and the macOS peer on 14.2. The old Linux and macOS peers fail. peer-win fails too even though its version looks modern, because the check defines no Windows minimum and a platform with no rule configured is treated as failing, not as unrestricted — a surprising rule worth freezing. Each rejected peer also loses its own view of peer-srv, since the policy is its only connectivity.",
                                                +  "peers": ["peer-srv", "peer-lin-ok", "peer-lin-old", "peer-win"]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-lin-ok.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-lin-ok.json
                                                new file mode 100644
                                                index 000000000..f299b9fcf
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-lin-ok.json
                                                @@ -0,0 +1,64 @@
                                                +{
                                                +  "Serial": "30",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.1/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-lin-ok.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=",
                                                +      "allowedIps": [
                                                +        "100.64.0.10/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-srv.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-lin-ok.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-srv.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.10"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.10",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLW9z"
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.10",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLW9z"
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-lin-old.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-lin-old.json
                                                new file mode 100644
                                                index 000000000..c0a5412fa
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-lin-old.json
                                                @@ -0,0 +1,33 @@
                                                +{
                                                +  "Serial": "30",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.2/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-lin-old.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeersIsEmpty": true,
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-lin-old.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.2"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-srv.json
                                                new file mode 100644
                                                index 000000000..91883d20b
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-srv.json
                                                @@ -0,0 +1,93 @@
                                                +{
                                                +  "Serial": "30",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.10/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-srv.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "XpdB4aptfFjgsQOHfEO65dNozY8R7EIw3/alAnXjl+k=",
                                                +      "allowedIps": [
                                                +        "100.64.0.1/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-lin-ok.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    },
                                                +    {
                                                +      "wgPubKey": "gRO02HiaHUKq2xTbYJhPsmGj06bK0HGU2tgL0pKB2yQ=",
                                                +      "allowedIps": [
                                                +        "100.64.0.3/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-mac-ok.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-lin-ok.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-mac-ok.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.3"
                                                +          },
                                                +          {
                                                +            "Name": "peer-srv.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.10"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLW9z"
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLW9z"
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.3",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLW9z"
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.3",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "443",
                                                +      "PolicyID": "cG9sLW9z"
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-win.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-win.json
                                                new file mode 100644
                                                index 000000000..0b0cafb26
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-win.json
                                                @@ -0,0 +1,33 @@
                                                +{
                                                +  "Serial": "30",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.5/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-win.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeersIsEmpty": true,
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-win.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.5"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/nmdata.json
                                                new file mode 100644
                                                index 000000000..018c0cc21
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/nmdata.json
                                                @@ -0,0 +1,43 @@
                                                +{
                                                +  "Network": {"Serial": 30},
                                                +  "Peers": {
                                                +    "peer-lin-ok": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0", "GoOS": "linux", "KernelVersion": "6.1.0-arch1"}},
                                                +    "peer-lin-old": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0", "GoOS": "linux", "KernelVersion": "5.4.0-generic"}},
                                                +    "peer-mac-ok": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0", "GoOS": "darwin", "OSVersion": "14.2"}},
                                                +    "peer-mac-old": {"IP": "100.64.0.4", "Meta": {"WtVersion": "0.60.0", "GoOS": "darwin", "OSVersion": "12.0"}},
                                                +    "peer-win": {"IP": "100.64.0.5", "Meta": {"WtVersion": "0.60.0", "GoOS": "windows", "KernelVersion": "10.0.19045"}},
                                                +    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0", "GoOS": "linux", "KernelVersion": "6.1.0-arch1"}}
                                                +  },
                                                +  "Groups": {
                                                +    "grp-clients": {"Peers": ["peer-lin-ok", "peer-lin-old", "peer-mac-ok", "peer-mac-old", "peer-win"]},
                                                +    "grp-srv": {"Peers": ["peer-srv"]}
                                                +  },
                                                +  "PostureChecks": {
                                                +    "chk-os": {
                                                +      "Checks": {
                                                +        "OSVersionCheck": {
                                                +          "Linux": {"MinKernelVersion": "6.0.0"},
                                                +          "Darwin": {"MinVersion": "13.0"}
                                                +        }
                                                +      }
                                                +    }
                                                +  },
                                                +  "Policies": [
                                                +    {
                                                +      "ID": "pol-os",
                                                +      "Enabled": true,
                                                +      "SourcePostureChecks": ["chk-os"],
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "tcp",
                                                +          "Ports": ["443"],
                                                +          "Bidirectional": true,
                                                +          "Sources": ["grp-clients"],
                                                +          "Destinations": ["grp-srv"]
                                                +        }
                                                +      ]
                                                +    }
                                                +  ]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/case.json
                                                new file mode 100644
                                                index 000000000..b730389a6
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/case.json
                                                @@ -0,0 +1,4 @@
                                                +{
                                                +  "description": "Process posture check, which picks the path for the peer's own platform. peer-lin-running and peer-mac-running each have their platform's process running and pass. peer-lin-stopped reports the same file but not running, so it fails — presence of the binary is not enough. peer-bsd runs an unsupported operating system, which the check reports as an error, and errors deny.",
                                                +  "peers": ["peer-srv", "peer-lin-running", "peer-lin-stopped", "peer-bsd"]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-bsd.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-bsd.json
                                                new file mode 100644
                                                index 000000000..f75459d39
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-bsd.json
                                                @@ -0,0 +1,33 @@
                                                +{
                                                +  "Serial": "34",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.4/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-bsd.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeersIsEmpty": true,
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-bsd.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.4"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-lin-running.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-lin-running.json
                                                new file mode 100644
                                                index 000000000..ddfa39472
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-lin-running.json
                                                @@ -0,0 +1,62 @@
                                                +{
                                                +  "Serial": "34",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.1/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-lin-running.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=",
                                                +      "allowedIps": [
                                                +        "100.64.0.10/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-srv.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-lin-running.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-srv.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.10"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.10",
                                                +      "Protocol": "ALL",
                                                +      "PolicyID": "cG9sLXByb2M="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.10",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "ALL",
                                                +      "PolicyID": "cG9sLXByb2M="
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-lin-stopped.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-lin-stopped.json
                                                new file mode 100644
                                                index 000000000..828a933f7
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-lin-stopped.json
                                                @@ -0,0 +1,33 @@
                                                +{
                                                +  "Serial": "34",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.2/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-lin-stopped.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeersIsEmpty": true,
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-lin-stopped.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.2"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-srv.json
                                                new file mode 100644
                                                index 000000000..6eb409623
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-srv.json
                                                @@ -0,0 +1,89 @@
                                                +{
                                                +  "Serial": "34",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.10/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-srv.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "d9LCTf7vwqctprOKyF95j17uPWpRjEeirHfB75RIGlk=",
                                                +      "allowedIps": [
                                                +        "100.64.0.1/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-lin-running.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    },
                                                +    {
                                                +      "wgPubKey": "rgP4xt50GcHp7fFgBoSt8yz5bp5AnOAVHMmOd+rTbR4=",
                                                +      "allowedIps": [
                                                +        "100.64.0.3/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-mac-running.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-lin-running.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-mac-running.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.3"
                                                +          },
                                                +          {
                                                +            "Name": "peer-srv.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.10"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Protocol": "ALL",
                                                +      "PolicyID": "cG9sLXByb2M="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "ALL",
                                                +      "PolicyID": "cG9sLXByb2M="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.3",
                                                +      "Protocol": "ALL",
                                                +      "PolicyID": "cG9sLXByb2M="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.3",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "ALL",
                                                +      "PolicyID": "cG9sLXByb2M="
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/nmdata.json
                                                new file mode 100644
                                                index 000000000..b112e2235
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/nmdata.json
                                                @@ -0,0 +1,70 @@
                                                +{
                                                +  "Network": {"Serial": 34},
                                                +  "Peers": {
                                                +    "peer-lin-running": {
                                                +      "IP": "100.64.0.1",
                                                +      "Meta": {
                                                +        "WtVersion": "0.60.0",
                                                +        "GoOS": "linux",
                                                +        "Files": [{"Path": "/usr/bin/agent", "ProcessIsRunning": true}]
                                                +      }
                                                +    },
                                                +    "peer-lin-stopped": {
                                                +      "IP": "100.64.0.2",
                                                +      "Meta": {
                                                +        "WtVersion": "0.60.0",
                                                +        "GoOS": "linux",
                                                +        "Files": [{"Path": "/usr/bin/agent"}]
                                                +      }
                                                +    },
                                                +    "peer-mac-running": {
                                                +      "IP": "100.64.0.3",
                                                +      "Meta": {
                                                +        "WtVersion": "0.60.0",
                                                +        "GoOS": "darwin",
                                                +        "Files": [{"Path": "/Applications/Agent.app", "ProcessIsRunning": true}]
                                                +      }
                                                +    },
                                                +    "peer-bsd": {
                                                +      "IP": "100.64.0.4",
                                                +      "Meta": {
                                                +        "WtVersion": "0.60.0",
                                                +        "GoOS": "freebsd",
                                                +        "Files": [{"Path": "/usr/bin/agent", "ProcessIsRunning": true}]
                                                +      }
                                                +    },
                                                +    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0", "GoOS": "linux"}}
                                                +  },
                                                +  "Groups": {
                                                +    "grp-clients": {"Peers": ["peer-lin-running", "peer-lin-stopped", "peer-mac-running", "peer-bsd"]},
                                                +    "grp-srv": {"Peers": ["peer-srv"]}
                                                +  },
                                                +  "PostureChecks": {
                                                +    "chk-proc": {
                                                +      "Checks": {
                                                +        "ProcessCheck": {
                                                +          "Processes": [
                                                +            {"LinuxPath": "/usr/bin/agent", "MacPath": "/Applications/Agent.app"}
                                                +          ]
                                                +        }
                                                +      }
                                                +    }
                                                +  },
                                                +  "Policies": [
                                                +    {
                                                +      "ID": "pol-proc",
                                                +      "Enabled": true,
                                                +      "SourcePostureChecks": ["chk-proc"],
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "all",
                                                +          "Bidirectional": true,
                                                +          "Sources": ["grp-clients"],
                                                +          "Destinations": ["grp-srv"]
                                                +        }
                                                +      ]
                                                +    }
                                                +  ]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/case.json
                                                new file mode 100644
                                                index 000000000..b30b06243
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/case.json
                                                @@ -0,0 +1,4 @@
                                                +{
                                                +  "description": "Posture check on a policy granting access to a network resource. peer-ok must receive the route to the resource through peer-r, while peer-bad fails the version check and must receive no route at all. The router's route firewall rule must narrow its SourceRanges to peer-ok's address only — a peer rejected by posture must not be permitted through the routing peer either, which is the enforcement that actually matters.",
                                                +  "peers": ["peer-ok", "peer-bad", "peer-r"]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-bad.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-bad.json
                                                new file mode 100644
                                                index 000000000..b1aa1a494
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-bad.json
                                                @@ -0,0 +1,34 @@
                                                +{
                                                +  "Serial": "38",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.2/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-bad.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeersIsEmpty": true,
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-bad.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.2"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "5353"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-ok.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-ok.json
                                                new file mode 100644
                                                index 000000000..6aa688eef
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-ok.json
                                                @@ -0,0 +1,56 @@
                                                +{
                                                +  "Serial": "38",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.1/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-ok.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
                                                +      "allowedIps": [
                                                +        "100.64.0.9/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-r.netbird.test",
                                                +      "agentVersion": "1.0.0"
                                                +    }
                                                +  ],
                                                +  "Routes": [
                                                +    {
                                                +      "ID": "res-db:peer-r",
                                                +      "Network": "10.80.0.0/24",
                                                +      "NetworkType": "1",
                                                +      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
                                                +      "Metric": "9999",
                                                +      "Masquerade": true,
                                                +      "NetID": "db-subnet",
                                                +      "keepRoute": true
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-ok.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "5353"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-r.json
                                                new file mode 100644
                                                index 000000000..192304ca6
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-r.json
                                                @@ -0,0 +1,69 @@
                                                +{
                                                +  "Serial": "38",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.9/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-r.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "XPXITqbev8jVtIkumbPt5vpohe2OHWvhNGO3z2mgcxM=",
                                                +      "allowedIps": [
                                                +        "100.64.0.1/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-ok.netbird.test",
                                                +      "agentVersion": "1.0.0"
                                                +    }
                                                +  ],
                                                +  "Routes": [
                                                +    {
                                                +      "ID": "res-db:peer-r",
                                                +      "Network": "10.80.0.0/24",
                                                +      "NetworkType": "1",
                                                +      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
                                                +      "Metric": "9999",
                                                +      "Masquerade": true,
                                                +      "NetID": "db-subnet",
                                                +      "keepRoute": true
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-r.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.9"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "5353"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRules": [
                                                +    {
                                                +      "sourceRanges": [
                                                +        "100.64.0.1/32"
                                                +      ],
                                                +      "destination": "10.80.0.0/24",
                                                +      "protocol": "TCP",
                                                +      "portInfo": {
                                                +        "port": 5432
                                                +      },
                                                +      "PolicyID": "cG9sLXJlcw==",
                                                +      "RouteID": "res-db:peer-r"
                                                +    }
                                                +  ],
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/nmdata.json
                                                new file mode 100644
                                                index 000000000..840c5a7f8
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/nmdata.json
                                                @@ -0,0 +1,48 @@
                                                +{
                                                +  "Network": {"Serial": 38},
                                                +  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
                                                +  "Peers": {
                                                +    "peer-ok": {"IP": "100.64.0.1", "Meta": {"WtVersion": "1.0.0"}},
                                                +    "peer-bad": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.30.0"}},
                                                +    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "1.0.0"}}
                                                +  },
                                                +  "Groups": {
                                                +    "grp-dev": {"Peers": ["peer-ok", "peer-bad"]}
                                                +  },
                                                +  "PostureChecks": {
                                                +    "chk-version": {"Checks": {"NBVersionCheck": {"MinVersion": "0.45.0"}}}
                                                +  },
                                                +  "Policies": [
                                                +    {
                                                +      "ID": "pol-res",
                                                +      "Enabled": true,
                                                +      "SourcePostureChecks": ["chk-version"],
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "tcp",
                                                +          "Ports": ["5432"],
                                                +          "Sources": ["grp-dev"],
                                                +          "DestinationResource": {"ID": "res-db", "Type": "subnet"}
                                                +        }
                                                +      ]
                                                +    }
                                                +  ],
                                                +  "ResourcePolicies": {"res-db": [{"ID": "pol-res"}]},
                                                +  "NetworkResources": [
                                                +    {
                                                +      "ID": "res-db",
                                                +      "NetworkID": "net-1",
                                                +      "Name": "db-subnet",
                                                +      "Type": "subnet",
                                                +      "Prefix": "10.80.0.0/24",
                                                +      "Enabled": true
                                                +    }
                                                +  ],
                                                +  "Routers": {
                                                +    "net-1": {
                                                +      "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
                                                +    }
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/case.json
                                                new file mode 100644
                                                index 000000000..203b89533
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/case.json
                                                @@ -0,0 +1,4 @@
                                                +{
                                                +  "description": "The same peer under two policies carrying different posture checks. peer-x is on an old agent version but in an allowed country, so the version-gated policy rejects it while the location-gated one admits it: it must reach peer-srv-b on 8443 and not peer-srv-a at all. Failing one policy's check must not leak into another policy's decision. peer-srv-a correspondingly sees nobody, peer-srv-b sees peer-x.",
                                                +  "peers": ["peer-x", "peer-srv-a", "peer-srv-b"]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-srv-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-srv-a.json
                                                new file mode 100644
                                                index 000000000..8008db39c
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-srv-a.json
                                                @@ -0,0 +1,33 @@
                                                +{
                                                +  "Serial": "36",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.11/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-srv-a.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeersIsEmpty": true,
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-srv-a.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.11"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "5353"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-srv-b.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-srv-b.json
                                                new file mode 100644
                                                index 000000000..c7bc45742
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-srv-b.json
                                                @@ -0,0 +1,64 @@
                                                +{
                                                +  "Serial": "36",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.12/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-srv-b.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "N1oKwtIwXdTDF0HdKDss0gPhxkqz+/Z/91734QZVCng=",
                                                +      "allowedIps": [
                                                +        "100.64.0.1/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-x.netbird.test",
                                                +      "agentVersion": "0.40.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-srv-b.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.12"
                                                +          },
                                                +          {
                                                +            "Name": "peer-x.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "5353"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Protocol": "TCP",
                                                +      "Port": "8443",
                                                +      "PolicyID": "cG9sLWxlbmllbnQ="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "8443",
                                                +      "PolicyID": "cG9sLWxlbmllbnQ="
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-x.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-x.json
                                                new file mode 100644
                                                index 000000000..461454233
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-x.json
                                                @@ -0,0 +1,64 @@
                                                +{
                                                +  "Serial": "36",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.1/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-x.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "fT/Mb0QBqXGx2q06gXqizvlXp5uz+ErGCaKmCEXVbMk=",
                                                +      "allowedIps": [
                                                +        "100.64.0.12/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-srv-b.netbird.test",
                                                +      "agentVersion": "1.0.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-srv-b.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.12"
                                                +          },
                                                +          {
                                                +            "Name": "peer-x.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "5353"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.12",
                                                +      "Protocol": "TCP",
                                                +      "Port": "8443",
                                                +      "PolicyID": "cG9sLWxlbmllbnQ="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.12",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "8443",
                                                +      "PolicyID": "cG9sLWxlbmllbnQ="
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/nmdata.json
                                                new file mode 100644
                                                index 000000000..2e0512194
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/nmdata.json
                                                @@ -0,0 +1,55 @@
                                                +{
                                                +  "Network": {"Serial": 36},
                                                +  "Peers": {
                                                +    "peer-x": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.40.0"}, "Location": {"CountryCode": "DE", "CityName": "Berlin"}},
                                                +    "peer-srv-a": {"IP": "100.64.0.11", "Meta": {"WtVersion": "1.0.0"}},
                                                +    "peer-srv-b": {"IP": "100.64.0.12", "Meta": {"WtVersion": "1.0.0"}}
                                                +  },
                                                +  "Groups": {
                                                +    "grp-clients": {"Peers": ["peer-x"]},
                                                +    "grp-srv-a": {"Peers": ["peer-srv-a"]},
                                                +    "grp-srv-b": {"Peers": ["peer-srv-b"]}
                                                +  },
                                                +  "PostureChecks": {
                                                +    "chk-version": {"Checks": {"NBVersionCheck": {"MinVersion": "0.45.0"}}},
                                                +    "chk-geo": {
                                                +      "Checks": {
                                                +        "GeoLocationCheck": {"Action": "allow", "Locations": [{"CountryCode": "DE"}]}
                                                +      }
                                                +    }
                                                +  },
                                                +  "Policies": [
                                                +    {
                                                +      "ID": "pol-strict",
                                                +      "Enabled": true,
                                                +      "SourcePostureChecks": ["chk-version"],
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "tcp",
                                                +          "Ports": ["443"],
                                                +          "Bidirectional": true,
                                                +          "Sources": ["grp-clients"],
                                                +          "Destinations": ["grp-srv-a"]
                                                +        }
                                                +      ]
                                                +    },
                                                +    {
                                                +      "ID": "pol-lenient",
                                                +      "Enabled": true,
                                                +      "SourcePostureChecks": ["chk-geo"],
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "tcp",
                                                +          "Ports": ["8443"],
                                                +          "Bidirectional": true,
                                                +          "Sources": ["grp-clients"],
                                                +          "Destinations": ["grp-srv-b"]
                                                +        }
                                                +      ]
                                                +    }
                                                +  ]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/case.json
                                                new file mode 100644
                                                index 000000000..5f9e98ea7
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/case.json
                                                @@ -0,0 +1,7 @@
                                                +{
                                                +  "description": "A reverse-proxy service targeting a domain network resource. The synthesised proxy-access ACL is a resource policy too: on the account path the resource-policy map was built after injection, so the routing peer must carry a route firewall rule sourced from the proxy peer for the resource's domain. The store reads the policies table and ResourcePolicies never holds it, so only the synthesis puts it there.",
                                                +  "peers": [
                                                +    "router-peer",
                                                +    "proxy-peer"
                                                +  ]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/proxy-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/proxy-peer.json
                                                new file mode 100644
                                                index 000000000..a9e74061f
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/proxy-peer.json
                                                @@ -0,0 +1,60 @@
                                                +{
                                                +  "Serial": "32",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.99/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "proxy-peer.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "4IuEBozN3DjkJXkJtBp/9Ekkr0zkfucwk9gqxWhV1hE=",
                                                +      "allowedIps": [
                                                +        "100.64.0.9/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "router-peer.netbird.test",
                                                +      "lazyState": "LazyStateLazy",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "Routes": [
                                                +    {
                                                +      "ID": "res-domain:router-peer",
                                                +      "Network": "192.0.2.0/32",
                                                +      "NetworkType": "3",
                                                +      "Peer": "4IuEBozN3DjkJXkJtBp/9Ekkr0zkfucwk9gqxWhV1hE=",
                                                +      "Metric": "9999",
                                                +      "Masquerade": true,
                                                +      "NetID": "app-domain",
                                                +      "Domains": [
                                                +        "app.internal"
                                                +      ],
                                                +      "keepRoute": true
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "proxy-peer.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.99"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/router-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/router-peer.json
                                                new file mode 100644
                                                index 000000000..d0bb48a3b
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/router-peer.json
                                                @@ -0,0 +1,80 @@
                                                +{
                                                +  "Serial": "32",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.9/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "router-peer.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "MgrwmZOHFZ+i0SXrfbBOcATxBAQsWKllrGL/32GvlxY=",
                                                +      "allowedIps": [
                                                +        "100.64.0.99/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "proxy-peer.netbird.test",
                                                +      "lazyState": "LazyStateLazy",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "Routes": [
                                                +    {
                                                +      "ID": "res-domain:router-peer",
                                                +      "Network": "192.0.2.0/32",
                                                +      "NetworkType": "3",
                                                +      "Peer": "4IuEBozN3DjkJXkJtBp/9Ekkr0zkfucwk9gqxWhV1hE=",
                                                +      "Metric": "9999",
                                                +      "Masquerade": true,
                                                +      "NetID": "app-domain",
                                                +      "Domains": [
                                                +        "app.internal"
                                                +      ],
                                                +      "keepRoute": true
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "router-peer.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.9"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "firewallRulesIsEmpty": true,
                                                +  "routesFirewallRules": [
                                                +    {
                                                +      "sourceRanges": [
                                                +        "100.64.0.99/32"
                                                +      ],
                                                +      "destination": "192.0.2.0/32",
                                                +      "protocol": "TCP",
                                                +      "portInfo": {
                                                +        "range": {
                                                +          "start": 443,
                                                +          "end": 443
                                                +        }
                                                +      },
                                                +      "isDynamic": true,
                                                +      "domains": [
                                                +        "app.internal"
                                                +      ],
                                                +      "PolicyID": "cHJveHktYWNjZXNzLXN2Yy0xLXByb3h5LXBlZXIt",
                                                +      "RouteID": "res-domain:router-peer"
                                                +    }
                                                +  ],
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/nmdata.json
                                                new file mode 100644
                                                index 000000000..cbf729d9f
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/nmdata.json
                                                @@ -0,0 +1,44 @@
                                                +{
                                                +  "Network": {"Serial": 32},
                                                +  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
                                                +  "Peers": {
                                                +    "router-peer": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "proxy-peer": {
                                                +      "IP": "100.64.0.99",
                                                +      "Meta": {"WtVersion": "0.60.0"},
                                                +      "ProxyMeta": {"Embedded": true, "Cluster": "eu.proxy.netbird.io"}
                                                +    }
                                                +  },
                                                +  "NetworkResources": [
                                                +    {
                                                +      "ID": "res-domain",
                                                +      "NetworkID": "net-1",
                                                +      "Name": "app-domain",
                                                +      "Type": "domain",
                                                +      "Domain": "app.internal",
                                                +      "Enabled": true
                                                +    }
                                                +  ],
                                                +  "Routers": {
                                                +    "net-1": {
                                                +      "router-peer": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
                                                +    }
                                                +  },
                                                +  "ProxyTargetedDomainResourceIDs": {"res-domain": {}},
                                                +  "Services": [
                                                +    {
                                                +      "ID": "svc-1",
                                                +      "Enabled": true,
                                                +      "Mode": "http",
                                                +      "ProxyCluster": "eu.proxy.netbird.io",
                                                +      "Targets": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Protocol": "https",
                                                +          "TargetID": "res-domain",
                                                +          "TargetType": "domain"
                                                +        }
                                                +      ]
                                                +    }
                                                +  ]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/case.json
                                                new file mode 100644
                                                index 000000000..d8450505f
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/case.json
                                                @@ -0,0 +1,7 @@
                                                +{
                                                +  "description": "A reverse-proxy service targeting a peer. The proxy-access ACL is synthesised from Services, never loaded from the policies table, and is what lets the cluster's embedded proxy peer reach the target on the target's port: proxy-peer gets an OUT rule to app-peer on TCP 8080 and app-peer the matching IN rule. Without the synthesis both maps are empty of each other.",
                                                +  "peers": [
                                                +    "proxy-peer",
                                                +    "app-peer"
                                                +  ]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/app-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/app-peer.json
                                                new file mode 100644
                                                index 000000000..0e71a62ba
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/app-peer.json
                                                @@ -0,0 +1,64 @@
                                                +{
                                                +  "Serial": "30",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.10/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "app-peer.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "MgrwmZOHFZ+i0SXrfbBOcATxBAQsWKllrGL/32GvlxY=",
                                                +      "allowedIps": [
                                                +        "100.64.0.99/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "proxy-peer.netbird.test",
                                                +      "lazyState": "LazyStateLazy",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "app-peer.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.10"
                                                +          },
                                                +          {
                                                +            "Name": "proxy-peer.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.99"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.99",
                                                +      "Protocol": "TCP",
                                                +      "PortInfo": {
                                                +        "range": {
                                                +          "start": 8080,
                                                +          "end": 8080
                                                +        }
                                                +      },
                                                +      "PolicyID": "cHJveHktYWNjZXNzLXN2Yy0xLXByb3h5LXBlZXIt"
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/proxy-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/proxy-peer.json
                                                new file mode 100644
                                                index 000000000..4c6053319
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/proxy-peer.json
                                                @@ -0,0 +1,65 @@
                                                +{
                                                +  "Serial": "30",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.99/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "proxy-peer.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "/wFxrqMtMwWNZak/f0UDddUkCZMTmxNuiuk4/RGGNcY=",
                                                +      "allowedIps": [
                                                +        "100.64.0.10/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "app-peer.netbird.test",
                                                +      "lazyState": "LazyStateLazy",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "app-peer.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.10"
                                                +          },
                                                +          {
                                                +            "Name": "proxy-peer.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.99"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.10",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "PortInfo": {
                                                +        "range": {
                                                +          "start": 8080,
                                                +          "end": 8080
                                                +        }
                                                +      },
                                                +      "PolicyID": "cHJveHktYWNjZXNzLXN2Yy0xLXByb3h5LXBlZXIt"
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/nmdata.json
                                                new file mode 100644
                                                index 000000000..b4645fb90
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/nmdata.json
                                                @@ -0,0 +1,29 @@
                                                +{
                                                +  "Network": {"Serial": 30},
                                                +  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
                                                +  "Peers": {
                                                +    "proxy-peer": {
                                                +      "IP": "100.64.0.99",
                                                +      "Meta": {"WtVersion": "0.60.0"},
                                                +      "ProxyMeta": {"Embedded": true, "Cluster": "eu.proxy.netbird.io"}
                                                +    },
                                                +    "app-peer": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
                                                +  },
                                                +  "Services": [
                                                +    {
                                                +      "ID": "svc-1",
                                                +      "Enabled": true,
                                                +      "Mode": "http",
                                                +      "ProxyCluster": "eu.proxy.netbird.io",
                                                +      "Targets": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Port": 8080,
                                                +          "Protocol": "http",
                                                +          "TargetID": "app-peer",
                                                +          "TargetType": "peer"
                                                +        }
                                                +      ]
                                                +    }
                                                +  ]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/case.json
                                                new file mode 100644
                                                index 000000000..8ef26f4cc
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/case.json
                                                @@ -0,0 +1,7 @@
                                                +{
                                                +  "description": "A private reverse-proxy service. The private-access ACL is synthesised from Services, never loaded from the policies table, and is what lets the service's AccessGroups reach the cluster's embedded proxy peer on TCP 80 and 443: user-peer gets OUT rules on both ports and proxy-peer the matching IN rules. Without the synthesis both maps are empty of each other.",
                                                +  "peers": [
                                                +    "user-peer",
                                                +    "proxy-peer"
                                                +  ]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/golden/proxy-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/golden/proxy-peer.json
                                                new file mode 100644
                                                index 000000000..7022f0c70
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/golden/proxy-peer.json
                                                @@ -0,0 +1,75 @@
                                                +{
                                                +  "Serial": "31",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.99/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "proxy-peer.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "v19TN/CymWAs/WppcLjz3atM+t4ySNdImGtoPu4wnT8=",
                                                +      "allowedIps": [
                                                +        "100.64.0.10/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "user-peer.netbird.test",
                                                +      "lazyState": "LazyStateLazy",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "proxy-peer.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.99"
                                                +          },
                                                +          {
                                                +            "Name": "user-peer.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.10"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.10",
                                                +      "Protocol": "TCP",
                                                +      "PortInfo": {
                                                +        "range": {
                                                +          "start": 443,
                                                +          "end": 443
                                                +        }
                                                +      },
                                                +      "PolicyID": "cHJpdmF0ZS1hY2Nlc3Mtc3ZjLTEtcHJveHktcGVlcg=="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.10",
                                                +      "Protocol": "TCP",
                                                +      "PortInfo": {
                                                +        "range": {
                                                +          "start": 80,
                                                +          "end": 80
                                                +        }
                                                +      },
                                                +      "PolicyID": "cHJpdmF0ZS1hY2Nlc3Mtc3ZjLTEtcHJveHktcGVlcg=="
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/golden/user-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/golden/user-peer.json
                                                new file mode 100644
                                                index 000000000..7197abf22
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/golden/user-peer.json
                                                @@ -0,0 +1,77 @@
                                                +{
                                                +  "Serial": "31",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.10/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "user-peer.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "MgrwmZOHFZ+i0SXrfbBOcATxBAQsWKllrGL/32GvlxY=",
                                                +      "allowedIps": [
                                                +        "100.64.0.99/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "proxy-peer.netbird.test",
                                                +      "lazyState": "LazyStateLazy",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "proxy-peer.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.99"
                                                +          },
                                                +          {
                                                +            "Name": "user-peer.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.10"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.99",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "PortInfo": {
                                                +        "range": {
                                                +          "start": 443,
                                                +          "end": 443
                                                +        }
                                                +      },
                                                +      "PolicyID": "cHJpdmF0ZS1hY2Nlc3Mtc3ZjLTEtcHJveHktcGVlcg=="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.99",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "PortInfo": {
                                                +        "range": {
                                                +          "start": 80,
                                                +          "end": 80
                                                +        }
                                                +      },
                                                +      "PolicyID": "cHJpdmF0ZS1hY2Nlc3Mtc3ZjLTEtcHJveHktcGVlcg=="
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/nmdata.json
                                                new file mode 100644
                                                index 000000000..066849bd4
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/nmdata.json
                                                @@ -0,0 +1,26 @@
                                                +{
                                                +  "Network": {"Serial": 31},
                                                +  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
                                                +  "Peers": {
                                                +    "user-peer": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "other-peer": {"IP": "100.64.0.11", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "proxy-peer": {
                                                +      "IP": "100.64.0.99",
                                                +      "Meta": {"WtVersion": "0.60.0"},
                                                +      "ProxyMeta": {"Embedded": true, "Cluster": "eu.proxy.netbird.io"}
                                                +    }
                                                +  },
                                                +  "Groups": {
                                                +    "grp-admins": {"Peers": ["user-peer"]}
                                                +  },
                                                +  "Services": [
                                                +    {
                                                +      "ID": "svc-1",
                                                +      "Enabled": true,
                                                +      "Private": true,
                                                +      "Mode": "http",
                                                +      "ProxyCluster": "eu.proxy.netbird.io",
                                                +      "AccessGroups": ["grp-admins", "grp-deleted"]
                                                +    }
                                                +  ]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/case.json
                                                new file mode 100644
                                                index 000000000..8d99a239c
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/case.json
                                                @@ -0,0 +1,4 @@
                                                +{
                                                +  "description": "Classic route with access control groups: instead of the wide-open default permit, peer-r's route firewall rule must be narrowed to the policy that targets the ACL group — protocol and port from that rule, SourceRanges limited to the two source peers. peer-a still receives the route itself.",
                                                +  "peers": ["peer-a", "peer-r"]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/golden/peer-a.json
                                                new file mode 100644
                                                index 000000000..c52aecce9
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/golden/peer-a.json
                                                @@ -0,0 +1,76 @@
                                                +{
                                                +  "Serial": "27",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.1/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-a.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
                                                +      "allowedIps": [
                                                +        "100.64.0.9/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-r.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "Routes": [
                                                +    {
                                                +      "ID": "rt-acl",
                                                +      "Network": "10.70.0.0/24",
                                                +      "NetworkType": "1",
                                                +      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
                                                +      "Metric": "9999",
                                                +      "Masquerade": true,
                                                +      "NetID": "db-net"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-a.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-r.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.9"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.9",
                                                +      "Protocol": "TCP",
                                                +      "Port": "3306",
                                                +      "PolicyID": "cG9sLWFjbA=="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.9",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "3306",
                                                +      "PolicyID": "cG9sLWFjbA=="
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/golden/peer-r.json
                                                new file mode 100644
                                                index 000000000..cc03ef08c
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/golden/peer-r.json
                                                @@ -0,0 +1,119 @@
                                                +{
                                                +  "Serial": "27",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.9/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-r.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=",
                                                +      "allowedIps": [
                                                +        "100.64.0.2/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-b.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    },
                                                +    {
                                                +      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
                                                +      "allowedIps": [
                                                +        "100.64.0.1/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-a.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "Routes": [
                                                +    {
                                                +      "ID": "rt-acl",
                                                +      "Network": "10.70.0.0/24",
                                                +      "NetworkType": "1",
                                                +      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
                                                +      "Metric": "9999",
                                                +      "Masquerade": true,
                                                +      "NetID": "db-net"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-a.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-b.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.2"
                                                +          },
                                                +          {
                                                +            "Name": "peer-r.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.9"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Protocol": "TCP",
                                                +      "Port": "3306",
                                                +      "PolicyID": "cG9sLWFjbA=="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "3306",
                                                +      "PolicyID": "cG9sLWFjbA=="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.2",
                                                +      "Protocol": "TCP",
                                                +      "Port": "3306",
                                                +      "PolicyID": "cG9sLWFjbA=="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.2",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "3306",
                                                +      "PolicyID": "cG9sLWFjbA=="
                                                +    }
                                                +  ],
                                                +  "routesFirewallRules": [
                                                +    {
                                                +      "sourceRanges": [
                                                +        "100.64.0.1/32",
                                                +        "100.64.0.2/32"
                                                +      ],
                                                +      "destination": "10.70.0.0/24",
                                                +      "protocol": "TCP",
                                                +      "portInfo": {
                                                +        "port": 3306
                                                +      },
                                                +      "PolicyID": "cG9sLWFjbA==",
                                                +      "RouteID": "rt-acl"
                                                +    }
                                                +  ],
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/nmdata.json
                                                new file mode 100644
                                                index 000000000..d7bfd0d08
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/nmdata.json
                                                @@ -0,0 +1,45 @@
                                                +{
                                                +  "Network": {"Serial": 27},
                                                +  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
                                                +  "Peers": {
                                                +    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
                                                +  },
                                                +  "Groups": {
                                                +    "grp-dev": {"Peers": ["peer-a", "peer-b"]},
                                                +    "grp-acl": {"Peers": ["peer-r"]}
                                                +  },
                                                +  "Policies": [
                                                +    {
                                                +      "ID": "pol-acl",
                                                +      "Enabled": true,
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "tcp",
                                                +          "Ports": ["3306"],
                                                +          "Bidirectional": true,
                                                +          "Sources": ["grp-dev"],
                                                +          "Destinations": ["grp-acl"]
                                                +        }
                                                +      ]
                                                +    }
                                                +  ],
                                                +  "Routes": [
                                                +    {
                                                +      "ID": "rt-acl",
                                                +      "NetID": "db-net",
                                                +      "Network": "10.70.0.0/24",
                                                +      "NetworkType": 1,
                                                +      "Peer": "peer-r",
                                                +      "PeerID": "peer-r",
                                                +      "Groups": ["grp-dev"],
                                                +      "AccessControlGroups": ["grp-acl"],
                                                +      "Metric": 9999,
                                                +      "Masquerade": true,
                                                +      "Enabled": true
                                                +    }
                                                +  ]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/case.json
                                                new file mode 100644
                                                index 000000000..52d45346a
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/case.json
                                                @@ -0,0 +1,4 @@
                                                +{
                                                +  "description": "Classic route served by a peer group instead of one peer: each routing peer's copy takes the route id with its own peer id appended and drops the PeerGroups field, and the distribution group's peer-a must receive both copies as an HA pair. Each router receives only its own copy plus a default-permit route firewall rule, because the route carries no access control groups. A policy connecting the two groups is required — route distribution follows peers the target may already talk to.",
                                                +  "peers": ["peer-a", "peer-r1"]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/golden/peer-a.json
                                                new file mode 100644
                                                index 000000000..fd9e32274
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/golden/peer-a.json
                                                @@ -0,0 +1,114 @@
                                                +{
                                                +  "Serial": "26",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.1/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-a.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=",
                                                +      "allowedIps": [
                                                +        "100.64.0.11/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-r1.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    },
                                                +    {
                                                +      "wgPubKey": "YC6sbWtvpB2d6W/XqsNIV9crsrXBoVDdwmhCoszjCps=",
                                                +      "allowedIps": [
                                                +        "100.64.0.12/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-r2.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "Routes": [
                                                +    {
                                                +      "ID": "rt-ha:peer-r1",
                                                +      "Network": "10.60.0.0/24",
                                                +      "NetworkType": "1",
                                                +      "Peer": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=",
                                                +      "Metric": "9999",
                                                +      "Masquerade": true,
                                                +      "NetID": "ha-net"
                                                +    },
                                                +    {
                                                +      "ID": "rt-ha:peer-r2",
                                                +      "Network": "10.60.0.0/24",
                                                +      "NetworkType": "1",
                                                +      "Peer": "YC6sbWtvpB2d6W/XqsNIV9crsrXBoVDdwmhCoszjCps=",
                                                +      "Metric": "9999",
                                                +      "Masquerade": true,
                                                +      "NetID": "ha-net"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-a.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-r1.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.11"
                                                +          },
                                                +          {
                                                +            "Name": "peer-r2.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.12"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.11",
                                                +      "Protocol": "TCP",
                                                +      "Port": "8080",
                                                +      "PolicyID": "cG9sLWNvbm4="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.11",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "8080",
                                                +      "PolicyID": "cG9sLWNvbm4="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.12",
                                                +      "Protocol": "TCP",
                                                +      "Port": "8080",
                                                +      "PolicyID": "cG9sLWNvbm4="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.12",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "8080",
                                                +      "PolicyID": "cG9sLWNvbm4="
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/golden/peer-r1.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/golden/peer-r1.json
                                                new file mode 100644
                                                index 000000000..bd8bdecd0
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/golden/peer-r1.json
                                                @@ -0,0 +1,93 @@
                                                +{
                                                +  "Serial": "26",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.11/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-r1.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
                                                +      "allowedIps": [
                                                +        "100.64.0.1/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-a.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "Routes": [
                                                +    {
                                                +      "ID": "rt-ha:peer-r1",
                                                +      "Network": "10.60.0.0/24",
                                                +      "NetworkType": "1",
                                                +      "Peer": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=",
                                                +      "Metric": "9999",
                                                +      "Masquerade": true,
                                                +      "NetID": "ha-net"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-a.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-r1.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.11"
                                                +          },
                                                +          {
                                                +            "Name": "peer-r2.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.12"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Protocol": "TCP",
                                                +      "Port": "8080",
                                                +      "PolicyID": "cG9sLWNvbm4="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "8080",
                                                +      "PolicyID": "cG9sLWNvbm4="
                                                +    }
                                                +  ],
                                                +  "routesFirewallRules": [
                                                +    {
                                                +      "sourceRanges": [
                                                +        "0.0.0.0/0"
                                                +      ],
                                                +      "destination": "10.60.0.0/24",
                                                +      "protocol": "ALL",
                                                +      "portInfo": {},
                                                +      "RouteID": "rt-ha:peer-r1"
                                                +    }
                                                +  ],
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/nmdata.json
                                                new file mode 100644
                                                index 000000000..8b81a585f
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/nmdata.json
                                                @@ -0,0 +1,43 @@
                                                +{
                                                +  "Network": {"Serial": 26},
                                                +  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
                                                +  "Peers": {
                                                +    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-r1": {"IP": "100.64.0.11", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-r2": {"IP": "100.64.0.12", "Meta": {"WtVersion": "0.60.0"}}
                                                +  },
                                                +  "Groups": {
                                                +    "grp-dev": {"Peers": ["peer-a"]},
                                                +    "grp-routers": {"Peers": ["peer-r1", "peer-r2"]}
                                                +  },
                                                +  "Policies": [
                                                +    {
                                                +      "ID": "pol-conn",
                                                +      "Enabled": true,
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "tcp",
                                                +          "Ports": ["8080"],
                                                +          "Bidirectional": true,
                                                +          "Sources": ["grp-dev"],
                                                +          "Destinations": ["grp-routers"]
                                                +        }
                                                +      ]
                                                +    }
                                                +  ],
                                                +  "Routes": [
                                                +    {
                                                +      "ID": "rt-ha",
                                                +      "NetID": "ha-net",
                                                +      "Network": "10.60.0.0/24",
                                                +      "NetworkType": 1,
                                                +      "PeerGroups": ["grp-routers"],
                                                +      "Groups": ["grp-dev"],
                                                +      "Metric": 9999,
                                                +      "Masquerade": true,
                                                +      "Enabled": true
                                                +    }
                                                +  ]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/case.json
                                                new file mode 100644
                                                index 000000000..a94d3653f
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/case.json
                                                @@ -0,0 +1,7 @@
                                                +{
                                                +  "description": "Classic route distributed to grp-dev plus a network resource behind router peer-r with a resource policy; peer-a gets routes and route firewall rules, peer-r gets the routing-peer view.",
                                                +  "peers": [
                                                +    "peer-a",
                                                +    "peer-r"
                                                +  ]
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/golden/peer-a.json
                                                new file mode 100644
                                                index 000000000..9426bd846
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/golden/peer-a.json
                                                @@ -0,0 +1,86 @@
                                                +{
                                                +  "Serial": "7",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.1/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-a.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
                                                +      "allowedIps": [
                                                +        "100.64.0.9/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-r.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "Routes": [
                                                +    {
                                                +      "ID": "res-db:peer-r",
                                                +      "Network": "10.10.0.0/24",
                                                +      "NetworkType": "1",
                                                +      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
                                                +      "Metric": "9999",
                                                +      "Masquerade": true,
                                                +      "NetID": "db",
                                                +      "keepRoute": true
                                                +    },
                                                +    {
                                                +      "ID": "rt-1",
                                                +      "Network": "10.20.0.0/24",
                                                +      "NetworkType": "1",
                                                +      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
                                                +      "Metric": "9999",
                                                +      "Masquerade": true,
                                                +      "NetID": "office-net"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-a.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-r.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.9"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.9",
                                                +      "Protocol": "TCP",
                                                +      "Port": "8080",
                                                +      "PolicyID": "cG9sLWNvbm4="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.9",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "8080",
                                                +      "PolicyID": "cG9sLWNvbm4="
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/golden/peer-r.json
                                                new file mode 100644
                                                index 000000000..e5971796d
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/golden/peer-r.json
                                                @@ -0,0 +1,138 @@
                                                +{
                                                +  "Serial": "7",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.9/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-r.netbird.test",
                                                +    "RoutingPeerDnsResolutionEnabled": true,
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=",
                                                +      "allowedIps": [
                                                +        "100.64.0.2/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-b.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    },
                                                +    {
                                                +      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
                                                +      "allowedIps": [
                                                +        "100.64.0.1/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-a.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "Routes": [
                                                +    {
                                                +      "ID": "res-db:peer-r",
                                                +      "Network": "10.10.0.0/24",
                                                +      "NetworkType": "1",
                                                +      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
                                                +      "Metric": "9999",
                                                +      "Masquerade": true,
                                                +      "NetID": "db",
                                                +      "keepRoute": true
                                                +    },
                                                +    {
                                                +      "ID": "rt-1",
                                                +      "Network": "10.20.0.0/24",
                                                +      "NetworkType": "1",
                                                +      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
                                                +      "Metric": "9999",
                                                +      "Masquerade": true,
                                                +      "NetID": "office-net"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-a.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-b.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.2"
                                                +          },
                                                +          {
                                                +            "Name": "peer-r.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.9"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Protocol": "TCP",
                                                +      "Port": "8080",
                                                +      "PolicyID": "cG9sLWNvbm4="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "8080",
                                                +      "PolicyID": "cG9sLWNvbm4="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.2",
                                                +      "Protocol": "TCP",
                                                +      "Port": "8080",
                                                +      "PolicyID": "cG9sLWNvbm4="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.2",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "8080",
                                                +      "PolicyID": "cG9sLWNvbm4="
                                                +    }
                                                +  ],
                                                +  "routesFirewallRules": [
                                                +    {
                                                +      "sourceRanges": [
                                                +        "0.0.0.0/0"
                                                +      ],
                                                +      "destination": "10.20.0.0/24",
                                                +      "protocol": "ALL",
                                                +      "portInfo": {},
                                                +      "RouteID": "rt-1"
                                                +    },
                                                +    {
                                                +      "sourceRanges": [
                                                +        "100.64.0.1/32",
                                                +        "100.64.0.2/32"
                                                +      ],
                                                +      "destination": "10.10.0.0/24",
                                                +      "protocol": "TCP",
                                                +      "portInfo": {
                                                +        "port": 5432
                                                +      },
                                                +      "PolicyID": "cG9sLWRi",
                                                +      "RouteID": "res-db:peer-r"
                                                +    }
                                                +  ],
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/nmdata.json
                                                new file mode 100644
                                                index 000000000..795047f50
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/nmdata.json
                                                @@ -0,0 +1,97 @@
                                                +{
                                                +  "Network": {"Serial": 7},
                                                +  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
                                                +  "Peers": {
                                                +    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
                                                +  },
                                                +  "Groups": {
                                                +    "grp-dev": {"Peers": ["peer-a", "peer-b"]},
                                                +    "grp-routers": {"Peers": ["peer-r"]}
                                                +  },
                                                +  "Policies": [
                                                +    {
                                                +      "ID": "pol-conn",
                                                +      "PublicID": "pol-conn-pub",
                                                +      "Enabled": true,
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "tcp",
                                                +          "Bidirectional": true,
                                                +          "Ports": ["8080"],
                                                +          "Sources": ["grp-dev"],
                                                +          "Destinations": ["grp-routers"]
                                                +        }
                                                +      ]
                                                +    },
                                                +    {
                                                +      "ID": "pol-db",
                                                +      "PublicID": "pol-db-pub",
                                                +      "Enabled": true,
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "tcp",
                                                +          "Ports": ["5432"],
                                                +          "Sources": ["grp-dev"],
                                                +          "DestinationResource": {"ID": "res-db", "Type": "subnet"}
                                                +        }
                                                +      ]
                                                +    }
                                                +  ],
                                                +  "ResourcePolicies": {
                                                +    "res-db": [
                                                +      {
                                                +        "ID": "pol-db",
                                                +        "PublicID": "pol-db-pub",
                                                +        "Enabled": true,
                                                +        "Rules": [
                                                +          {
                                                +            "Enabled": true,
                                                +            "Action": "accept",
                                                +            "Protocol": "tcp",
                                                +            "Ports": ["5432"],
                                                +            "Sources": ["grp-dev"],
                                                +            "DestinationResource": {"ID": "res-db", "Type": "subnet"}
                                                +          }
                                                +        ]
                                                +      }
                                                +    ]
                                                +  },
                                                +  "Routes": [
                                                +    {
                                                +      "ID": "rt-1",
                                                +      "PublicID": "rt-1-pub",
                                                +      "NetID": "office-net",
                                                +      "Network": "10.20.0.0/24",
                                                +      "NetworkType": 1,
                                                +      "Peer": "peer-r",
                                                +      "PeerID": "peer-r",
                                                +      "Metric": 9999,
                                                +      "Masquerade": true,
                                                +      "Enabled": true,
                                                +      "Groups": ["grp-dev"]
                                                +    }
                                                +  ],
                                                +  "NetworkResources": [
                                                +    {
                                                +      "ID": "res-db",
                                                +      "PublicID": "res-db-pub",
                                                +      "NetworkID": "net-1",
                                                +      "Name": "db",
                                                +      "Type": "subnet",
                                                +      "Prefix": "10.10.0.0/24",
                                                +      "Enabled": true
                                                +    }
                                                +  ],
                                                +  "Routers": {
                                                +    "net-1": {
                                                +      "peer-r": {"Masquerade": true, "Metric": 9999, "Enabled": true}
                                                +    }
                                                +  },
                                                +  "NetworkXIDToPublicID": {"net-1": "net-1-pub"}
                                                +}
                                                \ No newline at end of file
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/case.json
                                                new file mode 100644
                                                index 000000000..78ac576d9
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/case.json
                                                @@ -0,0 +1,4 @@
                                                +{
                                                +  "description": "netbird-ssh with AuthorizedGroups: grp-admins members may log in as root, grp-oncall (empty local-user list) as any machine user; peer-srv must receive both mappings in SshAuth, clients get plain TCP firewall rules. THE ENVELOPE EXPECTATION CURRENTLY FAILS, and should: encodeAuthorizedGroups/encodeGroupIDToUserIDs translate group keys via components.Groups, which never holds user-only groups, so the wire loses every authorized user while PeerConfig still reports sshEnabled — the peer runs sshd and denies every login. Pre-existing on main since PR #6711, not a regression. Fix the encoder, do not weaken this expectation.",
                                                +  "peers": ["peer-srv", "peer-a"]
                                                +}
                                                \ No newline at end of file
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/golden/peer-a.json
                                                new file mode 100644
                                                index 000000000..6560bc74d
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/golden/peer-a.json
                                                @@ -0,0 +1,63 @@
                                                +{
                                                +  "Serial": "10",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.1/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-a.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=",
                                                +      "allowedIps": [
                                                +        "100.64.0.10/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-srv.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-a.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-srv.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.10"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.10",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "PortInfo": {
                                                +        "range": {
                                                +          "start": 22022,
                                                +          "end": 22022
                                                +        }
                                                +      },
                                                +      "PolicyID": "cG9sLXNzaA=="
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/golden/peer-srv.json
                                                new file mode 100644
                                                index 000000000..0a66bcbf0
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/golden/peer-srv.json
                                                @@ -0,0 +1,109 @@
                                                +{
                                                +  "Serial": "10",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.10/10",
                                                +    "sshConfig": {
                                                +      "sshEnabled": true
                                                +    },
                                                +    "fqdn": "peer-srv.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=",
                                                +      "allowedIps": [
                                                +        "100.64.0.2/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-b.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    },
                                                +    {
                                                +      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
                                                +      "allowedIps": [
                                                +        "100.64.0.1/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-a.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-a.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-b.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.2"
                                                +          },
                                                +          {
                                                +            "Name": "peer-srv.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.10"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Protocol": "TCP",
                                                +      "PortInfo": {
                                                +        "range": {
                                                +          "start": 22022,
                                                +          "end": 22022
                                                +        }
                                                +      },
                                                +      "PolicyID": "cG9sLXNzaA=="
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.2",
                                                +      "Protocol": "TCP",
                                                +      "PortInfo": {
                                                +        "range": {
                                                +          "start": 22022,
                                                +          "end": 22022
                                                +        }
                                                +      },
                                                +      "PolicyID": "cG9sLXNzaA=="
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub",
                                                +    "AuthorizedUsers": [
                                                +      "CF6q+CJTtcJE8MVIcpPyOw==",
                                                +      "zSsmm7BAxWD/EuunyETFXA==",
                                                +      "0M0MizUGgS6HAaJa0LjGKQ=="
                                                +    ],
                                                +    "machineUsers": {
                                                +      "*": {
                                                +        "indexes": [
                                                +          2
                                                +        ]
                                                +      },
                                                +      "root": {
                                                +        "indexes": [
                                                +          0,
                                                +          1
                                                +        ]
                                                +      }
                                                +    }
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/nmdata.json
                                                new file mode 100644
                                                index 000000000..de0788ac9
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/nmdata.json
                                                @@ -0,0 +1,37 @@
                                                +{
                                                +  "Network": {"Serial": 10},
                                                +  "Peers": {
                                                +    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
                                                +  },
                                                +  "Groups": {
                                                +    "grp-dev": {"Peers": ["peer-a", "peer-b"]},
                                                +    "grp-srv": {"Peers": ["peer-srv"]}
                                                +  },
                                                +  "GroupIDToUserIDs": {
                                                +    "grp-admins": ["user-x", "user-y"],
                                                +    "grp-oncall": ["user-z"]
                                                +  },
                                                +  "Policies": [
                                                +    {
                                                +      "ID": "pol-ssh",
                                                +      "PublicID": "pol-ssh-pub",
                                                +      "Enabled": true,
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "netbird-ssh",
                                                +          "PortRanges": [{"Start": 22022, "End": 22022}],
                                                +          "Sources": ["grp-dev"],
                                                +          "Destinations": ["grp-srv"],
                                                +          "AuthorizedGroups": {
                                                +            "grp-admins": ["root"],
                                                +            "grp-oncall": []
                                                +          }
                                                +        }
                                                +      ]
                                                +    }
                                                +  ]
                                                +}
                                                \ No newline at end of file
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/case.json
                                                new file mode 100644
                                                index 000000000..e4799638e
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/case.json
                                                @@ -0,0 +1,4 @@
                                                +{
                                                +  "description": "netbird-ssh with a single AuthorizedUser: peer-srv's SshAuth maps the wildcard machine user to exactly user-solo.",
                                                +  "peers": ["peer-srv"]
                                                +}
                                                \ No newline at end of file
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/golden/peer-srv.json
                                                new file mode 100644
                                                index 000000000..dde0d4014
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/golden/peer-srv.json
                                                @@ -0,0 +1,74 @@
                                                +{
                                                +  "Serial": "11",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.10/10",
                                                +    "sshConfig": {
                                                +      "sshEnabled": true
                                                +    },
                                                +    "fqdn": "peer-srv.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
                                                +      "allowedIps": [
                                                +        "100.64.0.1/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-a.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-a.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-srv.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.10"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Protocol": "TCP",
                                                +      "PortInfo": {
                                                +        "range": {
                                                +          "start": 22022,
                                                +          "end": 22022
                                                +        }
                                                +      },
                                                +      "PolicyID": "cG9sLXNzaC11c2Vy"
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub",
                                                +    "AuthorizedUsers": [
                                                +      "/6cwl49UgLozU42NCr0RUA=="
                                                +    ],
                                                +    "machineUsers": {
                                                +      "*": {
                                                +        "indexes": [
                                                +          0
                                                +        ]
                                                +      }
                                                +    }
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/nmdata.json
                                                new file mode 100644
                                                index 000000000..1c92329a8
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/nmdata.json
                                                @@ -0,0 +1,29 @@
                                                +{
                                                +  "Network": {"Serial": 11},
                                                +  "Peers": {
                                                +    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
                                                +  },
                                                +  "Groups": {
                                                +    "grp-dev": {"Peers": ["peer-a"]},
                                                +    "grp-srv": {"Peers": ["peer-srv"]}
                                                +  },
                                                +  "Policies": [
                                                +    {
                                                +      "ID": "pol-ssh-user",
                                                +      "PublicID": "pol-ssh-user-pub",
                                                +      "Enabled": true,
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "netbird-ssh",
                                                +          "PortRanges": [{"Start": 22022, "End": 22022}],
                                                +          "Sources": ["grp-dev"],
                                                +          "Destinations": ["grp-srv"],
                                                +          "AuthorizedUser": "user-solo"
                                                +        }
                                                +      ]
                                                +    }
                                                +  ]
                                                +}
                                                \ No newline at end of file
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/case.json
                                                new file mode 100644
                                                index 000000000..8d4fb6c1a
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/case.json
                                                @@ -0,0 +1,4 @@
                                                +{
                                                +  "description": "netbird-ssh with neither AuthorizedGroups nor AuthorizedUser falls back to the account AllowedUserIDs under the wildcard machine user — and works with the peer's own SSHEnabled left off, unlike legacy SSH.",
                                                +  "peers": ["peer-srv"]
                                                +}
                                                \ No newline at end of file
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/golden/peer-srv.json
                                                new file mode 100644
                                                index 000000000..e63768a95
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/golden/peer-srv.json
                                                @@ -0,0 +1,76 @@
                                                +{
                                                +  "Serial": "12",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.10/10",
                                                +    "sshConfig": {
                                                +      "sshEnabled": true
                                                +    },
                                                +    "fqdn": "peer-srv.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
                                                +      "allowedIps": [
                                                +        "100.64.0.1/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-a.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-a.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-srv.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.10"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Protocol": "TCP",
                                                +      "PortInfo": {
                                                +        "range": {
                                                +          "start": 22022,
                                                +          "end": 22022
                                                +        }
                                                +      },
                                                +      "PolicyID": "cG9sLXNzaC1hbnk="
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub",
                                                +    "AuthorizedUsers": [
                                                +      "O8fBfcakRSAM4gX+YBNe+w==",
                                                +      "1vwcS03btOdBRX0dhz0NRg=="
                                                +    ],
                                                +    "machineUsers": {
                                                +      "*": {
                                                +        "indexes": [
                                                +          0,
                                                +          1
                                                +        ]
                                                +      }
                                                +    }
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/nmdata.json
                                                new file mode 100644
                                                index 000000000..97bfe3342
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/nmdata.json
                                                @@ -0,0 +1,29 @@
                                                +{
                                                +  "Network": {"Serial": 12},
                                                +  "Peers": {
                                                +    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
                                                +  },
                                                +  "Groups": {
                                                +    "grp-dev": {"Peers": ["peer-a"]},
                                                +    "grp-srv": {"Peers": ["peer-srv"]}
                                                +  },
                                                +  "AllowedUserIDs": {"user-1": {}, "user-2": {}},
                                                +  "Policies": [
                                                +    {
                                                +      "ID": "pol-ssh-any",
                                                +      "PublicID": "pol-ssh-any-pub",
                                                +      "Enabled": true,
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "netbird-ssh",
                                                +          "PortRanges": [{"Start": 22022, "End": 22022}],
                                                +          "Sources": ["grp-dev"],
                                                +          "Destinations": ["grp-srv"]
                                                +        }
                                                +      ]
                                                +    }
                                                +  ]
                                                +}
                                                \ No newline at end of file
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/case.json
                                                new file mode 100644
                                                index 000000000..1427fbd19
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/case.json
                                                @@ -0,0 +1,4 @@
                                                +{
                                                +  "description": "A tcp/22 policy implies legacy SSH only when the destination peer has SSHEnabled; here it does not, so peer-srv gets the firewall rules but no authorized users.",
                                                +  "peers": ["peer-srv"]
                                                +}
                                                \ No newline at end of file
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/golden/peer-srv.json
                                                new file mode 100644
                                                index 000000000..1ab6d4697
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/golden/peer-srv.json
                                                @@ -0,0 +1,64 @@
                                                +{
                                                +  "Serial": "13",
                                                +  "peerConfig": {
                                                +    "address": "100.64.0.10/10",
                                                +    "sshConfig": {},
                                                +    "fqdn": "peer-srv.netbird.test",
                                                +    "autoUpdate": {}
                                                +  },
                                                +  "remotePeers": [
                                                +    {
                                                +      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
                                                +      "allowedIps": [
                                                +        "100.64.0.1/32"
                                                +      ],
                                                +      "sshConfig": {},
                                                +      "fqdn": "peer-a.netbird.test",
                                                +      "agentVersion": "0.60.0"
                                                +    }
                                                +  ],
                                                +  "DNSConfig": {
                                                +    "ServiceEnable": true,
                                                +    "CustomZones": [
                                                +      {
                                                +        "Domain": "netbird.test.",
                                                +        "Records": [
                                                +          {
                                                +            "Name": "peer-a.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.1"
                                                +          },
                                                +          {
                                                +            "Name": "peer-srv.netbird.test",
                                                +            "Type": "1",
                                                +            "Class": "IN",
                                                +            "TTL": "300",
                                                +            "RData": "100.64.0.10"
                                                +          }
                                                +        ]
                                                +      }
                                                +    ],
                                                +    "ForwarderPort": "22054"
                                                +  },
                                                +  "FirewallRules": [
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Protocol": "TCP",
                                                +      "Port": "22",
                                                +      "PolicyID": "cG9sLXRjcDIy"
                                                +    },
                                                +    {
                                                +      "PeerIP": "100.64.0.1",
                                                +      "Direction": "OUT",
                                                +      "Protocol": "TCP",
                                                +      "Port": "22",
                                                +      "PolicyID": "cG9sLXRjcDIy"
                                                +    }
                                                +  ],
                                                +  "routesFirewallRulesIsEmpty": true,
                                                +  "sshAuth": {
                                                +    "UserIDClaim": "sub"
                                                +  }
                                                +}
                                                diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/nmdata.json
                                                new file mode 100644
                                                index 000000000..f00d0f06e
                                                --- /dev/null
                                                +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/nmdata.json
                                                @@ -0,0 +1,30 @@
                                                +{
                                                +  "Network": {"Serial": 13},
                                                +  "Peers": {
                                                +    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
                                                +    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
                                                +  },
                                                +  "Groups": {
                                                +    "grp-dev": {"Peers": ["peer-a"]},
                                                +    "grp-srv": {"Peers": ["peer-srv"]}
                                                +  },
                                                +  "AllowedUserIDs": {"user-1": {}},
                                                +  "Policies": [
                                                +    {
                                                +      "ID": "pol-tcp22",
                                                +      "PublicID": "pol-tcp22-pub",
                                                +      "Enabled": true,
                                                +      "Rules": [
                                                +        {
                                                +          "Enabled": true,
                                                +          "Action": "accept",
                                                +          "Protocol": "tcp",
                                                +          "Ports": ["22"],
                                                +          "Bidirectional": true,
                                                +          "Sources": ["grp-dev"],
                                                +          "Destinations": ["grp-srv"]
                                                +        }
                                                +      ]
                                                +    }
                                                +  ]
                                                +}
                                                \ No newline at end of file
                                                diff --git a/management/internals/controllers/network_map/update_channel/updatechannel.go b/management/internals/controllers/network_map/update_channel/updatechannel.go
                                                index 5f7db5300..91627bf15 100644
                                                --- a/management/internals/controllers/network_map/update_channel/updatechannel.go
                                                +++ b/management/internals/controllers/network_map/update_channel/updatechannel.go
                                                @@ -51,7 +51,7 @@ func (p *PeersUpdateManager) SendUpdate(ctx context.Context, peerID string, upda
                                                 		found = true
                                                 		select {
                                                 		case channel <- update:
                                                -			log.WithContext(ctx).Debugf("update was sent to channel for peer %s", peerID)
                                                +			log.WithContext(ctx).Tracef("update was sent to channel for peer %s", peerID)
                                                 		default:
                                                 			dropped = true
                                                 			log.WithContext(ctx).Warnf("channel for peer %s is %d full or closed", peerID, len(channel))
                                                diff --git a/management/internals/modules/agentnetwork/accesslog_ingest.go b/management/internals/modules/agentnetwork/accesslog_ingest.go
                                                new file mode 100644
                                                index 000000000..ecc1780f3
                                                --- /dev/null
                                                +++ b/management/internals/modules/agentnetwork/accesslog_ingest.go
                                                @@ -0,0 +1,230 @@
                                                +package agentnetwork
                                                +
                                                +import (
                                                +	"context"
                                                +	"math"
                                                +	"strconv"
                                                +	"strings"
                                                +
                                                +	log "github.com/sirupsen/logrus"
                                                +
                                                +	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
                                                +	"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
                                                +	"github.com/netbirdio/netbird/management/server/store"
                                                +)
                                                +
                                                +// Metadata keys the proxy stamps on agent-network access-log entries. These
                                                +// mirror the constants in proxy/internal/middleware/keys.go and form the wire
                                                +// contract between the proxy and management; management flattens them into
                                                +// queryable columns. Keep in sync with the proxy side.
                                                +const (
                                                +	metaKeyProvider            = "llm.provider"
                                                +	metaKeyModel               = "llm.model"
                                                +	metaKeyResolvedProviderID  = "llm.resolved_provider_id"
                                                +	metaKeySelectedPolicyID    = "llm.selected_policy_id"
                                                +	metaKeyPolicyDecision      = "llm_policy.decision"
                                                +	metaKeyPolicyReason        = "llm_policy.reason"
                                                +	metaKeyInputTokens         = "llm.input_tokens"          //nolint:gosec // metadata key name, not a credential
                                                +	metaKeyOutputTokens        = "llm.output_tokens"         //nolint:gosec // metadata key name, not a credential
                                                +	metaKeyTotalTokens         = "llm.total_tokens"          //nolint:gosec // metadata key name, not a credential
                                                +	metaKeyCachedInputTokens   = "llm.cached_input_tokens"   //nolint:gosec // metadata key name, not a credential
                                                +	metaKeyCacheCreationTokens = "llm.cache_creation_tokens" //nolint:gosec // metadata key name, not a credential
                                                +	metaKeyCostUSDInput        = "cost.usd_input"
                                                +	metaKeyCostUSDCachedInput  = "cost.usd_cached_input"
                                                +	metaKeyCostUSDCacheCreate  = "cost.usd_cache_creation"
                                                +	metaKeyCostUSDOutput       = "cost.usd_output"
                                                +	metaKeyStream              = "llm.stream"
                                                +	metaKeySessionID           = "llm.session_id"
                                                +	metaKeyAuthorisingGroups   = "llm.authorising_groups"
                                                +	metaKeyRequestPrompt       = "llm.request_prompt"
                                                +	metaKeyResponseCompletion  = "llm.response_completion"
                                                +)
                                                +
                                                +// IngestAccessLog flattens the metadata-bearing reverse-proxy access-log entry
                                                +// and persists it in the dedicated agent-network tables (instead of the shared
                                                +// reverse-proxy table), in two parts:
                                                +//
                                                +//   - The stripped usage record is written unconditionally — usage/cost is
                                                +//     collected on every request regardless of the account's log-collection
                                                +//     toggle (the proxy ships a usage-only entry when logging is disabled).
                                                +//   - The full access-log row (with request detail + prompt) is written only
                                                +//     when the account's EnableLogCollection setting is on. This setting read
                                                +//     is the authoritative gate; the proxy-side strip is defense in depth.
                                                +func IngestAccessLog(ctx context.Context, s store.Store, logEntry *accesslogs.AccessLogEntry) error {
                                                +	entry, groups := flattenAccessLog(logEntry)
                                                +
                                                +	usage, usageGroups := usageFromFlattenedLog(entry, groups)
                                                +	if err := s.CreateAgentNetworkUsage(ctx, usage, usageGroups); err != nil {
                                                +		log.WithContext(ctx).WithFields(log.Fields{
                                                +			"account_id": entry.AccountID,
                                                +			"model":      entry.Model,
                                                +		}).Errorf("failed to save agent-network usage: %v", err)
                                                +		return err
                                                +	}
                                                +
                                                +	settings, err := s.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, entry.AccountID)
                                                +	if err != nil {
                                                +		// No settings row (or a transient read error) means we can't confirm
                                                +		// log collection is enabled — usage is already saved, so skip the full
                                                +		// row rather than fail the whole ingest.
                                                +		log.WithContext(ctx).Debugf("skipping full agent-network access-log row for account %s: %v", entry.AccountID, err)
                                                +		return nil
                                                +	}
                                                +	if !settings.EnableLogCollection {
                                                +		return nil
                                                +	}
                                                +
                                                +	if err := s.CreateAgentNetworkAccessLog(ctx, entry, groups); err != nil {
                                                +		log.WithContext(ctx).WithFields(log.Fields{
                                                +			"account_id": entry.AccountID,
                                                +			"service_id": entry.ServiceID,
                                                +			"model":      entry.Model,
                                                +			"status":     entry.StatusCode,
                                                +		}).Errorf("failed to save agent-network access log: %v", err)
                                                +		return err
                                                +	}
                                                +	return nil
                                                +}
                                                +
                                                +// flattenAccessLog converts a reverse-proxy AccessLogEntry (whose LLM
                                                +// dimensions live in the opaque Metadata map) into the flattened
                                                +// agent-network row + authorising-group child rows.
                                                +func flattenAccessLog(e *accesslogs.AccessLogEntry) (*types.AgentNetworkAccessLog, []types.AgentNetworkAccessLogGroup) {
                                                +	meta := e.Metadata
                                                +
                                                +	var sourceIP string
                                                +	if e.GeoLocation.ConnectionIP != nil {
                                                +		sourceIP = e.GeoLocation.ConnectionIP.String()
                                                +	}
                                                +
                                                +	entry := &types.AgentNetworkAccessLog{
                                                +		ID:            e.ID,
                                                +		AccountID:     e.AccountID,
                                                +		ServiceID:     e.ServiceID,
                                                +		Timestamp:     e.Timestamp,
                                                +		UserID:        e.UserId,
                                                +		SourceIP:      sourceIP,
                                                +		Method:        e.Method,
                                                +		Host:          e.Host,
                                                +		Path:          e.Path,
                                                +		Duration:      e.Duration,
                                                +		StatusCode:    e.StatusCode,
                                                +		AuthMethod:    e.AuthMethodUsed,
                                                +		BytesUpload:   e.BytesUpload,
                                                +		BytesDownload: e.BytesDownload,
                                                +
                                                +		Provider:             meta[metaKeyProvider],
                                                +		Model:                meta[metaKeyModel],
                                                +		SessionID:            meta[metaKeySessionID],
                                                +		ResolvedProviderID:   meta[metaKeyResolvedProviderID],
                                                +		SelectedPolicyID:     meta[metaKeySelectedPolicyID],
                                                +		Decision:             meta[metaKeyPolicyDecision],
                                                +		DenyReason:           meta[metaKeyPolicyReason],
                                                +		InputTokens:          parseMetaInt(meta, metaKeyInputTokens),
                                                +		OutputTokens:         parseMetaInt(meta, metaKeyOutputTokens),
                                                +		TotalTokens:          parseMetaInt(meta, metaKeyTotalTokens),
                                                +		CachedInputTokens:    parseMetaInt(meta, metaKeyCachedInputTokens),
                                                +		CacheCreationTokens:  parseMetaInt(meta, metaKeyCacheCreationTokens),
                                                +		InputCostUSD:         parseMetaFloat(meta, metaKeyCostUSDInput),
                                                +		CachedInputCostUSD:   parseMetaFloat(meta, metaKeyCostUSDCachedInput),
                                                +		CacheCreationCostUSD: parseMetaFloat(meta, metaKeyCostUSDCacheCreate),
                                                +		OutputCostUSD:        parseMetaFloat(meta, metaKeyCostUSDOutput),
                                                +		Stream:               parseMetaBool(meta, metaKeyStream),
                                                +		RequestPrompt:        meta[metaKeyRequestPrompt],
                                                +		ResponseCompletion:   meta[metaKeyResponseCompletion],
                                                +	}
                                                +
                                                +	var groups []types.AgentNetworkAccessLogGroup
                                                +	for _, gid := range parseGroupCSV(meta[metaKeyAuthorisingGroups]) {
                                                +		groups = append(groups, types.AgentNetworkAccessLogGroup{
                                                +			LogID:     entry.ID,
                                                +			GroupID:   gid,
                                                +			AccountID: entry.AccountID,
                                                +		})
                                                +	}
                                                +	return entry, groups
                                                +}
                                                +
                                                +// usageFromFlattenedLog derives the stripped usage record (and its group child
                                                +// rows) from an already-flattened access-log entry. The usage row shares the
                                                +// log's ID so the two correlate.
                                                +func usageFromFlattenedLog(e *types.AgentNetworkAccessLog, groups []types.AgentNetworkAccessLogGroup) (*types.AgentNetworkUsage, []types.AgentNetworkUsageGroup) {
                                                +	usage := &types.AgentNetworkUsage{
                                                +		ID:                   e.ID,
                                                +		AccountID:            e.AccountID,
                                                +		Timestamp:            e.Timestamp,
                                                +		UserID:               e.UserID,
                                                +		ResolvedProviderID:   e.ResolvedProviderID,
                                                +		Provider:             e.Provider,
                                                +		Model:                e.Model,
                                                +		SessionID:            e.SessionID,
                                                +		InputTokens:          e.InputTokens,
                                                +		OutputTokens:         e.OutputTokens,
                                                +		TotalTokens:          e.TotalTokens,
                                                +		CachedInputTokens:    e.CachedInputTokens,
                                                +		CacheCreationTokens:  e.CacheCreationTokens,
                                                +		InputCostUSD:         e.InputCostUSD,
                                                +		CachedInputCostUSD:   e.CachedInputCostUSD,
                                                +		CacheCreationCostUSD: e.CacheCreationCostUSD,
                                                +		OutputCostUSD:        e.OutputCostUSD,
                                                +	}
                                                +
                                                +	usageGroups := make([]types.AgentNetworkUsageGroup, 0, len(groups))
                                                +	for _, g := range groups {
                                                +		usageGroups = append(usageGroups, types.AgentNetworkUsageGroup{
                                                +			UsageID:   usage.ID,
                                                +			GroupID:   g.GroupID,
                                                +			AccountID: g.AccountID,
                                                +		})
                                                +	}
                                                +	return usage, usageGroups
                                                +}
                                                +
                                                +// parseMetaInt parses a non-negative token count. Negative or unparseable
                                                +// values are clamped to 0 so a malformed metric can't persist a negative
                                                +// counter.
                                                +func parseMetaInt(meta map[string]string, key string) int64 {
                                                +	if v, err := strconv.ParseInt(strings.TrimSpace(meta[key]), 10, 64); err == nil && v >= 0 {
                                                +		return v
                                                +	}
                                                +	return 0
                                                +}
                                                +
                                                +// parseMetaFloat parses a non-negative, finite cost. Negative, NaN, Inf, or
                                                +// unparseable values are clamped to 0 so a malformed metric can't poison the
                                                +// stored cost.
                                                +func parseMetaFloat(meta map[string]string, key string) float64 {
                                                +	if v, err := strconv.ParseFloat(strings.TrimSpace(meta[key]), 64); err == nil && v >= 0 && !math.IsInf(v, 0) {
                                                +		return v
                                                +	}
                                                +	return 0
                                                +}
                                                +
                                                +func parseMetaBool(meta map[string]string, key string) bool {
                                                +	v, _ := strconv.ParseBool(strings.TrimSpace(meta[key]))
                                                +	return v
                                                +}
                                                +
                                                +// parseGroupCSV splits the comma-separated authorising-group id list the proxy
                                                +// emits, trimming blanks and de-duplicating. Dedup matters because the group
                                                +// rows are keyed by (log_id, group_id) / (usage_id, group_id): a repeated id
                                                +// in the CSV would otherwise produce a duplicate primary key and fail the
                                                +// insert transaction.
                                                +func parseGroupCSV(raw string) []string {
                                                +	if raw == "" {
                                                +		return nil
                                                +	}
                                                +	parts := strings.Split(raw, ",")
                                                +	out := make([]string, 0, len(parts))
                                                +	seen := make(map[string]struct{}, len(parts))
                                                +	for _, p := range parts {
                                                +		if p = strings.TrimSpace(p); p != "" {
                                                +			if _, dup := seen[p]; dup {
                                                +				continue
                                                +			}
                                                +			seen[p] = struct{}{}
                                                +			out = append(out, p)
                                                +		}
                                                +	}
                                                +	return out
                                                +}
                                                diff --git a/management/internals/modules/agentnetwork/accesslog_ingest_realstore_test.go b/management/internals/modules/agentnetwork/accesslog_ingest_realstore_test.go
                                                new file mode 100644
                                                index 000000000..cd81cfbe4
                                                --- /dev/null
                                                +++ b/management/internals/modules/agentnetwork/accesslog_ingest_realstore_test.go
                                                @@ -0,0 +1,149 @@
                                                +package agentnetwork
                                                +
                                                +import (
                                                +	"context"
                                                +	"testing"
                                                +	"time"
                                                +
                                                +	"github.com/stretchr/testify/assert"
                                                +	"github.com/stretchr/testify/require"
                                                +
                                                +	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
                                                +	"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
                                                +	"github.com/netbirdio/netbird/management/server/store"
                                                +)
                                                +
                                                +// newIngestTestEntry builds an agent-network reverse-proxy access-log entry whose
                                                +// LLM dimensions live in the opaque Metadata map, as the proxy ships it.
                                                +func newIngestTestEntry() *accesslogs.AccessLogEntry {
                                                +	return &accesslogs.AccessLogEntry{
                                                +		ID:           "log-1",
                                                +		AccountID:    testAccountID,
                                                +		ServiceID:    "svc-1",
                                                +		Timestamp:    time.Now().UTC(),
                                                +		Method:       "POST",
                                                +		Host:         testEndpoint,
                                                +		Path:         "/v1/chat/completions",
                                                +		StatusCode:   200,
                                                +		UserId:       "user-1",
                                                +		AgentNetwork: true,
                                                +		Metadata: map[string]string{
                                                +			metaKeyProvider:            "openai",
                                                +			metaKeyModel:               "gpt-5.4",
                                                +			metaKeyResolvedProviderID:  "prov-1",
                                                +			metaKeySessionID:           "sess-1",
                                                +			metaKeyInputTokens:         "100",
                                                +			metaKeyOutputTokens:        "50",
                                                +			metaKeyTotalTokens:         "1174",
                                                +			metaKeyCachedInputTokens:   "256",
                                                +			metaKeyCacheCreationTokens: "768",
                                                +			metaKeyCostUSDInput:        "0.0071",
                                                +			metaKeyCostUSDCachedInput:  "0.0009",
                                                +			metaKeyCostUSDCacheCreate:  "0.0020",
                                                +			metaKeyCostUSDOutput:       "0.0023",
                                                +			metaKeyStream:              "true",
                                                +			metaKeyRequestPrompt:       "hello",
                                                +			metaKeyResponseCompletion:  "world",
                                                +			// repeated id must be de-duplicated before the group rows insert.
                                                +			metaKeyAuthorisingGroups: "grp-eng,grp-eng,grp-ops",
                                                +		},
                                                +	}
                                                +}
                                                +
                                                +// TestIngestAccessLog_RealStore_LogCollectionOff persists the usage ledger
                                                +// unconditionally but skips the full access-log row when the account hasn't
                                                +// opted into log collection.
                                                +func TestIngestAccessLog_RealStore_LogCollectionOff(t *testing.T) {
                                                +	ctx := context.Background()
                                                +	s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
                                                +	require.NoError(t, err, "real sqlite test store must come up")
                                                +	defer cleanup()
                                                +
                                                +	settings := newSynthTestSettings()
                                                +	settings.EnableLogCollection = false
                                                +	require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings))
                                                +
                                                +	require.NoError(t, IngestAccessLog(ctx, s, newIngestTestEntry()))
                                                +
                                                +	usage, err := s.GetAgentNetworkUsageRows(ctx, store.LockingStrengthNone, testAccountID, types.AgentNetworkAccessLogFilter{})
                                                +	require.NoError(t, err)
                                                +	require.Len(t, usage, 1, "usage row must be written even with log collection off")
                                                +	assert.Equal(t, int64(100), usage[0].InputTokens, "input tokens must round-trip from metadata")
                                                +	assert.Equal(t, int64(50), usage[0].OutputTokens, "output tokens must round-trip from metadata")
                                                +	assert.Equal(t, int64(256), usage[0].CachedInputTokens, "cache-read tokens must round-trip from metadata")
                                                +	assert.Equal(t, int64(768), usage[0].CacheCreationTokens, "cache-write tokens must round-trip from metadata")
                                                +	// The per-bucket breakdown is the only cost state stored, and must survive
                                                +	// the write/read cycle as real columns — usage rows are the only cost
                                                +	// record for accounts with log collection off, so a dropped column here
                                                +	// loses the split permanently.
                                                +	assert.InDelta(t, 0.0071, usage[0].InputCostUSD, 1e-9, "input cost must round-trip from metadata")
                                                +	assert.InDelta(t, 0.0009, usage[0].CachedInputCostUSD, 1e-9, "cache-read cost must round-trip from metadata")
                                                +	assert.InDelta(t, 0.0020, usage[0].CacheCreationCostUSD, 1e-9, "cache-write cost must round-trip from metadata")
                                                +	assert.InDelta(t, 0.0023, usage[0].OutputCostUSD, 1e-9, "output cost must round-trip from metadata")
                                                +	// Aggregates are derived from the stored columns, never stored themselves.
                                                +	assert.InDelta(t, 0.0123, usage[0].TotalCostUSD(), 1e-9, "total is derived from the stored buckets")
                                                +	assert.InDelta(t, 0.0029, usage[0].CacheCostUSD(), 1e-9, "cache cost is derived from the two cache buckets")
                                                +
                                                +	logs, _, err := s.GetAgentNetworkAccessLogs(ctx, store.LockingStrengthNone, testAccountID, types.AgentNetworkAccessLogFilter{})
                                                +	require.NoError(t, err)
                                                +	assert.Empty(t, logs, "full access-log row must be skipped while log collection is off")
                                                +}
                                                +
                                                +// TestIngestAccessLog_RealStore_LogCollectionOn writes both the usage ledger and
                                                +// the full access-log row once the account opts in, carrying the request detail
                                                +// and prompt through.
                                                +func TestIngestAccessLog_RealStore_LogCollectionOn(t *testing.T) {
                                                +	ctx := context.Background()
                                                +	s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
                                                +	require.NoError(t, err, "real sqlite test store must come up")
                                                +	defer cleanup()
                                                +
                                                +	settings := newSynthTestSettings()
                                                +	settings.EnableLogCollection = true
                                                +	require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings))
                                                +
                                                +	require.NoError(t, IngestAccessLog(ctx, s, newIngestTestEntry()))
                                                +
                                                +	usage, err := s.GetAgentNetworkUsageRows(ctx, store.LockingStrengthNone, testAccountID, types.AgentNetworkAccessLogFilter{})
                                                +	require.NoError(t, err)
                                                +	require.Len(t, usage, 1, "usage row must be written when log collection is on")
                                                +
                                                +	logs, total, err := s.GetAgentNetworkAccessLogs(ctx, store.LockingStrengthNone, testAccountID, types.AgentNetworkAccessLogFilter{})
                                                +	require.NoError(t, err)
                                                +	require.Equal(t, int64(1), total, "exactly one access-log row expected")
                                                +	require.Len(t, logs, 1, "full access-log row must be written when log collection is on")
                                                +	assert.Equal(t, "gpt-5.4", logs[0].Model, "model must flatten from metadata")
                                                +	assert.Equal(t, int64(256), logs[0].CachedInputTokens, "cache-read tokens must flatten from metadata")
                                                +	assert.Equal(t, int64(768), logs[0].CacheCreationTokens, "cache-write tokens must flatten from metadata")
                                                +	assert.InDelta(t, 0.0029, logs[0].CacheCostUSD(), 1e-9, "cache cost is derived from the two cache buckets")
                                                +	assert.InDelta(t, 0.0123, logs[0].TotalCostUSD(), 1e-9, "total is derived from the stored buckets")
                                                +	assert.InDelta(t, 0.0071, logs[0].InputCostUSD, 1e-9, "input cost must flatten from metadata")
                                                +	assert.InDelta(t, 0.0009, logs[0].CachedInputCostUSD, 1e-9, "cache-read cost must flatten from metadata")
                                                +	assert.InDelta(t, 0.0020, logs[0].CacheCreationCostUSD, 1e-9, "cache-write cost must flatten from metadata")
                                                +	assert.InDelta(t, 0.0023, logs[0].OutputCostUSD, 1e-9, "output cost must flatten from metadata")
                                                +	assert.Equal(t, "hello", logs[0].RequestPrompt, "prompt must be retained when log collection is on")
                                                +	assert.Equal(t, "world", logs[0].ResponseCompletion, "completion must be retained when log collection is on")
                                                +	assert.True(t, logs[0].Stream, "stream flag must flatten from metadata")
                                                +}
                                                +
                                                +func TestParseGroupCSV_DedupAndTrim(t *testing.T) {
                                                +	assert.Nil(t, parseGroupCSV(""), "empty CSV yields no groups")
                                                +	assert.Equal(t, []string{"a", "b"}, parseGroupCSV(" a , b , a ,"),
                                                +		"group CSV must trim, drop blanks, and de-duplicate preserving first-seen order")
                                                +}
                                                +
                                                +func TestParseMetaInt_ClampsNegativeAndJunk(t *testing.T) {
                                                +	meta := map[string]string{"ok": " 42 ", "neg": "-5", "junk": "abc"}
                                                +	assert.Equal(t, int64(42), parseMetaInt(meta, "ok"), "valid count parses with surrounding space trimmed")
                                                +	assert.Equal(t, int64(0), parseMetaInt(meta, "neg"), "negative count clamps to 0")
                                                +	assert.Equal(t, int64(0), parseMetaInt(meta, "junk"), "unparseable count clamps to 0")
                                                +	assert.Equal(t, int64(0), parseMetaInt(meta, "missing"), "missing key clamps to 0")
                                                +}
                                                +
                                                +func TestParseMetaFloat_ClampsNegativeInfAndJunk(t *testing.T) {
                                                +	meta := map[string]string{"ok": "1.5", "neg": "-1", "inf": "Inf", "junk": "x"}
                                                +	assert.InDelta(t, 1.5, parseMetaFloat(meta, "ok"), 1e-9, "valid cost parses")
                                                +	assert.Equal(t, float64(0), parseMetaFloat(meta, "neg"), "negative cost clamps to 0")
                                                +	assert.Equal(t, float64(0), parseMetaFloat(meta, "inf"), "non-finite cost clamps to 0")
                                                +	assert.Equal(t, float64(0), parseMetaFloat(meta, "junk"), "unparseable cost clamps to 0")
                                                +}
                                                diff --git a/management/internals/modules/agentnetwork/accesslog_sessions_realstore_test.go b/management/internals/modules/agentnetwork/accesslog_sessions_realstore_test.go
                                                new file mode 100644
                                                index 000000000..94518c2f7
                                                --- /dev/null
                                                +++ b/management/internals/modules/agentnetwork/accesslog_sessions_realstore_test.go
                                                @@ -0,0 +1,343 @@
                                                +package agentnetwork
                                                +
                                                +import (
                                                +	"context"
                                                +	"testing"
                                                +	"time"
                                                +
                                                +	"github.com/stretchr/testify/assert"
                                                +	"github.com/stretchr/testify/require"
                                                +
                                                +	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
                                                +	"github.com/netbirdio/netbird/management/server/store"
                                                +)
                                                +
                                                +// baseTime is a fixed reference so session timestamps (and therefore the
                                                +// default MAX(timestamp) DESC ordering) are deterministic across runs.
                                                +var baseTime = time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC)
                                                +
                                                +// accessLogRow builds an agent-network access-log row for the shared test
                                                +// account. Functional options tweak the LLM dimensions a given test cares
                                                +// about; everything else gets a sane, allow/200 default.
                                                +func accessLogRow(id, sessionID string, ts time.Time, opts ...func(*types.AgentNetworkAccessLog)) *types.AgentNetworkAccessLog {
                                                +	e := &types.AgentNetworkAccessLog{
                                                +		ID:                 id,
                                                +		AccountID:          testAccountID,
                                                +		ServiceID:          "svc-1",
                                                +		Timestamp:          ts,
                                                +		UserID:             "user-1",
                                                +		SessionID:          sessionID,
                                                +		Method:             "POST",
                                                +		Host:               testEndpoint,
                                                +		Path:               "/v1/chat/completions",
                                                +		StatusCode:         200,
                                                +		Decision:           "allow",
                                                +		Provider:           "openai",
                                                +		Model:              "gpt-5.4",
                                                +		ResolvedProviderID: "prov-1",
                                                +		InputTokens:        100,
                                                +		OutputTokens:       50,
                                                +		TotalTokens:        150,
                                                +		InputCostUSD:       0.01,
                                                +	}
                                                +	for _, o := range opts {
                                                +		o(e)
                                                +	}
                                                +	return e
                                                +}
                                                +
                                                +func withUser(u string) func(*types.AgentNetworkAccessLog) {
                                                +	return func(e *types.AgentNetworkAccessLog) { e.UserID = u }
                                                +}
                                                +
                                                +func withModel(m string) func(*types.AgentNetworkAccessLog) {
                                                +	return func(e *types.AgentNetworkAccessLog) { e.Model = m }
                                                +}
                                                +
                                                +func withProvider(vendor, resolvedID string) func(*types.AgentNetworkAccessLog) {
                                                +	return func(e *types.AgentNetworkAccessLog) {
                                                +		e.Provider = vendor
                                                +		e.ResolvedProviderID = resolvedID
                                                +	}
                                                +}
                                                +
                                                +func withDeny(reason string) func(*types.AgentNetworkAccessLog) {
                                                +	return func(e *types.AgentNetworkAccessLog) {
                                                +		e.Decision = "deny"
                                                +		e.DenyReason = reason
                                                +		e.StatusCode = 403
                                                +	}
                                                +}
                                                +
                                                +func withTokens(in, out, total int64, cost float64) func(*types.AgentNetworkAccessLog) {
                                                +	return func(e *types.AgentNetworkAccessLog) {
                                                +		e.InputTokens = in
                                                +		e.OutputTokens = out
                                                +		e.TotalTokens = total
                                                +		e.InputCostUSD = cost
                                                +	}
                                                +}
                                                +
                                                +func withGroups(gids ...string) func(*types.AgentNetworkAccessLog) {
                                                +	return func(e *types.AgentNetworkAccessLog) { e.GroupIDs = gids }
                                                +}
                                                +
                                                +// seedAccessLogs writes rows (and their authorising-group child rows) directly
                                                +// into the store, bypassing ingest so a test can control every dimension.
                                                +func seedAccessLogs(t *testing.T, s store.Store, rows ...*types.AgentNetworkAccessLog) {
                                                +	t.Helper()
                                                +	ctx := context.Background()
                                                +	for _, r := range rows {
                                                +		var groups []types.AgentNetworkAccessLogGroup
                                                +		for _, g := range r.GroupIDs {
                                                +			groups = append(groups, types.AgentNetworkAccessLogGroup{
                                                +				LogID:     r.ID,
                                                +				GroupID:   g,
                                                +				AccountID: r.AccountID,
                                                +			})
                                                +		}
                                                +		require.NoError(t, s.CreateAgentNetworkAccessLog(ctx, r, groups), "seed access-log row %s", r.ID)
                                                +	}
                                                +}
                                                +
                                                +func newSessionsTestStore(t *testing.T) store.Store {
                                                +	t.Helper()
                                                +	s, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir())
                                                +	require.NoError(t, err, "real sqlite test store must come up")
                                                +	t.Cleanup(cleanup)
                                                +	return s
                                                +}
                                                +
                                                +// sessionIDs projects the session ids from a page of session summaries, in
                                                +// order, for concise ordering assertions.
                                                +func sessionIDs(sessions []*types.AgentNetworkAccessLogSession) []string {
                                                +	out := make([]string, 0, len(sessions))
                                                +	for _, s := range sessions {
                                                +		out = append(out, s.SessionID)
                                                +	}
                                                +	return out
                                                +}
                                                +
                                                +// TestAccessLogSessions_FoldAndAggregate verifies that multiple entries sharing
                                                +// a session id fold into one summary with summed usage, distinct
                                                +// provider/model lists, a deny rollup, and correct first/last activity bounds.
                                                +func TestAccessLogSessions_FoldAndAggregate(t *testing.T) {
                                                +	ctx := context.Background()
                                                +	s := newSessionsTestStore(t)
                                                +
                                                +	// sess-a: three entries spanning 3 minutes, two providers/models, one deny.
                                                +	seedAccessLogs(t, s,
                                                +		accessLogRow("a1", "sess-a", baseTime,
                                                +			withProvider("openai", "prov-openai"), withModel("gpt-5.4"),
                                                +			withTokens(100, 50, 150, 0.01), withGroups("grp-eng")),
                                                +		accessLogRow("a2", "sess-a", baseTime.Add(1*time.Minute),
                                                +			withProvider("anthropic", "prov-anthropic"), withModel("claude-haiku-4-5"),
                                                +			withTokens(200, 80, 280, 0.02), withGroups("grp-eng", "grp-ops")),
                                                +		accessLogRow("a3", "sess-a", baseTime.Add(2*time.Minute),
                                                +			withProvider("openai", "prov-openai"), withModel("gpt-5.4"),
                                                +			withTokens(10, 5, 15, 0.001), withDeny("llm_policy.token_cap_exceeded")),
                                                +		// sess-b: a single allow entry.
                                                +		accessLogRow("b1", "sess-b", baseTime.Add(30*time.Minute),
                                                +			withTokens(1, 2, 3, 0.5)),
                                                +	)
                                                +
                                                +	sessions, total, err := s.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, testAccountID, types.AgentNetworkAccessLogFilter{})
                                                +	require.NoError(t, err)
                                                +	require.Equal(t, int64(2), total, "two distinct sessions")
                                                +	require.Len(t, sessions, 2)
                                                +
                                                +	// Default sort is last-activity DESC, so sess-b (12:30) precedes sess-a (12:02).
                                                +	require.Equal(t, []string{"sess-b", "sess-a"}, sessionIDs(sessions))
                                                +
                                                +	a := sessions[1]
                                                +	assert.Equal(t, "sess-a", a.SessionID)
                                                +	assert.Equal(t, 3, a.RequestCount, "three requests folded")
                                                +	assert.Equal(t, int64(310), a.InputTokens, "input tokens summed")
                                                +	assert.Equal(t, int64(135), a.OutputTokens, "output tokens summed")
                                                +	assert.Equal(t, int64(445), a.TotalTokens, "total tokens summed")
                                                +	assert.InDelta(t, 0.031, a.TotalCostUSD(), 1e-9, "cost summed")
                                                +	assert.Equal(t, "deny", a.Decision, "any deny makes the session a deny")
                                                +	assert.ElementsMatch(t, []string{"openai", "anthropic"}, a.Providers, "distinct providers")
                                                +	assert.ElementsMatch(t, []string{"gpt-5.4", "claude-haiku-4-5"}, a.Models, "distinct models")
                                                +	assert.ElementsMatch(t, []string{"grp-eng", "grp-ops"}, a.GroupIDs, "union of authorising groups")
                                                +	assert.Equal(t, baseTime, a.StartedAt.UTC(), "started at is the earliest entry")
                                                +	assert.Equal(t, baseTime.Add(2*time.Minute), a.EndedAt.UTC(), "ended at is the latest entry")
                                                +	assert.Len(t, a.Entries, 3, "entries carried through")
                                                +
                                                +	b := sessions[0]
                                                +	assert.Equal(t, "sess-b", b.SessionID)
                                                +	assert.Equal(t, 1, b.RequestCount)
                                                +	assert.Equal(t, "allow", b.Decision)
                                                +}
                                                +
                                                +// TestAccessLogSessions_SessionlessRowsAreSingletons verifies that entries with
                                                +// no session id each form their own singleton session keyed by the row id.
                                                +func TestAccessLogSessions_SessionlessRowsAreSingletons(t *testing.T) {
                                                +	ctx := context.Background()
                                                +	s := newSessionsTestStore(t)
                                                +
                                                +	seedAccessLogs(t, s,
                                                +		accessLogRow("solo-1", "", baseTime),
                                                +		accessLogRow("solo-2", "", baseTime.Add(time.Minute)),
                                                +		// A real session with two entries, to prove they don't merge with the singletons.
                                                +		accessLogRow("g1", "sess-x", baseTime.Add(2*time.Minute)),
                                                +		accessLogRow("g2", "sess-x", baseTime.Add(3*time.Minute)),
                                                +	)
                                                +
                                                +	sessions, total, err := s.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, testAccountID, types.AgentNetworkAccessLogFilter{})
                                                +	require.NoError(t, err)
                                                +	require.Equal(t, int64(3), total, "two singletons + one grouped session")
                                                +	require.Len(t, sessions, 3)
                                                +
                                                +	for _, sess := range sessions {
                                                +		if sess.SessionID == "sess-x" {
                                                +			assert.Equal(t, 2, sess.RequestCount, "grouped session folds both entries")
                                                +		} else {
                                                +			assert.Empty(t, sess.SessionID, "singleton carries no session id")
                                                +			assert.Equal(t, 1, sess.RequestCount, "singleton has exactly one request")
                                                +		}
                                                +	}
                                                +}
                                                +
                                                +// TestAccessLogSessions_Pagination verifies that paging returns the correct
                                                +// slice of sessions in stable order, with a stable total across pages and no
                                                +// overlap between pages.
                                                +func TestAccessLogSessions_Pagination(t *testing.T) {
                                                +	ctx := context.Background()
                                                +	s := newSessionsTestStore(t)
                                                +
                                                +	// Five sessions, each a single entry, with increasing timestamps so the
                                                +	// default MAX(timestamp) DESC order is sess-5, sess-4, sess-3, sess-2, sess-1.
                                                +	rows := make([]*types.AgentNetworkAccessLog, 0, 5)
                                                +	for i := 1; i <= 5; i++ {
                                                +		rows = append(rows, accessLogRow(
                                                +			"row-"+itoa(i), "sess-"+itoa(i), baseTime.Add(time.Duration(i)*time.Minute)))
                                                +	}
                                                +	seedAccessLogs(t, s, rows...)
                                                +
                                                +	page := func(p int) []*types.AgentNetworkAccessLogSession {
                                                +		sessions, total, err := s.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, testAccountID,
                                                +			types.AgentNetworkAccessLogFilter{Page: p, PageSize: 2})
                                                +		require.NoError(t, err)
                                                +		require.Equal(t, int64(5), total, "total session count is stable across pages")
                                                +		return sessions
                                                +	}
                                                +
                                                +	assert.Equal(t, []string{"sess-5", "sess-4"}, sessionIDs(page(1)), "page 1: two newest")
                                                +	assert.Equal(t, []string{"sess-3", "sess-2"}, sessionIDs(page(2)), "page 2: next two")
                                                +	assert.Equal(t, []string{"sess-1"}, sessionIDs(page(3)), "page 3: remaining one")
                                                +	assert.Empty(t, page(4), "page 4: past the end is empty")
                                                +}
                                                +
                                                +// TestAccessLogSessions_Filtering verifies each filter is applied before
                                                +// grouping, so the session set (and total) reflect only matching entries.
                                                +func TestAccessLogSessions_Filtering(t *testing.T) {
                                                +	ctx := context.Background()
                                                +	s := newSessionsTestStore(t)
                                                +
                                                +	seedAccessLogs(t, s,
                                                +		accessLogRow("r1", "sess-1", baseTime.Add(1*time.Minute),
                                                +			withUser("alice"), withProvider("openai", "prov-openai"), withModel("gpt-5.4")),
                                                +		accessLogRow("r2", "sess-2", baseTime.Add(2*time.Minute),
                                                +			withUser("bob"), withProvider("anthropic", "prov-anthropic"), withModel("claude-haiku-4-5"),
                                                +			withDeny("llm_policy.no_authorized_provider"), withGroups("grp-ops")),
                                                +		accessLogRow("r3", "sess-3", baseTime.Add(3*time.Minute),
                                                +			withUser("alice"), withProvider("openai", "prov-openai"), withModel("gpt-5.4"),
                                                +			withGroups("grp-eng")),
                                                +	)
                                                +
                                                +	filterCases := []struct {
                                                +		name    string
                                                +		filter  types.AgentNetworkAccessLogFilter
                                                +		wantIDs []string
                                                +		wantTot int64
                                                +	}{
                                                +		{
                                                +			name:    "by session id",
                                                +			filter:  types.AgentNetworkAccessLogFilter{SessionID: strp("sess-2")},
                                                +			wantIDs: []string{"sess-2"},
                                                +			wantTot: 1,
                                                +		},
                                                +		{
                                                +			name:    "by user id",
                                                +			filter:  types.AgentNetworkAccessLogFilter{UserID: strp("alice")},
                                                +			wantIDs: []string{"sess-3", "sess-1"}, // last-activity DESC
                                                +			wantTot: 2,
                                                +		},
                                                +		{
                                                +			name:    "by model",
                                                +			filter:  types.AgentNetworkAccessLogFilter{Models: []string{"claude-haiku-4-5"}},
                                                +			wantIDs: []string{"sess-2"},
                                                +			wantTot: 1,
                                                +		},
                                                +		{
                                                +			name:    "by resolved provider id",
                                                +			filter:  types.AgentNetworkAccessLogFilter{ProviderIDs: []string{"prov-openai"}},
                                                +			wantIDs: []string{"sess-3", "sess-1"},
                                                +			wantTot: 2,
                                                +		},
                                                +		{
                                                +			name:    "by decision deny",
                                                +			filter:  types.AgentNetworkAccessLogFilter{Decision: strp("deny")},
                                                +			wantIDs: []string{"sess-2"},
                                                +			wantTot: 1,
                                                +		},
                                                +		{
                                                +			name:    "by authorising group",
                                                +			filter:  types.AgentNetworkAccessLogFilter{GroupIDs: []string{"grp-eng"}},
                                                +			wantIDs: []string{"sess-3"},
                                                +			wantTot: 1,
                                                +		},
                                                +		{
                                                +			name: "by date range excludes earlier",
                                                +			filter: types.AgentNetworkAccessLogFilter{
                                                +				StartDate: tp(baseTime.Add(90 * time.Second)), // after r1 (12:01), before r2 (12:02)
                                                +			},
                                                +			wantIDs: []string{"sess-3", "sess-2"},
                                                +			wantTot: 2,
                                                +		},
                                                +	}
                                                +
                                                +	for _, tc := range filterCases {
                                                +		tc := tc
                                                +		t.Run(tc.name, func(t *testing.T) {
                                                +			sessions, total, err := s.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, testAccountID, tc.filter)
                                                +			require.NoError(t, err)
                                                +			assert.Equal(t, tc.wantTot, total, "filtered total")
                                                +			assert.Equal(t, tc.wantIDs, sessionIDs(sessions), "filtered session ids in order")
                                                +		})
                                                +	}
                                                +}
                                                +
                                                +// TestAccessLogSessions_SortByCost verifies session-level aggregate sorting:
                                                +// ordering by summed cost, ascending and descending.
                                                +func TestAccessLogSessions_SortByCost(t *testing.T) {
                                                +	ctx := context.Background()
                                                +	s := newSessionsTestStore(t)
                                                +
                                                +	// cheap: 0.01 total; mid: 0.05 total; pricey: 0.20 total (two entries).
                                                +	seedAccessLogs(t, s,
                                                +		accessLogRow("c1", "cheap", baseTime.Add(1*time.Minute), withTokens(1, 1, 2, 0.01)),
                                                +		accessLogRow("m1", "mid", baseTime.Add(2*time.Minute), withTokens(1, 1, 2, 0.05)),
                                                +		accessLogRow("p1", "pricey", baseTime.Add(3*time.Minute), withTokens(1, 1, 2, 0.15)),
                                                +		accessLogRow("p2", "pricey", baseTime.Add(4*time.Minute), withTokens(1, 1, 2, 0.05)),
                                                +	)
                                                +
                                                +	desc, total, err := s.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, testAccountID,
                                                +		types.AgentNetworkAccessLogFilter{SortBy: "cost_usd", SortOrder: "desc"})
                                                +	require.NoError(t, err)
                                                +	require.Equal(t, int64(3), total)
                                                +	assert.Equal(t, []string{"pricey", "mid", "cheap"}, sessionIDs(desc), "descending by summed cost")
                                                +
                                                +	asc, _, err := s.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, testAccountID,
                                                +		types.AgentNetworkAccessLogFilter{SortBy: "cost_usd", SortOrder: "asc"})
                                                +	require.NoError(t, err)
                                                +	assert.Equal(t, []string{"cheap", "mid", "pricey"}, sessionIDs(asc), "ascending by summed cost")
                                                +}
                                                +
                                                +// strp / tp / itoa are tiny local helpers to keep the filter table terse.
                                                +func strp(s string) *string { return &s }
                                                +
                                                +func tp(t time.Time) *time.Time { return &t }
                                                +
                                                +func itoa(i int) string { return string(rune('0' + i)) }
                                                diff --git a/management/internals/modules/agentnetwork/affectedpeers_hook.go b/management/internals/modules/agentnetwork/affectedpeers_hook.go
                                                new file mode 100644
                                                index 000000000..58347666d
                                                --- /dev/null
                                                +++ b/management/internals/modules/agentnetwork/affectedpeers_hook.go
                                                @@ -0,0 +1,15 @@
                                                +package agentnetwork
                                                +
                                                +import "github.com/netbirdio/netbird/management/server/affectedpeers"
                                                +
                                                +// init registers the agent-network service synthesiser with the affectedpeers
                                                +// resolver. Agent-network reverse-proxy services are synthesised on demand and
                                                +// never persisted, so the resolver can't load them from the store; without them
                                                +// it can't fold the embedded proxy peer into the affected set on a client
                                                +// group/peer change, and the proxy never learns a newly authorised client until
                                                +// it reconnects. Registered here (rather than via a direct
                                                +// affectedpeers→agentnetwork import) to avoid an import cycle
                                                +// (agentnetwork → account → affectedpeers).
                                                +func init() {
                                                +	affectedpeers.SetAgentNetworkSynthesizer(SynthesizeServices)
                                                +}
                                                diff --git a/management/internals/modules/agentnetwork/agent_config.go b/management/internals/modules/agentnetwork/agent_config.go
                                                new file mode 100644
                                                index 000000000..902652479
                                                --- /dev/null
                                                +++ b/management/internals/modules/agentnetwork/agent_config.go
                                                @@ -0,0 +1,300 @@
                                                +package agentnetwork
                                                +
                                                +import (
                                                +	"context"
                                                +	"fmt"
                                                +
                                                +	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
                                                +	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
                                                +	"github.com/netbirdio/netbird/management/server/store"
                                                +)
                                                +
                                                +// GetAgentConfigForUser returns the Agent Network setup the calling user's
                                                +// groups authorize. It deliberately performs no role permission check:
                                                +// the result is scoped to the caller's own groups, which is strictly
                                                +// tighter than any role gate, so every authenticated user (any role) may
                                                +// read it. The group source matches enforcement: the proxy authorizes
                                                +// each Agent Network request against the calling user's groups as well —
                                                +// session validation resolves them from the same user record's
                                                +// auto-groups — so this answer and the proxy's verdict are computed from
                                                +// the same memberships.
                                                +func (m *managerImpl) GetAgentConfigForUser(ctx context.Context, accountID, userID string) (*types.AgentConfig, error) {
                                                +	user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
                                                +	if err != nil {
                                                +		return nil, fmt.Errorf("get user: %w", err)
                                                +	}
                                                +	return m.agentConfigForGroups(ctx, accountID, user.AutoGroups)
                                                +}
                                                +
                                                +// agentConfigForGroups computes the effective Agent Network setup for
                                                +// a set of caller groups: the account endpoint plus, per authorized
                                                +// provider, the effective model set. It mirrors what the proxy enforces
                                                +// at request time — the policy filter matches filterApplicablePolicies,
                                                +// the model logic matches policyPermitsModel, and orphan providers
                                                +// (enabled but referenced by no applicable policy) are omitted just like
                                                +// the router synthesizer omits them — so the answer never advertises
                                                +// anything the proxy would refuse.
                                                +//
                                                +// Configured tracks the account, not the caller: once the account has an
                                                +// endpoint every member gets it, with Providers empty for those no policy
                                                +// covers yet. The dashboard shows each user the same connection config
                                                +// regardless of role, and an empty provider list tells them to ask for
                                                +// access. Only the account having no Agent Network at all reads as not
                                                +// configured. Providers stays caller-scoped either way — the endpoint on
                                                +// its own authorizes nothing, and the proxy still refuses every request
                                                +// no policy permits.
                                                +func (m *managerImpl) agentConfigForGroups(ctx context.Context, accountID string, groupIDs []string) (*types.AgentConfig, error) {
                                                +	notConfigured := &types.AgentConfig{Providers: []types.AgentConfigProvider{}}
                                                +
                                                +	settings, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
                                                +	switch {
                                                +	case err == nil:
                                                +	case isNotFound(err):
                                                +		return notConfigured, nil
                                                +	default:
                                                +		return nil, fmt.Errorf("get agent network settings: %w", err)
                                                +	}
                                                +	if settings.Endpoint() == "" {
                                                +		return notConfigured, nil
                                                +	}
                                                +
                                                +	authorized, applicable, err := m.authorizedProvidersForGroups(ctx, accountID, groupIDs)
                                                +	if err != nil {
                                                +		return nil, err
                                                +	}
                                                +
                                                +	out := &types.AgentConfig{
                                                +		Configured: true,
                                                +		Endpoint:   "https://" + settings.Endpoint(),
                                                +		Providers:  make([]types.AgentConfigProvider, 0, len(authorized)),
                                                +	}
                                                +	if len(authorized) == 0 {
                                                +		return out, nil
                                                +	}
                                                +
                                                +	var guardrailsByID map[string]*types.Guardrail
                                                +	if anyPolicyHasGuardrails(applicable) {
                                                +		guardrailsByID, err = m.loadGuardrailsByID(ctx, accountID)
                                                +		if err != nil {
                                                +			return nil, err
                                                +		}
                                                +	}
                                                +	for _, p := range authorized {
                                                +		allAllowed, models := effectiveModelsForProvider(p, policiesForProvider(applicable, p.ID), guardrailsByID)
                                                +		flavor := ""
                                                +		if entry, ok := catalog.Lookup(p.ProviderID); ok {
                                                +			flavor = entry.ParserID
                                                +		}
                                                +		out.Providers = append(out.Providers, types.AgentConfigProvider{
                                                +			Name:             p.Name,
                                                +			CatalogID:        p.ProviderID,
                                                +			APIFlavor:        flavor,
                                                +			AllModelsAllowed: allAllowed,
                                                +			Models:           models,
                                                +		})
                                                +	}
                                                +	return out, nil
                                                +}
                                                +
                                                +// authorizedProvidersForGroups returns the enabled providers referenced
                                                +// by at least one enabled policy whose source groups intersect groupIDs —
                                                +// the providers the caller's own policies authorize — in created_at order
                                                +// with ID tiebreak, the same deterministic order the router synthesizer
                                                +// presents. The applicable policies come back alongside so callers that
                                                +// need per-provider policy context (the setup's model computation) don't
                                                +// re-filter. Both the self-service setup answer and the caller-scoped
                                                +// provider list are built from this selection, so what the dashboard
                                                +// offers and what the proxy enforces never diverge.
                                                +func (m *managerImpl) authorizedProvidersForGroups(ctx context.Context, accountID string, groupIDs []string) ([]*types.Provider, []*types.Policy, error) {
                                                +	policies, err := m.store.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, accountID)
                                                +	if err != nil {
                                                +		return nil, nil, fmt.Errorf("list account policies: %w", err)
                                                +	}
                                                +	applicable := filterPoliciesByGroups(policies, groupIDs)
                                                +	if len(applicable) == 0 {
                                                +		return nil, nil, nil
                                                +	}
                                                +
                                                +	providers, err := m.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID)
                                                +	if err != nil {
                                                +		return nil, nil, fmt.Errorf("list account providers: %w", err)
                                                +	}
                                                +
                                                +	// filterEnabledProviders carries the enabled filter and the
                                                +	// created_at/ID order shared with the router synthesizer.
                                                +	enabled := filterEnabledProviders(providers)
                                                +	authorized := make([]*types.Provider, 0, len(enabled))
                                                +	for _, p := range enabled {
                                                +		if len(policiesForProvider(applicable, p.ID)) == 0 {
                                                +			continue
                                                +		}
                                                +		authorized = append(authorized, p)
                                                +	}
                                                +	return authorized, applicable, nil
                                                +}
                                                +
                                                +// filterPoliciesByGroups returns the enabled policies whose SourceGroups
                                                +// intersect the caller's groups. Same group matching as
                                                +// filterApplicablePolicies, without the per-provider filter — the setup
                                                +// answer spans every provider the caller can reach.
                                                +func filterPoliciesByGroups(policies []*types.Policy, groupIDs []string) []*types.Policy {
                                                +	groupSet := make(map[string]struct{}, len(groupIDs))
                                                +	for _, g := range groupIDs {
                                                +		if g != "" {
                                                +			groupSet[g] = struct{}{}
                                                +		}
                                                +	}
                                                +	out := make([]*types.Policy, 0, len(policies))
                                                +	for _, p := range policies {
                                                +		if p == nil || !p.Enabled {
                                                +			continue
                                                +		}
                                                +		if !anyGroupMatches(p.SourceGroups, groupSet) {
                                                +			continue
                                                +		}
                                                +		out = append(out, p)
                                                +	}
                                                +	return out
                                                +}
                                                +
                                                +// policiesForProvider returns the subset of policies targeting the
                                                +// provider, order preserved.
                                                +func policiesForProvider(policies []*types.Policy, providerID string) []*types.Policy {
                                                +	out := make([]*types.Policy, 0, len(policies))
                                                +	for _, p := range policies {
                                                +		if sliceContains(p.DestinationProviderIDs, providerID) {
                                                +			out = append(out, p)
                                                +		}
                                                +	}
                                                +	return out
                                                +}
                                                +
                                                +// effectiveModelsForProvider derives the caller's effective model set for
                                                +// one provider from the applicable policies that target it, mirroring
                                                +// policyPermitsModel: a policy with no allowlist-enabled guardrail is
                                                +// unrestricted, and one unrestricted policy makes the whole provider
                                                +// unrestricted (the proxy would admit any model through it). Otherwise
                                                +// the union of the policies' allowlists applies, intersected with the
                                                +// provider's declared models when the operator declared any — the router
                                                +// only claims declared models, so an allowlisted-but-undeclared model is
                                                +// unreachable and must not be advertised. With no declared models the
                                                +// router claims every model, so the allowlist union stands alone.
                                                +// Allowlist entries and declared ids both compare through the canonical
                                                +// id the proxy's parser emits, so an allowlist may hold either form: the
                                                +// raw declared id the dashboard's picker copies from the provider, or
                                                +// the stripped id the parser matches at request time.
                                                +func effectiveModelsForProvider(provider *types.Provider, policies []*types.Policy, guardrailsByID map[string]*types.Guardrail) (bool, []string) {
                                                +	restricted := true
                                                +	union := make([]string, 0)
                                                +	seen := make(map[string]struct{})
                                                +	for _, p := range policies {
                                                +		policyRestricted := false
                                                +		for _, gID := range p.GuardrailIDs {
                                                +			g, ok := guardrailsByID[gID]
                                                +			if !ok || g == nil || !g.Checks.ModelAllowlist.Enabled {
                                                +				continue
                                                +			}
                                                +			policyRestricted = true
                                                +			for _, model := range g.Checks.ModelAllowlist.Models {
                                                +				key := canonicalModelKey(provider.ProviderID, model)
                                                +				if key == "" {
                                                +					continue
                                                +				}
                                                +				if _, dup := seen[key]; dup {
                                                +					continue
                                                +				}
                                                +				seen[key] = struct{}{}
                                                +				union = append(union, key)
                                                +			}
                                                +		}
                                                +		if !policyRestricted {
                                                +			restricted = false
                                                +		}
                                                +	}
                                                +
                                                +	declared := declaredModelIDs(provider)
                                                +	if !restricted {
                                                +		return true, declared
                                                +	}
                                                +	if len(provider.Models) == 0 {
                                                +		// No operator declaration: the router claims every model, so the
                                                +		// allowlist union is the effective set as-is.
                                                +		return false, union
                                                +	}
                                                +	out := make([]string, 0, len(declared))
                                                +	for _, id := range declared {
                                                +		// Compare through the canonical id the proxy's parser emits — a
                                                +		// Bedrock declaration may carry the region/version form
                                                +		// ("eu.anthropic.claude-...-v1:0") that the parser strips at
                                                +		// request time, and the raw forms would never intersect. The
                                                +		// declared id itself is what gets advertised, matching the
                                                +		// router's route claim.
                                                +		if _, ok := seen[canonicalModelKey(provider.ProviderID, id)]; ok {
                                                +			out = append(out, id)
                                                +		}
                                                +	}
                                                +	return false, out
                                                +}
                                                +
                                                +// canonicalModelKey builds the compare key for a model id: lowercased,
                                                +// trimmed, and canonicalized through the provider-aware normalization the
                                                +// proxy's parser applies. Lowercase/trim comes FIRST — the path-style
                                                +// strippers anchor on a lowercase id's tail, so a trailing space or a
                                                +// case-variant geography/version would otherwise survive into the key.
                                                +func canonicalModelKey(catalogProviderID, id string) string {
                                                +	return normaliseModelID(normalizePricingModelID(catalogProviderID, normaliseModelID(id)))
                                                +}
                                                +
                                                +// providerModelsByID maps effective model ids (as effectiveModelsForProvider
                                                +// returns them) back onto the operator's declared entries, keeping the
                                                +// declared casing and prices. With no operator declaration the ids are the
                                                +// allowlist union and have no declared entry to map to, so bare entries are
                                                +// synthesized — the router claims every model in that case, so those ids are
                                                +// reachable and belong in the answer.
                                                +func providerModelsByID(provider *types.Provider, ids []string) []types.ProviderModel {
                                                +	if len(provider.Models) == 0 {
                                                +		out := make([]types.ProviderModel, 0, len(ids))
                                                +		for _, id := range ids {
                                                +			out = append(out, types.ProviderModel{ID: id})
                                                +		}
                                                +		return out
                                                +	}
                                                +	keep := make(map[string]struct{}, len(ids))
                                                +	for _, id := range ids {
                                                +		keep[normaliseModelID(id)] = struct{}{}
                                                +	}
                                                +	out := make([]types.ProviderModel, 0, len(ids))
                                                +	for _, m := range provider.Models {
                                                +		if _, ok := keep[normaliseModelID(m.ID)]; ok {
                                                +			out = append(out, m)
                                                +		}
                                                +	}
                                                +	return out
                                                +}
                                                +
                                                +// declaredModelIDs returns the models a provider exposes: the operator's
                                                +// curated list when present, otherwise the catalog entry's models (an
                                                +// empty operator list means "all catalog models"). Gateway/custom catalog
                                                +// entries declare no models, so the result may be empty.
                                                +func declaredModelIDs(provider *types.Provider) []string {
                                                +	if ids := providerModelIDs(provider); len(ids) > 0 {
                                                +		return ids
                                                +	}
                                                +	entry, ok := catalog.Lookup(provider.ProviderID)
                                                +	if !ok {
                                                +		return []string{}
                                                +	}
                                                +	out := make([]string, 0, len(entry.Models))
                                                +	for _, m := range entry.Models {
                                                +		if m.ID != "" {
                                                +			out = append(out, m.ID)
                                                +		}
                                                +	}
                                                +	return out
                                                +}
                                                +
                                                +// GetAgentConfigForUser on the mock manager reports "not configured" so tests
                                                +// that don't care about setup still compile.
                                                +func (*mockManager) GetAgentConfigForUser(_ context.Context, _, _ string) (*types.AgentConfig, error) {
                                                +	return &types.AgentConfig{Providers: []types.AgentConfigProvider{}}, nil
                                                +}
                                                diff --git a/management/internals/modules/agentnetwork/agent_config_realstore_test.go b/management/internals/modules/agentnetwork/agent_config_realstore_test.go
                                                new file mode 100644
                                                index 000000000..a975434f2
                                                --- /dev/null
                                                +++ b/management/internals/modules/agentnetwork/agent_config_realstore_test.go
                                                @@ -0,0 +1,406 @@
                                                +package agentnetwork
                                                +
                                                +import (
                                                +	"context"
                                                +	"testing"
                                                +	"time"
                                                +
                                                +	"github.com/stretchr/testify/assert"
                                                +	"github.com/stretchr/testify/require"
                                                +
                                                +	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
                                                +	"github.com/netbirdio/netbird/management/internals/modules/permissions"
                                                +	"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
                                                +	"github.com/netbirdio/netbird/management/server/store"
                                                +	nbtypes "github.com/netbirdio/netbird/management/server/types"
                                                +)
                                                +
                                                +// These tests drive the effective-setup computation through the real
                                                +// sqlite store, mirroring the policyselect realstore suite: assert on
                                                +// observable answers (configured / providers / models), not on which
                                                +// store methods get called. The computation must agree with what the
                                                +// proxy enforces — policy filtering matches filterApplicablePolicies,
                                                +// model logic matches policyPermitsModel, and orphan providers are
                                                +// omitted like the router synthesizer omits them.
                                                +
                                                +func newAgentConfigTestMgr(t *testing.T) (*managerImpl, store.Store) {
                                                +	t.Helper()
                                                +	ctx := context.Background()
                                                +	s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
                                                +	require.NoError(t, err, "real sqlite test store must come up")
                                                +	t.Cleanup(cleanup)
                                                +	return &managerImpl{store: s}, s
                                                +}
                                                +
                                                +// newSetupTestGuardrail returns an allowlist-enabled guardrail.
                                                +func newSetupTestGuardrail(id string, models ...string) *types.Guardrail {
                                                +	return &types.Guardrail{
                                                +		ID:        id,
                                                +		AccountID: testAccountID,
                                                +		Name:      "allowlist " + id,
                                                +		Checks: types.GuardrailChecks{
                                                +			ModelAllowlist: types.GuardrailModelAllowlist{Enabled: true, Models: models},
                                                +		},
                                                +	}
                                                +}
                                                +
                                                +func TestAgentConfig_RealStore_NoSettingsRow(t *testing.T) {
                                                +	mgr, _ := newAgentConfigTestMgr(t)
                                                +
                                                +	setup, err := mgr.agentConfigForGroups(context.Background(), testAccountID, []string{"grp-eng"})
                                                +	require.NoError(t, err)
                                                +	assert.False(t, setup.Configured, "account without settings must read as not configured")
                                                +	assert.Empty(t, setup.Endpoint)
                                                +	assert.Empty(t, setup.Providers)
                                                +}
                                                +
                                                +func TestAgentConfig_RealStore_NoApplicablePolicy(t *testing.T) {
                                                +	mgr, s := newAgentConfigTestMgr(t)
                                                +	ctx := context.Background()
                                                +
                                                +	require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
                                                +	provider := newSynthTestProvider()
                                                +	require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
                                                +	require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
                                                +
                                                +	setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-other"})
                                                +	require.NoError(t, err)
                                                +	assert.True(t, setup.Configured, "the account is set up, so every member reads as configured")
                                                +	assert.Equal(t, "https://"+testEndpoint, setup.Endpoint, "every member gets the same connection config")
                                                +	assert.Empty(t, setup.Providers, "a caller no policy covers is authorized for nothing")
                                                +}
                                                +
                                                +func TestAgentConfig_RealStore_UnrestrictedPolicyListsDeclaredModels(t *testing.T) {
                                                +	mgr, s := newAgentConfigTestMgr(t)
                                                +	ctx := context.Background()
                                                +
                                                +	require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
                                                +	provider := newSynthTestProvider()
                                                +	require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
                                                +	require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
                                                +
                                                +	setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"})
                                                +	require.NoError(t, err)
                                                +	assert.True(t, setup.Configured)
                                                +	assert.Equal(t, "https://"+testEndpoint, setup.Endpoint)
                                                +	require.Len(t, setup.Providers, 1)
                                                +	p := setup.Providers[0]
                                                +	assert.Equal(t, "OpenAI", p.Name)
                                                +	assert.Equal(t, "openai_api", p.CatalogID)
                                                +	assert.Equal(t, "openai", p.APIFlavor)
                                                +	assert.True(t, p.AllModelsAllowed, "policy without allowlist guardrail is unrestricted")
                                                +	assert.Equal(t, []string{"gpt-5.4"}, p.Models, "declared models listed as a courtesy")
                                                +}
                                                +
                                                +func TestAgentConfig_RealStore_AllowlistIntersectsDeclaredModels(t *testing.T) {
                                                +	mgr, s := newAgentConfigTestMgr(t)
                                                +	ctx := context.Background()
                                                +
                                                +	require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
                                                +	provider := newSynthTestProvider()
                                                +	provider.Models = []types.ProviderModel{{ID: "gpt-5.4"}, {ID: "gpt-4o"}}
                                                +	require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
                                                +	// Allowlist admits gpt-5.4 (declared, odd casing/spacing) and gpt-4.1
                                                +	// (NOT declared — the router would never route it, so it must not be
                                                +	// advertised).
                                                +	require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", " GPT-5.4 ", "gpt-4.1")))
                                                +	require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")))
                                                +
                                                +	setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"})
                                                +	require.NoError(t, err)
                                                +	require.Len(t, setup.Providers, 1)
                                                +	p := setup.Providers[0]
                                                +	assert.False(t, p.AllModelsAllowed)
                                                +	assert.Equal(t, []string{"gpt-5.4"}, p.Models, "allowlist ∩ declared, in declared order and casing")
                                                +}
                                                +
                                                +func TestAgentConfig_RealStore_AllowlistMatchesBedrockDeclaredIDsCanonically(t *testing.T) {
                                                +	mgr, s := newAgentConfigTestMgr(t)
                                                +	ctx := context.Background()
                                                +
                                                +	require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
                                                +	// A Bedrock operator typically declares the region/version form the
                                                +	// vendor lists, while the allowlist holds the canonical id the proxy's
                                                +	// parser emits at request time. The intersection must compare through
                                                +	// the same normalization the parser applies, and the declared (raw)
                                                +	// id is what gets advertised — it is what the router claims.
                                                +	provider := newSynthTestProvider()
                                                +	provider.ProviderID = "bedrock_api"
                                                +	provider.Name = "Bedrock"
                                                +	provider.Models = []types.ProviderModel{
                                                +		{ID: "eu.anthropic.claude-sonnet-4-5-20250929-v1:0"},
                                                +		{ID: "eu.amazon.nova-pro-v1:0"},
                                                +	}
                                                +	require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
                                                +	require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", "anthropic.claude-sonnet-4-5")))
                                                +	require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")))
                                                +
                                                +	setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"})
                                                +	require.NoError(t, err)
                                                +	require.Len(t, setup.Providers, 1)
                                                +	p := setup.Providers[0]
                                                +	assert.False(t, p.AllModelsAllowed)
                                                +	assert.Equal(t, []string{"eu.anthropic.claude-sonnet-4-5-20250929-v1:0"}, p.Models,
                                                +		"the allowlisted canonical id must admit the declared region/version form, and only it")
                                                +}
                                                +
                                                +func TestAgentConfig_RealStore_AllowlistHoldsRawDeclaredIDs(t *testing.T) {
                                                +	// The dashboard's allowlist picker copies the provider's declared ids
                                                +	// verbatim, so for path-style providers the allowlist carries the
                                                +	// region/version form rather than the canonical id the parser emits.
                                                +	// Both forms must admit the declared model.
                                                +	cases := []struct {
                                                +		name      string
                                                +		catalogID string
                                                +		declared  string
                                                +		allowlist string
                                                +	}{
                                                +		{"bedrock", "bedrock_api", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", ""},
                                                +		{"vertex", "vertex_ai_api", "claude-sonnet-4-5@20250929", ""},
                                                +		// The geography/version strippers anchor on a lowercase tail, so a
                                                +		// case-variant entry must be lowercased before canonicalization or
                                                +		// the prefix and suffix survive into the compare key.
                                                +		{"bedrock-case-variant", "bedrock_api", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0",
                                                +			" EU.Anthropic.Claude-Sonnet-4-5-20250929-V1:0 "},
                                                +	}
                                                +	for _, tc := range cases {
                                                +		t.Run(tc.name, func(t *testing.T) {
                                                +			mgr, s := newAgentConfigTestMgr(t)
                                                +			ctx := context.Background()
                                                +
                                                +			allowlisted := tc.allowlist
                                                +			if allowlisted == "" {
                                                +				allowlisted = tc.declared
                                                +			}
                                                +			require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
                                                +			provider := newSynthTestProvider()
                                                +			provider.ProviderID = tc.catalogID
                                                +			provider.Name = tc.name
                                                +			provider.Models = []types.ProviderModel{{ID: tc.declared}}
                                                +			require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
                                                +			require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", allowlisted)))
                                                +			require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")))
                                                +
                                                +			setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"})
                                                +			require.NoError(t, err)
                                                +			require.Len(t, setup.Providers, 1)
                                                +			p := setup.Providers[0]
                                                +			assert.False(t, p.AllModelsAllowed)
                                                +			assert.Equal(t, []string{tc.declared}, p.Models,
                                                +				"an allowlist holding the raw declared id must admit that declared model")
                                                +		})
                                                +	}
                                                +}
                                                +
                                                +func TestAgentConfig_RealStore_UnrestrictedPolicyWinsOverRestricted(t *testing.T) {
                                                +	mgr, s := newAgentConfigTestMgr(t)
                                                +	ctx := context.Background()
                                                +
                                                +	require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
                                                +	provider := newSynthTestProvider()
                                                +	require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
                                                +	require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", "gpt-5.4")))
                                                +	restricted := newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")
                                                +	require.NoError(t, s.SaveAgentNetworkPolicy(ctx, restricted))
                                                +	open := newSynthTestPolicy(provider.ID, "grp-eng", "")
                                                +	open.ID = "pol-2"
                                                +	require.NoError(t, s.SaveAgentNetworkPolicy(ctx, open))
                                                +
                                                +	setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"})
                                                +	require.NoError(t, err)
                                                +	require.Len(t, setup.Providers, 1)
                                                +	assert.True(t, setup.Providers[0].AllModelsAllowed,
                                                +		"one applicable policy without an allowlist makes the provider unrestricted — the proxy would admit any model through it")
                                                +}
                                                +
                                                +func TestAgentConfig_RealStore_AllowlistUnionAcrossPolicies(t *testing.T) {
                                                +	mgr, s := newAgentConfigTestMgr(t)
                                                +	ctx := context.Background()
                                                +
                                                +	require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
                                                +	provider := newSynthTestProvider()
                                                +	provider.Models = []types.ProviderModel{{ID: "gpt-5.4"}, {ID: "gpt-4o"}, {ID: "o4-mini"}}
                                                +	require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
                                                +	require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", "gpt-5.4")))
                                                +	require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-2", "gpt-4o")))
                                                +	p1 := newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")
                                                +	require.NoError(t, s.SaveAgentNetworkPolicy(ctx, p1))
                                                +	p2 := newSynthTestPolicy(provider.ID, "grp-eng", "guard-2")
                                                +	p2.ID = "pol-2"
                                                +	require.NoError(t, s.SaveAgentNetworkPolicy(ctx, p2))
                                                +
                                                +	setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"})
                                                +	require.NoError(t, err)
                                                +	require.Len(t, setup.Providers, 1)
                                                +	p := setup.Providers[0]
                                                +	assert.False(t, p.AllModelsAllowed)
                                                +	assert.ElementsMatch(t, []string{"gpt-5.4", "gpt-4o"}, p.Models, "union of allowlists across applicable policies")
                                                +}
                                                +
                                                +func TestAgentConfig_RealStore_OrphanAndDisabledProvidersOmitted(t *testing.T) {
                                                +	mgr, s := newAgentConfigTestMgr(t)
                                                +	ctx := context.Background()
                                                +
                                                +	require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
                                                +	// Orphan: enabled but referenced by no policy.
                                                +	orphan := newSynthTestProvider()
                                                +	orphan.ID = "prov-orphan"
                                                +	require.NoError(t, s.SaveAgentNetworkProvider(ctx, orphan))
                                                +	// Disabled but referenced by an applicable policy.
                                                +	disabled := newSynthTestProvider()
                                                +	disabled.ID = "prov-disabled"
                                                +	disabled.Enabled = false
                                                +	require.NoError(t, s.SaveAgentNetworkProvider(ctx, disabled))
                                                +	require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(disabled.ID, "grp-eng", "")))
                                                +
                                                +	setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"})
                                                +	require.NoError(t, err)
                                                +	assert.True(t, setup.Configured)
                                                +	assert.Empty(t, setup.Providers, "neither an orphan nor a disabled provider is reachable for the caller")
                                                +}
                                                +
                                                +func TestAgentConfig_RealStore_DisabledPolicyIgnored(t *testing.T) {
                                                +	mgr, s := newAgentConfigTestMgr(t)
                                                +	ctx := context.Background()
                                                +
                                                +	require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
                                                +	provider := newSynthTestProvider()
                                                +	require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
                                                +	policy := newSynthTestPolicy(provider.ID, "grp-eng", "")
                                                +	policy.Enabled = false
                                                +	require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy))
                                                +
                                                +	setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"})
                                                +	require.NoError(t, err)
                                                +	assert.True(t, setup.Configured)
                                                +	assert.Empty(t, setup.Providers, "a disabled policy authorizes nothing")
                                                +}
                                                +
                                                +func TestAgentConfig_RealStore_UndeclaredModelsUseAllowlistAsIs(t *testing.T) {
                                                +	mgr, s := newAgentConfigTestMgr(t)
                                                +	ctx := context.Background()
                                                +
                                                +	require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
                                                +	// Gateway-style provider: no declared models — the router claims every
                                                +	// model, so the allowlist union is the effective set on its own.
                                                +	provider := newSynthTestProvider()
                                                +	provider.ProviderID = "litellm_proxy"
                                                +	provider.Name = "LiteLLM"
                                                +	provider.Models = nil
                                                +	require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
                                                +	require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", "claude-sonnet-4-5")))
                                                +	require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")))
                                                +
                                                +	setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"})
                                                +	require.NoError(t, err)
                                                +	require.Len(t, setup.Providers, 1)
                                                +	p := setup.Providers[0]
                                                +	assert.False(t, p.AllModelsAllowed)
                                                +	assert.Equal(t, []string{"claude-sonnet-4-5"}, p.Models)
                                                +}
                                                +
                                                +func TestAgentConfig_RealStore_ProvidersInCreatedAtOrder(t *testing.T) {
                                                +	mgr, s := newAgentConfigTestMgr(t)
                                                +	ctx := context.Background()
                                                +
                                                +	require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
                                                +	newer := newSynthTestProvider()
                                                +	newer.ID = "prov-newer"
                                                +	newer.Name = "Newer"
                                                +	newer.CreatedAt = time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
                                                +	require.NoError(t, s.SaveAgentNetworkProvider(ctx, newer))
                                                +	older := newSynthTestProvider()
                                                +	older.ID = "prov-older"
                                                +	older.Name = "Older"
                                                +	older.CreatedAt = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
                                                +	require.NoError(t, s.SaveAgentNetworkProvider(ctx, older))
                                                +
                                                +	policy := newSynthTestPolicy(newer.ID, "grp-eng", "")
                                                +	policy.DestinationProviderIDs = []string{newer.ID, older.ID}
                                                +	require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy))
                                                +
                                                +	setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"})
                                                +	require.NoError(t, err)
                                                +	require.Len(t, setup.Providers, 2)
                                                +	assert.Equal(t, "Older", setup.Providers[0].Name)
                                                +	assert.Equal(t, "Newer", setup.Providers[1].Name)
                                                +}
                                                +
                                                +// TestGetAgentConfigForUser_RealStore pins the self-service entry point: the
                                                +// user's group memberships (AutoGroups — the same groups the user's peers
                                                +// carry) scope the providers, while the account's endpoint reaches every
                                                +// member — a user outside every policy gets the config with nothing
                                                +// authorized in it.
                                                +func TestGetAgentConfigForUser_RealStore(t *testing.T) {
                                                +	mgr, s := newAgentConfigTestMgr(t)
                                                +	ctx := context.Background()
                                                +
                                                +	require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
                                                +	provider := newSynthTestProvider()
                                                +	require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
                                                +	require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
                                                +
                                                +	// users.account_id is a foreign key into accounts, enforced on
                                                +	// MySQL/Postgres, so the account row must exist before its users.
                                                +	require.NoError(t, s.SaveAccount(ctx, &nbtypes.Account{Id: testAccountID}))
                                                +	require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
                                                +		Id: "user-in", AccountID: testAccountID, Role: nbtypes.UserRoleUser, AutoGroups: []string{"grp-eng"},
                                                +	}))
                                                +	require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
                                                +		Id: "user-out", AccountID: testAccountID, Role: nbtypes.UserRoleUser, AutoGroups: []string{"grp-other"},
                                                +	}))
                                                +
                                                +	setupIn, err := mgr.GetAgentConfigForUser(ctx, testAccountID, "user-in")
                                                +	require.NoError(t, err)
                                                +	assert.True(t, setupIn.Configured)
                                                +	require.Len(t, setupIn.Providers, 1)
                                                +
                                                +	setupOut, err := mgr.GetAgentConfigForUser(ctx, testAccountID, "user-out")
                                                +	require.NoError(t, err)
                                                +	assert.True(t, setupOut.Configured, "the account is set up, so the user reads as configured")
                                                +	assert.Equal(t, "https://"+testEndpoint, setupOut.Endpoint)
                                                +	assert.Empty(t, setupOut.Providers, "user outside the policy's source groups is authorized for nothing")
                                                +}
                                                +
                                                +// TestGetUsageOverview_RealStore_SelfScoped pins the self-scope fallback:
                                                +// a caller without the account-wide usage grant gets the same aggregation
                                                +// the admin overview serves, but only ever their own rows — a user_id
                                                +// filter for someone else must be overridden, not honored, and never
                                                +// denied. A caller holding the grant keeps the account-wide view.
                                                +func TestGetUsageOverview_RealStore_SelfScoped(t *testing.T) {
                                                +	mgr, s := newAgentConfigTestMgr(t)
                                                +	mgr.permissionsManager = permissions.NewManager(s)
                                                +	ctx := context.Background()
                                                +
                                                +	require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
                                                +	require.NoError(t, s.SaveAccount(ctx, &nbtypes.Account{Id: testAccountID}))
                                                +	require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
                                                +		Id: "user-a", AccountID: testAccountID, Role: nbtypes.UserRoleUser,
                                                +	}))
                                                +	require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
                                                +		Id: "admin", AccountID: testAccountID, Role: nbtypes.UserRoleAdmin,
                                                +	}))
                                                +
                                                +	own1 := newIngestTestEntry()
                                                +	own1.ID, own1.UserId = "log-own-1", "user-a"
                                                +	own2 := newIngestTestEntry()
                                                +	own2.ID, own2.UserId = "log-own-2", "user-a"
                                                +	other := newIngestTestEntry()
                                                +	other.ID, other.UserId = "log-other", "user-b"
                                                +	for _, e := range []*accesslogs.AccessLogEntry{own1, own2, other} {
                                                +		require.NoError(t, IngestAccessLog(ctx, s, e))
                                                +	}
                                                +
                                                +	otherID := "user-b"
                                                +	filter := types.AgentNetworkAccessLogFilter{UserID: &otherID}
                                                +	buckets, err := mgr.GetUsageOverview(ctx, testAccountID, "user-a", filter, types.ParseUsageGranularity(""))
                                                +	require.NoError(t, err)
                                                +	require.Len(t, buckets, 1, "same-day rows aggregate into one daily bucket")
                                                +	assert.Equal(t, int64(200), buckets[0].InputTokens, "only the caller's two rows count — the foreign user_id filter is overridden")
                                                +	assert.Equal(t, int64(100), buckets[0].OutputTokens)
                                                +
                                                +	adminBuckets, err := mgr.GetUsageOverview(ctx, testAccountID, "admin", types.AgentNetworkAccessLogFilter{}, types.ParseUsageGranularity(""))
                                                +	require.NoError(t, err)
                                                +	require.Len(t, adminBuckets, 1)
                                                +	assert.Equal(t, int64(300), adminBuckets[0].InputTokens, "the account-wide grant keeps the unscoped view")
                                                +}
                                                diff --git a/management/internals/modules/agentnetwork/catalog/catalog.go b/management/internals/modules/agentnetwork/catalog/catalog.go
                                                new file mode 100644
                                                index 000000000..b58743798
                                                --- /dev/null
                                                +++ b/management/internals/modules/agentnetwork/catalog/catalog.go
                                                @@ -0,0 +1,1023 @@
                                                +// Package catalog defines the static set of Agent Network providers
                                                +// recognized by the management server. The catalog is consulted both to
                                                +// validate provider_id on create/update and to surface the available
                                                +// providers (and their models) to the dashboard.
                                                +package catalog
                                                +
                                                +import "github.com/netbirdio/netbird/shared/management/http/api"
                                                +
                                                +// Model is the in-memory representation of a catalog model.
                                                +//
                                                +// The three cache rates mirror the proxy cost meter's Entry semantics
                                                +// (USD per 1k tokens; 0 = no rate configured, that bucket bills at
                                                +// InputPer1k):
                                                +//   - CachedInputPer1k: OpenAI-shape rate for cached prompt tokens
                                                +//     (a SUBSET of input tokens). Typically 0.1-0.5x input.
                                                +//   - CacheReadPer1k / CacheCreationPer1k: Anthropic-shape rates for
                                                +//     the two ADDITIVE prompt-cache buckets. Typically 0.1x / 1.25x
                                                +//     input.
                                                +//
                                                +// The catalog is the single default-pricing source: the agentnetwork
                                                +// pricing package folds these models into per-surface tables that the
                                                +// synthesizer ships to the proxy's cost_meter.
                                                +type Model struct {
                                                +	ID                 string
                                                +	Label              string
                                                +	InputPer1k         float64
                                                +	OutputPer1k        float64
                                                +	CachedInputPer1k   float64
                                                +	CacheReadPer1k     float64
                                                +	CacheCreationPer1k float64
                                                +	ContextWindow      int
                                                +}
                                                +
                                                +// ProviderKind groups catalog entries for UI presentation. The split
                                                +// is semantic, not technical:
                                                +//   - KindProvider: the upstream is a vendor's first-party API (OpenAI,
                                                +//     Anthropic, Mistral, Bedrock, etc.) — NetBird talks straight to
                                                +//     the model provider.
                                                +//   - KindGateway: the upstream is itself a routing / aggregation layer
                                                +//     in front of multiple providers (LiteLLM, Portkey, Helicone, …).
                                                +//     These typically need NetBird identity stamped onto upstream
                                                +//     requests so the gateway's analytics and budgets attribute to the
                                                +//     real caller; that's what IdentityInjection is for.
                                                +//   - KindCustom: the catch-all "OpenAI-compatible self-hosted endpoint"
                                                +//     entry (vLLM, Ollama, custom inference servers).
                                                +//
                                                +// Frontend uses Kind to group the provider Select in the modal so an
                                                +// operator can spot at a glance which catalog entries proxy other
                                                +// providers vs. talk straight to one. Backend doesn't dispatch on Kind
                                                +// today; it's purely a presentation hint.
                                                +type ProviderKind string
                                                +
                                                +const (
                                                +	KindProvider ProviderKind = "provider"
                                                +	KindGateway  ProviderKind = "gateway"
                                                +	KindCustom   ProviderKind = "custom"
                                                +)
                                                +
                                                +// Provider is the in-memory representation of a catalog provider.
                                                +type Provider struct {
                                                +	ID          string
                                                +	Name        string
                                                +	Description string
                                                +	DefaultHost string
                                                +	// Kind groups this entry for UI presentation; see ProviderKind.
                                                +	Kind ProviderKind
                                                +	// AuthHeaderName is the HTTP header the provider's API expects
                                                +	// the credential under (e.g. "Authorization" for OpenAI,
                                                +	// "x-api-key" for Anthropic). Combined with AuthHeaderTemplate
                                                +	// at synthesis time to inject the auth header on every upstream
                                                +	// request.
                                                +	AuthHeaderName     string
                                                +	AuthHeaderTemplate string
                                                +	DefaultContentType string
                                                +	BrandColor         string
                                                +	// ParserID names the proxy LLM parser surface this provider
                                                +	// speaks (matches llm.Parser.ProviderName: "openai",
                                                +	// "anthropic"). Multiple catalog ids may share a parser surface
                                                +	// (e.g. azure_openai_api and mistral_api both speak the OpenAI
                                                +	// shape). Empty when no parser is yet implemented for the
                                                +	// surface — the proxy middleware then falls back to URL sniffing
                                                +	// or skips request-side enrichment.
                                                +	ParserID string
                                                +	// RouterVendors declares every parser surface a gateway route can serve.
                                                +	// Leave empty for single-surface providers, where ParserID remains the
                                                +	// router discriminator for backward compatibility.
                                                +	RouterVendors []string
                                                +	// PricingSurfaces names the cost-meter pricing surfaces this
                                                +	// provider's Models are priced under ("openai", "anthropic",
                                                +	// "bedrock" — the llm.Parser surface the request parser stamps as
                                                +	// llm.provider at billing time). NOT derivable from ParserID:
                                                +	// bedrock_api and vertex_ai_api leave ParserID empty (URL-sniffed)
                                                +	// yet price under "bedrock" / "anthropic", and kimi_api serves two
                                                +	// body shapes so it prices under both. Nil for gateway/custom
                                                +	// entries, which declare no models. Same (surface, model) pair
                                                +	// contributed by two providers must carry identical rates — the
                                                +	// pricing package's tests enforce that.
                                                +	PricingSurfaces []string
                                                +	// IdentityInjection, when non-nil, instructs the proxy to stamp
                                                +	// the caller's NetBird identity onto upstream requests under the
                                                +	// configured header names. Used for gateways like LiteLLM that
                                                +	// key budgets and attribution off request headers (the gateway
                                                +	// otherwise has no way to learn which user / group made the call).
                                                +	// The proxy strips the same header names from the inbound request
                                                +	// before stamping ours, so an app can't spoof identity by setting
                                                +	// these headers itself.
                                                +	IdentityInjection *IdentityInjection
                                                +	// ExtraHeaders is a catalog-declared list of additional per-
                                                +	// provider routing/config headers the proxy stamps on every
                                                +	// upstream request. Distinct from AuthHeaderName/Template (which
                                                +	// always carries the API_KEY) and from IdentityInjection (caller
                                                +	// identity). Each entry surfaces an optional input on the
                                                +	// dashboard's provider modal whose value lives on the provider
                                                +	// record's ExtraValues map (keyed by ExtraHeader.Name). Empty
                                                +	// list = no extra inputs rendered. Used today by Portkey for
                                                +	// "x-portkey-config: pc-..." (a saved-config id that resolves
                                                +	// upstream provider + credentials on Portkey's hosted side).
                                                +	ExtraHeaders []ExtraHeader
                                                +	Models       []Model
                                                +	// Discovery, when non-nil, describes how to ask this vendor which
                                                +	// models the operator's own credential can actually reach, so the
                                                +	// provider form can offer a live list instead of only the hand-curated
                                                +	// Models above. Nil entries keep free-text entry.
                                                +	Discovery *Discovery
                                                +}
                                                +
                                                +// ListingShape names the response envelope a vendor returns its model
                                                +// listing in. Every vendor invented its own, and none of them can be
                                                +// guessed from the request, so the catalog states it.
                                                +type ListingShape string
                                                +
                                                +const (
                                                +	// ShapeOpenAIData is {"data":[{"id":…}]} — OpenAI, and Anthropic, which
                                                +	// adopted the same envelope.
                                                +	ShapeOpenAIData ListingShape = "openai_data"
                                                +	// ShapeBedrockInferenceProfiles is
                                                +	// {"inferenceProfileSummaries":[{"inferenceProfileId":…}]}. The ids carry
                                                +	// the region prefix that makes them invocable, which is exactly what an
                                                +	// operator cannot reconstruct by hand.
                                                +	ShapeBedrockInferenceProfiles ListingShape = "bedrock_inference_profiles"
                                                +	// ShapeVertexPublisherModels is {"publisherModels":[{"name":…}]}, where
                                                +	// name is a resource path and the invocable id is its last segment joined
                                                +	// to a separate versionId field.
                                                +	ShapeVertexPublisherModels ListingShape = "vertex_publisher_models"
                                                +)
                                                +
                                                +// Discovery describes one vendor's model-listing endpoint.
                                                +//
                                                +// Host is deliberately separate from the provider record's upstream URL:
                                                +// Bedrock serves listings from the control plane (bedrock.) while
                                                +// inference must go to the runtime host (bedrock-runtime.), so the
                                                +// two cannot be the same value. Empty Host means "use the record's own
                                                +// upstream", which is right for every vendor that serves both from one host.
                                                +//
                                                +// The regionPlaceholder in Host is substituted from the provider record's
                                                +// region. Deriving the discovery host from the catalog rather than accepting
                                                +// one from the caller is also what keeps this from being an open proxy: the
                                                +// only hosts management will dial are the ones written here.
                                                +type Discovery struct {
                                                +	Host            string
                                                +	Path            string
                                                +	Query           string
                                                +	Shape           ListingShape
                                                +	// ExactModelsOnly omits wildcard patterns from listings when NetBird's
                                                +	// provider model rows cannot represent the vendor's matching semantics.
                                                +	ExactModelsOnly bool
                                                +	// Headers are static headers the vendor requires beyond the credential
                                                +	// (Anthropic versions its API through one and rejects a request without
                                                +	// it). The auth header itself comes from AuthHeaderName/Template.
                                                +	Headers map[string]string
                                                +}
                                                +
                                                +// RegionPlaceholder is replaced in Discovery.Host by the provider record's
                                                +// configured region.
                                                +const RegionPlaceholder = ""
                                                +
                                                +// ExtraHeader names a single optional per-provider routing/config
                                                +// header. Catalog declares N of these per provider type; the operator
                                                +// fills any subset on the provider record (see Provider.ExtraValues).
                                                +// At synth time, only entries with a non-empty operator value are
                                                +// stamped; the proxy's identity-inject middleware applies anti-spoof
                                                +// (Remove + Add) so a client can't supply these headers themselves.
                                                +//
                                                +// UI copy (label / help text / tooltip) for each known Name lives on
                                                +// the dashboard, not here — the backend's job is just to declare
                                                +// which wire headers are accepted. New provider needs an extra
                                                +// header? Add the Name here AND the matching UI copy on the dashboard.
                                                +type ExtraHeader struct {
                                                +	// Name is the wire header name, e.g. "x-portkey-config".
                                                +	Name string
                                                +}
                                                +
                                                +// IdentityInjection describes how the proxy stamps NetBird identity onto
                                                +// upstream gateway requests. Exactly one shape must be set — they're
                                                +// mutually exclusive and dispatched by the inject middleware.
                                                +//
                                                +// Shape choice tracks the wire convention the upstream gateway uses,
                                                +// not the vendor name. New gateways with a known shape become a catalog
                                                +// entry, not a new code path.
                                                +type IdentityInjection struct {
                                                +	// HeaderPair emits separate headers per identity dimension
                                                +	// (end-user id, tags as CSV). LiteLLM and OpenAI-compatible
                                                +	// self-hosted gateways that read identity from dedicated headers.
                                                +	HeaderPair *HeaderPairInjection
                                                +	// JSONMetadata emits a single header carrying a JSON object with
                                                +	// reserved keys for user / groups / etc. Portkey, Helicone-style
                                                +	// metadata headers, anything that wants a structured envelope.
                                                +	JSONMetadata *JSONMetadataInjection
                                                +}
                                                +
                                                +// HeaderPairInjection is the LiteLLM-style wire convention.
                                                +type HeaderPairInjection struct {
                                                +	// Customizable, when true, marks the wire header names as
                                                +	// operator-overridable: the dashboard surfaces EndUserIDHeader
                                                +	// and TagsHeader as editable inputs (defaults shown as
                                                +	// placeholders) and the synthesizer pulls the actual values from
                                                +	// the provider record's IdentityHeader* fields rather than from
                                                +	// these defaults. An empty operator value disables stamping for
                                                +	// that dimension. Used today for Bifrost, whose log-metadata /
                                                +	// telemetry header prefix (x-bf-lh-* vs x-bf-dim-*) is a
                                                +	// per-operator choice; LiteLLM and similar gateways with a fixed
                                                +	// wire protocol leave this false so the catalog defaults are
                                                +	// authoritative.
                                                +	Customizable bool
                                                +	// EndUserIDHeader receives the caller's display identity (user
                                                +	// email when the peer is attached to a user, else peer.Name),
                                                +	// e.g. "x-litellm-end-user-id".
                                                +	EndUserIDHeader string
                                                +	// TagsHeader receives the caller's NetBird group display names
                                                +	// as a CSV, e.g. "x-litellm-tags".
                                                +	TagsHeader string
                                                +	// TagsInBody, when true, additionally writes the tag list into
                                                +	// the request body's metadata.tags array (a JSON path the
                                                +	// gateway parses for budget enforcement). LiteLLM only honours
                                                +	// metadata.tags for tag-budget gating — its x-litellm-tags
                                                +	// header path feeds spend tracking but bypasses
                                                +	// _tag_max_budget_check entirely. Body inject is skipped when
                                                +	// the request body is empty, truncated, non-JSON, or when an
                                                +	// existing metadata field is a non-object value (defensive: we
                                                +	// never clobber a client-supplied non-object). The header path
                                                +	// remains a robust fallback for spend tracking in those cases.
                                                +	TagsInBody bool
                                                +	// EndUserIDInBody, when true, additionally writes the display
                                                +	// identity into the request body's top-level "user" field (the
                                                +	// OpenAI-standard end-user identifier). LiteLLM resolves the end
                                                +	// user id from headers first then body, so for LiteLLM this is
                                                +	// belt-and-suspenders. It matters when an OpenAI-compatible
                                                +	// gateway downstream of LiteLLM (or OpenAI direct, bypassing
                                                +	// LiteLLM) only reads the body, and as anti-spoof: client-
                                                +	// supplied "user" values are overwritten with our trusted
                                                +	// identity. Same skip rules as TagsInBody.
                                                +	EndUserIDInBody bool
                                                +}
                                                +
                                                +// JSONMetadataInjection is the Portkey-style wire convention: a single
                                                +// header carrying a JSON object. NetBird identity fields land under the
                                                +// configured reserved keys; missing keys (empty string) are skipped at
                                                +// emit time.
                                                +type JSONMetadataInjection struct {
                                                +	// Customizable, when true, marks the JSON keys as operator-
                                                +	// overridable. The dashboard surfaces UserKey and GroupsKey as
                                                +	// editable inputs (the catalog values shown as placeholders) and
                                                +	// the synthesizer pulls the actual JSON-key names from the
                                                +	// provider record's IdentityHeader* fields. Same field reuse as
                                                +	// HeaderPair's customizable path — the dimensions (user identity,
                                                +	// groups) are the same, only the wire encoding differs (JSON key
                                                +	// vs HTTP header name). An empty operator value disables emission
                                                +	// for that dimension. Used today for Cloudflare AI Gateway, whose
                                                +	// cf-aig-metadata header accepts arbitrary JSON keys; Portkey
                                                +	// leaves this false because its keys are reserved by the Portkey
                                                +	// schema.
                                                +	Customizable bool
                                                +	// Header is the wire header name carrying the JSON payload, e.g.
                                                +	// "x-portkey-metadata".
                                                +	Header string
                                                +	// UserKey is the JSON key for the caller's display identity.
                                                +	// Portkey reserves "_user" for this dimension.
                                                +	UserKey string
                                                +	// GroupsKey is the JSON key for the caller's NetBird groups,
                                                +	// emitted as a CSV string value (Portkey requires string values).
                                                +	GroupsKey string
                                                +	// MaxValueLength caps each emitted JSON value, in bytes. Portkey
                                                +	// enforces a 128-char limit per value; oversized values are
                                                +	// truncated rather than failing the request. 0 disables the cap.
                                                +	MaxValueLength int
                                                +	// Sanitize, when true, replaces characters outside the destination's
                                                +	// accepted set with '_' before emitting each value. AWS Bedrock's
                                                +	// X-Amzn-Bedrock-Request-Metadata restricts values to a limited character
                                                +	// class, so unsanitized group display names (e.g. containing spaces) would
                                                +	// make Bedrock reject the request with 400.
                                                +	Sanitize bool
                                                +}
                                                +
                                                +// providers is the canonical list of supported Agent Network providers.
                                                +// Update this list together with the dashboard's PROVIDER_CATALOG.
                                                +var providers = []Provider{
                                                +	{
                                                +		ID:                 "openai_api",
                                                +		Kind:               KindProvider,
                                                +		Name:               "OpenAI API",
                                                +		Description:        "GPT, Responses API, and Embeddings",
                                                +		DefaultHost:        "api.openai.com",
                                                +		AuthHeaderName:     "Authorization",
                                                +		AuthHeaderTemplate: "Bearer ${API_KEY}",
                                                +		DefaultContentType: "application/json",
                                                +		BrandColor:         "#10A37F",
                                                +		Discovery: &Discovery{
                                                +			Path:  "/v1/models",
                                                +			Shape: ShapeOpenAIData,
                                                +		},
                                                +		ParserID:        "openai",
                                                +		PricingSurfaces: []string{"openai"},
                                                +		// Pricing + context windows cross-checked against LiteLLM's
                                                +		// model_prices_and_context_window.json. Notable corrections from
                                                +		// earlier values: o4-mini repriced from $4/$16 to $1.10/$4.40
                                                +		// per MTok, gpt-4o from $5/$15 to $2.50/$10, and the GPT-5
                                                +		// family context windows split between 1.05M for full-size
                                                +		// models and 272K for mini/nano/codex variants.
                                                +		Models: []Model{
                                                +			{ID: "gpt-5.5", Label: "GPT-5.5", InputPer1k: 0.005, OutputPer1k: 0.030, CachedInputPer1k: 0.0005, ContextWindow: 1050000},
                                                +			{ID: "gpt-5.5-pro", Label: "GPT-5.5 Pro", InputPer1k: 0.030, OutputPer1k: 0.180, CachedInputPer1k: 0.003, ContextWindow: 1050000},
                                                +			{ID: "gpt-5.4", Label: "GPT-5.4", InputPer1k: 0.0025, OutputPer1k: 0.015, CachedInputPer1k: 0.00025, ContextWindow: 1050000},
                                                +			{ID: "gpt-5.4-pro", Label: "GPT-5.4 Pro", InputPer1k: 0.030, OutputPer1k: 0.180, CachedInputPer1k: 0.003, ContextWindow: 1050000},
                                                +			{ID: "gpt-5.4-mini", Label: "GPT-5.4 Mini", InputPer1k: 0.00075, OutputPer1k: 0.0045, CachedInputPer1k: 0.000075, ContextWindow: 272000},
                                                +			{ID: "gpt-5.4-nano", Label: "GPT-5.4 Nano", InputPer1k: 0.0002, OutputPer1k: 0.00125, CachedInputPer1k: 0.00002, ContextWindow: 272000},
                                                +			{ID: "gpt-5.3-codex", Label: "GPT-5.3 Codex", InputPer1k: 0.00175, OutputPer1k: 0.014, CachedInputPer1k: 0.000175, ContextWindow: 272000},
                                                +			{ID: "gpt-5.3-chat-latest", Label: "GPT-5.3 Chat", InputPer1k: 0.00175, OutputPer1k: 0.014, CachedInputPer1k: 0.000175, ContextWindow: 128000},
                                                +			{ID: "o4-mini", Label: "o4-mini", InputPer1k: 0.0011, OutputPer1k: 0.0044, CachedInputPer1k: 0.000275, ContextWindow: 200000},
                                                +			{ID: "gpt-4.1", Label: "GPT-4.1", InputPer1k: 0.002, OutputPer1k: 0.008, CachedInputPer1k: 0.0005, ContextWindow: 1047576},
                                                +			{ID: "gpt-4.1-mini", Label: "GPT-4.1 mini", InputPer1k: 0.0004, OutputPer1k: 0.0016, CachedInputPer1k: 0.0001, ContextWindow: 1047576},
                                                +			{ID: "gpt-4.1-nano", Label: "GPT-4.1 nano", InputPer1k: 0.0001, OutputPer1k: 0.0004, CachedInputPer1k: 0.000025, ContextWindow: 1047576},
                                                +			{ID: "gpt-4o", Label: "GPT-4o", InputPer1k: 0.0025, OutputPer1k: 0.010, CachedInputPer1k: 0.00125, ContextWindow: 128000},
                                                +			{ID: "gpt-4o-mini", Label: "GPT-4o mini", InputPer1k: 0.00015, OutputPer1k: 0.0006, CachedInputPer1k: 0.000075, ContextWindow: 128000},
                                                +			{ID: "gpt-4-turbo", Label: "GPT-4 Turbo", InputPer1k: 0.01, OutputPer1k: 0.03, ContextWindow: 128000},
                                                +			{ID: "gpt-3.5-turbo", Label: "GPT-3.5 Turbo", InputPer1k: 0.0005, OutputPer1k: 0.0015, ContextWindow: 16385},
                                                +			{ID: "text-embedding-3-large", Label: "text-embedding-3-large", InputPer1k: 0.00013, OutputPer1k: 0, ContextWindow: 8191},
                                                +			{ID: "text-embedding-3-small", Label: "text-embedding-3-small", InputPer1k: 0.00002, OutputPer1k: 0, ContextWindow: 8191},
                                                +		},
                                                +	},
                                                +	{
                                                +		ID:                 "anthropic_api",
                                                +		Kind:               KindProvider,
                                                +		Name:               "Anthropic API",
                                                +		Description:        "Claude Messages API",
                                                +		DefaultHost:        "api.anthropic.com",
                                                +		AuthHeaderName:     "x-api-key",
                                                +		AuthHeaderTemplate: "${API_KEY}",
                                                +		DefaultContentType: "application/json",
                                                +		BrandColor:         "#D97757",
                                                +		Discovery: &Discovery{
                                                +			Path: "/v1/models",
                                                +			// The default page is short and a picker wants the whole
                                                +			// catalogue in one call.
                                                +			Query: "limit=1000",
                                                +			Shape: ShapeOpenAIData,
                                                +			// Anthropic versions its API through a header and refuses a
                                                +			// request that omits it, listing included.
                                                +			Headers: map[string]string{"anthropic-version": "2023-06-01"},
                                                +		},
                                                +		ParserID:        "anthropic",
                                                +		PricingSurfaces: []string{"anthropic"},
                                                +		// Per Anthropic's current model lineup. Pricing in USD per 1k
                                                +		// tokens. Context windows: 4.6+ family is 1M; Haiku 4.5 stays at
                                                +		// 200K. claude-3-7-sonnet and claude-3-5-haiku retired
                                                +		// 2026-02-19 — dropped from the catalog. claude-opus-4-1
                                                +		// deprecated, retires 2026-08-05 — kept until the cutover.
                                                +		// claude-mythos-5 omitted: Project Glasswing access only, not a
                                                +		// general-availability target. claude-fable-5 requires the
                                                +		// account to be on >= 30-day data retention or all requests
                                                +		// 400.
                                                +		Models: []Model{
                                                +			{ID: "claude-opus-5", Label: "Claude Opus 5", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
                                                +			{ID: "claude-sonnet-5", Label: "Claude Sonnet 5", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000},
                                                +			{ID: "claude-fable-5", Label: "Claude Fable 5", InputPer1k: 0.010, OutputPer1k: 0.050, CacheReadPer1k: 0.001, CacheCreationPer1k: 0.0125, ContextWindow: 1000000},
                                                +			{ID: "claude-opus-4-8", Label: "Claude Opus 4.8", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
                                                +			{ID: "claude-opus-4-7", Label: "Claude Opus 4.7", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
                                                +			{ID: "claude-opus-4-6", Label: "Claude Opus 4.6", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
                                                +			{ID: "claude-opus-4-1", Label: "Claude Opus 4.1 (deprecated, retires 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, CacheReadPer1k: 0.0015, CacheCreationPer1k: 0.01875, ContextWindow: 200000},
                                                +			{ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000},
                                                +			{ID: "claude-sonnet-4-5", Label: "Claude Sonnet 4.5", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 200000},
                                                +			{ID: "claude-haiku-4-5", Label: "Claude Haiku 4.5", InputPer1k: 0.001, OutputPer1k: 0.005, CacheReadPer1k: 0.0001, CacheCreationPer1k: 0.00125, ContextWindow: 200000},
                                                +		},
                                                +	},
                                                +	{
                                                +		ID:                 "azure_openai_api",
                                                +		Kind:               KindProvider,
                                                +		Name:               "Azure OpenAI API",
                                                +		Description:        "Azure-hosted OpenAI deployments",
                                                +		DefaultHost:        ".openai.azure.com",
                                                +		AuthHeaderName:     "api-key",
                                                +		AuthHeaderTemplate: "${API_KEY}",
                                                +		DefaultContentType: "application/json",
                                                +		BrandColor:         "#0078D4",
                                                +		ParserID:           "openai",
                                                +		PricingSurfaces:    []string{"openai"},
                                                +		// Mirrors openai_api pricing — Azure resells OpenAI models at the
                                                +		// same per-token rates, just under different deployment names.
                                                +		Models: []Model{
                                                +			{ID: "gpt-5.5", Label: "GPT-5.5 (Azure)", InputPer1k: 0.005, OutputPer1k: 0.030, CachedInputPer1k: 0.0005, ContextWindow: 1050000},
                                                +			{ID: "gpt-5.4", Label: "GPT-5.4 (Azure)", InputPer1k: 0.0025, OutputPer1k: 0.015, CachedInputPer1k: 0.00025, ContextWindow: 1050000},
                                                +			{ID: "gpt-5.4-mini", Label: "GPT-5.4 Mini (Azure)", InputPer1k: 0.00075, OutputPer1k: 0.0045, CachedInputPer1k: 0.000075, ContextWindow: 272000},
                                                +			{ID: "gpt-5.4-nano", Label: "GPT-5.4 Nano (Azure)", InputPer1k: 0.0002, OutputPer1k: 0.00125, CachedInputPer1k: 0.00002, ContextWindow: 272000},
                                                +			{ID: "o4-mini", Label: "o4-mini (Azure)", InputPer1k: 0.0011, OutputPer1k: 0.0044, CachedInputPer1k: 0.000275, ContextWindow: 200000},
                                                +			{ID: "gpt-4.1", Label: "GPT-4.1 (Azure)", InputPer1k: 0.002, OutputPer1k: 0.008, CachedInputPer1k: 0.0005, ContextWindow: 1047576},
                                                +			{ID: "gpt-4.1-mini", Label: "GPT-4.1 mini (Azure)", InputPer1k: 0.0004, OutputPer1k: 0.0016, CachedInputPer1k: 0.0001, ContextWindow: 1047576},
                                                +			{ID: "gpt-4o", Label: "GPT-4o (Azure)", InputPer1k: 0.0025, OutputPer1k: 0.010, CachedInputPer1k: 0.00125, ContextWindow: 128000},
                                                +			{ID: "gpt-4o-mini", Label: "GPT-4o mini (Azure)", InputPer1k: 0.00015, OutputPer1k: 0.0006, CachedInputPer1k: 0.000075, ContextWindow: 128000},
                                                +			{ID: "gpt-35-turbo", Label: "GPT-3.5 Turbo (Azure)", InputPer1k: 0.0005, OutputPer1k: 0.0015, ContextWindow: 16385},
                                                +		},
                                                +	},
                                                +	{
                                                +		ID:                 "bedrock_api",
                                                +		Kind:               KindProvider,
                                                +		Name:               "AWS Bedrock API",
                                                +		Description:        "Anthropic, Meta, Cohere via Bedrock",
                                                +		DefaultHost:        "bedrock-runtime..amazonaws.com",
                                                +		AuthHeaderName:     "Authorization",
                                                +		AuthHeaderTemplate: "Bearer ${API_KEY}",
                                                +		DefaultContentType: "application/json",
                                                +		BrandColor:         "#FF9900",
                                                +		// Listings come from the CONTROL PLANE, not the runtime host in
                                                +		// DefaultHost above: ListInferenceProfiles is not an operation
                                                +		// bedrock-runtime implements, and answers 
                                                +		// there. Inference has to go to the runtime host, so the two hosts
                                                +		// genuinely differ and Discovery.Host carries the difference.
                                                +		//
                                                +		// Inference profiles rather than foundation models because the profile
                                                +		// id is the invocable one: it carries the region prefix (eu., us.,
                                                +		// global.) that AWS requires and that cannot be derived from the
                                                +		// configured region — an eu-central-1 account legitimately holds
                                                +		// global.* profiles.
                                                +		Discovery: &Discovery{
                                                +			Host:  "bedrock." + RegionPlaceholder + ".amazonaws.com",
                                                +			Path:  "/inference-profiles",
                                                +			Shape: ShapeBedrockInferenceProfiles,
                                                +		},
                                                +		// ParserID stays empty (path-style dispatch via IsBedrockPathStyle);
                                                +		// the request parser meters these under the "bedrock" surface.
                                                +		PricingSurfaces: []string{"bedrock"},
                                                +		// Anthropic models on Bedrock take the anthropic.* prefix and
                                                +		// follow the same lineup / pricing as the first-party Anthropic
                                                +		// catalog entry above. claude-3-7-sonnet and claude-3-5-haiku
                                                +		// were retired upstream on 2026-02-19 — dropped from the
                                                +		// Bedrock list too. Amazon Nova entries cross-checked against
                                                +		// LiteLLM (added Nova Micro + the new Nova 2 Lite preview).
                                                +		// Llama 3.3 70B entry kept unchanged — LiteLLM tracks only
                                                +		// per-region Llama 3 entries; standalone 3.3 not yet listed.
                                                +		Models: []Model{
                                                +			{ID: "anthropic.claude-opus-5", Label: "Claude Opus 5 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
                                                +			{ID: "anthropic.claude-sonnet-5", Label: "Claude Sonnet 5 (Bedrock)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000},
                                                +			{ID: "anthropic.claude-opus-4-8", Label: "Claude Opus 4.8 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
                                                +			{ID: "anthropic.claude-opus-4-7", Label: "Claude Opus 4.7 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
                                                +			{ID: "anthropic.claude-opus-4-6", Label: "Claude Opus 4.6 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
                                                +			{ID: "anthropic.claude-opus-4-1", Label: "Claude Opus 4.1 (Bedrock, deprecated 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, CacheReadPer1k: 0.0015, CacheCreationPer1k: 0.01875, ContextWindow: 200000},
                                                +			{ID: "anthropic.claude-sonnet-4-6", Label: "Claude Sonnet 4.6 (Bedrock)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000},
                                                +			{ID: "anthropic.claude-sonnet-4-5", Label: "Claude Sonnet 4.5 (Bedrock)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 200000},
                                                +			{ID: "anthropic.claude-haiku-4-5", Label: "Claude Haiku 4.5 (Bedrock)", InputPer1k: 0.001, OutputPer1k: 0.005, CacheReadPer1k: 0.0001, CacheCreationPer1k: 0.00125, ContextWindow: 200000},
                                                +			{ID: "meta.llama3-3-70b-instruct", Label: "Llama 3.3 70B (Bedrock)", InputPer1k: 0.00072, OutputPer1k: 0.00072, ContextWindow: 128000},
                                                +			{ID: "amazon.nova-2-lite", Label: "Amazon Nova 2 Lite (Bedrock, preview)", InputPer1k: 0.0003, OutputPer1k: 0.0025, ContextWindow: 1000000},
                                                +			{ID: "amazon.nova-pro", Label: "Amazon Nova Pro (Bedrock)", InputPer1k: 0.0008, OutputPer1k: 0.0032, ContextWindow: 300000},
                                                +			{ID: "amazon.nova-lite", Label: "Amazon Nova Lite (Bedrock)", InputPer1k: 0.00006, OutputPer1k: 0.00024, ContextWindow: 300000},
                                                +			{ID: "amazon.nova-micro", Label: "Amazon Nova Micro (Bedrock)", InputPer1k: 0.000035, OutputPer1k: 0.00014, ContextWindow: 128000},
                                                +		},
                                                +		// Bedrock accepts a cost-allocation metadata header; stamp the caller's
                                                +		// user + authorizing group so spend can be attributed in AWS Cost
                                                +		// Management. Sanitized because Bedrock restricts the value character set.
                                                +		IdentityInjection: &IdentityInjection{
                                                +			JSONMetadata: &JSONMetadataInjection{
                                                +				Header:         "X-Amzn-Bedrock-Request-Metadata",
                                                +				UserKey:        "user",
                                                +				GroupsKey:      "group",
                                                +				MaxValueLength: 256,
                                                +				Sanitize:       true,
                                                +			},
                                                +		},
                                                +	},
                                                +	{
                                                +		ID:                 "vertex_ai_api",
                                                +		Kind:               KindProvider,
                                                +		Name:               "Google Vertex AI API",
                                                +		Description:        "Anthropic Claude models hosted on Vertex AI",
                                                +		DefaultHost:        "-aiplatform.googleapis.com",
                                                +		AuthHeaderName:     "Authorization",
                                                +		AuthHeaderTemplate: "Bearer ${API_KEY}",
                                                +		DefaultContentType: "application/json",
                                                +		BrandColor:         "#4285F4",
                                                +		// Only the v1beta1 publisher listing answers: the v1 form and the
                                                +		// project-scoped form under BOTH versions return 404. That means the
                                                +		// list is publisher-global — it cannot say which models this project
                                                +		// has enabled — so it is offered as a suggestion beside the catalog
                                                +		// rather than replacing it. See the discovery e2e for the probes.
                                                +		Discovery: &Discovery{
                                                +			Path:  "/v1beta1/publishers/anthropic/models",
                                                +			Shape: ShapeVertexPublisherModels,
                                                +		},
                                                +		// ParserID stays empty (path-style dispatch via IsVertexPathStyle);
                                                +		// Anthropic-on-Vertex requests are metered under the "anthropic"
                                                +		// surface with the bare, unversioned model id.
                                                +		PricingSurfaces: []string{"anthropic"},
                                                +		// Vertex carries the model in the URL path and authenticates with a
                                                +		// service-account-minted OAuth token (api_key = "keyfile::").
                                                +		// Only Anthropic-on-Vertex is metered today: the request parser maps the
                                                +		// anthropic publisher to the Anthropic parser, so the lineup + prices
                                                +		// mirror the first-party Anthropic catalog (LiteLLM vertex_ai/claude-*
                                                +		// confirms the same per-token rates; cross-region profiles in eu/apac
                                                +		// carry a ~10% premium that base pricing does not model). Gemini (the
                                                +		// google publisher) is intentionally omitted until a Gemini parser
                                                +		// exists — the router denies unmeterable publishers rather than forward
                                                +		// them uncounted.
                                                +		Models: []Model{
                                                +			{ID: "claude-opus-5", Label: "Claude Opus 5 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
                                                +			{ID: "claude-sonnet-5", Label: "Claude Sonnet 5 (Vertex)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000},
                                                +			{ID: "claude-fable-5", Label: "Claude Fable 5 (Vertex)", InputPer1k: 0.010, OutputPer1k: 0.050, CacheReadPer1k: 0.001, CacheCreationPer1k: 0.0125, ContextWindow: 1000000},
                                                +			{ID: "claude-opus-4-8", Label: "Claude Opus 4.8 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
                                                +			{ID: "claude-opus-4-7", Label: "Claude Opus 4.7 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
                                                +			{ID: "claude-opus-4-6", Label: "Claude Opus 4.6 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
                                                +			{ID: "claude-opus-4-1", Label: "Claude Opus 4.1 (Vertex, deprecated 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, CacheReadPer1k: 0.0015, CacheCreationPer1k: 0.01875, ContextWindow: 200000},
                                                +			{ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6 (Vertex)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000},
                                                +			{ID: "claude-sonnet-4-5", Label: "Claude Sonnet 4.5 (Vertex)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 200000},
                                                +			{ID: "claude-haiku-4-5", Label: "Claude Haiku 4.5 (Vertex)", InputPer1k: 0.001, OutputPer1k: 0.005, CacheReadPer1k: 0.0001, CacheCreationPer1k: 0.00125, ContextWindow: 200000},
                                                +		},
                                                +	},
                                                +	{
                                                +		ID:                 "mistral_api",
                                                +		Kind:               KindProvider,
                                                +		Name:               "Mistral API",
                                                +		Description:        "Mistral cloud API",
                                                +		DefaultHost:        "api.mistral.ai",
                                                +		AuthHeaderName:     "Authorization",
                                                +		AuthHeaderTemplate: "Bearer ${API_KEY}",
                                                +		DefaultContentType: "application/json",
                                                +		BrandColor:         "#FF7000",
                                                +		ParserID:           "openai",
                                                +		PricingSurfaces:    []string{"openai"},
                                                +		// Pricing + context windows cross-checked against LiteLLM. Key
                                                +		// gotchas the marketing page hides:
                                                +		//   - `mistral-medium-latest` aliases to Medium 3.1 ($0.40/$2),
                                                +		//     NOT Medium 3.5 ($1.50/$7.50). Catalog exposes both.
                                                +		//   - `mistral-large-latest` aliases to Large 3 — 262K context,
                                                +		//     cheaper than Medium 3.5.
                                                +		//   - Magistral models are tuned for reasoning but cap context
                                                +		//     at only 40K (vs 128K-262K elsewhere).
                                                +		//   - `codestral-latest` still routes to the old 2405 build
                                                +		//     ($1/$3) per LiteLLM; the newer codestral-2508 is both
                                                +		//     cheaper and longer-context. Both exposed.
                                                +		//   - Pixtral was folded into the main Large/Medium series; no
                                                +		//     standalone vision entry.
                                                +		Models: []Model{
                                                +			{ID: "mistral-large-latest", Label: "Mistral Large 3", InputPer1k: 0.0005, OutputPer1k: 0.0015, ContextWindow: 262144},
                                                +			{ID: "mistral-medium-latest", Label: "Mistral Medium 3.1", InputPer1k: 0.0004, OutputPer1k: 0.002, ContextWindow: 131072},
                                                +			{ID: "mistral-medium-3-5", Label: "Mistral Medium 3.5", InputPer1k: 0.0015, OutputPer1k: 0.0075, ContextWindow: 262144},
                                                +			{ID: "mistral-small-latest", Label: "Mistral Small 3.2", InputPer1k: 0.00006, OutputPer1k: 0.00018, ContextWindow: 131072},
                                                +			{ID: "magistral-medium-latest", Label: "Magistral Medium (reasoning)", InputPer1k: 0.002, OutputPer1k: 0.005, ContextWindow: 40000},
                                                +			{ID: "magistral-small-latest", Label: "Magistral Small (reasoning)", InputPer1k: 0.0005, OutputPer1k: 0.0015, ContextWindow: 40000},
                                                +			{ID: "devstral-medium-latest", Label: "Devstral Medium 2 (coding)", InputPer1k: 0.0004, OutputPer1k: 0.002, ContextWindow: 256000},
                                                +			{ID: "devstral-small-latest", Label: "Devstral Small 2 (coding)", InputPer1k: 0.0001, OutputPer1k: 0.0003, ContextWindow: 256000},
                                                +			{ID: "codestral-2508", Label: "Codestral 2508", InputPer1k: 0.0003, OutputPer1k: 0.0009, ContextWindow: 256000},
                                                +			{ID: "codestral-latest", Label: "Codestral (legacy 2405)", InputPer1k: 0.001, OutputPer1k: 0.003, ContextWindow: 32000},
                                                +			{ID: "ministral-3-14b-2512", Label: "Ministral 3 14B", InputPer1k: 0.0002, OutputPer1k: 0.0002, ContextWindow: 262144},
                                                +			{ID: "ministral-8b-latest", Label: "Ministral 8B", InputPer1k: 0.00015, OutputPer1k: 0.00015, ContextWindow: 262144},
                                                +			{ID: "ministral-3-3b-2512", Label: "Ministral 3 3B", InputPer1k: 0.0001, OutputPer1k: 0.0001, ContextWindow: 131072},
                                                +			{ID: "mistral-embed", Label: "Mistral Embed", InputPer1k: 0.0001, OutputPer1k: 0, ContextWindow: 8192},
                                                +		},
                                                +	},
                                                +	{
                                                +		ID:                 "kimi_api",
                                                +		Kind:               KindProvider,
                                                +		Name:               "Kimi (Moonshot AI) API",
                                                +		Description:        "Kimi K3 / K2 models via the Moonshot AI platform",
                                                +		DefaultHost:        "api.moonshot.ai",
                                                +		AuthHeaderName:     "Authorization",
                                                +		AuthHeaderTemplate: "Bearer ${API_KEY}",
                                                +		DefaultContentType: "application/json",
                                                +		BrandColor:         "#1A1A2E",
                                                +		// ParserID empty on purpose: Moonshot serves two body shapes on
                                                +		// the same host and key, and the proxy's URL sniffer dispatches
                                                +		// both (same pattern as Bifrost). /v1/chat/completions matches
                                                +		// OpenAIParser; the Anthropic-compatible endpoint the official
                                                +		// Claude Code guide uses (/anthropic/v1/messages) contains
                                                +		// "/v1/messages" and matches AnthropicParser. Pinning "openai"
                                                +		// here would misparse the Claude Code path — the primary way
                                                +		// teams consume Kimi for coding today. Both endpoints accept the
                                                +		// same Moonshot key via Authorization: Bearer (Claude Code's
                                                +		// ANTHROPIC_AUTH_TOKEN rides that header too).
                                                +		//
                                                +		// api.moonshot.ai is the international platform; mainland-China
                                                +		// accounts live on api.moonshot.cn with separate billing —
                                                +		// operators there override the host on the provider record. The
                                                +		// kimi.com subscription coding endpoint (api.kimi.com/coding,
                                                +		// model id "k3") is account-bound seat licensing rather than a
                                                +		// meterable platform key, so it's deliberately not the default.
                                                +		ParserID: "",
                                                +		// Both body shapes are metered: /v1/chat/completions under
                                                +		// "openai", /anthropic/v1/messages under "anthropic" — so the
                                                +		// K3 entry is priced on both surfaces.
                                                +		PricingSurfaces: []string{"openai", "anthropic"},
                                                +		// Pricing per Moonshot's platform rates at K3 launch (July 2026):
                                                +		// $3/$15 per MTok with $0.30 cached input, flat across the 1M-token
                                                +		// window. kimi-k3 is the ONLY model the platform serves newer
                                                +		// accounts — K2-era ids (kimi-k2-thinking) and even the kimi-latest
                                                +		// alias return resource_not_found_error, verified live 2026-07-21 —
                                                +		// so it's the only catalog entry. Grandfathered accounts with K2
                                                +		// access can still type those ids on the provider's model rows.
                                                +		// The consumer app's "K3 Swarm Max" mode is not an API SKU, so it
                                                +		// doesn't appear here.
                                                +		Models: []Model{
                                                +			// Carries both cache shapes: Moonshot reports cache hits
                                                +			// OpenAI-style on /v1/chat/completions (CachedInputPer1k)
                                                +			// and Anthropic-style on /anthropic/v1/messages
                                                +			// (CacheReadPer1k) — $0.30/MTok either way. Each surface's
                                                +			// cost formula reads only its own field, so the superset
                                                +			// entry prices both endpoints correctly. No cache-creation
                                                +			// rate published; writes bill at the input rate.
                                                +			{ID: "kimi-k3", Label: "Kimi K3", InputPer1k: 0.003, OutputPer1k: 0.015, CachedInputPer1k: 0.0003, CacheReadPer1k: 0.0003, ContextWindow: 1000000},
                                                +		},
                                                +	},
                                                +	{
                                                +		ID:                 "litellm_proxy",
                                                +		Kind:               KindGateway,
                                                +		Name:               "LiteLLM Proxy",
                                                +		Description:        "Bring your own LiteLLM proxy with NetBird identity stamped on every request",
                                                +		DefaultHost:        "",
                                                +		AuthHeaderName:     "Authorization",
                                                +		AuthHeaderTemplate: "Bearer ${API_KEY}",
                                                +		DefaultContentType: "application/json",
                                                +		BrandColor:         "#0EA5E9",
                                                +		ParserID:           "openai",
                                                +		// IdentityInjection requires a LiteLLM virtual key minted with
                                                +		// metadata.allow_client_tags=true; the master key silently drops
                                                +		// caller tags. Tags go out via both the x-litellm-tags header and
                                                +		// body metadata.tags: LiteLLM enforces budgets from the body only,
                                                +		// so the header is the spend-tracking fallback when body injection
                                                +		// can't run. See the Agent Network provider docs for key setup.
                                                +		IdentityInjection: &IdentityInjection{
                                                +			HeaderPair: &HeaderPairInjection{
                                                +				EndUserIDHeader: "x-litellm-end-user-id",
                                                +				TagsHeader:      "x-litellm-tags",
                                                +				TagsInBody:      true,
                                                +				EndUserIDInBody: true,
                                                +			},
                                                +		},
                                                +		Models: []Model{},
                                                +	},
                                                +	{
                                                +		ID:                 "agentgateway",
                                                +		Kind:               KindGateway,
                                                +		Name:               "agentgateway",
                                                +		Description:        "Bring your own agentgateway with trusted NetBird identity stamped on every request",
                                                +		DefaultHost:        "",
                                                +		AuthHeaderName:     "Authorization",
                                                +		AuthHeaderTemplate: "Bearer ${API_KEY}",
                                                +		DefaultContentType: "application/json",
                                                +		BrandColor:         "#8023C3",
                                                +		// Agentgateway accepts both OpenAI and Anthropic request shapes.
                                                +		// Leave ParserID empty so the proxy detects the shape from the URL.
                                                +		ParserID:        "",
                                                +		RouterVendors:   []string{"openai", "anthropic"},
                                                +		PricingSurfaces: []string{"openai", "anthropic"},
                                                +		Discovery: &Discovery{
                                                +			Path:            "/v1/models",
                                                +			Shape:           ShapeOpenAIData,
                                                +			ExactModelsOnly: true,
                                                +		},
                                                +		IdentityInjection: &IdentityInjection{
                                                +			HeaderPair: &HeaderPairInjection{
                                                +				EndUserIDHeader: "x-netbird-user-id",
                                                +				TagsHeader:      "x-netbird-groups",
                                                +			},
                                                +		},
                                                +		Models: []Model{},
                                                +	},
                                                +	{
                                                +		ID:          "portkey",
                                                +		Kind:        KindGateway,
                                                +		Name:        "Portkey AI Gateway",
                                                +		Description: "Portkey AI Gateway with NetBird identity stamped via x-portkey-metadata",
                                                +		DefaultHost: "api.portkey.ai",
                                                +		// Portkey hosted requires x-portkey-api-key (account key)
                                                +		// plus a routing decision per request. The simplest routing
                                                +		// path is a saved Portkey config id stamped via
                                                +		// x-portkey-config — operators paste the pc-... id once and
                                                +		// Portkey resolves the upstream provider + virtual key from
                                                +		// it. ExtraHeaders below surfaces the input. Alternative:
                                                +		// callers author "@org/model" in the body; both flows
                                                +		// coexist (per-request authoring still works without a
                                                +		// configured value).
                                                +		AuthHeaderName:     "x-portkey-api-key",
                                                +		AuthHeaderTemplate: "${API_KEY}",
                                                +		DefaultContentType: "application/json",
                                                +		BrandColor:         "#FF5C00",
                                                +		ParserID:           "openai",
                                                +		IdentityInjection: &IdentityInjection{
                                                +			JSONMetadata: &JSONMetadataInjection{
                                                +				Header:         "x-portkey-metadata",
                                                +				UserKey:        "_user",
                                                +				GroupsKey:      "groups",
                                                +				MaxValueLength: 128,
                                                +			},
                                                +		},
                                                +		ExtraHeaders: []ExtraHeader{
                                                +			{Name: "x-portkey-config"},
                                                +		},
                                                +		Models: []Model{},
                                                +	},
                                                +	{
                                                +		ID:                 "bifrost",
                                                +		Kind:               KindGateway,
                                                +		Name:               "Bifrost",
                                                +		Description:        "Maxim AI's Bifrost gateway. Point upstream URL at /openai/v1 or /anthropic/v1 on your Bifrost host depending on which body shape your apps use.",
                                                +		DefaultHost:        "",
                                                +		AuthHeaderName:     "Authorization",
                                                +		AuthHeaderTemplate: "Bearer ${API_KEY}",
                                                +		DefaultContentType: "application/json",
                                                +		BrandColor:         "#7C3AED",
                                                +		// ParserID empty: the proxy's request parser sniffs the URL
                                                +		// path. Bifrost's /openai/v1/... contains "/v1/chat/completions"
                                                +		// (matches OpenAIParser.DetectFromURL); /anthropic/v1/messages
                                                +		// contains "/v1/messages" (matches AnthropicParser). Operators
                                                +		// who paste a different prefix get no usage parsing and the
                                                +		// cost meter skips with skipMissingProvider — degraded but
                                                +		// non-fatal.
                                                +		ParserID: "",
                                                +		// Identity-injection headers are operator-customisable. The
                                                +		// HeaderPair values below are PLACEHOLDERS surfaced by the
                                                +		// dashboard; the actual values stamped on the wire come from
                                                +		// the provider record's IdentityHeaderUserID /
                                                +		// IdentityHeaderGroups fields. An empty operator value
                                                +		// disables stamping for that dimension (the inject middleware
                                                +		// already no-ops on empty header names). Defaulting to the
                                                +		// x-bf-dim- family so the values land in Bifrost's
                                                +		// Prometheus/OTEL pipelines when the operator declares the
                                                +		// label names in their client.prometheus_labels config — see
                                                +		// docs.getbifrost.ai/features/telemetry. Operators who use
                                                +		// the always-on x-bf-lh- log-metadata family (no Bifrost-side
                                                +		// declaration required) just edit the inputs.
                                                +		//
                                                +		// Bifrost virtual keys (sk-bf-*) ride Authorization: Bearer.
                                                +		// Operators provision the VK on their Bifrost (UI /
                                                +		// config.json / POST /api/governance/virtual-keys) and paste
                                                +		// the returned sk-bf-... as ${API_KEY}. Pin v1.4+ to avoid
                                                +		// the v1.3.0 x-bf-vk regression (maximhq/bifrost#632).
                                                +		IdentityInjection: &IdentityInjection{
                                                +			HeaderPair: &HeaderPairInjection{
                                                +				EndUserIDHeader: "x-bf-dim-netbird_user_id",
                                                +				TagsHeader:      "x-bf-dim-netbird_groups",
                                                +				Customizable:    true,
                                                +			},
                                                +		},
                                                +		Models: []Model{},
                                                +	},
                                                +	{
                                                +		ID:                 "cloudflare_ai_gateway",
                                                +		Kind:               KindGateway,
                                                +		Name:               "Cloudflare AI Gateway",
                                                +		Description:        "Cloudflare AI Gateway. Operator pastes the gateway URL (with the upstream provider slug like /openai or /anthropic so the URL sniffer dispatches to the right parser) and a per-gateway authentication token. Recommended setup is BYOK / Stored Keys: Cloudflare manages the upstream provider credential and the gateway token is the only secret NetBird needs.",
                                                +		DefaultHost:        "",
                                                +		AuthHeaderName:     "cf-aig-authorization",
                                                +		AuthHeaderTemplate: "Bearer ${API_KEY}",
                                                +		DefaultContentType: "application/json",
                                                +		BrandColor:         "#F38020",
                                                +		// ParserID empty: like Bifrost, the proxy's parser-detect
                                                +		// sniffs the URL path. /openai/... contains the OpenAI hint
                                                +		// substrings; /anthropic/v1/messages contains /v1/messages
                                                +		// (matches AnthropicParser). The /compat universal endpoint
                                                +		// also speaks OpenAI shape so OpenAIParser handles it.
                                                +		// Operators who paste a different prefix degrade to no-cost
                                                +		// (skipMissingProvider) but the request still flows.
                                                +		ParserID: "",
                                                +		// cf-aig-metadata is a single header carrying a JSON object;
                                                +		// up to five string/number/boolean values per request. NetBird
                                                +		// occupies two slots (user id + groups CSV) and leaves three
                                                +		// for operator-added context. JSON keys are operator-
                                                +		// customisable so Cloudflare-side log filters can use the
                                                +		// operator's existing label conventions instead of NetBird's
                                                +		// defaults — hence Customizable=true. The dashboard surfaces
                                                +		// the catalog values as placeholders; only the values stored
                                                +		// on the provider record's IdentityHeader* fields land on the
                                                +		// wire (empty operator value = key is omitted from the JSON,
                                                +		// since applyJSONMetadata already skips empty keys).
                                                +		IdentityInjection: &IdentityInjection{
                                                +			JSONMetadata: &JSONMetadataInjection{
                                                +				Header:       "cf-aig-metadata",
                                                +				UserKey:      "netbird_user_id",
                                                +				GroupsKey:    "netbird_groups",
                                                +				Customizable: true,
                                                +				// Cloudflare's docs don't specify a per-value cap;
                                                +				// leaving 0 disables the truncate path. Header-level
                                                +				// constraint is "5 entries max" rather than length.
                                                +				MaxValueLength: 0,
                                                +			},
                                                +		},
                                                +		Models: []Model{},
                                                +	},
                                                +	{
                                                +		ID:                 "vercel_ai_gateway",
                                                +		Kind:               KindGateway,
                                                +		Name:               "Vercel AI Gateway",
                                                +		Description:        "Vercel's unified API for hundreds of models. Single endpoint, OpenAI-compatible body, model dispatch via prefix (openai/..., anthropic/..., google/..., xai/...). Per-user / per-tag attribution lands in Vercel's Custom Reporting API and observability dashboard.",
                                                +		DefaultHost:        "",
                                                +		AuthHeaderName:     "Authorization",
                                                +		AuthHeaderTemplate: "Bearer ${API_KEY}",
                                                +		DefaultContentType: "application/json",
                                                +		BrandColor:         "#000000",
                                                +		// Vercel always speaks OpenAI shape on /v1/chat/completions —
                                                +		// the model prefix in the body picks the upstream provider.
                                                +		// No URL sniffing needed; pin the parser directly.
                                                +		ParserID: "openai",
                                                +		// HeaderPair shape with fixed wire names dictated by Vercel's
                                                +		// Custom Reporting API contract. Customizable=false because
                                                +		// renaming the headers makes Vercel silently stop attributing
                                                +		// — the gateway's reporting endpoint only matches its own
                                                +		// header names. Same fixed-protocol position as LiteLLM.
                                                +		//
                                                +		// Caveats operators should know:
                                                +		//   - up to 10 tags total per request (deduped); 11+ → HTTP 400
                                                +		//   - each tag must be 1-64 chars
                                                +		//   - user up to 256 chars (NetBird user emails fit)
                                                +		//   - $0.075 per 1k unique user/tag values written
                                                +		// We don't enforce the caps in the inject middleware today;
                                                +		// operators in groups beyond the 10-tag limit will see Vercel
                                                +		// 400s and need to re-scope their group memberships.
                                                +		IdentityInjection: &IdentityInjection{
                                                +			HeaderPair: &HeaderPairInjection{
                                                +				EndUserIDHeader: "ai-reporting-user",
                                                +				TagsHeader:      "ai-reporting-tags",
                                                +			},
                                                +		},
                                                +		Models: []Model{},
                                                +	},
                                                +	{
                                                +		ID:                 "openrouter",
                                                +		Kind:               KindGateway,
                                                +		Name:               "OpenRouter",
                                                +		Description:        "OpenRouter's unified API for hundreds of models. Single endpoint at openrouter.ai/api/v1, OpenAI-compatible body, model dispatch via prefix (anthropic/claude-..., openai/gpt-..., google/gemini-..., etc.). Per-user attribution lands in OpenRouter's analytics via the OpenAI-standard `user` body field; OpenRouter has no groups / tags dimension at request time.",
                                                +		DefaultHost:        "openrouter.ai/api/v1",
                                                +		AuthHeaderName:     "Authorization",
                                                +		AuthHeaderTemplate: "Bearer ${API_KEY}",
                                                +		DefaultContentType: "application/json",
                                                +		BrandColor:         "#6F4FF2",
                                                +		// OpenRouter is single-endpoint OpenAI-shape on /api/v1/chat/completions —
                                                +		// model prefix in the body picks the upstream provider.
                                                +		// Pinning the parser saves URL sniffing.
                                                +		ParserID: "openai",
                                                +		// HeaderPair shape with EndUserIDInBody as the only active
                                                +		// dimension. OpenRouter's per-user attribution is the
                                                +		// OpenAI-standard `user` body field, not a header — and
                                                +		// OpenRouter offers no per-request groups / tags dimension at
                                                +		// all. Customizable=false because the field name is locked by
                                                +		// OpenAI's spec; renaming would just defeat the inject.
                                                +		IdentityInjection: &IdentityInjection{
                                                +			HeaderPair: &HeaderPairInjection{
                                                +				EndUserIDInBody: true,
                                                +			},
                                                +		},
                                                +		// HTTP-Referer + X-OpenRouter-Title surface in OpenRouter's
                                                +		// app rankings and per-app analytics. Operators paste their
                                                +		// own app URL + display name on the provider record so their
                                                +		// requests show under their brand instead of "no app". Both
                                                +		// are static per-deployment, not per-request, hence the
                                                +		// ExtraHeaders mechanism (operator-typed value, stamped on
                                                +		// every request to this provider). Skip X-OpenRouter-Categories
                                                +		// for now — the marketplace-categories dimension is
                                                +		// niche-enough that we'd add it on demand.
                                                +		ExtraHeaders: []ExtraHeader{
                                                +			{Name: "HTTP-Referer"},
                                                +			{Name: "X-OpenRouter-Title"},
                                                +		},
                                                +		Models: []Model{},
                                                +	},
                                                +	{
                                                +		// vLLM is an OpenAI-compatible self-hosted server. It behaves like
                                                +		// the generic custom entry; it gets its own catalog id purely so it
                                                +		// surfaces as a named "vLLM" choice in the provider picker.
                                                +		ID:                 "vllm",
                                                +		Kind:               KindCustom,
                                                +		Name:               "vLLM",
                                                +		Description:        "Self-hosted vLLM (OpenAI-compatible)",
                                                +		DefaultHost:        "",
                                                +		AuthHeaderName:     "Authorization",
                                                +		AuthHeaderTemplate: "Bearer ${API_KEY}",
                                                +		DefaultContentType: "application/json",
                                                +		BrandColor:         "#30A2FF",
                                                +		Models:             []Model{},
                                                +	},
                                                +	{
                                                +		ID:                 "custom",
                                                +		Kind:               KindCustom,
                                                +		Name:               "Custom / Self-hosted",
                                                +		Description:        "OpenAI-compatible endpoint (vLLM, Ollama, …)",
                                                +		DefaultHost:        "",
                                                +		AuthHeaderName:     "Authorization",
                                                +		AuthHeaderTemplate: "Bearer ${API_KEY}",
                                                +		DefaultContentType: "application/json",
                                                +		BrandColor:         "#9CA3AF",
                                                +		Models:             []Model{},
                                                +	},
                                                +}
                                                +
                                                +// All returns a copy of the full catalog.
                                                +func All() []Provider {
                                                +	out := make([]Provider, len(providers))
                                                +	copy(out, providers)
                                                +	return out
                                                +}
                                                +
                                                +// Lookup returns the catalog entry with the given id, if any.
                                                +func Lookup(id string) (Provider, bool) {
                                                +	for _, p := range providers {
                                                +		if p.ID == id {
                                                +			return p, true
                                                +		}
                                                +	}
                                                +	return Provider{}, false
                                                +}
                                                +
                                                +// IsKnown reports whether the given id refers to a catalog entry.
                                                +func IsKnown(id string) bool {
                                                +	_, ok := Lookup(id)
                                                +	return ok
                                                +}
                                                +
                                                +// IsVertexPathStyle reports whether a provider uses the Google Vertex AI
                                                +// request shape — the model is carried in the URL path
                                                +// (/v1/projects/{p}/locations/{r}/publishers/{pub}/models/{model}:{action})
                                                +// rather than the body, so the proxy routes it by path instead of by model.
                                                +func IsVertexPathStyle(providerID string) bool {
                                                +	return providerID == "vertex_ai_api"
                                                +}
                                                +
                                                +// IsBedrockPathStyle reports whether a provider uses the AWS Bedrock request
                                                +// shape — the model is carried in the URL path (/model/{modelId}/{action},
                                                +// action being invoke, invoke-with-response-stream, converse, or
                                                +// converse-stream) rather than the body, so the proxy routes it by path.
                                                +func IsBedrockPathStyle(providerID string) bool {
                                                +	return providerID == "bedrock_api"
                                                +}
                                                +
                                                +// ToAPIResponse renders a catalog provider as the API representation.
                                                +func (p Provider) ToAPIResponse() api.AgentNetworkCatalogProvider {
                                                +	models := make([]api.AgentNetworkCatalogModel, 0, len(p.Models))
                                                +	for _, m := range p.Models {
                                                +		am := api.AgentNetworkCatalogModel{
                                                +			Id:            m.ID,
                                                +			Label:         m.Label,
                                                +			InputPer1k:    m.InputPer1k,
                                                +			OutputPer1k:   m.OutputPer1k,
                                                +			ContextWindow: m.ContextWindow,
                                                +		}
                                                +		// Cache rates are emitted only when configured so the dashboard
                                                +		// can prefill them; 0 stays off the wire (absent = no rate).
                                                +		if m.CachedInputPer1k > 0 {
                                                +			v := m.CachedInputPer1k
                                                +			am.CachedInputPer1k = &v
                                                +		}
                                                +		if m.CacheReadPer1k > 0 {
                                                +			v := m.CacheReadPer1k
                                                +			am.CacheReadPer1k = &v
                                                +		}
                                                +		if m.CacheCreationPer1k > 0 {
                                                +			v := m.CacheCreationPer1k
                                                +			am.CacheCreationPer1k = &v
                                                +		}
                                                +		models = append(models, am)
                                                +	}
                                                +	kind := api.AgentNetworkCatalogProviderKindProvider
                                                +	switch p.Kind {
                                                +	case KindGateway:
                                                +		kind = api.AgentNetworkCatalogProviderKindGateway
                                                +	case KindCustom:
                                                +		kind = api.AgentNetworkCatalogProviderKindCustom
                                                +	}
                                                +	resp := api.AgentNetworkCatalogProvider{
                                                +		Id:                 p.ID,
                                                +		Name:               p.Name,
                                                +		Description:        p.Description,
                                                +		DefaultHost:        p.DefaultHost,
                                                +		Kind:               kind,
                                                +		AuthHeaderTemplate: p.AuthHeaderTemplate,
                                                +		DefaultContentType: p.DefaultContentType,
                                                +		BrandColor:         p.BrandColor,
                                                +		Models:             models,
                                                +	}
                                                +	if len(p.PricingSurfaces) > 0 {
                                                +		surfaces := append([]string(nil), p.PricingSurfaces...)
                                                +		resp.PricingSurfaces = &surfaces
                                                +	}
                                                +	if len(p.ExtraHeaders) > 0 {
                                                +		extras := make([]api.AgentNetworkCatalogExtraHeader, 0, len(p.ExtraHeaders))
                                                +		for _, h := range p.ExtraHeaders {
                                                +			extras = append(extras, api.AgentNetworkCatalogExtraHeader{
                                                +				Name: h.Name,
                                                +			})
                                                +		}
                                                +		resp.ExtraHeaders = &extras
                                                +	}
                                                +	// Surface IdentityInjection so the dashboard can decide whether
                                                +	// to render editable inputs vs. a read-only mappings strip per
                                                +	// shape's customizable flag. HeaderPair (Bifrost) and
                                                +	// JSONMetadata (Cloudflare, Portkey) are mutually exclusive on a
                                                +	// given catalog entry; emit whichever shape is set.
                                                +	if p.IdentityInjection != nil {
                                                +		injection := &api.AgentNetworkCatalogIdentityInjection{}
                                                +		if hp := p.IdentityInjection.HeaderPair; hp != nil {
                                                +			injection.HeaderPair = &api.AgentNetworkCatalogHeaderPairInjection{
                                                +				Customizable:    hp.Customizable,
                                                +				EndUserIdHeader: hp.EndUserIDHeader,
                                                +				TagsHeader:      hp.TagsHeader,
                                                +			}
                                                +		}
                                                +		if jm := p.IdentityInjection.JSONMetadata; jm != nil {
                                                +			injection.JsonMetadata = &api.AgentNetworkCatalogJSONMetadataInjection{
                                                +				Customizable: jm.Customizable,
                                                +				Header:       jm.Header,
                                                +				UserKey:      jm.UserKey,
                                                +				GroupsKey:    jm.GroupsKey,
                                                +			}
                                                +		}
                                                +		if injection.HeaderPair != nil || injection.JsonMetadata != nil {
                                                +			resp.IdentityInjection = injection
                                                +		}
                                                +	}
                                                +	return resp
                                                +}
                                                diff --git a/management/internals/modules/agentnetwork/catalog/catalog_test.go b/management/internals/modules/agentnetwork/catalog/catalog_test.go
                                                new file mode 100644
                                                index 000000000..8abd3a312
                                                --- /dev/null
                                                +++ b/management/internals/modules/agentnetwork/catalog/catalog_test.go
                                                @@ -0,0 +1,86 @@
                                                +package catalog
                                                +
                                                +import (
                                                +	"testing"
                                                +
                                                +	"github.com/stretchr/testify/assert"
                                                +	"github.com/stretchr/testify/require"
                                                +
                                                +	"github.com/netbirdio/netbird/shared/management/http/api"
                                                +)
                                                +
                                                +// TestClaudeLineupSelectable pins the models Claude Code resolves to by
                                                +// default. A model absent from the lineup can't be ticked on a provider
                                                +// record, so llm_router denies it as not-routable and the operator has no
                                                +// way to authorise the client's own default.
                                                +func TestClaudeLineupSelectable(t *testing.T) {
                                                +	for providerID, wanted := range map[string][]string{
                                                +		"anthropic_api": {"claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5"},
                                                +		"bedrock_api":   {"anthropic.claude-opus-5", "anthropic.claude-sonnet-5", "anthropic.claude-haiku-4-5"},
                                                +		"vertex_ai_api": {"claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5"},
                                                +	} {
                                                +		provider, ok := Lookup(providerID)
                                                +		require.True(t, ok, "catalog must define %s", providerID)
                                                +
                                                +		selectable := make(map[string]Model, len(provider.Models))
                                                +		for _, m := range provider.Models {
                                                +			selectable[m.ID] = m
                                                +		}
                                                +		for _, id := range wanted {
                                                +			model, found := selectable[id]
                                                +			require.True(t, found, "%s must offer %s", providerID, id)
                                                +			assert.NotEmpty(t, model.Label, "%s/%s needs a label for the picker", providerID, id)
                                                +			assert.Positive(t, model.InputPer1k, "%s/%s needs an input rate", providerID, id)
                                                +			assert.Positive(t, model.OutputPer1k, "%s/%s needs an output rate", providerID, id)
                                                +			assert.Positive(t, model.ContextWindow, "%s/%s needs a context window", providerID, id)
                                                +		}
                                                +	}
                                                +}
                                                +
                                                +func TestAgentgatewayCatalogEntry(t *testing.T) {
                                                +	entry, ok := Lookup("agentgateway")
                                                +	require.True(t, ok, "agentgateway must be available in the provider catalog")
                                                +
                                                +	assert.Equal(t, KindGateway, entry.Kind, "agentgateway must be grouped with AI gateways")
                                                +	assert.Empty(t, entry.DefaultHost, "operators must provide their agentgateway proxy URL")
                                                +	assert.Equal(t, "Authorization", entry.AuthHeaderName)
                                                +	assert.Equal(t, "Bearer ${API_KEY}", entry.AuthHeaderTemplate)
                                                +	assert.Equal(t, "application/json", entry.DefaultContentType)
                                                +	assert.Empty(t, entry.ParserID, "URL detection must select the OpenAI or Anthropic parser")
                                                +	assert.Equal(t, []string{"openai", "anthropic"}, entry.RouterVendors,
                                                +		"agentgateway must accept both parser surfaces")
                                                +	assert.Equal(t, []string{"openai", "anthropic"}, entry.PricingSurfaces,
                                                +		"agentgateway models can use either pricing surface")
                                                +	assert.Empty(t, entry.Models, "an empty model list makes agentgateway a catch-all route")
                                                +	require.NotNil(t, entry.Discovery)
                                                +	assert.Empty(t, entry.Discovery.Host, "discovery must use the configured proxy URL")
                                                +	assert.Equal(t, "/v1/models", entry.Discovery.Path)
                                                +	assert.Equal(t, ShapeOpenAIData, entry.Discovery.Shape)
                                                +	assert.True(t, entry.Discovery.ExactModelsOnly,
                                                +		"wildcard model semantics are not supported by NetBird")
                                                +
                                                +	require.NotNil(t, entry.IdentityInjection)
                                                +	require.NotNil(t, entry.IdentityInjection.HeaderPair)
                                                +	assert.Nil(t, entry.IdentityInjection.JSONMetadata)
                                                +	assert.False(t, entry.IdentityInjection.HeaderPair.Customizable,
                                                +		"NetBird identity header names are part of the integration contract")
                                                +	assert.Equal(t, "x-netbird-user-id", entry.IdentityInjection.HeaderPair.EndUserIDHeader)
                                                +	assert.Equal(t, "x-netbird-groups", entry.IdentityInjection.HeaderPair.TagsHeader)
                                                +	assert.False(t, entry.IdentityInjection.HeaderPair.EndUserIDInBody)
                                                +	assert.False(t, entry.IdentityInjection.HeaderPair.TagsInBody)
                                                +}
                                                +
                                                +func TestAgentgatewayCatalogAPIResponse(t *testing.T) {
                                                +	entry, ok := Lookup("agentgateway")
                                                +	require.True(t, ok)
                                                +
                                                +	resp := entry.ToAPIResponse()
                                                +	assert.Equal(t, "agentgateway", resp.Id)
                                                +	assert.Equal(t, api.AgentNetworkCatalogProviderKindGateway, resp.Kind)
                                                +	assert.Empty(t, resp.Models)
                                                +	require.NotNil(t, resp.IdentityInjection)
                                                +	require.NotNil(t, resp.IdentityInjection.HeaderPair)
                                                +	assert.False(t, resp.IdentityInjection.HeaderPair.Customizable)
                                                +	assert.Equal(t, "x-netbird-user-id", resp.IdentityInjection.HeaderPair.EndUserIdHeader)
                                                +	assert.Equal(t, "x-netbird-groups", resp.IdentityInjection.HeaderPair.TagsHeader)
                                                +}
                                                diff --git a/management/internals/modules/agentnetwork/credentialcheck.go b/management/internals/modules/agentnetwork/credentialcheck.go
                                                new file mode 100644
                                                index 000000000..a4787d019
                                                --- /dev/null
                                                +++ b/management/internals/modules/agentnetwork/credentialcheck.go
                                                @@ -0,0 +1,135 @@
                                                +package agentnetwork
                                                +
                                                +import (
                                                +	"context"
                                                +	"errors"
                                                +	"net/http"
                                                +
                                                +	log "github.com/sirupsen/logrus"
                                                +
                                                +	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/modeldiscovery"
                                                +	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
                                                +	"github.com/netbirdio/netbird/shared/management/status"
                                                +)
                                                +
                                                +// ModelLister is the vendor-facing half of the credential check.
                                                +// modeldiscovery.Client is the only production implementation; it is an
                                                +// interface because the check runs on a write path, so without a seam every
                                                +// test that saves a provider would reach a vendor to do it.
                                                +type ModelLister interface {
                                                +	Fetch(ctx context.Context, req modeldiscovery.Request) ([]modeldiscovery.Model, error)
                                                +}
                                                +
                                                +// checkProviderCredential refuses a record whose upstream or credential the
                                                +// vendor will not accept.
                                                +//
                                                +// It reuses the discovery Fetch rather than a lighter status probe so it
                                                +// exercises the path the model picker takes: a URL answering 200 with a login
                                                +// page fails here instead of producing an empty picker later.
                                                +func (m *managerImpl) checkProviderCredential(ctx context.Context, provider *types.Provider) error {
                                                +	// A record that asks the proxy to skip certificate verification is one this
                                                +	// check cannot speak for. Discovery verifies certificates, so a self-hosted
                                                +	// endpoint behind a self-signed one would be refused for a reason the
                                                +	// operator already told us to ignore — a lockout of exactly the setup the
                                                +	// flag exists for. Sending the credential over a connection management
                                                +	// declines to verify is the other way out, and a worse one.
                                                +	if provider.SkipTLSVerification {
                                                +		log.WithContext(ctx).Debugf("agent network provider %s not credential-checked: tls verification is disabled for it", provider.ProviderID)
                                                +		return nil
                                                +	}
                                                +
                                                +	_, err := m.modelDiscovery.Fetch(ctx, modeldiscovery.Request{
                                                +		CatalogID:   provider.ProviderID,
                                                +		UpstreamURL: provider.UpstreamURL,
                                                +		APIKey:      provider.APIKey,
                                                +	})
                                                +	if err == nil {
                                                +		return nil
                                                +	}
                                                +
                                                +	message, blocking := credentialCheckFailure(err)
                                                +	if !blocking {
                                                +		log.WithContext(ctx).Debugf("agent network provider %s not credential-checked: %v", provider.ProviderID, err)
                                                +		return nil
                                                +	}
                                                +
                                                +	// WriteError logs only what we return, and that carries no status code,
                                                +	// so the vendor's number is recorded here or nowhere.
                                                +	log.WithContext(ctx).Infof("agent network provider %s failed its credential check: %v", provider.ProviderID, err)
                                                +
                                                +	return status.Errorf(status.InvalidArgument, "%s", message)
                                                +}
                                                +
                                                +// discoveryFailure renders a failed model listing for the operator who pressed
                                                +// the button. Every outcome here is something they did or configured — a key
                                                +// the vendor refused, an upstream that does not answer — so it owes them the
                                                +// same sentence a refused save gives, not the generic 500 an unclassified
                                                +// error turns into.
                                                +//
                                                +// ErrNoDiscovery and ErrInvalidRequest pass through untouched: the handler
                                                +// already maps them, and "this provider has no listing endpoint" is a fact
                                                +// about the catalog rather than a failure to report as one.
                                                +func discoveryFailure(ctx context.Context, catalogID string, err error) error {
                                                +	if errors.Is(err, modeldiscovery.ErrNoDiscovery) || errors.Is(err, modeldiscovery.ErrInvalidRequest) {
                                                +		return err
                                                +	}
                                                +
                                                +	message, _ := credentialCheckFailure(err)
                                                +	if message == "" {
                                                +		return err
                                                +	}
                                                +
                                                +	// The operator's message carries no status code, so the vendor's number is
                                                +	// recorded here or nowhere.
                                                +	log.WithContext(ctx).Infof("agent network model discovery for %s failed: %v", catalogID, err)
                                                +
                                                +	return status.Errorf(status.InvalidArgument, "%s", message)
                                                +}
                                                +
                                                +// credentialCheckFailure renders a discovery failure as the sentence the
                                                +// provider form shows, and reports whether it should block the write.
                                                +//
                                                +// The strings survive WriteError lowercasing them, and never echo the
                                                +// operator's URL: paths are case-sensitive, so an echoed URL comes back
                                                +// altered and describes something they did not type.
                                                +func credentialCheckFailure(err error) (message string, blocking bool) {
                                                +	// Not checkable. The record may be perfectly good and we have no way to
                                                +	// ask, so reporting a failure would be a guess.
                                                +	switch {
                                                +	case errors.Is(err, modeldiscovery.ErrNoDiscovery),
                                                +		errors.Is(err, modeldiscovery.ErrNoDiscoveryHost),
                                                +		errors.Is(err, modeldiscovery.ErrPrivateHost):
                                                +		return "", false
                                                +	}
                                                +
                                                +	var vendor *modeldiscovery.VendorStatusError
                                                +	if errors.As(err, &vendor) {
                                                +		switch vendor.Status {
                                                +		case http.StatusUnauthorized, http.StatusForbidden:
                                                +			return "the provider rejected the credential", true
                                                +		case http.StatusNotFound, http.StatusMethodNotAllowed:
                                                +			return "the upstream url did not answer a model listing", true
                                                +		default:
                                                +			// 5xx and 429 included: an outage still leaves the record
                                                +			// unverified, which is what this refuses to save.
                                                +			return "the provider returned an error", true
                                                +		}
                                                +	}
                                                +
                                                +	var unreachable *modeldiscovery.UnreachableError
                                                +	if errors.As(err, &unreachable) {
                                                +		if reason := unreachable.Reason(); reason != "" {
                                                +			return "the upstream url could not be reached: " + reason, true
                                                +		}
                                                +		return "the upstream url could not be reached", true
                                                +	}
                                                +
                                                +	if errors.Is(err, modeldiscovery.ErrUnparseableListing) {
                                                +		return "the upstream url answered, but not with a model listing", true
                                                +	}
                                                +
                                                +	// Ours rather than the vendor's — a request this code built badly, or a
                                                +	// catalog entry that does not match its parser. Still unverified, so it
                                                +	// still blocks.
                                                +	return "the provider could not be checked", true
                                                +}
                                                diff --git a/management/internals/modules/agentnetwork/credentialcheck_test.go b/management/internals/modules/agentnetwork/credentialcheck_test.go
                                                new file mode 100644
                                                index 000000000..e1dc8c537
                                                --- /dev/null
                                                +++ b/management/internals/modules/agentnetwork/credentialcheck_test.go
                                                @@ -0,0 +1,605 @@
                                                +package agentnetwork
                                                +
                                                +import (
                                                +	"context"
                                                +	"crypto/tls"
                                                +	"errors"
                                                +	"fmt"
                                                +	"net"
                                                +	"os"
                                                +	"syscall"
                                                +	"testing"
                                                +
                                                +	"github.com/stretchr/testify/require"
                                                +
                                                +	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/modeldiscovery"
                                                +	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
                                                +	"github.com/netbirdio/netbird/management/internals/modules/permissions/modules"
                                                +	"github.com/netbirdio/netbird/management/internals/modules/permissions/operations"
                                                +	"github.com/netbirdio/netbird/management/server/store"
                                                +	"github.com/netbirdio/netbird/shared/management/status"
                                                +)
                                                +
                                                +// stubLister stands in for the vendor on the write path. It records what it
                                                +// was asked so a test can assert not only that the check ran, but that it ran
                                                +// against the right upstream and the right credential — and, for an edit that
                                                +// touches neither, that it did not run at all.
                                                +type stubLister struct {
                                                +	err      error
                                                +	requests []modeldiscovery.Request
                                                +}
                                                +
                                                +func (s *stubLister) Fetch(_ context.Context, req modeldiscovery.Request) ([]modeldiscovery.Model, error) {
                                                +	s.requests = append(s.requests, req)
                                                +	if s.err != nil {
                                                +		return nil, s.err
                                                +	}
                                                +	return []modeldiscovery.Model{{ID: "a-model", PricingKnown: true}}, nil
                                                +}
                                                +
                                                +func (s *stubLister) calls() int { return len(s.requests) }
                                                +
                                                +func (s *stubLister) only(t *testing.T) modeldiscovery.Request {
                                                +	t.Helper()
                                                +	require.Len(t, s.requests, 1, "the vendor must be asked exactly once")
                                                +	return s.requests[0]
                                                +}
                                                +
                                                +// TestCredentialCheckFailure_SeparatesTheUrlFromTheCredential is the contract
                                                +// the provider form is written against: an operator gets told which of the two
                                                +// fields they have to look at, and the message says so without a status code
                                                +// and without echoing the URL back at them.
                                                +func TestCredentialCheckFailure_SeparatesTheUrlFromTheCredential(t *testing.T) {
                                                +	cases := []struct {
                                                +		name string
                                                +		err  error
                                                +		want string
                                                +	}{
                                                +		{
                                                +			name: "401 is the credential",
                                                +			err:  &modeldiscovery.VendorStatusError{Provider: "OpenAI", Status: 401},
                                                +			want: "the provider rejected the credential",
                                                +		},
                                                +		{
                                                +			name: "403 is the credential",
                                                +			err:  &modeldiscovery.VendorStatusError{Provider: "Bedrock", Status: 403},
                                                +			want: "the provider rejected the credential",
                                                +		},
                                                +		{
                                                +			// The host authenticated us fine and then said it has no such
                                                +			// endpoint, which is the URL being wrong rather than the key.
                                                +			name: "404 is the url",
                                                +			err:  &modeldiscovery.VendorStatusError{Provider: "OpenAI", Status: 404},
                                                +			want: "the upstream url did not answer a model listing",
                                                +		},
                                                +		{
                                                +			name: "405 is the url",
                                                +			err:  &modeldiscovery.VendorStatusError{Provider: "OpenAI", Status: 405},
                                                +			want: "the upstream url did not answer a model listing",
                                                +		},
                                                +		{
                                                +			name: "500 is the vendor",
                                                +			err:  &modeldiscovery.VendorStatusError{Provider: "Anthropic", Status: 500},
                                                +			want: "the provider returned an error",
                                                +		},
                                                +		{
                                                +			name: "503 is the vendor",
                                                +			err:  &modeldiscovery.VendorStatusError{Provider: "Anthropic", Status: 503},
                                                +			want: "the provider returned an error",
                                                +		},
                                                +		{
                                                +			name: "429 is the vendor",
                                                +			err:  &modeldiscovery.VendorStatusError{Provider: "Anthropic", Status: 429},
                                                +			want: "the provider returned an error",
                                                +		},
                                                +	}
                                                +
                                                +	for _, tc := range cases {
                                                +		t.Run(tc.name, func(t *testing.T) {
                                                +			got, blocking := credentialCheckFailure(tc.err)
                                                +			require.True(t, blocking, "a vendor refusal must block the write")
                                                +			require.Equal(t, tc.want, got)
                                                +		})
                                                +	}
                                                +}
                                                +
                                                +// TestCredentialCheckFailure_NamesTheTransportFault covers the failures that
                                                +// never reached the vendor. The distinction inside them is worth keeping: a
                                                +// refused connection is a wrong port and an unknown host is a wrong hostname,
                                                +// and an operator staring at a URL they believe in needs to be told which.
                                                +func TestCredentialCheckFailure_NamesTheTransportFault(t *testing.T) {
                                                +	cases := []struct {
                                                +		name string
                                                +		err  error
                                                +		want string
                                                +	}{
                                                +		{
                                                +			name: "unknown host",
                                                +			err:  &net.DNSError{Err: "no such host", Name: "api.example.com", IsNotFound: true},
                                                +			want: "the upstream url could not be reached: no such host",
                                                +		},
                                                +		{
                                                +			name: "dns failure that is not a missing name",
                                                +			err:  &net.DNSError{Err: "server misbehaving", Name: "api.example.com"},
                                                +			want: "the upstream url could not be reached: dns lookup failed",
                                                +		},
                                                +		{
                                                +			name: "connection refused",
                                                +			err:  &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED},
                                                +			want: "the upstream url could not be reached: connection refused",
                                                +		},
                                                +		{
                                                +			name: "host unreachable",
                                                +			err:  &net.OpError{Op: "dial", Net: "tcp", Err: syscall.EHOSTUNREACH},
                                                +			want: "the upstream url could not be reached: host unreachable",
                                                +		},
                                                +		{
                                                +			name: "timeout",
                                                +			err:  fmt.Errorf("dial: %w", os.ErrDeadlineExceeded),
                                                +			want: "the upstream url could not be reached: connection timed out",
                                                +		},
                                                +		{
                                                +			name: "context deadline",
                                                +			err:  fmt.Errorf("dial: %w", context.DeadlineExceeded),
                                                +			want: "the upstream url could not be reached: connection timed out",
                                                +		},
                                                +		{
                                                +			name: "untrusted certificate",
                                                +			err:  &tls.CertificateVerificationError{},
                                                +			want: "the upstream url could not be reached: tls certificate not trusted",
                                                +		},
                                                +		{
                                                +			name: "plaintext service on an https url",
                                                +			err:  tls.RecordHeaderError{Msg: "first record does not look like a TLS handshake"},
                                                +			want: "the upstream url could not be reached: not a tls endpoint",
                                                +		},
                                                +		{
                                                +			// Nothing we recognise. Better to say only that it could not be
                                                +			// reached than to paste a Go error into the provider form.
                                                +			name: "cause we do not recognise",
                                                +			err:  errors.New("something went sideways"),
                                                +			want: "the upstream url could not be reached",
                                                +		},
                                                +	}
                                                +
                                                +	for _, tc := range cases {
                                                +		t.Run(tc.name, func(t *testing.T) {
                                                +			wrapped := &modeldiscovery.UnreachableError{Provider: "OpenAI", Err: tc.err}
                                                +			got, blocking := credentialCheckFailure(wrapped)
                                                +			require.True(t, blocking, "an unreachable upstream must block the write")
                                                +			require.Equal(t, tc.want, got)
                                                +		})
                                                +	}
                                                +}
                                                +
                                                +// TestCredentialCheckFailure_AnAnsweringUrlThatIsNotTheApi covers the case a
                                                +// status probe would wave through: the host is up, the credential was accepted
                                                +// or not required, and the body is a login page. Reusing the discovery parser
                                                +// for the check is what catches it.
                                                +func TestCredentialCheckFailure_AnAnsweringUrlThatIsNotTheApi(t *testing.T) {
                                                +	err := fmt.Errorf("%w: decode model listing: unexpected token", modeldiscovery.ErrUnparseableListing)
                                                +
                                                +	got, blocking := credentialCheckFailure(err)
                                                +	require.True(t, blocking)
                                                +	require.Equal(t, "the upstream url answered, but not with a model listing", got)
                                                +}
                                                +
                                                +// TestCredentialCheckFailure_WhatCannotBeCheckedIsNotAFailure pins the
                                                +// difference between "this record is wrong" and "we have no way to ask". A
                                                +// gateway with no listing endpoint, a Bedrock record pointed at a proxy, and a
                                                +// self-hosted endpoint the proxy reaches through the tunnel are all legitimate
                                                +// providers. Blocking them would make the feature a lockout.
                                                +func TestCredentialCheckFailure_WhatCannotBeCheckedIsNotAFailure(t *testing.T) {
                                                +	cases := map[string]error{
                                                +		"no listing endpoint": modeldiscovery.ErrNoDiscovery,
                                                +		"no derivable host":   fmt.Errorf("%w: %w: bedrock", modeldiscovery.ErrInvalidRequest, modeldiscovery.ErrNoDiscoveryHost),
                                                +		"private upstream":    fmt.Errorf("%w: %w: 10.0.0.5", modeldiscovery.ErrInvalidRequest, modeldiscovery.ErrPrivateHost),
                                                +	}
                                                +
                                                +	for name, err := range cases {
                                                +		t.Run(name, func(t *testing.T) {
                                                +			message, blocking := credentialCheckFailure(err)
                                                +			require.False(t, blocking, "a provider we cannot check must still save")
                                                +			require.Empty(t, message)
                                                +		})
                                                +	}
                                                +}
                                                +
                                                +// TestCredentialCheckFailure_AnUnrecognisedFailureStillBlocks covers a fault of
                                                +// ours rather than the vendor's — a malformed request this code built, or a
                                                +// catalog entry whose parser does not match its endpoint. The record went
                                                +// unverified either way, and silently saving what we could not check is the
                                                +// thing this feature exists to prevent.
                                                +func TestCredentialCheckFailure_AnUnrecognisedFailureStillBlocks(t *testing.T) {
                                                +	message, blocking := credentialCheckFailure(errors.New("no parser for listing shape \"\""))
                                                +	require.True(t, blocking)
                                                +	require.Equal(t, "the provider could not be checked", message)
                                                +}
                                                +
                                                +// newCheckedProvider returns a record shaped the way the handler guarantees
                                                +// one: a known catalog id, a public upstream and a key.
                                                +func newCheckedProvider(accountID string) *types.Provider {
                                                +	provider := types.NewProvider(accountID)
                                                +	provider.ProviderID = "openai_api"
                                                +	provider.Name = "openai"
                                                +	provider.UpstreamURL = "https://api.openai.com"
                                                +	provider.APIKey = "sk-good"
                                                +	provider.Enabled = true
                                                +	return provider
                                                +}
                                                +
                                                +// TestCreateProvider_RefusesARecordTheVendorRejects is the whole point of the
                                                +// feature: a key with a character missing used to save cleanly and surface
                                                +// minutes later as a failed request with nothing pointing back at the record.
                                                +func TestCreateProvider_RefusesARecordTheVendorRejects(t *testing.T) {
                                                +	ctx := context.Background()
                                                +	f := newBootstrapFixture(t)
                                                +	f.vendor.err = &modeldiscovery.VendorStatusError{Provider: "OpenAI", Status: 401}
                                                +	f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
                                                +
                                                +	_, err := f.manager.CreateProvider(ctx, "user1", newCheckedProvider("account1"))
                                                +
                                                +	require.Error(t, err)
                                                +	require.Contains(t, err.Error(), "the provider rejected the credential")
                                                +
                                                +	var sErr *status.Error
                                                +	require.ErrorAs(t, err, &sErr)
                                                +	require.Equal(t, status.InvalidArgument, sErr.Type(), "the refusal must reach the caller as a 422")
                                                +
                                                +	stored, err := f.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, "account1")
                                                +	require.NoError(t, err)
                                                +	require.Empty(t, stored, "a record that failed its check must not be written")
                                                +}
                                                +
                                                +// TestCreateProvider_ChecksTheCredentialItWasGiven pins what the vendor is
                                                +// asked with, since a check run against the wrong upstream or a stale key
                                                +// would pass while proving nothing.
                                                +func TestCreateProvider_ChecksTheCredentialItWasGiven(t *testing.T) {
                                                +	ctx := context.Background()
                                                +	f := newBootstrapFixture(t)
                                                +	f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
                                                +
                                                +	_, err := f.manager.CreateProvider(ctx, "user1", newCheckedProvider("account1"))
                                                +	require.NoError(t, err)
                                                +
                                                +	asked := f.vendor.only(t)
                                                +	require.Equal(t, "openai_api", asked.CatalogID)
                                                +	require.Equal(t, "https://api.openai.com", asked.UpstreamURL)
                                                +	require.Equal(t, "sk-good", asked.APIKey)
                                                +}
                                                +
                                                +// TestUpdateProvider_AUrlOnlyChangeIsCheckedAgainstTheStoredKey covers the
                                                +// case that shaped where the check sits. The key never returns to the browser,
                                                +// so an operator editing only the URL has none to offer — the stored one is
                                                +// the only credential there is, and the new URL still has to be proven with
                                                +// it.
                                                +func TestUpdateProvider_AUrlOnlyChangeIsCheckedAgainstTheStoredKey(t *testing.T) {
                                                +	ctx := context.Background()
                                                +	f := newBootstrapFixture(t)
                                                +	f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
                                                +
                                                +	created, err := f.manager.CreateProvider(ctx, "user1", newCheckedProvider("account1"))
                                                +	require.NoError(t, err)
                                                +	f.vendor.requests = nil
                                                +
                                                +	f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Update, true)
                                                +	edit := newCheckedProvider("account1")
                                                +	edit.ID = created.ID
                                                +	edit.UpstreamURL = "https://gateway.example.com"
                                                +	edit.APIKey = "" // the form sends no key when it was not retyped
                                                +
                                                +	_, err = f.manager.UpdateProvider(ctx, "user1", edit)
                                                +	require.NoError(t, err)
                                                +
                                                +	asked := f.vendor.only(t)
                                                +	require.Equal(t, "https://gateway.example.com", asked.UpstreamURL, "the new url must be what gets tested")
                                                +	require.Equal(t, "sk-good", asked.APIKey, "and the stored key must be what tests it")
                                                +}
                                                +
                                                +// TestUpdateProvider_AFailedRotationLeavesTheWorkingKeyInPlace is the
                                                +// half-applied state the check must never produce: refusing the new key while
                                                +// having already replaced the old one would take the provider down.
                                                +func TestUpdateProvider_AFailedRotationLeavesTheWorkingKeyInPlace(t *testing.T) {
                                                +	ctx := context.Background()
                                                +	f := newBootstrapFixture(t)
                                                +	f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
                                                +
                                                +	created, err := f.manager.CreateProvider(ctx, "user1", newCheckedProvider("account1"))
                                                +	require.NoError(t, err)
                                                +
                                                +	f.vendor.err = &modeldiscovery.VendorStatusError{Provider: "OpenAI", Status: 403}
                                                +	f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Update, true)
                                                +	rotation := newCheckedProvider("account1")
                                                +	rotation.ID = created.ID
                                                +	rotation.APIKey = "sk-typo"
                                                +
                                                +	_, err = f.manager.UpdateProvider(ctx, "user1", rotation)
                                                +	require.Error(t, err)
                                                +	require.Contains(t, err.Error(), "the provider rejected the credential")
                                                +
                                                +	stored, err := f.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, "account1", created.ID)
                                                +	require.NoError(t, err)
                                                +	require.Equal(t, "sk-good", stored.APIKey, "the rejected key must not have replaced the working one")
                                                +}
                                                +
                                                +// TestUpdateProvider_AnEditTouchingNeitherFieldAsksNoVendor keeps renames,
                                                +// model rows and price edits off the vendor's doorstep. They have nothing new
                                                +// to prove, and making them wait on a vendor — or fail because one is having a
                                                +// bad day — would be a tax on edits that carry no risk.
                                                +func TestUpdateProvider_AnEditTouchingNeitherFieldAsksNoVendor(t *testing.T) {
                                                +	ctx := context.Background()
                                                +	f := newBootstrapFixture(t)
                                                +	f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
                                                +
                                                +	created, err := f.manager.CreateProvider(ctx, "user1", newCheckedProvider("account1"))
                                                +	require.NoError(t, err)
                                                +	f.vendor.requests = nil
                                                +	// Any call at all now would fail the update, which is what makes the
                                                +	// assertion below load-bearing rather than decorative.
                                                +	f.vendor.err = &modeldiscovery.VendorStatusError{Provider: "OpenAI", Status: 500}
                                                +
                                                +	f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Update, true)
                                                +	rename := newCheckedProvider("account1")
                                                +	rename.ID = created.ID
                                                +	rename.Name = "openai-renamed"
                                                +	rename.APIKey = ""
                                                +
                                                +	_, err = f.manager.UpdateProvider(ctx, "user1", rename)
                                                +	require.NoError(t, err, "an edit that changes neither url nor key must not be checked")
                                                +	require.Zero(t, f.vendor.calls(), "and must not reach the vendor at all")
                                                +}
                                                +
                                                +// TestCreateProvider_AProviderWeCannotCheckStillSaves covers the eleven
                                                +// catalog entries with no listing endpoint, a Bedrock record behind a proxy,
                                                +// and a self-hosted endpoint on a private network. None of those are evidence
                                                +// the record is wrong, and refusing them would make this a lockout.
                                                +func TestCreateProvider_AProviderWeCannotCheckStillSaves(t *testing.T) {
                                                +	cases := map[string]error{
                                                +		"gateway with no listing endpoint": modeldiscovery.ErrNoDiscovery,
                                                +		"bedrock behind a proxy":           fmt.Errorf("%w: %w", modeldiscovery.ErrInvalidRequest, modeldiscovery.ErrNoDiscoveryHost),
                                                +		"self-hosted on a private network": fmt.Errorf("%w: %w", modeldiscovery.ErrInvalidRequest, modeldiscovery.ErrPrivateHost),
                                                +	}
                                                +
                                                +	for name, vendorErr := range cases {
                                                +		t.Run(name, func(t *testing.T) {
                                                +			ctx := context.Background()
                                                +			f := newBootstrapFixture(t)
                                                +			f.vendor.err = vendorErr
                                                +			f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
                                                +
                                                +			created, err := f.manager.CreateProvider(ctx, "user1", newCheckedProvider("account1"))
                                                +			require.NoError(t, err)
                                                +			require.NotNil(t, created)
                                                +
                                                +			stored, err := f.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, "account1")
                                                +			require.NoError(t, err)
                                                +			require.Len(t, stored, 1, "a provider we cannot check must still be written")
                                                +		})
                                                +	}
                                                +}
                                                +
                                                +// TestDiscoveryFailure_TellsTheOperatorWhatWentWrong covers the button, not the
                                                +// save. Pressing "Load models from provider" against a bad key used to answer
                                                +// "internal server error", which names neither the thing that failed nor
                                                +// anything the operator could act on — every outcome here is their key or their
                                                +// URL.
                                                +func TestDiscoveryFailure_TellsTheOperatorWhatWentWrong(t *testing.T) {
                                                +	cases := map[string]struct {
                                                +		err  error
                                                +		want string
                                                +	}{
                                                +		"refused credential": {
                                                +			err:  &modeldiscovery.VendorStatusError{Provider: "Bedrock", Status: 403},
                                                +			want: "the provider rejected the credential",
                                                +		},
                                                +		"upstream that is not the api": {
                                                +			err:  &modeldiscovery.VendorStatusError{Provider: "OpenAI", Status: 404},
                                                +			want: "the upstream url did not answer a model listing",
                                                +		},
                                                +		"upstream that does not resolve": {
                                                +			err: &modeldiscovery.UnreachableError{
                                                +				Provider: "OpenAI",
                                                +				Err:      &net.DNSError{Err: "no such host", Name: "api.example.com", IsNotFound: true},
                                                +			},
                                                +			want: "the upstream url could not be reached: no such host",
                                                +		},
                                                +		"vendor having a bad day": {
                                                +			err:  &modeldiscovery.VendorStatusError{Provider: "Anthropic", Status: 503},
                                                +			want: "the provider returned an error",
                                                +		},
                                                +	}
                                                +
                                                +	for name, tc := range cases {
                                                +		t.Run(name, func(t *testing.T) {
                                                +			err := discoveryFailure(context.Background(), "openai_api", tc.err)
                                                +			require.EqualError(t, err, tc.want)
                                                +
                                                +			var sErr *status.Error
                                                +			require.ErrorAs(t, err, &sErr)
                                                +			require.Equal(t, status.InvalidArgument, sErr.Type(),
                                                +				"a failure the operator caused must not read as a server fault")
                                                +		})
                                                +	}
                                                +}
                                                +
                                                +// TestDiscoveryFailure_LeavesTheCatalogFactsAlone keeps the two outcomes the
                                                +// handler already maps. A provider with no listing endpoint is a fact about the
                                                +// catalog entry, and the caller falls back to the catalog's own models rather
                                                +// than showing an error at all — rewriting it as a refusal would turn a normal
                                                +// path into one.
                                                +func TestDiscoveryFailure_LeavesTheCatalogFactsAlone(t *testing.T) {
                                                +	for name, err := range map[string]error{
                                                +		"no listing endpoint": modeldiscovery.ErrNoDiscovery,
                                                +		"bad request":         fmt.Errorf("%w: unknown catalog provider", modeldiscovery.ErrInvalidRequest),
                                                +	} {
                                                +		t.Run(name, func(t *testing.T) {
                                                +			require.Equal(t, err, discoveryFailure(context.Background(), "openai_api", err),
                                                +				"the handler's own mapping must still see the original error")
                                                +		})
                                                +	}
                                                +}
                                                +
                                                +// TestDiscoverProviderModels_SurfacesTheVendorRefusal drives the manager rather
                                                +// than the classifier, so a future refactor that stops translating on this path
                                                +// fails here rather than silently going back to 500s.
                                                +func TestDiscoverProviderModels_SurfacesTheVendorRefusal(t *testing.T) {
                                                +	ctx := context.Background()
                                                +	f := newBootstrapFixture(t)
                                                +	f.vendor.err = &modeldiscovery.VendorStatusError{Provider: "OpenAI", Status: 401}
                                                +	f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
                                                +
                                                +	_, err := f.manager.DiscoverProviderModels(ctx, "account1", "user1", modeldiscovery.Request{
                                                +		CatalogID:   "openai_api",
                                                +		UpstreamURL: "https://api.openai.com",
                                                +		APIKey:      "sk-wrong",
                                                +	}, "")
                                                +
                                                +	require.EqualError(t, err, "the provider rejected the credential")
                                                +}
                                                +
                                                +// TestDiscoverProviderModels_ListsAgainstTheUrlOnTheForm covers the edit the
                                                +// operator cannot otherwise make: the upstream has been retyped and the
                                                +// credential has not, because the API never returned it to be retyped. Naming
                                                +// the record supplies the key; the request supplies the URL under test.
                                                +func TestDiscoverProviderModels_ListsAgainstTheUrlOnTheForm(t *testing.T) {
                                                +	ctx := context.Background()
                                                +	f := newBootstrapFixture(t)
                                                +	// Twice: the create, and the listing, which is gated on Create too.
                                                +	f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
                                                +	f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
                                                +
                                                +	created, err := f.manager.CreateProvider(ctx, "user1", newCheckedProvider("account1"))
                                                +	require.NoError(t, err)
                                                +	f.vendor.requests = nil
                                                +
                                                +	_, err = f.manager.DiscoverProviderModels(ctx, "account1", "user1", modeldiscovery.Request{
                                                +		CatalogID:   "openai_api",
                                                +		UpstreamURL: "https://gateway.example.com",
                                                +	}, created.ID)
                                                +	require.NoError(t, err)
                                                +
                                                +	asked := f.vendor.only(t)
                                                +	require.Equal(t, "https://gateway.example.com", asked.UpstreamURL, "the typed url must be the one listed against")
                                                +	require.Equal(t, "sk-good", asked.APIKey, "and the stored key must be what lists it")
                                                +}
                                                +
                                                +// TestDiscoverProviderModels_FallsBackToTheStoredUrl keeps the plain refresh
                                                +// working: a request naming only the record still reaches the saved upstream.
                                                +func TestDiscoverProviderModels_FallsBackToTheStoredUrl(t *testing.T) {
                                                +	ctx := context.Background()
                                                +	f := newBootstrapFixture(t)
                                                +	f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
                                                +	f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
                                                +
                                                +	created, err := f.manager.CreateProvider(ctx, "user1", newCheckedProvider("account1"))
                                                +	require.NoError(t, err)
                                                +	stored := f.vendor.only(t).UpstreamURL
                                                +	f.vendor.requests = nil
                                                +
                                                +	_, err = f.manager.DiscoverProviderModels(ctx, "account1", "user1", modeldiscovery.Request{
                                                +		CatalogID: "openai_api",
                                                +	}, created.ID)
                                                +	require.NoError(t, err)
                                                +
                                                +	require.Equal(t, stored, f.vendor.only(t).UpstreamURL)
                                                +}
                                                +
                                                +// TestUpdateProvider_MovingARecordToAnotherVendorIsChecked covers the edit that
                                                +// changes neither field the vendor judges and still invalidates both. The
                                                +// catalog entry decides which vendor is asked and under which auth header, so
                                                +// the unchanged credential is now being offered somewhere it has never been
                                                +// accepted.
                                                +func TestUpdateProvider_MovingARecordToAnotherVendorIsChecked(t *testing.T) {
                                                +	ctx := context.Background()
                                                +	f := newBootstrapFixture(t)
                                                +	f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
                                                +
                                                +	created, err := f.manager.CreateProvider(ctx, "user1", newCheckedProvider("account1"))
                                                +	require.NoError(t, err)
                                                +	f.vendor.requests = nil
                                                +
                                                +	f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Update, true)
                                                +	edit := newCheckedProvider("account1")
                                                +	edit.ID = created.ID
                                                +	edit.ProviderID = "anthropic_api"
                                                +	edit.APIKey = ""
                                                +
                                                +	_, err = f.manager.UpdateProvider(ctx, "user1", edit)
                                                +	require.NoError(t, err)
                                                +
                                                +	require.Equal(t, "anthropic_api", f.vendor.only(t).CatalogID,
                                                +		"the new vendor is the one that has to accept the key")
                                                +}
                                                +
                                                +// TestCreateProvider_ASkipTlsRecordIsNotCheckedAgainstItsCertificate covers the
                                                +// lockout the check would otherwise be: the flag exists for a self-hosted
                                                +// endpoint behind a certificate nothing public can verify, and discovery
                                                +// verifies certificates. Refusing the save would reject the record for the one
                                                +// reason the operator already declared they accept.
                                                +func TestCreateProvider_ASkipTlsRecordIsNotCheckedAgainstItsCertificate(t *testing.T) {
                                                +	ctx := context.Background()
                                                +	f := newBootstrapFixture(t)
                                                +	f.vendor.err = &modeldiscovery.UnreachableError{
                                                +		Provider: "OpenAI",
                                                +		Err:      &tls.CertificateVerificationError{},
                                                +	}
                                                +	f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
                                                +
                                                +	provider := newCheckedProvider("account1")
                                                +	provider.SkipTLSVerification = true
                                                +
                                                +	created, err := f.manager.CreateProvider(ctx, "user1", provider)
                                                +	require.NoError(t, err, "a record we were told not to verify must still save")
                                                +	require.NotEmpty(t, created.ID)
                                                +	require.Zero(t, f.vendor.calls(), "and the vendor must not be asked at all")
                                                +}
                                                +
                                                +// TestCreateProvider_TheStoredKeyIsTheOneThatWasChecked pins the two halves to
                                                +// one value. The vendor call trims the credential before building its auth
                                                +// header; the synthesiser substitutes the stored one verbatim. A key pasted
                                                +// with surrounding whitespace would otherwise pass its check and then fail
                                                +// every request the provider serves.
                                                +func TestCreateProvider_TheStoredKeyIsTheOneThatWasChecked(t *testing.T) {
                                                +	ctx := context.Background()
                                                +	f := newBootstrapFixture(t)
                                                +	f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
                                                +
                                                +	provider := newCheckedProvider("account1")
                                                +	provider.APIKey = "  sk-good\n"
                                                +
                                                +	created, err := f.manager.CreateProvider(ctx, "user1", provider)
                                                +	require.NoError(t, err)
                                                +
                                                +	require.Equal(t, "sk-good", f.vendor.only(t).APIKey, "the vendor is asked about the trimmed key")
                                                +
                                                +	stored, err := f.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, "account1", created.ID)
                                                +	require.NoError(t, err)
                                                +	require.Equal(t, "sk-good", stored.APIKey, "and that is the one the proxy will send")
                                                +}
                                                +
                                                +// TestUpdateProvider_TurningTlsVerificationBackOnChecksTheRecord covers the
                                                +// hole the skip-TLS exemption opens on its own. Such a record is stored without
                                                +// ever being checked, so the moment verification is switched back on is the
                                                +// first moment it can be checked at all — and none of the three fields the
                                                +// re-check usually watches has to move for that to happen.
                                                +func TestUpdateProvider_TurningTlsVerificationBackOnChecksTheRecord(t *testing.T) {
                                                +	ctx := context.Background()
                                                +	f := newBootstrapFixture(t)
                                                +	f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
                                                +
                                                +	unchecked := newCheckedProvider("account1")
                                                +	unchecked.SkipTLSVerification = true
                                                +	created, err := f.manager.CreateProvider(ctx, "user1", unchecked)
                                                +	require.NoError(t, err)
                                                +	require.Zero(t, f.vendor.calls(), "the create was exempt")
                                                +
                                                +	f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Update, true)
                                                +	edit := newCheckedProvider("account1")
                                                +	edit.ID = created.ID
                                                +	edit.APIKey = ""
                                                +	edit.SkipTLSVerification = false
                                                +
                                                +	_, err = f.manager.UpdateProvider(ctx, "user1", edit)
                                                +	require.NoError(t, err)
                                                +	require.Equal(t, 1, f.vendor.calls(), "switching verification on must check what was never checked")
                                                +}
                                                diff --git a/management/internals/modules/agentnetwork/handlers/access_log_handler.go b/management/internals/modules/agentnetwork/handlers/access_log_handler.go
                                                new file mode 100644
                                                index 000000000..4484d8c91
                                                --- /dev/null
                                                +++ b/management/internals/modules/agentnetwork/handlers/access_log_handler.go
                                                @@ -0,0 +1,134 @@
                                                +package handlers
                                                +
                                                +import (
                                                +	"net/http"
                                                +	"time"
                                                +
                                                +	"github.com/gorilla/mux"
                                                +
                                                +	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
                                                +	nbcontext "github.com/netbirdio/netbird/management/server/context"
                                                +	"github.com/netbirdio/netbird/shared/management/http/api"
                                                +	"github.com/netbirdio/netbird/shared/management/http/util"
                                                +)
                                                +
                                                +// addAccessLogEndpoints registers the read-only, server-side-filtered
                                                +// agent-network access-log listing and the aggregated usage overview.
                                                +func (h *handler) addAccessLogEndpoints(router *mux.Router) {
                                                +	router.HandleFunc("/agent-network/access-logs", h.listAccessLogs).Methods("GET", "OPTIONS")
                                                +	router.HandleFunc("/agent-network/access-log-sessions", h.listAccessLogSessions).Methods("GET", "OPTIONS")
                                                +	router.HandleFunc("/agent-network/usage/overview", h.getUsageOverview).Methods("GET", "OPTIONS")
                                                +}
                                                +
                                                +func (h *handler) getUsageOverview(w http.ResponseWriter, r *http.Request) {
                                                +	userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	// Reuse the access-log filter for the shared date/user/group/provider/model
                                                +	// params; pagination/sort/search are irrelevant for an aggregate.
                                                +	var filter types.AgentNetworkAccessLogFilter
                                                +	if err := filter.ParseFromRequest(r); err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +	// Bound the aggregation window so an unbounded or over-wide query can't load
                                                +	// an account's entire usage history into memory.
                                                +	filter.ApplyUsageOverviewBounds(time.Now())
                                                +	granularity := types.ParseUsageGranularity(r.URL.Query().Get("granularity"))
                                                +
                                                +	buckets, err := h.manager.GetUsageOverview(r.Context(), userAuth.AccountId, userAuth.UserId, filter, granularity)
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	out := make([]api.AgentNetworkUsageBucket, 0, len(buckets))
                                                +	for _, b := range buckets {
                                                +		out = append(out, b.ToAPIResponse())
                                                +	}
                                                +	util.WriteJSONObject(r.Context(), w, out)
                                                +}
                                                +
                                                +func (h *handler) listAccessLogs(w http.ResponseWriter, r *http.Request) {
                                                +	userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	var filter types.AgentNetworkAccessLogFilter
                                                +	if err := filter.ParseFromRequest(r); err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	rows, total, err := h.manager.ListAccessLogs(r.Context(), userAuth.AccountId, userAuth.UserId, filter)
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	data := make([]api.AgentNetworkAccessLog, 0, len(rows))
                                                +	for _, row := range rows {
                                                +		data = append(data, row.ToAPIResponse())
                                                +	}
                                                +
                                                +	pageSize := filter.GetLimit()
                                                +	totalPages := 0
                                                +	if pageSize > 0 {
                                                +		totalPages = int((total + int64(pageSize) - 1) / int64(pageSize))
                                                +	}
                                                +
                                                +	util.WriteJSONObject(r.Context(), w, api.AgentNetworkAccessLogsResponse{
                                                +		Data:         data,
                                                +		Page:         filter.Page,
                                                +		PageSize:     pageSize,
                                                +		TotalRecords: int(total),
                                                +		TotalPages:   totalPages,
                                                +	})
                                                +}
                                                +
                                                +// listAccessLogSessions returns the access logs grouped by session: the page
                                                +// unit is a session (total counts sessions), each carrying an aggregate summary
                                                +// and its ordered entries. Accepts the same filters as listAccessLogs.
                                                +func (h *handler) listAccessLogSessions(w http.ResponseWriter, r *http.Request) {
                                                +	userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	var filter types.AgentNetworkAccessLogFilter
                                                +	if err := filter.ParseFromRequest(r); err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	sessions, total, err := h.manager.ListAccessLogSessions(r.Context(), userAuth.AccountId, userAuth.UserId, filter)
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	data := make([]api.AgentNetworkAccessLogSession, 0, len(sessions))
                                                +	for _, sess := range sessions {
                                                +		data = append(data, sess.ToAPIResponse())
                                                +	}
                                                +
                                                +	pageSize := filter.GetLimit()
                                                +	totalPages := 0
                                                +	if pageSize > 0 {
                                                +		totalPages = int((total + int64(pageSize) - 1) / int64(pageSize))
                                                +	}
                                                +
                                                +	util.WriteJSONObject(r.Context(), w, api.AgentNetworkAccessLogSessionsResponse{
                                                +		Data:         data,
                                                +		Page:         filter.Page,
                                                +		PageSize:     pageSize,
                                                +		TotalRecords: int(total),
                                                +		TotalPages:   totalPages,
                                                +	})
                                                +}
                                                diff --git a/management/internals/modules/agentnetwork/handlers/agent_config_handler.go b/management/internals/modules/agentnetwork/handlers/agent_config_handler.go
                                                new file mode 100644
                                                index 000000000..0d6c45110
                                                --- /dev/null
                                                +++ b/management/internals/modules/agentnetwork/handlers/agent_config_handler.go
                                                @@ -0,0 +1,56 @@
                                                +package handlers
                                                +
                                                +import (
                                                +	"net/http"
                                                +
                                                +	"github.com/gorilla/mux"
                                                +
                                                +	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
                                                +	nbcontext "github.com/netbirdio/netbird/management/server/context"
                                                +	"github.com/netbirdio/netbird/shared/management/http/api"
                                                +	"github.com/netbirdio/netbird/shared/management/http/util"
                                                +)
                                                +
                                                +// addAgentConfigEndpoints registers the self-service agent-config route.
                                                +// It is available to every authenticated user regardless of role: the
                                                +// providers in the response are scoped strictly to the caller, which is
                                                +// tighter than any role gate could be. The caller's own usage and requests are served by
                                                +// the regular usage/logs endpoints, which self-scope for callers without
                                                +// the account-wide grants.
                                                +func (h *handler) addAgentConfigEndpoints(router *mux.Router) {
                                                +	router.HandleFunc("/agent-network/agent-config", h.getAgentConfig).Methods("GET", "OPTIONS")
                                                +}
                                                +
                                                +func (h *handler) getAgentConfig(w http.ResponseWriter, r *http.Request) {
                                                +	userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	setup, err := h.manager.GetAgentConfigForUser(r.Context(), userAuth.AccountId, userAuth.UserId)
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	util.WriteJSONObject(r.Context(), w, agentConfigToAPI(setup))
                                                +}
                                                +
                                                +func agentConfigToAPI(setup *types.AgentConfig) api.AgentNetworkAgentConfig {
                                                +	providers := make([]api.AgentNetworkAgentConfigProvider, 0, len(setup.Providers))
                                                +	for _, p := range setup.Providers {
                                                +		providers = append(providers, api.AgentNetworkAgentConfigProvider{
                                                +			Name:             p.Name,
                                                +			CatalogId:        p.CatalogID,
                                                +			ApiFlavor:        p.APIFlavor,
                                                +			AllModelsAllowed: p.AllModelsAllowed,
                                                +			Models:           p.Models,
                                                +		})
                                                +	}
                                                +	return api.AgentNetworkAgentConfig{
                                                +		Configured: setup.Configured,
                                                +		Endpoint:   setup.Endpoint,
                                                +		Providers:  providers,
                                                +	}
                                                +}
                                                diff --git a/management/internals/modules/agentnetwork/handlers/budget_handler.go b/management/internals/modules/agentnetwork/handlers/budget_handler.go
                                                new file mode 100644
                                                index 000000000..5630de17f
                                                --- /dev/null
                                                +++ b/management/internals/modules/agentnetwork/handlers/budget_handler.go
                                                @@ -0,0 +1,172 @@
                                                +package handlers
                                                +
                                                +import (
                                                +	"encoding/json"
                                                +	"net/http"
                                                +	"strings"
                                                +
                                                +	"github.com/gorilla/mux"
                                                +
                                                +	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
                                                +	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"
                                                +)
                                                +
                                                +// addBudgetRuleEndpoints registers the account-level budget rule routes.
                                                +func (h *handler) addBudgetRuleEndpoints(router *mux.Router) {
                                                +	router.HandleFunc("/agent-network/budget-rules", h.getAllBudgetRules).Methods("GET", "OPTIONS")
                                                +	router.HandleFunc("/agent-network/budget-rules", h.createBudgetRule).Methods("POST", "OPTIONS")
                                                +	router.HandleFunc("/agent-network/budget-rules/{ruleId}", h.getBudgetRule).Methods("GET", "OPTIONS")
                                                +	router.HandleFunc("/agent-network/budget-rules/{ruleId}", h.updateBudgetRule).Methods("PUT", "OPTIONS")
                                                +	router.HandleFunc("/agent-network/budget-rules/{ruleId}", h.deleteBudgetRule).Methods("DELETE", "OPTIONS")
                                                +}
                                                +
                                                +func (h *handler) getAllBudgetRules(w http.ResponseWriter, r *http.Request) {
                                                +	userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	rules, err := h.manager.GetAllBudgetRules(r.Context(), userAuth.AccountId, userAuth.UserId)
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	out := make([]*api.AgentNetworkBudgetRule, 0, len(rules))
                                                +	for _, rule := range rules {
                                                +		out = append(out, rule.ToAPIResponse())
                                                +	}
                                                +	util.WriteJSONObject(r.Context(), w, out)
                                                +}
                                                +
                                                +func (h *handler) getBudgetRule(w http.ResponseWriter, r *http.Request) {
                                                +	userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	ruleID := mux.Vars(r)["ruleId"]
                                                +	if ruleID == "" {
                                                +		util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "budget rule ID is required"), w)
                                                +		return
                                                +	}
                                                +
                                                +	rule, err := h.manager.GetBudgetRule(r.Context(), userAuth.AccountId, userAuth.UserId, ruleID)
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +	util.WriteJSONObject(r.Context(), w, rule.ToAPIResponse())
                                                +}
                                                +
                                                +func (h *handler) createBudgetRule(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.AgentNetworkBudgetRuleRequest
                                                +	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
                                                +		util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
                                                +		return
                                                +	}
                                                +
                                                +	if err := validateBudgetRule(&req); err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	rule := types.NewAccountBudgetRule(userAuth.AccountId)
                                                +	rule.FromAPIRequest(&req)
                                                +
                                                +	created, err := h.manager.CreateBudgetRule(r.Context(), userAuth.UserId, rule)
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +	util.WriteJSONObject(r.Context(), w, created.ToAPIResponse())
                                                +}
                                                +
                                                +func (h *handler) updateBudgetRule(w http.ResponseWriter, r *http.Request) {
                                                +	userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	ruleID := mux.Vars(r)["ruleId"]
                                                +	if ruleID == "" {
                                                +		util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "budget rule ID is required"), w)
                                                +		return
                                                +	}
                                                +
                                                +	var req api.AgentNetworkBudgetRuleRequest
                                                +	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
                                                +		util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
                                                +		return
                                                +	}
                                                +
                                                +	if err := validateBudgetRule(&req); err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	rule := &types.AccountBudgetRule{ID: ruleID, AccountID: userAuth.AccountId}
                                                +	rule.FromAPIRequest(&req)
                                                +
                                                +	updated, err := h.manager.UpdateBudgetRule(r.Context(), userAuth.UserId, rule)
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +	util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse())
                                                +}
                                                +
                                                +func (h *handler) deleteBudgetRule(w http.ResponseWriter, r *http.Request) {
                                                +	userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	ruleID := mux.Vars(r)["ruleId"]
                                                +	if ruleID == "" {
                                                +		util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "budget rule ID is required"), w)
                                                +		return
                                                +	}
                                                +
                                                +	if err := h.manager.DeleteBudgetRule(r.Context(), userAuth.AccountId, userAuth.UserId, ruleID); err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +	util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
                                                +}
                                                +
                                                +// validateBudgetRule rejects malformed budget rules. It reuses the policy limit
                                                +// validation since the cap shape is identical, and rejects empty target entries.
                                                +func validateBudgetRule(req *api.AgentNetworkBudgetRuleRequest) error {
                                                +	if strings.TrimSpace(req.Name) == "" {
                                                +		return status.Errorf(status.InvalidArgument, "name is required")
                                                +	}
                                                +	if req.TargetGroups != nil {
                                                +		for _, id := range *req.TargetGroups {
                                                +			if strings.TrimSpace(id) == "" {
                                                +				return status.Errorf(status.InvalidArgument, "target_groups must not contain empty entries")
                                                +			}
                                                +		}
                                                +	}
                                                +	if req.TargetUsers != nil {
                                                +		for _, id := range *req.TargetUsers {
                                                +			if strings.TrimSpace(id) == "" {
                                                +				return status.Errorf(status.InvalidArgument, "target_users must not contain empty entries")
                                                +			}
                                                +		}
                                                +	}
                                                +	return validatePolicyLimits(req.Limits)
                                                +}
                                                diff --git a/management/internals/modules/agentnetwork/handlers/budget_handler_test.go b/management/internals/modules/agentnetwork/handlers/budget_handler_test.go
                                                new file mode 100644
                                                index 000000000..3a7709461
                                                --- /dev/null
                                                +++ b/management/internals/modules/agentnetwork/handlers/budget_handler_test.go
                                                @@ -0,0 +1,131 @@
                                                +package handlers
                                                +
                                                +import (
                                                +	"context"
                                                +	"encoding/json"
                                                +	"net/http"
                                                +	"testing"
                                                +
                                                +	"github.com/stretchr/testify/assert"
                                                +	"github.com/stretchr/testify/require"
                                                +
                                                +	agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
                                                +	"github.com/netbirdio/netbird/shared/management/http/api"
                                                +)
                                                +
                                                +// TestBudgetRuleHandler_RoundTrip seeds a budget rule via the store and asserts
                                                +// the GET wire shape carries targets and the reused PolicyLimits cap shape. The
                                                +// create/update/delete success paths go through accountManager.StoreEvent which
                                                +// this fixture doesn't wire — they are covered by the manager-level no-mock
                                                +// test (TestAgentNetwork_BudgetRuleCRUD_RealManager).
                                                +func TestBudgetRuleHandler_RoundTrip(t *testing.T) {
                                                +	f := newAgentNetworkHandlerFixture(t)
                                                +
                                                +	rule := &agentNetworkTypes.AccountBudgetRule{
                                                +		ID:           "ainbud_test",
                                                +		AccountID:    testAccountID,
                                                +		Name:         "org-monthly",
                                                +		Enabled:      true,
                                                +		TargetGroups: []string{"grp-eng"},
                                                +		TargetUsers:  []string{"user-alice"},
                                                +		Limits: agentNetworkTypes.PolicyLimits{
                                                +			TokenLimit:  agentNetworkTypes.PolicyTokenLimit{Enabled: true, GroupCap: 100000, UserCap: 10000, WindowSeconds: 2_592_000},
                                                +			BudgetLimit: agentNetworkTypes.PolicyBudgetLimit{Enabled: true, GroupCapUsd: 500, WindowSeconds: 2_592_000},
                                                +		},
                                                +	}
                                                +	require.NoError(t, f.store.SaveAgentNetworkBudgetRule(context.Background(), rule))
                                                +
                                                +	rec := f.do(t, http.MethodGet, "/agent-network/budget-rules/"+rule.ID, "")
                                                +	require.Equal(t, http.StatusOK, rec.Code, "GET must succeed: %s", rec.Body.String())
                                                +
                                                +	var got api.AgentNetworkBudgetRule
                                                +	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
                                                +	assert.Equal(t, "org-monthly", got.Name, "name must round-trip")
                                                +	assert.Equal(t, []string{"grp-eng"}, got.TargetGroups, "target groups must round-trip")
                                                +	assert.Equal(t, []string{"user-alice"}, got.TargetUsers, "target users must round-trip")
                                                +	assert.Equal(t, int64(100000), got.Limits.TokenLimit.GroupCap, "token group cap must round-trip")
                                                +	assert.Equal(t, int64(2_592_000), got.Limits.BudgetLimit.WindowSeconds, "budget window must round-trip")
                                                +}
                                                +
                                                +// TestBudgetRuleHandler_ListReturnsArray asserts the list endpoint returns a
                                                +// JSON array (never null) for an account with no rules.
                                                +func TestBudgetRuleHandler_ListReturnsArray(t *testing.T) {
                                                +	f := newAgentNetworkHandlerFixture(t)
                                                +
                                                +	rec := f.do(t, http.MethodGet, "/agent-network/budget-rules", "")
                                                +	require.Equal(t, http.StatusOK, rec.Code, "GET must succeed: %s", rec.Body.String())
                                                +	assert.Equal(t, "[]", trimSpace(rec.Body.String()), "empty account must return an empty array, not null")
                                                +}
                                                +
                                                +// TestBudgetRuleHandler_RejectsMissingName covers the validation path (which
                                                +// runs before the manager call, so it works without a wired accountManager).
                                                +func TestBudgetRuleHandler_RejectsMissingName(t *testing.T) {
                                                +	f := newAgentNetworkHandlerFixture(t)
                                                +
                                                +	body := `{
                                                +        "name": "",
                                                +        "limits": {
                                                +            "token_limit": {"enabled": false, "group_cap": 0, "user_cap": 0, "window_seconds": 0},
                                                +            "budget_limit": {"enabled": false, "group_cap_usd": 0, "user_cap_usd": 0, "window_seconds": 0}
                                                +        }
                                                +    }`
                                                +	rec := f.do(t, http.MethodPost, "/agent-network/budget-rules", body)
                                                +	assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
                                                +		"missing name must be rejected as a validation error (not a route/auth 4xx): got %d body=%s", rec.Code, rec.Body.String())
                                                +	assert.Contains(t, rec.Body.String(), "name",
                                                +		"rejection body must name the offending field, proving the validation path: %s", rec.Body.String())
                                                +}
                                                +
                                                +// TestBudgetRuleHandler_RejectsSubMinuteWindow proves budget rules reuse the
                                                +// policy-limit validation (enabled limit needs window >= 60s).
                                                +func TestBudgetRuleHandler_RejectsSubMinuteWindow(t *testing.T) {
                                                +	f := newAgentNetworkHandlerFixture(t)
                                                +
                                                +	body := `{
                                                +        "name": "bad-window",
                                                +        "limits": {
                                                +            "token_limit": {"enabled": true, "group_cap": 1000, "user_cap": 0, "window_seconds": 30},
                                                +            "budget_limit": {"enabled": false, "group_cap_usd": 0, "user_cap_usd": 0, "window_seconds": 0}
                                                +        }
                                                +    }`
                                                +	rec := f.do(t, http.MethodPost, "/agent-network/budget-rules", body)
                                                +	assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
                                                +		"sub-minute window must be rejected as a validation error (not a route/auth 4xx): got %d body=%s", rec.Code, rec.Body.String())
                                                +	assert.Contains(t, rec.Body.String(), "window_seconds",
                                                +		"rejection body must name the offending window_seconds field, proving the validation path: %s", rec.Body.String())
                                                +}
                                                +
                                                +// TestSettingsHandler_GetExposesCollectionToggles asserts the GET settings wire
                                                +// shape carries the account-level collection toggles after a store seed.
                                                +func TestSettingsHandler_GetExposesCollectionToggles(t *testing.T) {
                                                +	f := newAgentNetworkHandlerFixture(t)
                                                +
                                                +	require.NoError(t, f.store.SaveAgentNetworkSettings(context.Background(), &agentNetworkTypes.Settings{
                                                +		AccountID:              testAccountID,
                                                +		Domain:                 "violet.eu.proxy.netbird.io",
                                                +		ProxyAddress:           "eu.proxy.netbird.io",
                                                +		EnableLogCollection:    true,
                                                +		EnablePromptCollection: true,
                                                +		RedactPii:              false,
                                                +	}))
                                                +
                                                +	rec := f.do(t, http.MethodGet, "/agent-network/settings", "")
                                                +	require.Equal(t, http.StatusOK, rec.Code, "GET must succeed: %s", rec.Body.String())
                                                +
                                                +	var got api.AgentNetworkSettings
                                                +	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
                                                +	assert.True(t, got.EnableLogCollection, "log collection toggle must surface on the wire")
                                                +	assert.True(t, got.EnablePromptCollection, "prompt collection toggle must surface on the wire")
                                                +	assert.False(t, got.RedactPii, "redact toggle must surface its false value")
                                                +	assert.Equal(t, "violet.eu.proxy.netbird.io", got.Endpoint, "endpoint stays computed from immutable cluster+subdomain")
                                                +}
                                                +
                                                +func trimSpace(s string) string {
                                                +	for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == ' ' || s[len(s)-1] == '\t' || s[len(s)-1] == '\r') {
                                                +		s = s[:len(s)-1]
                                                +	}
                                                +	for len(s) > 0 && (s[0] == '\n' || s[0] == ' ' || s[0] == '\t' || s[0] == '\r') {
                                                +		s = s[1:]
                                                +	}
                                                +	return s
                                                +}
                                                diff --git a/management/internals/modules/agentnetwork/handlers/consumption_handler.go b/management/internals/modules/agentnetwork/handlers/consumption_handler.go
                                                new file mode 100644
                                                index 000000000..654f23109
                                                --- /dev/null
                                                +++ b/management/internals/modules/agentnetwork/handlers/consumption_handler.go
                                                @@ -0,0 +1,53 @@
                                                +package handlers
                                                +
                                                +import (
                                                +	"net/http"
                                                +
                                                +	"github.com/gorilla/mux"
                                                +
                                                +	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
                                                +	nbcontext "github.com/netbirdio/netbird/management/server/context"
                                                +	"github.com/netbirdio/netbird/shared/management/http/api"
                                                +	"github.com/netbirdio/netbird/shared/management/http/util"
                                                +)
                                                +
                                                +// addConsumptionEndpoints registers the read-only Agent Network
                                                +// consumption listing — backs the dashboard's basic counter view.
                                                +func (h *handler) addConsumptionEndpoints(router *mux.Router) {
                                                +	router.HandleFunc("/agent-network/consumption", h.listConsumption).Methods("GET", "OPTIONS")
                                                +}
                                                +
                                                +func (h *handler) listConsumption(w http.ResponseWriter, r *http.Request) {
                                                +	userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	rows, err := h.manager.ListConsumption(r.Context(), userAuth.AccountId, userAuth.UserId)
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	out := make([]api.AgentNetworkConsumption, 0, len(rows))
                                                +	for _, row := range rows {
                                                +		out = append(out, consumptionToAPI(row))
                                                +	}
                                                +	util.WriteJSONObject(r.Context(), w, out)
                                                +}
                                                +
                                                +func consumptionToAPI(c *types.Consumption) api.AgentNetworkConsumption {
                                                +	windowStart := c.WindowStartUTC
                                                +	updatedAt := c.UpdatedAt
                                                +	return api.AgentNetworkConsumption{
                                                +		DimensionKind:  api.AgentNetworkConsumptionDimensionKind(c.DimensionKind),
                                                +		DimensionId:    c.DimensionID,
                                                +		WindowSeconds:  c.WindowSeconds,
                                                +		WindowStartUtc: windowStart,
                                                +		TokensInput:    c.TokensInput,
                                                +		TokensOutput:   c.TokensOutput,
                                                +		CostUsd:        c.CostUSD,
                                                +		UpdatedAt:      &updatedAt,
                                                +	}
                                                +}
                                                diff --git a/management/internals/modules/agentnetwork/handlers/guardrails_handler.go b/management/internals/modules/agentnetwork/handlers/guardrails_handler.go
                                                new file mode 100644
                                                index 000000000..81f19b9f1
                                                --- /dev/null
                                                +++ b/management/internals/modules/agentnetwork/handlers/guardrails_handler.go
                                                @@ -0,0 +1,171 @@
                                                +package handlers
                                                +
                                                +import (
                                                +	"encoding/json"
                                                +	"net/http"
                                                +	"strings"
                                                +
                                                +	"github.com/gorilla/mux"
                                                +
                                                +	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
                                                +	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"
                                                +)
                                                +
                                                +// addGuardrailEndpoints registers all Agent Network guardrail routes.
                                                +func (h *handler) addGuardrailEndpoints(router *mux.Router) {
                                                +	router.HandleFunc("/agent-network/guardrails", h.getAllGuardrails).Methods("GET", "OPTIONS")
                                                +	router.HandleFunc("/agent-network/guardrails", h.createGuardrail).Methods("POST", "OPTIONS")
                                                +	router.HandleFunc("/agent-network/guardrails/{guardrailId}", h.getGuardrail).Methods("GET", "OPTIONS")
                                                +	router.HandleFunc("/agent-network/guardrails/{guardrailId}", h.updateGuardrail).Methods("PUT", "OPTIONS")
                                                +	router.HandleFunc("/agent-network/guardrails/{guardrailId}", h.deleteGuardrail).Methods("DELETE", "OPTIONS")
                                                +}
                                                +
                                                +func (h *handler) getAllGuardrails(w http.ResponseWriter, r *http.Request) {
                                                +	userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	guardrails, err := h.manager.GetAllGuardrails(r.Context(), userAuth.AccountId, userAuth.UserId)
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	out := make([]*api.AgentNetworkGuardrail, 0, len(guardrails))
                                                +	for _, g := range guardrails {
                                                +		out = append(out, g.ToAPIResponse())
                                                +	}
                                                +	util.WriteJSONObject(r.Context(), w, out)
                                                +}
                                                +
                                                +func (h *handler) getGuardrail(w http.ResponseWriter, r *http.Request) {
                                                +	userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	guardrailID := mux.Vars(r)["guardrailId"]
                                                +	if guardrailID == "" {
                                                +		util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "guardrail ID is required"), w)
                                                +		return
                                                +	}
                                                +
                                                +	guardrail, err := h.manager.GetGuardrail(r.Context(), userAuth.AccountId, userAuth.UserId, guardrailID)
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +	util.WriteJSONObject(r.Context(), w, guardrail.ToAPIResponse())
                                                +}
                                                +
                                                +func (h *handler) createGuardrail(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.AgentNetworkGuardrailRequest
                                                +	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
                                                +		util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
                                                +		return
                                                +	}
                                                +
                                                +	if err := validateGuardrail(&req); err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	guardrail := types.NewGuardrail(userAuth.AccountId)
                                                +	guardrail.FromAPIRequest(&req)
                                                +
                                                +	created, err := h.manager.CreateGuardrail(r.Context(), userAuth.UserId, guardrail)
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	util.WriteJSONObject(r.Context(), w, created.ToAPIResponse())
                                                +}
                                                +
                                                +func (h *handler) updateGuardrail(w http.ResponseWriter, r *http.Request) {
                                                +	userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	guardrailID := mux.Vars(r)["guardrailId"]
                                                +	if guardrailID == "" {
                                                +		util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "guardrail ID is required"), w)
                                                +		return
                                                +	}
                                                +
                                                +	var req api.AgentNetworkGuardrailRequest
                                                +	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
                                                +		util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
                                                +		return
                                                +	}
                                                +
                                                +	if err := validateGuardrail(&req); err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	guardrail := &types.Guardrail{
                                                +		ID:        guardrailID,
                                                +		AccountID: userAuth.AccountId,
                                                +	}
                                                +	guardrail.FromAPIRequest(&req)
                                                +
                                                +	updated, err := h.manager.UpdateGuardrail(r.Context(), userAuth.UserId, guardrail)
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse())
                                                +}
                                                +
                                                +func (h *handler) deleteGuardrail(w http.ResponseWriter, r *http.Request) {
                                                +	userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	guardrailID := mux.Vars(r)["guardrailId"]
                                                +	if guardrailID == "" {
                                                +		util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "guardrail ID is required"), w)
                                                +		return
                                                +	}
                                                +
                                                +	if err := h.manager.DeleteGuardrail(r.Context(), userAuth.AccountId, userAuth.UserId, guardrailID); err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
                                                +}
                                                +
                                                +func validateGuardrail(req *api.AgentNetworkGuardrailRequest) error {
                                                +	if strings.TrimSpace(req.Name) == "" {
                                                +		return status.Errorf(status.InvalidArgument, "name is required")
                                                +	}
                                                +
                                                +	c := req.Checks
                                                +	if c.ModelAllowlist.Enabled {
                                                +		for _, id := range c.ModelAllowlist.Models {
                                                +			if strings.TrimSpace(id) == "" {
                                                +				return status.Errorf(status.InvalidArgument, "model_allowlist.models must not contain empty entries")
                                                +			}
                                                +		}
                                                +	}
                                                +	return nil
                                                +}
                                                diff --git a/management/internals/modules/agentnetwork/handlers/handlers_test.go b/management/internals/modules/agentnetwork/handlers/handlers_test.go
                                                new file mode 100644
                                                index 000000000..2665c9777
                                                --- /dev/null
                                                +++ b/management/internals/modules/agentnetwork/handlers/handlers_test.go
                                                @@ -0,0 +1,270 @@
                                                +package handlers
                                                +
                                                +import (
                                                +	"context"
                                                +	"encoding/json"
                                                +	"io"
                                                +	"net/http"
                                                +	"net/http/httptest"
                                                +	"runtime"
                                                +	"strings"
                                                +	"testing"
                                                +
                                                +	"go.uber.org/mock/gomock"
                                                +	"github.com/gorilla/mux"
                                                +	"github.com/stretchr/testify/assert"
                                                +	"github.com/stretchr/testify/require"
                                                +
                                                +	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
                                                +	agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
                                                +	"github.com/netbirdio/netbird/management/server/account"
                                                +	nbcontext "github.com/netbirdio/netbird/management/server/context"
                                                +	"github.com/netbirdio/netbird/management/internals/modules/permissions"
                                                +	"github.com/netbirdio/netbird/management/server/store"
                                                +	nbtypes "github.com/netbirdio/netbird/management/server/types"
                                                +	"github.com/netbirdio/netbird/shared/auth"
                                                +	"github.com/netbirdio/netbird/shared/management/http/api"
                                                +)
                                                +
                                                +const (
                                                +	testAccountID = "acc-1"
                                                +	testUserID    = "user-bob"
                                                +)
                                                +
                                                +// agentNetworkHandlerFixture builds a real agentnetwork.Manager with
                                                +// a sqlite store and an always-allow permissions mock, then exposes
                                                +// the HTTP handlers via a gorilla router. Tests issue requests
                                                +// through httptest and assert on the wire shape — the same path the
                                                +// dashboard exercises.
                                                +type agentNetworkHandlerFixture struct {
                                                +	store   store.Store
                                                +	manager agentnetwork.Manager
                                                +	router  *mux.Router
                                                +}
                                                +
                                                +func newAgentNetworkHandlerFixture(t *testing.T) *agentNetworkHandlerFixture {
                                                +	t.Helper()
                                                +	if runtime.GOOS == "windows" {
                                                +		t.Skip("sqlite store not properly supported on Windows yet")
                                                +	}
                                                +	t.Setenv("NETBIRD_STORE_ENGINE", string(nbtypes.SqliteStoreEngine))
                                                +
                                                +	st, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir())
                                                +	require.NoError(t, err)
                                                +	t.Cleanup(cleanUp)
                                                +
                                                +	ctrl := gomock.NewController(t)
                                                +	perms := permissions.NewMockManager(ctrl)
                                                +	// Always-allow: the handler tests are about wire shape, not
                                                +	// authz. Authz is covered by the manager's own tests.
                                                +	perms.EXPECT().
                                                +		ValidateUserPermissions(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
                                                +		Return(true, context.Background(), nil).
                                                +		AnyTimes()
                                                +
                                                +	// Swallow activity events so the mutation paths (create/update/delete)
                                                +	// are exercisable through the HTTP layer.
                                                +	accounts := account.NewMockManager(ctrl)
                                                +	accounts.EXPECT().
                                                +		StoreEvent(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
                                                +		AnyTimes()
                                                +	accounts.EXPECT().
                                                +		UpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).
                                                +		AnyTimes()
                                                +
                                                +	manager := agentnetwork.NewManager(st, perms, accounts, nil)
                                                +	h := &handler{manager: manager}
                                                +
                                                +	router := mux.NewRouter()
                                                +	router.HandleFunc("/agent-network/providers", h.createProvider).Methods("POST")
                                                +	router.HandleFunc("/agent-network/providers/{providerId}", h.getProvider).Methods("GET")
                                                +	router.HandleFunc("/agent-network/providers/{providerId}", h.updateProvider).Methods("PUT")
                                                +	h.addPolicyEndpoints(router)
                                                +	h.addConsumptionEndpoints(router)
                                                +	h.addBudgetRuleEndpoints(router)
                                                +	h.addSettingsEndpoints(router)
                                                +
                                                +	return &agentNetworkHandlerFixture{
                                                +		store:   st,
                                                +		manager: manager,
                                                +		router:  router,
                                                +	}
                                                +}
                                                +
                                                +func (f *agentNetworkHandlerFixture) do(t *testing.T, method, path, body string) *httptest.ResponseRecorder {
                                                +	t.Helper()
                                                +	var reader io.Reader
                                                +	if body != "" {
                                                +		reader = strings.NewReader(body)
                                                +	}
                                                +	req := httptest.NewRequest(method, path, reader)
                                                +	if body != "" {
                                                +		req.Header.Set("Content-Type", "application/json")
                                                +	}
                                                +	req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{
                                                +		UserId:    testUserID,
                                                +		AccountId: testAccountID,
                                                +	})
                                                +	rec := httptest.NewRecorder()
                                                +	f.router.ServeHTTP(rec, req)
                                                +	return rec
                                                +}
                                                +
                                                +// seedProvider persists a minimal provider record so policy create
                                                +// passes the manager's destination_provider_ids existence check.
                                                +func (f *agentNetworkHandlerFixture) seedProvider(t *testing.T, id string) {
                                                +	t.Helper()
                                                +	require.NoError(t, f.store.SaveAgentNetworkProvider(context.Background(), &agentNetworkTypes.Provider{
                                                +		ID:                id,
                                                +		AccountID:         testAccountID,
                                                +		ProviderID:        "openai_api",
                                                +		Name:              "test-" + id,
                                                +		UpstreamURL:       "https://api.openai.com",
                                                +		APIKey:            "sk-test",
                                                +		Enabled:           true,
                                                +		SessionPrivateKey: "test-priv-key",
                                                +		SessionPublicKey:  "test-pub-key",
                                                +	}))
                                                +}
                                                +
                                                +// TestPolicyHandler_WindowSecondsRoundTrip ports bash 10 to Go:
                                                +// assert that a policy with window_seconds on both Token + Budget
                                                +// halves round-trips through GET unchanged AND that legacy
                                                +// window_hours / window_days are absent from the JSON response. We
                                                +// seed the policy directly via the store rather than POST-ing
                                                +// because the create path goes through the manager's
                                                +// accountManager.StoreEvent which we don't wire in this fixture; the
                                                +// on-wire shape is what matters here, and the POST validation path
                                                +// is covered separately by the RejectsSubMinuteWindow test.
                                                +func TestPolicyHandler_WindowSecondsRoundTrip(t *testing.T) {
                                                +	f := newAgentNetworkHandlerFixture(t)
                                                +
                                                +	policy := &agentNetworkTypes.Policy{
                                                +		ID:                     "ainpol_test",
                                                +		AccountID:              testAccountID,
                                                +		Name:                   "round-trip",
                                                +		Enabled:                true,
                                                +		SourceGroups:           []string{"grp-engineers"},
                                                +		DestinationProviderIDs: []string{"prov-1"},
                                                +		Limits: agentNetworkTypes.PolicyLimits{
                                                +			TokenLimit:  agentNetworkTypes.PolicyTokenLimit{Enabled: true, GroupCap: 10000, UserCap: 5000, WindowSeconds: 86_400},
                                                +			BudgetLimit: agentNetworkTypes.PolicyBudgetLimit{Enabled: true, GroupCapUsd: 10.0, UserCapUsd: 2.5, WindowSeconds: 2_592_000},
                                                +		},
                                                +	}
                                                +	require.NoError(t, f.store.SaveAgentNetworkPolicy(context.Background(), policy))
                                                +
                                                +	rec := f.do(t, http.MethodGet, "/agent-network/policies/"+policy.ID, "")
                                                +	require.Equal(t, http.StatusOK, rec.Code, "GET must succeed: %s", rec.Body.String())
                                                +
                                                +	var got api.AgentNetworkPolicy
                                                +	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
                                                +	assert.Equal(t, int64(86_400), got.Limits.TokenLimit.WindowSeconds, "token_limit.window_seconds must round-trip")
                                                +	assert.Equal(t, int64(2_592_000), got.Limits.BudgetLimit.WindowSeconds, "budget_limit.window_seconds must round-trip")
                                                +
                                                +	// Legacy field names must NOT appear in the response — would
                                                +	// signal that the management server is still emitting the old
                                                +	// shape and would fool a v1 dashboard into rendering days/hours.
                                                +	assert.NotContains(t, rec.Body.String(), "window_hours",
                                                +		"legacy window_hours field must be absent from the on-wire response")
                                                +	assert.NotContains(t, rec.Body.String(), "window_days",
                                                +		"legacy window_days field must be absent from the on-wire response")
                                                +}
                                                +
                                                +// TestPolicyHandler_RejectsSubMinuteWindow ports bash 20 to Go: an
                                                +// enabled limit with window_seconds < 60 must surface as a 4xx
                                                +// because anything finer than per-minute produces an untenable
                                                +// volume of consumption rows for a feature whose value comes from
                                                +// per-window cap enforcement.
                                                +func TestPolicyHandler_RejectsSubMinuteWindow(t *testing.T) {
                                                +	f := newAgentNetworkHandlerFixture(t)
                                                +	f.seedProvider(t, "prov-1")
                                                +
                                                +	body := `{
                                                +        "name": "sub-minute-window",
                                                +        "enabled": true,
                                                +        "source_groups": ["grp-engineers"],
                                                +        "destination_provider_ids": ["prov-1"],
                                                +        "guardrail_ids": [],
                                                +        "limits": {
                                                +            "token_limit": {"enabled": true, "group_cap": 10000, "user_cap": 5000, "window_seconds": 30},
                                                +            "budget_limit": {"enabled": false, "group_cap_usd": 0, "user_cap_usd": 0, "window_seconds": 0}
                                                +        }
                                                +    }`
                                                +	rec := f.do(t, http.MethodPost, "/agent-network/policies", body)
                                                +	// 422 specifically (InvalidArgument) proves the window-validation path —
                                                +	// a route miss would be 404 and an auth failure 403, so a generic 4xx
                                                +	// would let those false-pass.
                                                +	assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
                                                +		"enabled token_limit with window_seconds<60 must be rejected as a validation error: got %d body=%s", rec.Code, rec.Body.String())
                                                +	assert.Contains(t, rec.Body.String(), "window_seconds",
                                                +		"rejection body must name the offending window_seconds field, proving it's the validation path: %s", rec.Body.String())
                                                +}
                                                +
                                                +// TestConsumptionHandler_EmptyAccountReturnsArray ports bash 30 to
                                                +// Go: GET /agent-network/consumption on a clean account always
                                                +// returns a JSON array (possibly empty), never a 404 / 500. The
                                                +// dashboard depends on this shape to render its empty state.
                                                +func TestConsumptionHandler_EmptyAccountReturnsArray(t *testing.T) {
                                                +	f := newAgentNetworkHandlerFixture(t)
                                                +
                                                +	rec := f.do(t, http.MethodGet, "/agent-network/consumption", "")
                                                +	require.Equal(t, http.StatusOK, rec.Code)
                                                +
                                                +	var rows []api.AgentNetworkConsumption
                                                +	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &rows),
                                                +		"response must always be a JSON array — even when empty: %s", rec.Body.String())
                                                +	assert.Empty(t, rows)
                                                +}
                                                +
                                                +// TestConsumptionHandler_PopulatedAccountListsRows mirrors the
                                                +// /consumption read after a few RecordConsumption calls. Validates
                                                +// the wire shape carries every field the dashboard reads (dim_kind,
                                                +// dim_id, window_seconds, window_start_utc, tokens, cost_usd) and
                                                +// rows are ordered window-newest-first.
                                                +func TestConsumptionHandler_PopulatedAccountListsRows(t *testing.T) {
                                                +	f := newAgentNetworkHandlerFixture(t)
                                                +
                                                +	require.NoError(t, f.manager.RecordConsumption(
                                                +		context.Background(), testAccountID,
                                                +		agentNetworkTypes.DimensionGroup, "grp-engineers",
                                                +		86_400, 100, 50, 0.0125,
                                                +	))
                                                +	require.NoError(t, f.manager.RecordConsumption(
                                                +		context.Background(), testAccountID,
                                                +		agentNetworkTypes.DimensionUser, testUserID,
                                                +		86_400, 100, 50, 0.0125,
                                                +	))
                                                +
                                                +	rec := f.do(t, http.MethodGet, "/agent-network/consumption", "")
                                                +	require.Equal(t, http.StatusOK, rec.Code)
                                                +
                                                +	var rows []api.AgentNetworkConsumption
                                                +	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &rows))
                                                +	require.Len(t, rows, 2, "two RecordConsumption calls must yield two rows")
                                                +
                                                +	// Index by dim_kind so we can assert the full wire shape of each row,
                                                +	// including the dimension id and the aligned window start the dashboard
                                                +	// keys on. Both rows share totals and window.
                                                +	byKind := make(map[string]api.AgentNetworkConsumption, len(rows))
                                                +	for _, row := range rows {
                                                +		assert.Equal(t, int64(100), row.TokensInput)
                                                +		assert.Equal(t, int64(50), row.TokensOutput)
                                                +		assert.InDelta(t, 0.0125, row.CostUsd, 1e-9)
                                                +		assert.Equal(t, int64(86_400), row.WindowSeconds)
                                                +		assert.False(t, row.WindowStartUtc.IsZero(), "window_start_utc must be set on every row")
                                                +		byKind[string(row.DimensionKind)] = row
                                                +	}
                                                +
                                                +	groupRow, ok := byKind["group"]
                                                +	require.True(t, ok, "group dimension must surface")
                                                +	assert.Equal(t, "grp-engineers", groupRow.DimensionId, "group row must carry the source group id as dimension_id")
                                                +
                                                +	userRow, ok := byKind["user"]
                                                +	require.True(t, ok, "user dimension must surface")
                                                +	assert.Equal(t, testUserID, userRow.DimensionId, "user row must carry the user id as dimension_id")
                                                +
                                                +	// Both rows fall in the same aligned window (same length, recorded
                                                +	// together), so window_start_utc must match across them.
                                                +	assert.Equal(t, groupRow.WindowStartUtc, userRow.WindowStartUtc,
                                                +		"rows recorded in the same window must share the aligned window_start_utc")
                                                +}
                                                diff --git a/management/internals/modules/agentnetwork/handlers/model_discovery_handler_test.go b/management/internals/modules/agentnetwork/handlers/model_discovery_handler_test.go
                                                new file mode 100644
                                                index 000000000..389c2ae50
                                                --- /dev/null
                                                +++ b/management/internals/modules/agentnetwork/handlers/model_discovery_handler_test.go
                                                @@ -0,0 +1,178 @@
                                                +package handlers
                                                +
                                                +import (
                                                +	"context"
                                                +	"encoding/json"
                                                +	"fmt"
                                                +	"net/http"
                                                +	"net/http/httptest"
                                                +	"strings"
                                                +	"testing"
                                                +
                                                +	"github.com/stretchr/testify/assert"
                                                +	"github.com/stretchr/testify/require"
                                                +
                                                +	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
                                                +	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/modeldiscovery"
                                                +	nbcontext "github.com/netbirdio/netbird/management/server/context"
                                                +	"github.com/netbirdio/netbird/shared/auth"
                                                +	"github.com/netbirdio/netbird/shared/management/http/api"
                                                +)
                                                +
                                                +// discoveryManagerStub records what the handler asked for and returns a canned
                                                +// answer. The Manager interface is embedded rather than implemented: only the
                                                +// one method is reachable from this handler, and a call to any other should
                                                +// fail loudly rather than silently return a zero value.
                                                +type discoveryManagerStub struct {
                                                +	agentnetwork.Manager
                                                +
                                                +	gotReq      modeldiscovery.Request
                                                +	gotRecordID string
                                                +	models      []modeldiscovery.Model
                                                +	err         error
                                                +}
                                                +
                                                +func (s *discoveryManagerStub) DiscoverProviderModels(
                                                +	_ context.Context, _, _ string, req modeldiscovery.Request, recordID string,
                                                +) ([]modeldiscovery.Model, error) {
                                                +	s.gotReq = req
                                                +	s.gotRecordID = recordID
                                                +	return s.models, s.err
                                                +}
                                                +
                                                +// postDiscovery drives the handler with an authenticated request.
                                                +func postDiscovery(t *testing.T, stub *discoveryManagerStub, body string) *httptest.ResponseRecorder {
                                                +	t.Helper()
                                                +	h := &handler{manager: stub}
                                                +
                                                +	req := httptest.NewRequest(http.MethodPost, "/agent-network/catalog/providers/models", strings.NewReader(body))
                                                +	req = req.WithContext(nbcontext.SetUserAuthInContext(req.Context(), auth.UserAuth{
                                                +		AccountId: "acc-1",
                                                +		UserId:    "user-1",
                                                +	}))
                                                +
                                                +	rec := httptest.NewRecorder()
                                                +	h.discoverProviderModels(rec, req)
                                                +	return rec
                                                +}
                                                +
                                                +func TestDiscoverModelsReturnsTheVendorList(t *testing.T) {
                                                +	stub := &discoveryManagerStub{models: []modeldiscovery.Model{
                                                +		{ID: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", Label: "EU Claude Haiku 4.5", PricingKnown: true},
                                                +		{ID: "global.cohere.embed-v4:0", Label: "Global Cohere Embed v4"},
                                                +		// A vendor that supplies no display name at all. Bedrock does for
                                                +		// every profile, but the OpenAI listing carries none.
                                                +		{ID: "gpt-4o-mini", PricingKnown: true},
                                                +	}}
                                                +
                                                +	rec := postDiscovery(t, stub, `{
                                                +		"catalog_provider_id":"bedrock_api",
                                                +		"upstream_url":"https://bedrock-runtime.eu-central-1.amazonaws.com",
                                                +		"api_key":"aws-bearer"
                                                +	}`)
                                                +	require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
                                                +
                                                +	var out api.AgentNetworkModelDiscoveryResponse
                                                +	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out))
                                                +	require.Len(t, out.Models, 3)
                                                +
                                                +	assert.Equal(t, "eu.anthropic.claude-haiku-4-5-20251001-v1:0", out.Models[0].Id)
                                                +	assert.True(t, out.Models[0].PricingKnown)
                                                +	// An unpriced model must say so rather than arriving indistinguishable
                                                +	// from a priced one: registering it silently would meter at zero.
                                                +	assert.False(t, out.Models[1].PricingKnown)
                                                +
                                                +	require.NotNil(t, out.Models[0].Label, "the vendor supplied a display name")
                                                +	assert.Equal(t, "EU Claude Haiku 4.5", *out.Models[0].Label)
                                                +	// A vendor that supplies no name must omit the key rather than send an
                                                +	// empty string: the dashboard falls back to the id on absence, and would
                                                +	// render a blank row for "".
                                                +	assert.Nil(t, out.Models[2].Label, "an absent label must not serialize")
                                                +	assert.NotContains(t, rec.Body.String(), `"label":""`)
                                                +
                                                +	assert.Equal(t, "bedrock_api", stub.gotReq.CatalogID)
                                                +	assert.Equal(t, "aws-bearer", stub.gotReq.APIKey)
                                                +	// The upstream is what the region is read back out of for Bedrock, so
                                                +	// losing it here would break discovery for every regional provider.
                                                +	assert.Equal(t, "https://bedrock-runtime.eu-central-1.amazonaws.com", stub.gotReq.UpstreamURL)
                                                +	assert.Empty(t, stub.gotRecordID)
                                                +}
                                                +
                                                +func TestDiscoverModelsUsesAStoredRecordWithoutAKey(t *testing.T) {
                                                +	stub := &discoveryManagerStub{}
                                                +
                                                +	rec := postDiscovery(t, stub, `{"catalog_provider_id":"openai_api","provider_id":"prov-42"}`)
                                                +	require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
                                                +
                                                +	// The dashboard refreshes a saved provider's list without ever holding
                                                +	// the credential, so the record id has to reach the manager.
                                                +	assert.Equal(t, "prov-42", stub.gotRecordID)
                                                +	assert.Empty(t, stub.gotReq.APIKey)
                                                +}
                                                +
                                                +// TestDiscoverModelsRefusesMixedCredentials covers the case where a caller
                                                +// names a saved provider AND supplies a key. Accepting it would run an
                                                +// arbitrary credential under the identity of a record the caller may only be
                                                +// permitted to read.
                                                +func TestDiscoverModelsRefusesMixedCredentials(t *testing.T) {
                                                +	stub := &discoveryManagerStub{}
                                                +
                                                +	rec := postDiscovery(t, stub, `{
                                                +		"catalog_provider_id":"openai_api",
                                                +		"provider_id":"prov-42",
                                                +		"api_key":"sk-attacker"
                                                +	}`)
                                                +	assert.Equal(t, http.StatusBadRequest, rec.Code)
                                                +	assert.Empty(t, stub.gotRecordID, "the request must be refused before it reaches the manager")
                                                +}
                                                +
                                                +// TestDiscoverModelsReportsNoDiscoveryDistinctly matters because the caller
                                                +// falls back to the catalog's own model list on this outcome. Collapsing it
                                                +// into a generic 500 would turn "this provider has no listing endpoint" into
                                                +// "something went wrong", and the form would show an error instead of a list.
                                                +func TestDiscoverModelsReportsNoDiscoveryDistinctly(t *testing.T) {
                                                +	stub := &discoveryManagerStub{err: modeldiscovery.ErrNoDiscovery}
                                                +
                                                +	rec := postDiscovery(t, stub, `{"catalog_provider_id":"litellm_proxy","upstream_url":"https://gw.example.com","api_key":"sk"}`)
                                                +	assert.Equal(t, http.StatusUnprocessableEntity, rec.Code)
                                                +}
                                                +
                                                +// TestDiscoverModelsTrimsTheCatalogID pins that the id the emptiness check
                                                +// accepts is the id the manager receives. A padded value that clears the check
                                                +// but reaches the catalog untrimmed misses the lookup, and the operator is told
                                                +// their provider does not exist.
                                                +func TestDiscoverModelsTrimsTheCatalogID(t *testing.T) {
                                                +	stub := &discoveryManagerStub{}
                                                +
                                                +	rec := postDiscovery(t, stub, `{"catalog_provider_id":"  openai_api  ","api_key":"sk"}`)
                                                +	require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
                                                +	assert.Equal(t, "openai_api", stub.gotReq.CatalogID)
                                                +}
                                                +
                                                +// TestDiscoverModelsReportsCallerInputAsBadRequest covers the other half of the
                                                +// error mapping. These failures are all reachable from a well-formed request
                                                +// with a bad field value, so answering 500 both misinforms the operator and
                                                +// puts their typo into the server's error rate.
                                                +func TestDiscoverModelsReportsCallerInputAsBadRequest(t *testing.T) {
                                                +	stub := &discoveryManagerStub{
                                                +		err: fmt.Errorf("%w: unknown catalog provider %q", modeldiscovery.ErrInvalidRequest, "nope"),
                                                +	}
                                                +
                                                +	rec := postDiscovery(t, stub, `{"catalog_provider_id":"nope","api_key":"sk"}`)
                                                +	assert.Equal(t, http.StatusBadRequest, rec.Code)
                                                +	assert.Contains(t, rec.Body.String(), "unknown catalog provider")
                                                +}
                                                +
                                                +func TestDiscoverModelsRejectsMalformedRequests(t *testing.T) {
                                                +	for name, body := range map[string]string{
                                                +		"not json":               `{`,
                                                +		"no catalog provider":    `{"api_key":"sk"}`,
                                                +		"blank catalog provider": `{"catalog_provider_id":"   ","api_key":"sk"}`,
                                                +	} {
                                                +		t.Run(name, func(t *testing.T) {
                                                +			stub := &discoveryManagerStub{}
                                                +			rec := postDiscovery(t, stub, body)
                                                +			assert.Equal(t, http.StatusBadRequest, rec.Code)
                                                +		})
                                                +	}
                                                +}
                                                diff --git a/management/internals/modules/agentnetwork/handlers/policies_handler.go b/management/internals/modules/agentnetwork/handlers/policies_handler.go
                                                new file mode 100644
                                                index 000000000..b821a5295
                                                --- /dev/null
                                                +++ b/management/internals/modules/agentnetwork/handlers/policies_handler.go
                                                @@ -0,0 +1,228 @@
                                                +package handlers
                                                +
                                                +import (
                                                +	"encoding/json"
                                                +	"net/http"
                                                +	"strings"
                                                +
                                                +	"github.com/gorilla/mux"
                                                +
                                                +	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
                                                +	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"
                                                +)
                                                +
                                                +// minWindowSeconds is the floor enforced on enabled token / budget
                                                +// limit windows. One minute is short enough for fine-grained burst
                                                +// control without producing untenable consumption-row volume at scale.
                                                +const minWindowSeconds int64 = 60
                                                +
                                                +// addPolicyEndpoints registers all Agent Network policy routes on the
                                                +// shared handler.
                                                +func (h *handler) addPolicyEndpoints(router *mux.Router) {
                                                +	router.HandleFunc("/agent-network/policies", h.getAllPolicies).Methods("GET", "OPTIONS")
                                                +	router.HandleFunc("/agent-network/policies", h.createPolicy).Methods("POST", "OPTIONS")
                                                +	router.HandleFunc("/agent-network/policies/{policyId}", h.getPolicy).Methods("GET", "OPTIONS")
                                                +	router.HandleFunc("/agent-network/policies/{policyId}", h.updatePolicy).Methods("PUT", "OPTIONS")
                                                +	router.HandleFunc("/agent-network/policies/{policyId}", h.deletePolicy).Methods("DELETE", "OPTIONS")
                                                +}
                                                +
                                                +func (h *handler) getAllPolicies(w http.ResponseWriter, r *http.Request) {
                                                +	userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	policies, err := h.manager.GetAllPolicies(r.Context(), userAuth.AccountId, userAuth.UserId)
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	out := make([]*api.AgentNetworkPolicy, 0, len(policies))
                                                +	for _, p := range policies {
                                                +		out = append(out, p.ToAPIResponse())
                                                +	}
                                                +	util.WriteJSONObject(r.Context(), w, out)
                                                +}
                                                +
                                                +func (h *handler) getPolicy(w http.ResponseWriter, r *http.Request) {
                                                +	userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	policyID := mux.Vars(r)["policyId"]
                                                +	if policyID == "" {
                                                +		util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "policy ID is required"), w)
                                                +		return
                                                +	}
                                                +
                                                +	policy, err := h.manager.GetPolicy(r.Context(), userAuth.AccountId, userAuth.UserId, policyID)
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +	util.WriteJSONObject(r.Context(), w, policy.ToAPIResponse())
                                                +}
                                                +
                                                +func (h *handler) createPolicy(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.AgentNetworkPolicyRequest
                                                +	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
                                                +		util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
                                                +		return
                                                +	}
                                                +
                                                +	if err := validatePolicy(&req); err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	policy := types.NewPolicy(userAuth.AccountId)
                                                +	policy.FromAPIRequest(&req)
                                                +
                                                +	created, err := h.manager.CreatePolicy(r.Context(), userAuth.UserId, policy)
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	util.WriteJSONObject(r.Context(), w, created.ToAPIResponse())
                                                +}
                                                +
                                                +func (h *handler) updatePolicy(w http.ResponseWriter, r *http.Request) {
                                                +	userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	policyID := mux.Vars(r)["policyId"]
                                                +	if policyID == "" {
                                                +		util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "policy ID is required"), w)
                                                +		return
                                                +	}
                                                +
                                                +	var req api.AgentNetworkPolicyRequest
                                                +	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
                                                +		util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
                                                +		return
                                                +	}
                                                +
                                                +	if err := validatePolicy(&req); err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	policy := &types.Policy{
                                                +		ID:        policyID,
                                                +		AccountID: userAuth.AccountId,
                                                +	}
                                                +	policy.FromAPIRequest(&req)
                                                +
                                                +	updated, err := h.manager.UpdatePolicy(r.Context(), userAuth.UserId, policy)
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse())
                                                +}
                                                +
                                                +func (h *handler) deletePolicy(w http.ResponseWriter, r *http.Request) {
                                                +	userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	policyID := mux.Vars(r)["policyId"]
                                                +	if policyID == "" {
                                                +		util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "policy ID is required"), w)
                                                +		return
                                                +	}
                                                +
                                                +	if err := h.manager.DeletePolicy(r.Context(), userAuth.AccountId, userAuth.UserId, policyID); err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
                                                +}
                                                +
                                                +func validatePolicy(req *api.AgentNetworkPolicyRequest) error {
                                                +	if strings.TrimSpace(req.Name) == "" {
                                                +		return status.Errorf(status.InvalidArgument, "name is required")
                                                +	}
                                                +	if len(req.SourceGroups) == 0 {
                                                +		return status.Errorf(status.InvalidArgument, "source_groups must contain at least one group id")
                                                +	}
                                                +	for _, id := range req.SourceGroups {
                                                +		if strings.TrimSpace(id) == "" {
                                                +			return status.Errorf(status.InvalidArgument, "source_groups must not contain empty entries")
                                                +		}
                                                +	}
                                                +	if len(req.DestinationProviderIds) == 0 {
                                                +		return status.Errorf(status.InvalidArgument, "destination_provider_ids must contain at least one provider id")
                                                +	}
                                                +	for _, id := range req.DestinationProviderIds {
                                                +		if strings.TrimSpace(id) == "" {
                                                +			return status.Errorf(status.InvalidArgument, "destination_provider_ids must not contain empty entries")
                                                +		}
                                                +	}
                                                +	if req.GuardrailIds != nil {
                                                +		for _, id := range *req.GuardrailIds {
                                                +			if strings.TrimSpace(id) == "" {
                                                +				return status.Errorf(status.InvalidArgument, "guardrail_ids must not contain empty entries")
                                                +			}
                                                +		}
                                                +	}
                                                +	if req.Limits != nil {
                                                +		if err := validatePolicyLimits(*req.Limits); err != nil {
                                                +			return err
                                                +		}
                                                +	}
                                                +	return nil
                                                +}
                                                +
                                                +func validatePolicyLimits(l api.AgentNetworkPolicyLimits) error {
                                                +	if l.TokenLimit.Enabled {
                                                +		if l.TokenLimit.WindowSeconds < minWindowSeconds {
                                                +			return status.Errorf(status.InvalidArgument, "limits.token_limit.window_seconds must be at least %d (one minute) when enabled", minWindowSeconds)
                                                +		}
                                                +		if l.TokenLimit.GroupCap < 0 {
                                                +			return status.Errorf(status.InvalidArgument, "limits.token_limit.group_cap must not be negative")
                                                +		}
                                                +		if l.TokenLimit.UserCap < 0 {
                                                +			return status.Errorf(status.InvalidArgument, "limits.token_limit.user_cap must not be negative")
                                                +		}
                                                +		if l.TokenLimit.GroupCap == 0 && l.TokenLimit.UserCap == 0 {
                                                +			return status.Errorf(status.InvalidArgument, "limits.token_limit requires group_cap or user_cap to be greater than zero when enabled")
                                                +		}
                                                +	}
                                                +	if l.BudgetLimit.Enabled {
                                                +		if l.BudgetLimit.WindowSeconds < minWindowSeconds {
                                                +			return status.Errorf(status.InvalidArgument, "limits.budget_limit.window_seconds must be at least %d (one minute) when enabled", minWindowSeconds)
                                                +		}
                                                +		if l.BudgetLimit.GroupCapUsd < 0 {
                                                +			return status.Errorf(status.InvalidArgument, "limits.budget_limit.group_cap_usd must not be negative")
                                                +		}
                                                +		if l.BudgetLimit.UserCapUsd < 0 {
                                                +			return status.Errorf(status.InvalidArgument, "limits.budget_limit.user_cap_usd must not be negative")
                                                +		}
                                                +		if l.BudgetLimit.GroupCapUsd == 0 && l.BudgetLimit.UserCapUsd == 0 {
                                                +			return status.Errorf(status.InvalidArgument, "limits.budget_limit requires group_cap_usd or user_cap_usd to be greater than zero when enabled")
                                                +		}
                                                +	}
                                                +	return nil
                                                +}
                                                diff --git a/management/internals/modules/agentnetwork/handlers/providers_handler.go b/management/internals/modules/agentnetwork/handlers/providers_handler.go
                                                new file mode 100644
                                                index 000000000..81a6d6eb0
                                                --- /dev/null
                                                +++ b/management/internals/modules/agentnetwork/handlers/providers_handler.go
                                                @@ -0,0 +1,385 @@
                                                +// Package handlers serves the Agent Network HTTP API.
                                                +//
                                                +// All persistence is delegated to agentnetwork.Manager so this layer only
                                                +// translates between the wire format (api.AgentNetworkProvider*) and the
                                                +// domain types.
                                                +package handlers
                                                +
                                                +import (
                                                +	"encoding/json"
                                                +	"errors"
                                                +	"math"
                                                +	"net/http"
                                                +	"net/url"
                                                +	"strings"
                                                +
                                                +	"github.com/gorilla/mux"
                                                +
                                                +	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
                                                +	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
                                                +	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/modeldiscovery"
                                                +	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing"
                                                +	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
                                                +	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 agentnetwork.Manager
                                                +}
                                                +
                                                +// RegisterEndpoints registers all Agent Network routes.
                                                +func RegisterEndpoints(manager agentnetwork.Manager, router *mux.Router) {
                                                +	h := &handler{manager: manager}
                                                +	router.HandleFunc("/agent-network/catalog/providers", h.getCatalogProviders).Methods("GET", "OPTIONS")
                                                +	router.HandleFunc("/agent-network/catalog/providers/models", h.discoverProviderModels).Methods("POST", "OPTIONS")
                                                +	router.HandleFunc("/agent-network/providers", h.getAllProviders).Methods("GET", "OPTIONS")
                                                +	router.HandleFunc("/agent-network/providers", h.createProvider).Methods("POST", "OPTIONS")
                                                +	router.HandleFunc("/agent-network/providers/{providerId}", h.getProvider).Methods("GET", "OPTIONS")
                                                +	router.HandleFunc("/agent-network/providers/{providerId}", h.updateProvider).Methods("PUT", "OPTIONS")
                                                +	router.HandleFunc("/agent-network/providers/{providerId}", h.deleteProvider).Methods("DELETE", "OPTIONS")
                                                +	h.addPolicyEndpoints(router)
                                                +	h.addGuardrailEndpoints(router)
                                                +	h.addSettingsEndpoints(router)
                                                +	h.addConsumptionEndpoints(router)
                                                +	h.addAccessLogEndpoints(router)
                                                +	h.addBudgetRuleEndpoints(router)
                                                +	h.addAgentConfigEndpoints(router)
                                                +}
                                                +
                                                +func (h *handler) getCatalogProviders(w http.ResponseWriter, r *http.Request) {
                                                +	if _, err := nbcontext.GetUserAuthFromContext(r.Context()); err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	entries := catalog.All()
                                                +	out := make([]api.AgentNetworkCatalogProvider, 0, len(entries))
                                                +	for _, e := range entries {
                                                +		resp := e.ToAPIResponse()
                                                +		applyDefaultPricing(e, &resp)
                                                +		out = append(out, resp)
                                                +	}
                                                +	util.WriteJSONObject(r.Context(), w, out)
                                                +}
                                                +
                                                +// discoverProviderModels asks the vendor which models the operator's own
                                                +// credential can reach, so the provider form can offer a live list rather than
                                                +// only the static catalog.
                                                +func (h *handler) discoverProviderModels(w http.ResponseWriter, r *http.Request) {
                                                +	userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	var body api.AgentNetworkModelDiscoveryRequest
                                                +	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
                                                +		util.WriteErrorResponse("invalid json", http.StatusBadRequest, w)
                                                +		return
                                                +	}
                                                +	// Trimmed once and carried, not trimmed for the emptiness test and then
                                                +	// discarded: a padded " openai_api " would clear the check here and miss
                                                +	// the catalog lookup, reporting the provider as unknown.
                                                +	catalogID := strings.TrimSpace(body.CatalogProviderId)
                                                +	if catalogID == "" {
                                                +		util.WriteErrorResponse("catalog_provider_id is required", http.StatusBadRequest, w)
                                                +		return
                                                +	}
                                                +
                                                +	recordID := strValue(body.ProviderId)
                                                +	req := modeldiscovery.Request{
                                                +		CatalogID:   catalogID,
                                                +		UpstreamURL: strValue(body.UpstreamUrl),
                                                +		APIKey:      strValue(body.ApiKey),
                                                +	}
                                                +	// One source of credential or the other, never a mix: taking a key from
                                                +	// the request while addressing a saved record would let a caller run an
                                                +	// arbitrary credential against a provider they can only read.
                                                +	if recordID != "" && req.APIKey != "" {
                                                +		util.WriteErrorResponse("provide either provider_id or api_key, not both", http.StatusBadRequest, w)
                                                +		return
                                                +	}
                                                +
                                                +	models, err := h.manager.DiscoverProviderModels(r.Context(), userAuth.AccountId, userAuth.UserId, req, recordID)
                                                +	if err != nil {
                                                +		// A provider with no listing endpoint is a fact about the catalog
                                                +		// entry, not a failure: the caller falls back to the catalog's own
                                                +		// models, so it must be able to tell the two apart.
                                                +		if errors.Is(err, modeldiscovery.ErrNoDiscovery) {
                                                +			util.WriteErrorResponse(err.Error(), http.StatusUnprocessableEntity, w)
                                                +			return
                                                +		}
                                                +		// An unknown provider, an unusable upstream, a missing region or a
                                                +		// missing key are all things the caller sent, reachable from a
                                                +		// well-formed request. Reporting them as 500 tells the operator the
                                                +		// server broke and buries genuine faults in the error rate.
                                                +		if errors.Is(err, modeldiscovery.ErrInvalidRequest) {
                                                +			util.WriteErrorResponse(err.Error(), http.StatusBadRequest, w)
                                                +			return
                                                +		}
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	out := api.AgentNetworkModelDiscoveryResponse{Models: make([]api.AgentNetworkDiscoveredModel, 0, len(models))}
                                                +	for _, m := range models {
                                                +		entry := api.AgentNetworkDiscoveredModel{
                                                +			Id:           m.ID,
                                                +			PricingKnown: m.PricingKnown,
                                                +			// Sent even when zero: the form prefills every discovered model as
                                                +			// an editable row, and an unpriced one is shown at zero and flagged
                                                +			// rather than left out.
                                                +			InputPer1k:  m.InputPer1k,
                                                +			OutputPer1k: m.OutputPer1k,
                                                +			// Cache rates stay absent when unset, matching the catalog
                                                +			// response — a zero would read as "free", not "not applicable".
                                                +			CachedInputPer1k:   positiveRatePtr(m.CachedInputPer1k),
                                                +			CacheReadPer1k:     positiveRatePtr(m.CacheReadPer1k),
                                                +			CacheCreationPer1k: positiveRatePtr(m.CacheCreationPer1k),
                                                +		}
                                                +		if m.Label != "" {
                                                +			label := m.Label
                                                +			entry.Label = &label
                                                +		}
                                                +		out.Models = append(out.Models, entry)
                                                +	}
                                                +	util.WriteJSONObject(r.Context(), w, out)
                                                +}
                                                +
                                                +// strValue reads an optional string field, treating absent as empty.
                                                +func strValue(v *string) string {
                                                +	if v == nil {
                                                +		return ""
                                                +	}
                                                +	return strings.TrimSpace(*v)
                                                +}
                                                +
                                                +// applyDefaultPricing overwrites the catalog response's model rates with
                                                +// the LIVE default pricing table, which may differ from the compiled-in
                                                +// catalog rates when the operator provides a defaults_llm_pricing.yaml.
                                                +// This keeps the dashboard's model-row prefill identical to what the
                                                +// proxy will actually bill — the same table the synthesizer ships.
                                                +func applyDefaultPricing(cp catalog.Provider, resp *api.AgentNetworkCatalogProvider) {
                                                +	if len(cp.PricingSurfaces) == 0 {
                                                +		return
                                                +	}
                                                +	for i := range resp.Models {
                                                +		m := &resp.Models[i]
                                                +		e, ok := pricing.LookupDefault(cp.PricingSurfaces, m.Id)
                                                +		if !ok {
                                                +			continue
                                                +		}
                                                +		m.InputPer1k = e.InputPer1k
                                                +		m.OutputPer1k = e.OutputPer1k
                                                +		m.CachedInputPer1k = positiveRatePtr(e.CachedInputPer1k)
                                                +		m.CacheReadPer1k = positiveRatePtr(e.CacheReadPer1k)
                                                +		m.CacheCreationPer1k = positiveRatePtr(e.CacheCreationPer1k)
                                                +	}
                                                +}
                                                +
                                                +// positiveRatePtr renders a cache rate for the API: absent (nil) when
                                                +// unset, matching the catalog response convention.
                                                +func positiveRatePtr(v float64) *float64 {
                                                +	if v <= 0 {
                                                +		return nil
                                                +	}
                                                +	return &v
                                                +}
                                                +
                                                +func (h *handler) getAllProviders(w http.ResponseWriter, r *http.Request) {
                                                +	userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	providers, err := h.manager.GetAllProviders(r.Context(), userAuth.AccountId, userAuth.UserId)
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	out := make([]*api.AgentNetworkProvider, 0, len(providers))
                                                +	for _, p := range providers {
                                                +		out = append(out, p.ToAPIResponse())
                                                +	}
                                                +	util.WriteJSONObject(r.Context(), w, out)
                                                +}
                                                +
                                                +func (h *handler) getProvider(w http.ResponseWriter, r *http.Request) {
                                                +	userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	providerID := mux.Vars(r)["providerId"]
                                                +	if providerID == "" {
                                                +		util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "provider ID is required"), w)
                                                +		return
                                                +	}
                                                +
                                                +	provider, err := h.manager.GetProvider(r.Context(), userAuth.AccountId, userAuth.UserId, providerID)
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +	util.WriteJSONObject(r.Context(), w, provider.ToAPIResponse())
                                                +}
                                                +
                                                +func (h *handler) createProvider(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.AgentNetworkProviderRequest
                                                +	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
                                                +		util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
                                                +		return
                                                +	}
                                                +
                                                +	if err := validate(&req, true); err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	provider := types.NewProvider(userAuth.AccountId)
                                                +	provider.FromAPIRequest(&req)
                                                +
                                                +	created, err := h.manager.CreateProvider(r.Context(), userAuth.UserId, provider)
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	util.WriteJSONObject(r.Context(), w, created.ToAPIResponse())
                                                +}
                                                +
                                                +func (h *handler) updateProvider(w http.ResponseWriter, r *http.Request) {
                                                +	userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	providerID := mux.Vars(r)["providerId"]
                                                +	if providerID == "" {
                                                +		util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "provider ID is required"), w)
                                                +		return
                                                +	}
                                                +
                                                +	var req api.AgentNetworkProviderRequest
                                                +	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
                                                +		util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
                                                +		return
                                                +	}
                                                +
                                                +	if err := validate(&req, false); err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	provider := &types.Provider{
                                                +		ID:        providerID,
                                                +		AccountID: userAuth.AccountId,
                                                +	}
                                                +	provider.FromAPIRequest(&req)
                                                +
                                                +	updated, err := h.manager.UpdateProvider(r.Context(), userAuth.UserId, provider)
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse())
                                                +}
                                                +
                                                +func (h *handler) deleteProvider(w http.ResponseWriter, r *http.Request) {
                                                +	userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	providerID := mux.Vars(r)["providerId"]
                                                +	if providerID == "" {
                                                +		util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "provider ID is required"), w)
                                                +		return
                                                +	}
                                                +
                                                +	if err := h.manager.DeleteProvider(r.Context(), userAuth.AccountId, userAuth.UserId, providerID); err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
                                                +}
                                                +
                                                +func validate(req *api.AgentNetworkProviderRequest, requireAPIKey bool) error {
                                                +	if strings.TrimSpace(req.ProviderId) == "" {
                                                +		return status.Errorf(status.InvalidArgument, "provider_id is required")
                                                +	}
                                                +	if !catalog.IsKnown(req.ProviderId) {
                                                +		return status.Errorf(status.InvalidArgument, "provider_id %q is not a known catalog provider", req.ProviderId)
                                                +	}
                                                +	if strings.TrimSpace(req.Name) == "" {
                                                +		return status.Errorf(status.InvalidArgument, "name is required")
                                                +	}
                                                +	if strings.TrimSpace(req.UpstreamUrl) == "" {
                                                +		return status.Errorf(status.InvalidArgument, "upstream_url is required")
                                                +	}
                                                +	u, err := url.Parse(strings.TrimSpace(req.UpstreamUrl))
                                                +	if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
                                                +		return status.Errorf(status.InvalidArgument, "upstream_url must be a full http(s) URL")
                                                +	}
                                                +	if requireAPIKey && (req.ApiKey == nil || strings.TrimSpace(*req.ApiKey) == "") {
                                                +		return status.Errorf(status.InvalidArgument, "api_key is required")
                                                +	}
                                                +	// An update omits api_key to keep the stored credential. A key that is
                                                +	// present but blank is not that: Provider.FromAPIRequest drops it exactly
                                                +	// as if it were absent, so a rotation the operator believes they performed
                                                +	// would answer 200 having changed nothing. Refuse it here, where the
                                                +	// request still carries the difference between absent and blank.
                                                +	if req.ApiKey != nil && strings.TrimSpace(*req.ApiKey) == "" {
                                                +		return status.Errorf(status.InvalidArgument, "api_key must be omitted to keep the stored credential rather than sent blank")
                                                +	}
                                                +	if req.Models != nil {
                                                +		for i, m := range *req.Models {
                                                +			if err := validateModel(i, m); err != nil {
                                                +				return err
                                                +			}
                                                +		}
                                                +	}
                                                +	return nil
                                                +}
                                                +
                                                +// validateModel is the single ingress guard for operator-entered pricing:
                                                +// these rates are synthesized into the proxy's cost_meter config verbatim,
                                                +// and a negative or non-finite rate there would poison every cost the
                                                +// proxy records, so reject at the API boundary.
                                                +func validateModel(i int, m api.AgentNetworkProviderModel) error {
                                                +	if strings.TrimSpace(m.Id) == "" {
                                                +		return status.Errorf(status.InvalidArgument, "models[%d]: id is required", i)
                                                +	}
                                                +	rates := map[string]*float64{
                                                +		"input_per_1k":          &m.InputPer1k,
                                                +		"output_per_1k":         &m.OutputPer1k,
                                                +		"cached_input_per_1k":   m.CachedInputPer1k,
                                                +		"cache_read_per_1k":     m.CacheReadPer1k,
                                                +		"cache_creation_per_1k": m.CacheCreationPer1k,
                                                +	}
                                                +	for field, v := range rates {
                                                +		if v == nil {
                                                +			continue
                                                +		}
                                                +		if *v < 0 || math.IsNaN(*v) || math.IsInf(*v, 0) {
                                                +			return status.Errorf(status.InvalidArgument, "models[%d] (%s): %s must be a finite, non-negative USD rate", i, m.Id, field)
                                                +		}
                                                +	}
                                                +	return nil
                                                +}
                                                diff --git a/management/internals/modules/agentnetwork/handlers/providers_handler_test.go b/management/internals/modules/agentnetwork/handlers/providers_handler_test.go
                                                new file mode 100644
                                                index 000000000..033de3b8a
                                                --- /dev/null
                                                +++ b/management/internals/modules/agentnetwork/handlers/providers_handler_test.go
                                                @@ -0,0 +1,138 @@
                                                +package handlers
                                                +
                                                +import (
                                                +	"encoding/json"
                                                +	"math"
                                                +	nethttp "net/http"
                                                +	"testing"
                                                +
                                                +	"github.com/stretchr/testify/assert"
                                                +	"github.com/stretchr/testify/require"
                                                +
                                                +	"github.com/netbirdio/netbird/shared/management/http/api"
                                                +)
                                                +
                                                +func f(v float64) *float64 { return &v }
                                                +
                                                +// TestValidate_ModelRates guards the single ingress point for operator-entered
                                                +// pricing. These rates flow verbatim into the proxy's cost_meter config at
                                                +// synthesis time; the proxy treats a bad rate as a chain-build failure, so
                                                +// rejecting here is what keeps an account's gateway from going down.
                                                +func TestValidate_ModelRates(t *testing.T) {
                                                +	base := func(models ...api.AgentNetworkProviderModel) *api.AgentNetworkProviderRequest {
                                                +		key := "sk-test"
                                                +		return &api.AgentNetworkProviderRequest{
                                                +			ProviderId:  "openai_api",
                                                +			Name:        "OpenAI",
                                                +			UpstreamUrl: "https://api.openai.com",
                                                +			ApiKey:      &key,
                                                +			Models:      &models,
                                                +		}
                                                +	}
                                                +
                                                +	valid := api.AgentNetworkProviderModel{
                                                +		Id: "gpt-4o", InputPer1k: 0.0025, OutputPer1k: 0.01,
                                                +		CachedInputPer1k: f(0.00125),
                                                +	}
                                                +	require.NoError(t, validate(base(valid), true), "finite non-negative rates must pass")
                                                +
                                                +	zeroRates := api.AgentNetworkProviderModel{Id: "self-hosted-llama", InputPer1k: 0, OutputPer1k: 0}
                                                +	require.NoError(t, validate(base(zeroRates), true), "explicit zero prices are allowed (free / self-hosted models)")
                                                +
                                                +	cases := map[string]api.AgentNetworkProviderModel{
                                                +		"empty id":           {Id: "  ", InputPer1k: 0.001, OutputPer1k: 0.002},
                                                +		"negative input":     {Id: "m", InputPer1k: -0.001, OutputPer1k: 0.002},
                                                +		"negative output":    {Id: "m", InputPer1k: 0.001, OutputPer1k: -0.002},
                                                +		"NaN input":          {Id: "m", InputPer1k: math.NaN(), OutputPer1k: 0.002},
                                                +		"Inf output":         {Id: "m", InputPer1k: 0.001, OutputPer1k: math.Inf(1)},
                                                +		"negative cached":    {Id: "m", InputPer1k: 0.001, OutputPer1k: 0.002, CachedInputPer1k: f(-1)},
                                                +		"NaN cache read":     {Id: "m", InputPer1k: 0.001, OutputPer1k: 0.002, CacheReadPer1k: f(math.NaN())},
                                                +		"Inf cache creation": {Id: "m", InputPer1k: 0.001, OutputPer1k: 0.002, CacheCreationPer1k: f(math.Inf(-1))},
                                                +	}
                                                +	for name, m := range cases {
                                                +		assert.Error(t, validate(base(m), true), "case %q must be rejected", name)
                                                +	}
                                                +}
                                                +
                                                +// TestValidate_ABlankApiKeyIsNotTheSameAsAnOmittedOne covers the one shape the
                                                +// manager's own guard cannot see. Provider.FromAPIRequest assigns the key only
                                                +// when it trims to something, so a request carrying "   " arrives at
                                                +// UpdateProvider indistinguishable from one that omitted it — the stored
                                                +// credential is kept and the write answers 200, telling an operator who thinks
                                                +// they just rotated a key that it worked.
                                                +//
                                                +// The request still knows the difference, so the refusal belongs here.
                                                +func TestValidate_ABlankApiKeyIsNotTheSameAsAnOmittedOne(t *testing.T) {
                                                +	req := func(key *string) *api.AgentNetworkProviderRequest {
                                                +		return &api.AgentNetworkProviderRequest{
                                                +			ProviderId:  "openai_api",
                                                +			Name:        "OpenAI",
                                                +			UpstreamUrl: "https://api.openai.com",
                                                +			ApiKey:      key,
                                                +		}
                                                +	}
                                                +
                                                +	blank := "   "
                                                +	err := validate(req(&blank), false)
                                                +	require.Error(t, err, "a blank api_key on update must not be read as 'keep what is stored'")
                                                +	assert.Contains(t, err.Error(), "api_key")
                                                +
                                                +	require.NoError(t, validate(req(nil), false), "an omitted api_key is how an update keeps the stored credential")
                                                +
                                                +	// Create already refuses this, and keeps its own message: a caller who sent
                                                +	// no usable key is told the field is required rather than being told how to
                                                +	// preserve a credential that does not exist yet.
                                                +	err = validate(req(&blank), true)
                                                +	require.Error(t, err)
                                                +	assert.Contains(t, err.Error(), "api_key is required")
                                                +}
                                                +
                                                +// TestProviderHandler_UpdateReplacesFullState pins the update contract shared
                                                +// with the other PUT endpoints: the request replaces the provider's mutable
                                                +// state, so optional fields absent from the JSON land as their zero values.
                                                +// The two exceptions are server-side: the api_key (a secret — omitted means
                                                +// "not rotated") and the session keypair, both preserved by the manager. The
                                                +// identity headers stay on the wire as explicit empty strings so a cleared
                                                +// value round-trips.
                                                +func TestProviderHandler_UpdateReplacesFullState(t *testing.T) {
                                                +	f := newAgentNetworkHandlerFixture(t)
                                                +
                                                +	// A private upstream: the save-time credential check leaves it unchecked
                                                +	// rather than spending "sk-test" against the real api.openai.com, which
                                                +	// the vendor refuses.
                                                +	create := `{
                                                +        "provider_id": "openai_api",
                                                +        "name": "openai",
                                                +        "upstream_url": "https://10.255.255.1",
                                                +        "api_key": "sk-test",
                                                +        "enabled": true,
                                                +        "metadata_disabled": true,
                                                +        "skip_tls_verification": true,
                                                +        "extra_values": {"x-portkey-config": "pc-prod-3f2a"},
                                                +        "identity_header_user_id": "x-bf-dim-netbird_user_id",
                                                +        "models": [{"id": "gpt-4o", "input_per_1k": 0.0025, "output_per_1k": 0.01}]
                                                +    }`
                                                +	rec := f.do(t, nethttp.MethodPost, "/agent-network/providers", create)
                                                +	require.Equal(t, nethttp.StatusOK, rec.Code, "create must succeed: %s", rec.Body.String())
                                                +
                                                +	var created api.AgentNetworkProvider
                                                +	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created))
                                                +
                                                +	// Minimal update: only the required fields, no api_key. Everything
                                                +	// optional must land as its zero value.
                                                +	update := `{"provider_id": "openai_api", "name": "openai-renamed", "upstream_url": "https://10.255.255.1", "enabled": true}`
                                                +	rec = f.do(t, nethttp.MethodPut, "/agent-network/providers/"+created.Id, update)
                                                +	require.Equal(t, nethttp.StatusOK, rec.Code, "update without api_key must succeed (key is preserved): %s", rec.Body.String())
                                                +
                                                +	var updated api.AgentNetworkProvider
                                                +	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &updated))
                                                +	assert.Equal(t, "openai-renamed", updated.Name, "sent field must apply")
                                                +	assert.True(t, updated.Enabled, "sent field must apply")
                                                +	assert.False(t, updated.MetadataDisabled, "omitted metadata_disabled must land as false — PUT replaces the full state")
                                                +	assert.False(t, updated.SkipTlsVerification, "omitted skip_tls_verification must land as false")
                                                +	assert.Nil(t, updated.ExtraValues, "omitted extra_values must be cleared")
                                                +	assert.Equal(t, "", updated.IdentityHeaderUserId, "omitted identity header must be cleared yet stay on the wire")
                                                +	assert.Empty(t, updated.Models, "omitted models must be cleared")
                                                +	assert.Contains(t, rec.Body.String(), `"identity_header_user_id":""`,
                                                +		"cleared identity header must round-trip as an explicit empty string")
                                                +}
                                                diff --git a/management/internals/modules/agentnetwork/handlers/settings_handler.go b/management/internals/modules/agentnetwork/handlers/settings_handler.go
                                                new file mode 100644
                                                index 000000000..0a365f9ce
                                                --- /dev/null
                                                +++ b/management/internals/modules/agentnetwork/handlers/settings_handler.go
                                                @@ -0,0 +1,127 @@
                                                +package handlers
                                                +
                                                +import (
                                                +	"encoding/json"
                                                +	"net/http"
                                                +
                                                +	"github.com/gorilla/mux"
                                                +
                                                +	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
                                                +	nbcontext "github.com/netbirdio/netbird/management/server/context"
                                                +	"github.com/netbirdio/netbird/shared/management/http/api"
                                                +	"github.com/netbirdio/netbird/shared/management/http/util"
                                                +)
                                                +
                                                +// addSettingsEndpoints registers the Agent Network settings routes. POST
                                                +// bootstraps the settings row, assigning the account's immutable endpoint;
                                                +// GET reads it (defaults with an empty endpoint before bootstrap); PUT
                                                +// carries every field, replacing the mutable collection toggles and rejecting
                                                +// any change to the identity fields; DELETE removes the row — guarded so it
                                                +// stays a bootstrap-repair operation — releasing the endpoint for a fresh
                                                +// bootstrap.
                                                +func (h *handler) addSettingsEndpoints(router *mux.Router) {
                                                +	router.HandleFunc("/agent-network/settings", h.getSettings).Methods("GET", "OPTIONS")
                                                +	router.HandleFunc("/agent-network/settings", h.createSettings).Methods("POST", "OPTIONS")
                                                +	router.HandleFunc("/agent-network/settings", h.updateSettings).Methods("PUT", "OPTIONS")
                                                +	router.HandleFunc("/agent-network/settings", h.deleteSettings).Methods("DELETE", "OPTIONS")
                                                +}
                                                +
                                                +// createSettings bootstraps the account's settings row. Exactly one of
                                                +// proxy_address (labeled endpoint; the server allocates the label) and
                                                +// endpoint (self-addressed, claimed verbatim) must be provided; optional
                                                +// collection toggles ride along with defaults for omitted fields.
                                                +func (h *handler) createSettings(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.AgentNetworkSettingsCreateRequest
                                                +	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
                                                +		util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
                                                +		return
                                                +	}
                                                +
                                                +	settings := types.DefaultSettings(userAuth.AccountId)
                                                +	settings.FromAPICreateRequest(&req)
                                                +
                                                +	proxyAddress := ""
                                                +	if req.ProxyAddress != nil {
                                                +		proxyAddress = *req.ProxyAddress
                                                +	}
                                                +	endpoint := ""
                                                +	if req.Endpoint != nil {
                                                +		endpoint = *req.Endpoint
                                                +	}
                                                +
                                                +	created, err := h.manager.CreateSettings(r.Context(), userAuth.UserId, settings, proxyAddress, endpoint)
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +	util.WriteJSONObject(r.Context(), w, created.ToAPIResponse())
                                                +}
                                                +
                                                +// updateSettings replaces the mutable settings fields on the account's row.
                                                +// A request carrying a cluster bootstraps the row when the account doesn't
                                                +// have one yet.
                                                +func (h *handler) updateSettings(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.AgentNetworkSettingsRequest
                                                +	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
                                                +		util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
                                                +		return
                                                +	}
                                                +
                                                +	settings := &types.Settings{AccountID: userAuth.AccountId}
                                                +	settings.FromAPIRequest(&req)
                                                +
                                                +	updated, err := h.manager.UpdateSettings(r.Context(), userAuth.UserId, settings)
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +	util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse())
                                                +}
                                                +
                                                +// deleteSettings removes the account's settings row, releasing the endpoint.
                                                +// The manager refuses (412) while providers exist or a proxy is actively
                                                +// serving the endpoint; a later POST bootstraps fresh, allocating a new
                                                +// endpoint.
                                                +func (h *handler) deleteSettings(w http.ResponseWriter, r *http.Request) {
                                                +	userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	if err := h.manager.DeleteSettings(r.Context(), userAuth.AccountId, userAuth.UserId); err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +	util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
                                                +}
                                                +
                                                +// getSettings returns the account's agent-network settings. Accounts that
                                                +// haven't been bootstrapped yet read as the defaults with an empty cluster,
                                                +// subdomain and endpoint; the manager synthesises that view.
                                                +func (h *handler) getSettings(w http.ResponseWriter, r *http.Request) {
                                                +	userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +
                                                +	settings, err := h.manager.GetSettings(r.Context(), userAuth.AccountId, userAuth.UserId)
                                                +	if err != nil {
                                                +		util.WriteError(r.Context(), err, w)
                                                +		return
                                                +	}
                                                +	util.WriteJSONObject(r.Context(), w, settings.ToAPIResponse())
                                                +}
                                                diff --git a/management/internals/modules/agentnetwork/handlers/settings_handler_test.go b/management/internals/modules/agentnetwork/handlers/settings_handler_test.go
                                                new file mode 100644
                                                index 000000000..400208e1c
                                                --- /dev/null
                                                +++ b/management/internals/modules/agentnetwork/handlers/settings_handler_test.go
                                                @@ -0,0 +1,395 @@
                                                +package handlers
                                                +
                                                +import (
                                                +	"context"
                                                +	"encoding/json"
                                                +	"fmt"
                                                +	"net/http"
                                                +	"strings"
                                                +	"testing"
                                                +	"time"
                                                +
                                                +	"github.com/stretchr/testify/assert"
                                                +	"github.com/stretchr/testify/require"
                                                +
                                                +	rpproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
                                                +	"github.com/netbirdio/netbird/shared/management/http/api"
                                                +)
                                                +
                                                +// TestSettingsHandler_GetUnbootstrappedReturnsDefaults pins the settings-read
                                                +// convention shared with the account and DNS settings endpoints: settings
                                                +// always read as a JSON object. Before bootstrap that object carries the
                                                +// defaults with an empty endpoint/proxy_address (the "not bootstrapped"
                                                +// signal) and no timestamps — never a 404 and never the legacy null body.
                                                +func TestSettingsHandler_GetUnbootstrappedReturnsDefaults(t *testing.T) {
                                                +	f := newAgentNetworkHandlerFixture(t)
                                                +
                                                +	rec := f.do(t, http.MethodGet, "/agent-network/settings", "")
                                                +	require.Equal(t, http.StatusOK, rec.Code,
                                                +		"unbootstrapped account must read as 200 with defaults: got %d body=%s", rec.Code, rec.Body.String())
                                                +	require.NotEqual(t, "null", trimSpace(rec.Body.String()),
                                                +		"the legacy 200+null shape must not come back")
                                                +
                                                +	var got api.AgentNetworkSettings
                                                +	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
                                                +	assert.Empty(t, got.Endpoint, "endpoint must be empty until bootstrapped")
                                                +	assert.Empty(t, got.ProxyAddress, "proxy address must be empty until bootstrapped")
                                                +	assert.False(t, got.Dedicated, "an unbootstrapped account has no serving shape")
                                                +	assert.True(t, got.EnableLogCollection, "defaults must show log collection on, matching bootstrap")
                                                +	assert.False(t, got.EnablePromptCollection, "defaults must show prompt collection off")
                                                +	assert.False(t, got.RedactPii, "defaults must show redaction off")
                                                +	require.NotNil(t, got.AccessLogRetentionDays)
                                                +	assert.Equal(t, 30, *got.AccessLogRetentionDays, "defaults must show the bootstrap retention")
                                                +	assert.Nil(t, got.CreatedAt, "no timestamps before a row exists")
                                                +	assert.Nil(t, got.UpdatedAt, "no timestamps before a row exists")
                                                +}
                                                +
                                                +// TestSettingsHandler_PostBootstrapsLabeled covers the labeled bootstrap
                                                +// shape: a POST carrying a proxy_address allocates a label beneath it, so the
                                                +// endpoint hangs one label under the shared cluster's address and the pin is
                                                +// not dedicated. Toggles riding along apply; omitted ones keep defaults.
                                                +func TestSettingsHandler_PostBootstrapsLabeled(t *testing.T) {
                                                +	f := newAgentNetworkHandlerFixture(t)
                                                +
                                                +	rec := f.do(t, http.MethodPost, "/agent-network/settings",
                                                +		`{"proxy_address": "eu.proxy.netbird.io", "enable_prompt_collection": true, "access_log_retention_days": 14}`)
                                                +	require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
                                                +
                                                +	var got api.AgentNetworkSettings
                                                +	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
                                                +	assert.Equal(t, "eu.proxy.netbird.io", got.ProxyAddress, "proxy address must be pinned from the request")
                                                +	require.NotEmpty(t, got.Endpoint, "endpoint must be allocated at bootstrap")
                                                +	assert.True(t, strings.HasSuffix(got.Endpoint, ".eu.proxy.netbird.io"),
                                                +		"labeled endpoint must hang off the proxy address: %s", got.Endpoint)
                                                +	label := strings.TrimSuffix(got.Endpoint, ".eu.proxy.netbird.io")
                                                +	assert.NotContains(t, label, ".", "the allocated label must be a single DNS label: %s", label)
                                                +	assert.False(t, got.Dedicated, "a labeled pin is not dedicated")
                                                +	assert.True(t, got.EnableLogCollection, "omitted toggle must keep its default")
                                                +	assert.True(t, got.EnablePromptCollection, "toggle from the bootstrap request must apply")
                                                +	require.NotNil(t, got.AccessLogRetentionDays)
                                                +	assert.Equal(t, 14, *got.AccessLogRetentionDays, "retention from the bootstrap request must apply")
                                                +	assert.NotNil(t, got.CreatedAt, "a persisted row carries timestamps")
                                                +
                                                +	// The row is now readable via GET.
                                                +	rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
                                                +	require.Equal(t, http.StatusOK, rec.Code, "GET after bootstrap must succeed")
                                                +	var read api.AgentNetworkSettings
                                                +	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &read))
                                                +	assert.Equal(t, got.Endpoint, read.Endpoint, "GET must return the bootstrapped endpoint")
                                                +}
                                                +
                                                +// TestSettingsHandler_PostBootstrapsSelfAddressed covers the dedicated shape:
                                                +// a POST carrying an endpoint claims the hostname verbatim, the proxy address
                                                +// equals it, and the pin reads as dedicated. The claim is legitimate before
                                                +// any proxy declares the address (address-first).
                                                +func TestSettingsHandler_PostBootstrapsSelfAddressed(t *testing.T) {
                                                +	f := newAgentNetworkHandlerFixture(t)
                                                +
                                                +	rec := f.do(t, http.MethodPost, "/agent-network/settings",
                                                +		`{"endpoint": "Brave-Otter.Gateway.Example.com"}`)
                                                +	require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
                                                +
                                                +	var got api.AgentNetworkSettings
                                                +	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
                                                +	assert.Equal(t, "brave-otter.gateway.example.com", got.Endpoint,
                                                +		"endpoint must be claimed verbatim, lowercased")
                                                +	assert.Equal(t, got.Endpoint, got.ProxyAddress, "self-addressed: the proxy address is the endpoint")
                                                +	assert.True(t, got.Dedicated, "a self-addressed pin is dedicated")
                                                +	assert.True(t, got.EnableLogCollection, "omitted toggles must keep their defaults")
                                                +}
                                                +
                                                +// TestSettingsHandler_PostRequiresExactlyOneIdentityField pins the request
                                                +// contract: proxy_address and endpoint are mutually exclusive and one is
                                                +// required — both or neither is a validation error, not a guess.
                                                +func TestSettingsHandler_PostRequiresExactlyOneIdentityField(t *testing.T) {
                                                +	f := newAgentNetworkHandlerFixture(t)
                                                +
                                                +	rec := f.do(t, http.MethodPost, "/agent-network/settings", `{}`)
                                                +	assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
                                                +		"empty POST must be rejected: got %d body=%s", rec.Code, rec.Body.String())
                                                +
                                                +	rec = f.do(t, http.MethodPost, "/agent-network/settings",
                                                +		`{"proxy_address": "eu.proxy.netbird.io", "endpoint": "brave-otter.gateway.example.com"}`)
                                                +	assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
                                                +		"POST with both identity fields must be rejected: got %d body=%s", rec.Code, rec.Body.String())
                                                +}
                                                +
                                                +// TestSettingsHandler_PostRejectsMalformedHostnames pins per-write input
                                                +// validation: shapes canonicalization cannot repair — trailing dots, embedded
                                                +// whitespace, empty labels — are rejected with a validation error instead of
                                                +// landing in an immutable column.
                                                +func TestSettingsHandler_PostRejectsMalformedHostnames(t *testing.T) {
                                                +	f := newAgentNetworkHandlerFixture(t)
                                                +
                                                +	for name, body := range map[string]string{
                                                +		"trailing dot":     `{"endpoint": "gateway.example.com."}`,
                                                +		"leading dot":      `{"endpoint": ".gateway.example.com"}`,
                                                +		"inner whitespace": `{"endpoint": "gate way.example.com"}`,
                                                +		"empty label":      `{"proxy_address": "eu..proxy.netbird.io"}`,
                                                +	} {
                                                +		rec := f.do(t, http.MethodPost, "/agent-network/settings", body)
                                                +		assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
                                                +			"%s must be rejected: got %d body=%s", name, rec.Code, rec.Body.String())
                                                +	}
                                                +}
                                                +
                                                +// TestSettingsHandler_PostConflictsOnSecondBootstrap pins that bootstrap is a
                                                +// one-time create: a second POST returns 409 and leaves the row untouched.
                                                +func TestSettingsHandler_PostConflictsOnSecondBootstrap(t *testing.T) {
                                                +	f := newAgentNetworkHandlerFixture(t)
                                                +
                                                +	rec := f.do(t, http.MethodPost, "/agent-network/settings", `{"proxy_address": "eu.proxy.netbird.io"}`)
                                                +	require.Equal(t, http.StatusOK, rec.Code, "first bootstrap must succeed: %s", rec.Body.String())
                                                +	var first api.AgentNetworkSettings
                                                +	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &first))
                                                +
                                                +	rec = f.do(t, http.MethodPost, "/agent-network/settings", `{"proxy_address": "us.proxy.netbird.io"}`)
                                                +	assert.Equal(t, http.StatusConflict, rec.Code,
                                                +		"second bootstrap must 409: got %d body=%s", rec.Code, rec.Body.String())
                                                +
                                                +	rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
                                                +	require.Equal(t, http.StatusOK, rec.Code)
                                                +	var got api.AgentNetworkSettings
                                                +	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
                                                +	assert.Equal(t, first.Endpoint, got.Endpoint, "the original endpoint must survive the rejected bootstrap")
                                                +	assert.Equal(t, first.ProxyAddress, got.ProxyAddress, "the original proxy address must survive")
                                                +}
                                                +
                                                +// TestSettingsHandler_PutBeforeBootstrapIs404 pins that a PUT cannot conjure a
                                                +// settings row out of nothing — bootstrap is the explicit POST — and the
                                                +// error points the caller there.
                                                +func TestSettingsHandler_PutBeforeBootstrapIs404(t *testing.T) {
                                                +	f := newAgentNetworkHandlerFixture(t)
                                                +
                                                +	rec := f.do(t, http.MethodPut, "/agent-network/settings",
                                                +		`{"enable_log_collection": false, "enable_prompt_collection": false, "redact_pii": false}`)
                                                +	assert.Equal(t, http.StatusNotFound, rec.Code,
                                                +		"PUT on an unbootstrapped account must 404: got %d body=%s", rec.Code, rec.Body.String())
                                                +	assert.Contains(t, rec.Body.String(), "/api/agent-network/settings",
                                                +		"the error must point the caller at the bootstrap POST: %s", rec.Body.String())
                                                +}
                                                +
                                                +// TestSettingsHandler_PutReplacesMutableFields pins the update contract shared
                                                +// with the other PUT endpoints: the request carries every field, replacing the
                                                +// mutable ones. The identity fields ride along as a required echo of the
                                                +// assigned values — compared, never written — so the endpoint and proxy
                                                +// address survive every accepted update.
                                                +func TestSettingsHandler_PutReplacesMutableFields(t *testing.T) {
                                                +	f := newAgentNetworkHandlerFixture(t)
                                                +
                                                +	rec := f.do(t, http.MethodPost, "/agent-network/settings",
                                                +		`{"proxy_address": "eu.proxy.netbird.io", "enable_prompt_collection": true, "redact_pii": true, "access_log_retention_days": 14}`)
                                                +	require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
                                                +
                                                +	var before api.AgentNetworkSettings
                                                +	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &before))
                                                +
                                                +	rec = f.do(t, http.MethodPut, "/agent-network/settings", fmt.Sprintf(
                                                +		`{"endpoint": %q, "proxy_address": %q, "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false, "access_log_retention_days": 7}`,
                                                +		before.Endpoint, before.ProxyAddress))
                                                +	require.Equal(t, http.StatusOK, rec.Code, "update PUT must succeed: %s", rec.Body.String())
                                                +
                                                +	var got api.AgentNetworkSettings
                                                +	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
                                                +	assert.True(t, got.EnableLogCollection, "sent toggle must apply")
                                                +	assert.False(t, got.EnablePromptCollection, "sent toggle must apply")
                                                +	assert.False(t, got.RedactPii, "sent toggle must apply")
                                                +	require.NotNil(t, got.AccessLogRetentionDays)
                                                +	assert.Equal(t, 7, *got.AccessLogRetentionDays, "sent retention must apply")
                                                +	assert.Equal(t, before.Endpoint, got.Endpoint, "endpoint must survive updates untouched")
                                                +	assert.Equal(t, before.ProxyAddress, got.ProxyAddress, "proxy address must survive updates untouched")
                                                +}
                                                +
                                                +// TestSettingsHandler_PutRejectsChangedIdentity pins the immutability contract:
                                                +// the PUT carries the identity fields like every other field, but they are an
                                                +// echo — a request carrying a different endpoint or proxy address is rejected
                                                +// as a validation error and the row is left untouched. The comparison is
                                                +// lenient about casing (the stored values are normalized lowercase), so a
                                                +// client replaying a GET response with different casing is not rejected.
                                                +func TestSettingsHandler_PutRejectsChangedIdentity(t *testing.T) {
                                                +	f := newAgentNetworkHandlerFixture(t)
                                                +
                                                +	rec := f.do(t, http.MethodPost, "/agent-network/settings",
                                                +		`{"proxy_address": "eu.proxy.netbird.io", "enable_prompt_collection": true}`)
                                                +	require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
                                                +	var before api.AgentNetworkSettings
                                                +	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &before))
                                                +
                                                +	for name, body := range map[string]string{
                                                +		"changed endpoint": fmt.Sprintf(
                                                +			`{"endpoint": "other.gateway.example.com", "proxy_address": %q, "enable_log_collection": false, "enable_prompt_collection": false, "redact_pii": false, "access_log_retention_days": 30}`,
                                                +			before.ProxyAddress),
                                                +		"changed proxy_address": fmt.Sprintf(
                                                +			`{"endpoint": %q, "proxy_address": "us.proxy.netbird.io", "enable_log_collection": false, "enable_prompt_collection": false, "redact_pii": false, "access_log_retention_days": 30}`,
                                                +			before.Endpoint),
                                                +		"omitted identity": `{"enable_log_collection": false, "enable_prompt_collection": false, "redact_pii": false, "access_log_retention_days": 30}`,
                                                +	} {
                                                +		rec = f.do(t, http.MethodPut, "/agent-network/settings", body)
                                                +		assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
                                                +			"%s must be rejected: got %d body=%s", name, rec.Code, rec.Body.String())
                                                +	}
                                                +
                                                +	// The rejected updates must not have applied anything — toggles included.
                                                +	rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
                                                +	require.Equal(t, http.StatusOK, rec.Code)
                                                +	var got api.AgentNetworkSettings
                                                +	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
                                                +	assert.Equal(t, before.Endpoint, got.Endpoint, "rejected PUT must not change the endpoint")
                                                +	assert.Equal(t, before.ProxyAddress, got.ProxyAddress, "rejected PUT must not change the proxy address")
                                                +	assert.True(t, got.EnablePromptCollection, "rejected PUT must not apply its toggles")
                                                +
                                                +	// An uppercased echo of the assigned values still names the same host and
                                                +	// must be accepted.
                                                +	rec = f.do(t, http.MethodPut, "/agent-network/settings", fmt.Sprintf(
                                                +		`{"endpoint": %q, "proxy_address": %q, "enable_log_collection": true, "enable_prompt_collection": true, "redact_pii": false, "access_log_retention_days": 30}`,
                                                +		strings.ToUpper(before.Endpoint), strings.ToUpper(before.ProxyAddress)))
                                                +	assert.Equal(t, http.StatusOK, rec.Code,
                                                +		"an uppercased identity echo must be accepted: got %d body=%s", rec.Code, rec.Body.String())
                                                +}
                                                +
                                                +// TestSettingsHandler_PutOmittedRetentionLandsAsZero documents a residual the
                                                +// required-ness of access_log_retention_days does not remove. Marking the field
                                                +// required changes the generated client type from *int to int, so a generated
                                                +// client cannot omit it — but nothing validates OpenAPI required-ness at
                                                +// runtime, so a hand-rolled body without the field still decodes as 0, which
                                                +// the API documents as "keep indefinitely".
                                                +//
                                                +// That is the same latitude the three booleans already have, so it is left
                                                +// consistent rather than special-cased. This test exists to make the gap
                                                +// explicit: if request validation is ever added, this expectation is what
                                                +// changes.
                                                +func TestSettingsHandler_PutOmittedRetentionLandsAsZero(t *testing.T) {
                                                +	f := newAgentNetworkHandlerFixture(t)
                                                +
                                                +	rec := f.do(t, http.MethodPost, "/agent-network/settings",
                                                +		`{"proxy_address": "eu.proxy.netbird.io", "access_log_retention_days": 14}`)
                                                +	require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
                                                +	var before api.AgentNetworkSettings
                                                +	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &before))
                                                +
                                                +	rec = f.do(t, http.MethodPut, "/agent-network/settings", fmt.Sprintf(
                                                +		`{"endpoint": %q, "proxy_address": %q, "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`,
                                                +		before.Endpoint, before.ProxyAddress))
                                                +	require.Equal(t, http.StatusOK, rec.Code, "update PUT must succeed: %s", rec.Body.String())
                                                +
                                                +	var got api.AgentNetworkSettings
                                                +	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
                                                +	require.NotNil(t, got.AccessLogRetentionDays)
                                                +	assert.Equal(t, 0, *got.AccessLogRetentionDays,
                                                +		"a non-conforming body that omits retention still replaces it with the zero value")
                                                +}
                                                +
                                                +// TestSettingsHandler_DeleteBeforeBootstrapIs404 pins that DELETE on an
                                                +// account with no settings row is a 404, mirroring the PUT.
                                                +func TestSettingsHandler_DeleteBeforeBootstrapIs404(t *testing.T) {
                                                +	f := newAgentNetworkHandlerFixture(t)
                                                +
                                                +	rec := f.do(t, http.MethodDelete, "/agent-network/settings", "")
                                                +	assert.Equal(t, http.StatusNotFound, rec.Code,
                                                +		"DELETE on an unbootstrapped account must 404: got %d body=%s", rec.Code, rec.Body.String())
                                                +}
                                                +
                                                +// TestSettingsHandler_DeleteBlockedByProviders pins the first delete guard:
                                                +// while any provider exists for the account, the delete is refused with 412
                                                +// and the row survives. Providers route through the endpoint — the guard
                                                +// keeps DELETE a bootstrap-repair operation rather than a way to abandon a
                                                +// configured gateway.
                                                +func TestSettingsHandler_DeleteBlockedByProviders(t *testing.T) {
                                                +	f := newAgentNetworkHandlerFixture(t)
                                                +
                                                +	rec := f.do(t, http.MethodPost, "/agent-network/settings", `{"proxy_address": "eu.proxy.netbird.io"}`)
                                                +	require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
                                                +	var before api.AgentNetworkSettings
                                                +	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &before))
                                                +
                                                +	f.seedProvider(t, "prov-guard")
                                                +
                                                +	rec = f.do(t, http.MethodDelete, "/agent-network/settings", "")
                                                +	assert.Equal(t, http.StatusPreconditionFailed, rec.Code,
                                                +		"delete with a provider present must be refused: got %d body=%s", rec.Code, rec.Body.String())
                                                +
                                                +	rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
                                                +	require.Equal(t, http.StatusOK, rec.Code)
                                                +	var got api.AgentNetworkSettings
                                                +	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
                                                +	assert.Equal(t, before.Endpoint, got.Endpoint, "the refused delete must leave the row intact")
                                                +}
                                                +
                                                +// TestSettingsHandler_DeleteBlockedByActiveProxy pins the second delete
                                                +// guard: while a proxy is actively serving the endpoint — an active proxy
                                                +// row declaring the endpoint hostname as its cluster address, the dedicated
                                                +// shape — the delete is refused with 412. A proxy that has disconnected no
                                                +// longer blocks: the guard is about a live serving path, not history.
                                                +//
                                                +// The proxy declares its address with mixed casing on purpose: Connect
                                                +// stores the declared address verbatim while the settings row is normalized
                                                +// lowercase, and hostnames are case-insensitive, so the guard must match
                                                +// across the casing difference rather than be sidestepped by it.
                                                +func TestSettingsHandler_DeleteBlockedByActiveProxy(t *testing.T) {
                                                +	f := newAgentNetworkHandlerFixture(t)
                                                +
                                                +	const endpoint = "gw.dedicated.example.com"
                                                +	rec := f.do(t, http.MethodPost, "/agent-network/settings", fmt.Sprintf(`{"endpoint": %q}`, endpoint))
                                                +	require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
                                                +
                                                +	now := time.Now()
                                                +	accountID := testAccountID
                                                +	proxyRow := &rpproxy.Proxy{
                                                +		ID:             "proxy-guard",
                                                +		SessionID:      "sess-1",
                                                +		ClusterAddress: "GW.Dedicated.Example.Com",
                                                +		AccountID:      &accountID,
                                                +		LastSeen:       now,
                                                +		ConnectedAt:    &now,
                                                +		Status:         rpproxy.StatusConnected,
                                                +	}
                                                +	require.NoError(t, f.store.SaveProxy(context.Background(), proxyRow))
                                                +
                                                +	rec = f.do(t, http.MethodDelete, "/agent-network/settings", "")
                                                +	assert.Equal(t, http.StatusPreconditionFailed, rec.Code,
                                                +		"delete with an active proxy at the endpoint must be refused: got %d body=%s", rec.Code, rec.Body.String())
                                                +
                                                +	// Once the proxy disconnects it no longer serves the endpoint, so the
                                                +	// delete goes through.
                                                +	require.NoError(t, f.store.DisconnectProxy(context.Background(), proxyRow.ID, proxyRow.SessionID))
                                                +	rec = f.do(t, http.MethodDelete, "/agent-network/settings", "")
                                                +	assert.Equal(t, http.StatusOK, rec.Code,
                                                +		"delete after the proxy disconnected must succeed: got %d body=%s", rec.Code, rec.Body.String())
                                                +}
                                                +
                                                +// TestSettingsHandler_DeleteReleasesEndpointForFreshBootstrap pins the
                                                +// full-reset semantic that gives replace-on-change clients (e.g. Terraform's
                                                +// RequiresReplace) a real path: with both guards clear the delete succeeds,
                                                +// the account reads as the defaults again, and a fresh bootstrap draws a
                                                +// fresh label. The released hostname is not reserved — a fresh draw may even
                                                +// legitimately re-pick it — so the assertions check the new row's shape, not
                                                +// that the label differs.
                                                +func TestSettingsHandler_DeleteReleasesEndpointForFreshBootstrap(t *testing.T) {
                                                +	f := newAgentNetworkHandlerFixture(t)
                                                +
                                                +	rec := f.do(t, http.MethodPost, "/agent-network/settings",
                                                +		`{"proxy_address": "eu.proxy.netbird.io", "enable_prompt_collection": true}`)
                                                +	require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
                                                +
                                                +	rec = f.do(t, http.MethodDelete, "/agent-network/settings", "")
                                                +	require.Equal(t, http.StatusOK, rec.Code,
                                                +		"delete with both guards clear must succeed: got %d body=%s", rec.Code, rec.Body.String())
                                                +
                                                +	rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
                                                +	require.Equal(t, http.StatusOK, rec.Code)
                                                +	var after api.AgentNetworkSettings
                                                +	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &after))
                                                +	assert.Empty(t, after.Endpoint, "a deleted account must read as unbootstrapped defaults")
                                                +	assert.False(t, after.EnablePromptCollection, "the deleted row's toggles must not linger")
                                                +
                                                +	rec = f.do(t, http.MethodPost, "/agent-network/settings", `{"proxy_address": "eu.proxy.netbird.io"}`)
                                                +	require.Equal(t, http.StatusOK, rec.Code, "re-bootstrap after delete must succeed: %s", rec.Body.String())
                                                +	var second api.AgentNetworkSettings
                                                +	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &second))
                                                +	require.NotEmpty(t, second.Endpoint, "the fresh bootstrap must allocate an endpoint")
                                                +	assert.True(t, strings.HasSuffix(second.Endpoint, ".eu.proxy.netbird.io"),
                                                +		"the fresh endpoint must hang beneath the requested proxy address: %s", second.Endpoint)
                                                +	assert.False(t, second.EnablePromptCollection,
                                                +		"the fresh row must carry bootstrap defaults, not the deleted row's toggles")
                                                +	assert.NotNil(t, second.CreatedAt, "the fresh row is persisted and carries timestamps")
                                                +}
                                                diff --git a/management/internals/modules/agentnetwork/labelgen/adjectives.go b/management/internals/modules/agentnetwork/labelgen/adjectives.go
                                                new file mode 100644
                                                index 000000000..5058c2f1c
                                                --- /dev/null
                                                +++ b/management/internals/modules/agentnetwork/labelgen/adjectives.go
                                                @@ -0,0 +1,37 @@
                                                +package labelgen
                                                +
                                                +// adjectives is the descriptor half of a generated label. It pairs with the
                                                +// noun pool in words.go to form `-` labels, and is kept
                                                +// separate because words.go is almost entirely nouns — drawing both halves
                                                +// from it produced unreadable pairs like "millet-hammock". Entries are
                                                +// lowercase ASCII, 4-12 chars, free of hyphens and digits, screened for
                                                +// offensive/brand/region-specific terms, and disjoint from the noun pool
                                                +// (enforced by TestAdjectives_AreDisjointFromNouns).
                                                +var adjectives = []string{
                                                +	"able", "active", "adept", "agile", "airy", "alert", "amiable", "ample",
                                                +	"ancient", "ardent", "artful", "astute", "balmy", "blithe", "bold", "bonny",
                                                +	"brave", "breezy", "brisk", "bubbly", "buoyant", "bushy", "candid", "canny",
                                                +	"cheery", "chilly", "chipper", "chunky", "civil", "classic", "clever", "comely",
                                                +	"compact", "cordial", "cosmic", "courtly", "crafty", "creamy", "crisp", "cuddly",
                                                +	"curious", "dainty", "dapper", "daring", "dashing", "deft", "dewy", "diligent",
                                                +	"downy", "dreamy", "dulcet", "durable", "dusky", "eager", "earnest", "earthy",
                                                +	"easy", "elated", "elegant", "epic", "fabled", "faithful", "fancy", "fearless",
                                                +	"feisty", "fervent", "fleet", "fluffy", "fond", "frisky", "frosty", "gallant",
                                                +	"genial", "genteel", "gentle", "giddy", "gilded", "glad", "glassy", "gleaming",
                                                +	"glossy", "graceful", "grand", "grainy", "hale", "hardy", "hearty", "hefty",
                                                +	"honest", "hopeful", "humble", "hushed", "immense", "jaunty", "jolly", "jovial",
                                                +	"joyful", "jubilant", "keen", "kindly", "kindred", "lanky", "leafy", "limber",
                                                +	"lively", "lofty", "loyal", "lucent", "lucid", "luminous", "lush", "maroon",
                                                +	"mellow", "merry", "mighty", "mindful", "mirthful", "misty", "modest", "muted",
                                                +	"nifty", "nimble", "noble", "patient", "peaceful", "pearly", "peppy", "perky",
                                                +	"petite", "placid", "playful", "pleasant", "plucky", "plush", "polite", "posh",
                                                +	"prancing", "pristine", "prompt", "proud", "prudent", "quaint", "quick", "quirky",
                                                +	"radiant", "ready", "regal", "restful", "robust", "rosy", "ruddy", "rugged",
                                                +	"sandy", "satin", "saucy", "savvy", "sedate", "serene", "shady", "shiny",
                                                +	"silken", "silky", "sincere", "sleek", "slender", "smart", "smooth", "snappy",
                                                +	"snug", "soaring", "sparkly", "spiffy", "spirited", "sprightly", "spry", "stalwart",
                                                +	"stately", "steady", "sterling", "stoic", "stormy", "stout", "sturdy", "sunlit",
                                                +	"supple", "svelte", "tawny", "tender", "tidy", "timeless", "trusty", "upbeat",
                                                +	"urbane", "valiant", "vast", "vernal", "vibrant", "vintage", "whimsy", "willing",
                                                +	"windy", "winsome", "wintry", "witty", "worthy", "zesty", "zippy",
                                                +}
                                                diff --git a/management/internals/modules/agentnetwork/labelgen/labelgen.go b/management/internals/modules/agentnetwork/labelgen/labelgen.go
                                                new file mode 100644
                                                index 000000000..bd5b0129d
                                                --- /dev/null
                                                +++ b/management/internals/modules/agentnetwork/labelgen/labelgen.go
                                                @@ -0,0 +1,83 @@
                                                +// Package labelgen produces DNS-safe Agent Network subdomain labels.
                                                +package labelgen
                                                +
                                                +import (
                                                +	"fmt"
                                                +	"sort"
                                                +	"sync"
                                                +
                                                +	"github.com/netbirdio/netbird/management/server/util"
                                                +)
                                                +
                                                +// pickAttempts caps the random retries before falling back to the
                                                +// suffixed form. Eight is a soft compromise: with a near-empty taken
                                                +// set the very first pick almost always succeeds; when the wordlist is
                                                +// densely populated the fallback eventually fires anyway.
                                                +const pickAttempts = 8
                                                +
                                                +var (
                                                +	dedupOnce sync.Once
                                                +	uniqWords []string
                                                +)
                                                +
                                                +// uniqueWords returns the wordlist deduplicated and sorted for
                                                +// deterministic exhaustion behaviour. Lazy-built once per process.
                                                +func uniqueWords() []string {
                                                +	dedupOnce.Do(func() {
                                                +		seen := make(map[string]struct{}, len(words))
                                                +		uniqWords = make([]string, 0, len(words))
                                                +		for _, w := range words {
                                                +			if _, ok := seen[w]; ok {
                                                +				continue
                                                +			}
                                                +			seen[w] = struct{}{}
                                                +			uniqWords = append(uniqWords, w)
                                                +		}
                                                +		sort.Strings(uniqWords)
                                                +	})
                                                +	return uniqWords
                                                +}
                                                +
                                                +// PickUnique selects a label not already in `taken`. It tries up to
                                                +// pickAttempts random picks; on exhaustion it scans the deduplicated
                                                +// wordlist for any remaining free entry, and if none is left appends
                                                +// `-` to a random word and returns.
                                                +func PickUnique(taken map[string]struct{}, fallbackSuffix string) string {
                                                +	pool := uniqueWords()
                                                +	if len(pool) == 0 {
                                                +		return fallbackSuffix
                                                +	}
                                                +
                                                +	for i := 0; i < pickAttempts; i++ {
                                                +		w := pool[util.RandIntn(len(pool))]
                                                +		if _, ok := taken[w]; !ok {
                                                +			return w
                                                +		}
                                                +	}
                                                +
                                                +	for _, w := range pool {
                                                +		if _, ok := taken[w]; !ok {
                                                +			return w
                                                +		}
                                                +	}
                                                +
                                                +	w := pool[util.RandIntn(len(pool))]
                                                +	return fmt.Sprintf("%s-%s", w, fallbackSuffix)
                                                +}
                                                +
                                                +// PickTuple returns an adjective-noun label such as "brave-otter". It is still
                                                +// a single DNS label.
                                                +//
                                                +// Unlike PickUnique it takes no `taken` set and has no fallback suffix. The
                                                +// noun pool holds 857 entries, which is ample per cluster but a hard ceiling
                                                +// once labels must be unique across one shared zone; pairing an adjective with
                                                +// a noun spans len(adjectives) * 857 instead. Uniqueness is enforced by a
                                                +// database constraint and retried by the caller, rather than guessed from a
                                                +// pre-read set that a concurrent allocation can invalidate.
                                                +func PickTuple() string {
                                                +	nouns := uniqueWords()
                                                +	if len(nouns) == 0 || len(adjectives) == 0 {
                                                +		return ""
                                                +	}
                                                +	return adjectives[util.RandIntn(len(adjectives))] + "-" + nouns[util.RandIntn(len(nouns))]
                                                +}
                                                diff --git a/management/internals/modules/agentnetwork/labelgen/labelgen_test.go b/management/internals/modules/agentnetwork/labelgen/labelgen_test.go
                                                new file mode 100644
                                                index 000000000..dda0f09ba
                                                --- /dev/null
                                                +++ b/management/internals/modules/agentnetwork/labelgen/labelgen_test.go
                                                @@ -0,0 +1,161 @@
                                                +package labelgen
                                                +
                                                +import (
                                                +	"slices"
                                                +	"strings"
                                                +	"testing"
                                                +
                                                +	"github.com/stretchr/testify/assert"
                                                +	"github.com/stretchr/testify/require"
                                                +)
                                                +
                                                +// TestPickUnique_ReturnsWordFromPool confirms a pick against an empty
                                                +// taken set is always drawn verbatim from the wordlist.
                                                +func TestPickUnique_ReturnsWordFromPool(t *testing.T) {
                                                +	got := PickUnique(map[string]struct{}{}, "abcd")
                                                +
                                                +	assert.True(t, slices.Contains(uniqueWords(), got), "Pick %q must be drawn from the wordlist", got)
                                                +}
                                                +
                                                +// TestPickUnique_AvoidsTakenWordsWhenMostAreReserved seeds taken with
                                                +// every word in the pool except a handful and confirms PickUnique
                                                +// finds one of the remaining free entries instead of returning the
                                                +// fallback form.
                                                +func TestPickUnique_AvoidsTakenWordsWhenMostAreReserved(t *testing.T) {
                                                +	pool := uniqueWords()
                                                +	require.NotEmpty(t, pool, "wordlist must be populated for the test to mean anything")
                                                +
                                                +	free := map[string]struct{}{
                                                +		pool[0]:           {},
                                                +		pool[len(pool)/2]: {},
                                                +		pool[len(pool)-1]: {},
                                                +	}
                                                +
                                                +	taken := make(map[string]struct{}, len(pool))
                                                +	for _, w := range pool {
                                                +		if _, ok := free[w]; ok {
                                                +			continue
                                                +		}
                                                +		taken[w] = struct{}{}
                                                +	}
                                                +
                                                +	got := PickUnique(taken, "abcd")
                                                +
                                                +	_, isFree := free[got]
                                                +	assert.True(t, isFree, "PickUnique must return one of the free words; got %q", got)
                                                +	assert.NotContains(t, got, "-", "Free pick must not be the suffix fallback form")
                                                +}
                                                +
                                                +// TestPickUnique_FallsBackWhenAllReserved exhausts the pool and
                                                +// confirms PickUnique appends the supplied suffix instead of
                                                +// returning a duplicate.
                                                +func TestPickUnique_FallsBackWhenAllReserved(t *testing.T) {
                                                +	pool := uniqueWords()
                                                +
                                                +	taken := make(map[string]struct{}, len(pool))
                                                +	for _, w := range pool {
                                                +		taken[w] = struct{}{}
                                                +	}
                                                +
                                                +	got := PickUnique(taken, "abcd")
                                                +
                                                +	assert.True(t, strings.HasSuffix(got, "-abcd"), "Exhausted pool must produce -; got %q", got)
                                                +
                                                +	prefix := strings.TrimSuffix(got, "-abcd")
                                                +	found := false
                                                +	for _, w := range pool {
                                                +		if w == prefix {
                                                +			found = true
                                                +			break
                                                +		}
                                                +	}
                                                +	assert.True(t, found, "Fallback prefix must be drawn from the wordlist; got %q", prefix)
                                                +}
                                                +
                                                +// TestUniqueWords_DropsDuplicates guards against authoring slips in
                                                +// words.go: every entry must be unique and DNS-safe.
                                                +func TestUniqueWords_DropsDuplicates(t *testing.T) {
                                                +	pool := uniqueWords()
                                                +	seen := make(map[string]struct{}, len(pool))
                                                +	for _, w := range pool {
                                                +		_, dup := seen[w]
                                                +		assert.False(t, dup, "Duplicate entry %q in deduplicated pool", w)
                                                +		seen[w] = struct{}{}
                                                +		assert.GreaterOrEqual(t, len(w), 4, "Word %q is shorter than 4 chars", w)
                                                +		assert.LessOrEqual(t, len(w), 12, "Word %q is longer than 12 chars", w)
                                                +		for _, r := range w {
                                                +			ok := r >= 'a' && r <= 'z'
                                                +			assert.True(t, ok, "Word %q contains non-lowercase-ASCII rune %q", w, r)
                                                +		}
                                                +	}
                                                +	assert.GreaterOrEqual(t, len(pool), 500, "Pool must contain at least 500 unique words")
                                                +}
                                                +
                                                +// TestPickTuple_ShapeAndPoolMembership locks the wire-visible shape: an
                                                +// adjective and a noun, each from its own pool, joined by a single hyphen so
                                                +// the result stays one DNS label.
                                                +func TestPickTuple_ShapeAndPoolMembership(t *testing.T) {
                                                +	nouns := uniqueWords()
                                                +	inNouns := make(map[string]struct{}, len(nouns))
                                                +	for _, w := range nouns {
                                                +		inNouns[w] = struct{}{}
                                                +	}
                                                +	inAdjectives := make(map[string]struct{}, len(adjectives))
                                                +	for _, a := range adjectives {
                                                +		inAdjectives[a] = struct{}{}
                                                +	}
                                                +
                                                +	for i := 0; i < 200; i++ {
                                                +		got := PickTuple()
                                                +
                                                +		parts := strings.Split(got, "-")
                                                +		require.Len(t, parts, 2, "PickTuple must produce exactly two hyphen-joined words; got %q", got)
                                                +
                                                +		_, adjOK := inAdjectives[parts[0]]
                                                +		assert.True(t, adjOK, "First half must be an adjective; %q not in adjectives (from %q)", parts[0], got)
                                                +		_, nounOK := inNouns[parts[1]]
                                                +		assert.True(t, nounOK, "Second half must be a noun; %q not in words (from %q)", parts[1], got)
                                                +
                                                +		assert.LessOrEqual(t, len(got), 63, "Label must fit a DNS label; got %q (%d chars)", got, len(got))
                                                +	}
                                                +}
                                                +
                                                +// TestAdjectives_AreDisjointFromNouns keeps the namespace a clean product and
                                                +// prevents nonsense like "azure-azure": a handful of the noun pool's entries
                                                +// are adjectival, and any overlap would let the same word land on both sides.
                                                +func TestAdjectives_AreDisjointFromNouns(t *testing.T) {
                                                +	nouns := make(map[string]struct{}, len(uniqueWords()))
                                                +	for _, w := range uniqueWords() {
                                                +		nouns[w] = struct{}{}
                                                +	}
                                                +	for _, a := range adjectives {
                                                +		_, clash := nouns[a]
                                                +		assert.False(t, clash, "Adjective %q also appears in the noun pool; remove it from one list", a)
                                                +	}
                                                +}
                                                +
                                                +// TestAdjectives_AreDNSSafeAndDeduplicated mirrors the curation contract stated
                                                +// in words.go: lowercase ASCII, 4-12 chars, no digits or hyphens, no repeats.
                                                +func TestAdjectives_AreDNSSafeAndDeduplicated(t *testing.T) {
                                                +	seen := make(map[string]struct{}, len(adjectives))
                                                +	for _, a := range adjectives {
                                                +		_, dup := seen[a]
                                                +		assert.False(t, dup, "Duplicate adjective %q", a)
                                                +		seen[a] = struct{}{}
                                                +
                                                +		assert.Regexp(t, `^[a-z]{4,12}$`, a, "Adjective %q must be 4-12 lowercase ASCII letters", a)
                                                +	}
                                                +	assert.Greater(t, len(adjectives), 150, "Adjective pool too small to give a useful namespace")
                                                +}
                                                +
                                                +// TestPickTuple_SpansALargeNamespace guards the reason we moved to tuples: a
                                                +// single-word pool caps the GLOBAL namespace at 857. Drawing many tuples must
                                                +// yield overwhelmingly distinct values.
                                                +func TestPickTuple_SpansALargeNamespace(t *testing.T) {
                                                +	seen := make(map[string]struct{}, 2000)
                                                +	for i := 0; i < 2000; i++ {
                                                +		seen[PickTuple()] = struct{}{}
                                                +	}
                                                +	assert.Greater(t, len(seen), 1900,
                                                +		"2000 draws should be nearly all distinct across a ~200k namespace; got %d unique", len(seen))
                                                +}
                                                diff --git a/management/internals/modules/agentnetwork/labelgen/words.go b/management/internals/modules/agentnetwork/labelgen/words.go
                                                new file mode 100644
                                                index 000000000..2028ff23d
                                                --- /dev/null
                                                +++ b/management/internals/modules/agentnetwork/labelgen/words.go
                                                @@ -0,0 +1,136 @@
                                                +// Package labelgen produces DNS-safe Agent Network subdomain labels.
                                                +//
                                                +// The wordlist below is a curated subset drawn from public-domain
                                                +// nature / common-noun pools (e.g. EFF's diceware lists). Every entry
                                                +// is lowercase ASCII, 4–12 chars, no hyphens, no digits, and was
                                                +// hand-checked to avoid offensive, brand, or region-specific terms.
                                                +package labelgen
                                                +
                                                +// words is the pool PickUnique selects from. The slice is intentionally
                                                +// not sorted — random picks distribute across the list naturally.
                                                +var words = []string{
                                                +	"acorn", "adobe", "agate", "alder", "almond", "alpine", "amber", "amethyst",
                                                +	"anchor", "antler", "apple", "apricot", "arcade", "arctic", "arrow", "ashen",
                                                +	"aspen", "atlas", "atom", "aurora", "autumn", "azure",
                                                +	"badger", "bamboo", "banana", "banjo", "barley", "barn", "basalt", "basil",
                                                +	"basin", "bayou", "beach", "beacon", "beaver", "beech", "beetle", "berry",
                                                +	"birch", "bison", "blossom", "blue", "bobcat", "bonsai", "boulder", "branch",
                                                +	"brass", "breeze", "bridge", "bright", "brook", "broom", "brown", "buffalo",
                                                +	"bumble", "burrow", "butter", "button",
                                                +	"cabin", "cactus", "calm", "camel", "campfire", "canary", "candle", "canoe",
                                                +	"canyon", "cardinal", "carrot", "cascade", "castle", "cedar", "celery", "cello",
                                                +	"cement", "cherry", "chestnut", "chime", "cinnamon", "cinder", "citron", "clay",
                                                +	"clear", "cliff", "clock", "cloud", "clover", "coast", "cobalt", "cobble",
                                                +	"cocoa", "coffee", "comet", "compass", "copper", "coral", "corner", "cosmos",
                                                +	"cotton", "cougar", "country", "coyote", "cove", "crane", "crater", "creek",
                                                +	"crescent", "crimson", "crocus", "crystal", "cypress",
                                                +	"daffodil", "dahlia", "daisy", "dawn", "deer", "delta", "denim", "desert",
                                                +	"dewdrop", "diamond", "dolphin", "doodle", "dove", "dragon", "drift", "drop",
                                                +	"dune", "dusk", "dusty",
                                                +	"eagle", "earth", "echo", "elder", "elkhorn", "ember", "emerald", "emperor",
                                                +	"evergreen", "evening",
                                                +	"falcon", "fawn", "feather", "fern", "fiddle", "field", "fiesta", "finch",
                                                +	"firepit", "firefly", "fjord", "flame", "flax", "fleece", "flint", "floral",
                                                +	"flower", "flute", "foal", "foggy", "forest", "fountain", "foxglove", "fresh",
                                                +	"frost", "fuchsia", "fudge",
                                                +	"gable", "galaxy", "garden", "garnet", "gazelle", "geode", "geyser", "ginger",
                                                +	"glacier", "glade", "glass", "glow", "gold", "goose", "gorge", "gourd",
                                                +	"granite", "grape", "grass", "gravel", "grayling", "greenery", "grizzly", "grove",
                                                +	"gull", "gumdrop", "gust",
                                                +	"hammock", "harbor", "harvest", "hawk", "hazel", "heather", "hedge", "heron",
                                                +	"hibiscus", "hickory", "hideaway", "highland", "hill", "hive", "hollow", "honey",
                                                +	"hopper", "horizon", "hummingbird", "husky",
                                                +	"iceberg", "indigo", "iris", "island", "ivory", "ivybush",
                                                +	"jade", "jasmine", "jasper", "jaybird", "jelly", "jewel", "jonquil", "journey",
                                                +	"juniper", "jupiter", "jute",
                                                +	"kale", "kangaroo", "kayak", "kelp", "kestrel", "kettle", "khaki", "kindling",
                                                +	"kingfisher", "kiwi", "knapweed", "koala",
                                                +	"lagoon", "lake", "lantern", "larch", "lark", "laurel", "lava", "lavender",
                                                +	"leaf", "lemon", "lichen", "light", "lilac", "lily", "lime", "limestone",
                                                +	"linden", "linen", "lion", "lobster", "locust", "loon", "lotus", "lumber",
                                                +	"lunar", "lupine", "lynx",
                                                +	"madrone", "magenta", "magnolia", "mahogany", "mallow", "mango", "manor", "maple",
                                                +	"marble", "marigold", "marina", "marlin", "marsh", "mauve", "meadow", "melody",
                                                +	"melon", "merlin", "metal", "midnight", "milk", "millet", "mineral", "mint",
                                                +	"mirror", "mist", "mitten", "molasses", "moon", "moose", "morning", "moss",
                                                +	"mountain", "mulberry", "muscat", "mustard",
                                                +	"narwhal", "navy", "nectar", "needle", "nest", "nettle", "newt", "nightfall",
                                                +	"noon", "nook", "north", "nova", "nutmeg",
                                                +	"oaken", "oasis", "oatmeal", "ocean", "ochre", "octagon", "olive", "onyx",
                                                +	"opal", "orange", "orbit", "orchard", "orchid", "oregano", "orion", "osprey",
                                                +	"otter", "outpost", "owlet", "oyster",
                                                +	"painter", "palace", "palm", "pansy", "panther", "papaya", "paprika", "parsley",
                                                +	"partridge", "passage", "pastel", "patio", "peach", "peacock", "pear", "pearl",
                                                +	"pebble", "pecan", "pelican", "penguin", "peony", "pepper", "perch", "peridot",
                                                +	"pewter", "phoenix", "pier", "pillar", "pine", "pineapple", "pinto", "piper",
                                                +	"pistachio", "plain", "planet", "plateau", "platinum", "plum", "plume", "polar",
                                                +	"pollen", "pond", "poplar", "poppy", "porcelain", "portal", "portrait", "potato",
                                                +	"prairie", "primrose", "prism", "puffin", "pumpkin",
                                                +	"quail", "quartz", "quaver", "quill", "quince", "quinoa",
                                                +	"rabbit", "raccoon", "radish", "rain", "rainbow", "raindrop", "rapids", "raspberry",
                                                +	"raven", "ravine", "redwood", "reed", "reef", "ridge", "river", "robin",
                                                +	"rocket", "rubyred", "rose", "rosemary", "rosewood", "ruffle", "rugby", "russet",
                                                +	"rustic", "ryefield",
                                                +	"saffron", "sage", "salmon", "sand", "sandstone", "sapphire", "savanna", "scarlet",
                                                +	"scout", "seal", "season", "seaweed", "sequoia", "shadow", "shamrock", "shell",
                                                +	"sherbet", "shore", "silver", "siskin", "skybloom", "skyline", "sleet", "smoke",
                                                +	"snail", "snapdragon", "snow", "snowflake", "snowy", "solar", "song", "sonic",
                                                +	"sorrel", "south", "sparkle", "sparrow", "spice", "spider", "spinach", "spire",
                                                +	"spring", "sprout", "spruce", "squirrel", "starfish", "starlight", "stoat", "stone",
                                                +	"stork", "storm", "stream", "studio", "summer", "sunbeam", "sundew", "sunny",
                                                +	"sunrise", "sunset", "swallow", "swan", "sweet", "sycamore",
                                                +	"tangelo", "tangerine", "tansy", "taupe", "teak", "teal", "thicket", "thistle",
                                                +	"thrush", "thunder", "tide", "tiger", "tinder", "topaz", "torch", "tortoise",
                                                +	"tower", "trail", "tranquil", "tundra", "tulip", "turquoise", "turtle", "twig",
                                                +	"twilight",
                                                +	"umber", "uplands",
                                                +	"valley", "vanilla", "velvet", "venus", "verdant", "verdigris", "vermilion", "violet",
                                                +	"vista", "vivid", "volcano", "vortex",
                                                +	"walnut", "warbler", "watercress", "waterfall", "wave", "waxwing", "weasel", "westwind",
                                                +	"whale", "whisker", "whisper", "wicker", "wildwood", "willow", "winter", "wisp",
                                                +	"wisteria", "wolf", "wombat", "woodland", "woolly", "wren", "wreath",
                                                +	"yarrow", "yellow", "yewtree", "yodel",
                                                +	"zebra", "zenith", "zephyr", "zinnia",
                                                +	"alabaster", "alfalfa", "almanac", "anise", "antelope", "arbor", "arena", "armadillo",
                                                +	"avocet", "azalea", "balsam", "bayou", "beacon", "blizzard", "bluebell", "bluebird",
                                                +	"bluejay", "bobolink", "borage", "boreal", "buckeye", "buckthorn", "buttercup",
                                                +	"cabana", "calico", "canopy", "caraway", "cardamom", "cattail", "celadon", "centaur",
                                                +	"chambray", "chamois", "champlain", "chestnuts", "chickadee", "chinook", "chipmunk", "cinnabar",
                                                +	"cirrus", "citrine", "clematis", "copperhead",
                                                +	"crocodile", "currant", "cuttlebone", "daffy", "dapple", "delphinium", "dervish", "diamondback",
                                                +	"dogwood", "dolphins", "dragonfly", "driftwood", "dusk", "dustpan", "ebony", "edelweiss",
                                                +	"emperor", "endive", "estuary", "everglade", "fairway", "feldspar", "fennel", "fieldstone",
                                                +	"firebrand", "firefly", "fireweed", "firework", "flagstone", "fossil", "frostbite", "galleon",
                                                +	"gardener", "geranium", "gingko", "ginseng", "goldfish", "goldfinch", "goldenrod", "graphite",
                                                +	"greenfinch", "guppy", "haiku", "halibut", "hammerhead", "harbinger", "harvest", "hatchling",
                                                +	"havana", "hawthorn", "hazelnut", "heartwood", "henna", "heron", "highrise", "homestead",
                                                +	"honeycomb", "honeydew", "horseshoe", "hyacinth", "iceland", "icicle", "indigobird", "ironwood",
                                                +	"jacaranda", "jamboree", "javelina", "jellyfish", "junebug", "kaleido", "kayaker", "kerchief",
                                                +	"keystone", "kingdom", "labrador", "lacewing", "ladybug", "lakeside", "lamplight", "leopard",
                                                +	"lighthouse", "lilypad", "lullaby", "magnet", "mahonia", "mandolin", "manzanita", "maraschino",
                                                +	"mariner", "marsupial", "mastodon", "matterhorn", "mayflower", "mayfly", "meadowlark", "merlot",
                                                +	"meteor", "midshipman", "millpond", "mimosa", "minnow", "mockingbird", "molten", "monarch",
                                                +	"monsoon", "moondust", "moonlight", "moorland", "morning", "mossland", "mountain", "mulch",
                                                +	"narcissus", "nautilus", "nettlebush", "northstar", "nuthatch", "obsidian", "okra", "olivine",
                                                +	"opalescent", "orchidea", "orchard", "ornament", "outrigger", "oxalis", "paddler", "paintbrush",
                                                +	"papyrus", "paradise", "pasture", "patchwork", "pathway", "peridot", "periwinkle", "petalbloom",
                                                +	"petrel", "petunia", "phlox", "pikeperch", "pinecone", "pioneer", "pipevine", "platypus",
                                                +	"pomelo", "pondweed", "porpoise", "powder", "promise", "puddle", "pumice", "puzzle",
                                                +	"quetzal", "quicksilver", "raccoon", "ragwort", "rainforest", "ramble", "rapid", "rascal",
                                                +	"raspberry", "redbud", "redfern", "redpoll", "reedling", "ringtail", "riverbed", "riverbird",
                                                +	"riverstone", "rockcress", "roebuck", "rosebay", "rosehip", "rosemary", "rowan", "rumble",
                                                +	"runaway", "rustler", "sagebrush", "sailcloth", "salamander", "salsify", "samphire", "sandbar",
                                                +	"sanddollar", "sandpiper", "santolina", "sapodilla", "sassafras", "scallion", "schooner", "seafoam",
                                                +	"seafrost", "seagrass", "seahorse", "seaport", "seashell", "seaspray", "shamble", "shimmer",
                                                +	"shoreline", "silkmoth", "silverfox", "skylark", "snapdragon", "snowberry", "snowdrop", "snowfall",
                                                +	"snowmelt", "softwood", "songbird", "sorghum", "southwind", "speedwell", "spinnaker", "spruce",
                                                +	"starlight", "starling", "stormcloud", "summit", "sundance", "sundew", "sundial", "sunflower",
                                                +	"surface", "swallowtail", "sweetcorn", "sycamore", "tabletop", "tamarack", "tamarind", "tangerine",
                                                +	"tarragon", "telescope", "thicket", "thrasher", "thunder", "thyme", "tideline", "timberland",
                                                +	"tinderbox", "topiary", "torchwood", "totem", "tradewind", "treasure", "tremolo", "trinket",
                                                +	"trumpetvine", "tugboat", "tundra", "turnstone", "underbrush", "vagabond", "valerian", "vanilla",
                                                +	"velveteen", "vermilion", "vinca", "vineyard", "violet", "voyager", "wagonwheel", "walnutwood",
                                                +	"watermark", "watershed", "waterway", "wavefront", "westerly", "whaleback", "whetstone", "wicker",
                                                +	"wildbloom", "wildflower", "wilderness", "windsong", "windward", "winterberry", "woodbine", "woodfern",
                                                +	"woodland", "woodthrush", "woolgrass", "yellowfin", "zenithal", "zucchini",
                                                +}
                                                diff --git a/management/internals/modules/agentnetwork/manager.go b/management/internals/modules/agentnetwork/manager.go
                                                new file mode 100644
                                                index 000000000..a6ff44c54
                                                --- /dev/null
                                                +++ b/management/internals/modules/agentnetwork/manager.go
                                                @@ -0,0 +1,1404 @@
                                                +package agentnetwork
                                                +
                                                +import (
                                                +	"context"
                                                +	"errors"
                                                +	"fmt"
                                                +	"slices"
                                                +	"strings"
                                                +	"sync"
                                                +	"time"
                                                +
                                                +	log "github.com/sirupsen/logrus"
                                                +
                                                +	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/labelgen"
                                                +	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/modeldiscovery"
                                                +	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
                                                +	"github.com/netbirdio/netbird/management/internals/modules/permissions"
                                                +	"github.com/netbirdio/netbird/management/internals/modules/permissions/modules"
                                                +	"github.com/netbirdio/netbird/management/internals/modules/permissions/operations"
                                                +	"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
                                                +	"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/store"
                                                +	"github.com/netbirdio/netbird/shared/management/status"
                                                +)
                                                +
                                                +// ensureSessionKeys mints an ed25519 session keypair on the provider
                                                +// when one is missing. Idempotent: skips when both fields are already
                                                +// populated (e.g. update or migrated rows). The keys are used by the
                                                +// synthesised reverse-proxy service to sign / verify session JWTs
                                                +// after a successful OIDC handshake.
                                                +func ensureSessionKeys(p *types.Provider) error {
                                                +	if p.SessionPrivateKey != "" && p.SessionPublicKey != "" {
                                                +		return nil
                                                +	}
                                                +	pair, err := sessionkey.GenerateKeyPair()
                                                +	if err != nil {
                                                +		return fmt.Errorf("generate provider session keys: %w", err)
                                                +	}
                                                +	p.SessionPrivateKey = pair.PrivateKey
                                                +	p.SessionPublicKey = pair.PublicKey
                                                +	return nil
                                                +}
                                                +
                                                +// Manager governs the lifecycle of Agent Network providers and policies.
                                                +type Manager interface {
                                                +	GetAllProviders(ctx context.Context, accountID, userID string) ([]*types.Provider, error)
                                                +	GetProvider(ctx context.Context, accountID, userID, providerID string) (*types.Provider, error)
                                                +	CreateProvider(ctx context.Context, userID string, provider *types.Provider) (*types.Provider, error)
                                                +	UpdateProvider(ctx context.Context, userID string, provider *types.Provider) (*types.Provider, error)
                                                +	DeleteProvider(ctx context.Context, accountID, userID, providerID string) error
                                                +	DiscoverProviderModels(ctx context.Context, accountID, userID string, req modeldiscovery.Request, recordID string) ([]modeldiscovery.Model, error)
                                                +
                                                +	GetAllPolicies(ctx context.Context, accountID, userID string) ([]*types.Policy, error)
                                                +	GetPolicy(ctx context.Context, accountID, userID, policyID string) (*types.Policy, error)
                                                +	CreatePolicy(ctx context.Context, userID string, policy *types.Policy) (*types.Policy, error)
                                                +	UpdatePolicy(ctx context.Context, userID string, policy *types.Policy) (*types.Policy, error)
                                                +	DeletePolicy(ctx context.Context, accountID, userID, policyID string) error
                                                +
                                                +	GetAllGuardrails(ctx context.Context, accountID, userID string) ([]*types.Guardrail, error)
                                                +	GetGuardrail(ctx context.Context, accountID, userID, guardrailID string) (*types.Guardrail, error)
                                                +	CreateGuardrail(ctx context.Context, userID string, guardrail *types.Guardrail) (*types.Guardrail, error)
                                                +	UpdateGuardrail(ctx context.Context, userID string, guardrail *types.Guardrail) (*types.Guardrail, error)
                                                +	DeleteGuardrail(ctx context.Context, accountID, userID, guardrailID string) error
                                                +
                                                +	GetAllBudgetRules(ctx context.Context, accountID, userID string) ([]*types.AccountBudgetRule, error)
                                                +	GetBudgetRule(ctx context.Context, accountID, userID, ruleID string) (*types.AccountBudgetRule, error)
                                                +	CreateBudgetRule(ctx context.Context, userID string, rule *types.AccountBudgetRule) (*types.AccountBudgetRule, error)
                                                +	UpdateBudgetRule(ctx context.Context, userID string, rule *types.AccountBudgetRule) (*types.AccountBudgetRule, error)
                                                +	DeleteBudgetRule(ctx context.Context, accountID, userID, ruleID string) error
                                                +
                                                +	GetSettings(ctx context.Context, accountID, userID string) (*types.Settings, error)
                                                +	CreateSettings(ctx context.Context, userID string, settings *types.Settings, proxyAddress, endpoint string) (*types.Settings, error)
                                                +	UpdateSettings(ctx context.Context, userID string, settings *types.Settings) (*types.Settings, error)
                                                +	DeleteSettings(ctx context.Context, accountID, userID string) error
                                                +
                                                +	ListConsumption(ctx context.Context, accountID, userID string) ([]*types.Consumption, error)
                                                +	ListAccessLogs(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLog, int64, error)
                                                +	ListAccessLogSessions(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLogSession, int64, error)
                                                +	GetUsageOverview(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter, granularity types.UsageGranularity) ([]*types.AgentNetworkUsageBucket, error)
                                                +	StartAccessLogCleanup(ctx context.Context, cleanupIntervalHours int)
                                                +	RecordConsumption(ctx context.Context, accountID string, kind types.ConsumptionDimension, dimID string, windowSeconds, tokensIn, tokensOut int64, costUSD float64) error
                                                +	RecordAccountBudgetUsage(ctx context.Context, accountID, userID string, groupIDs []string, tokensIn, tokensOut int64, costUSD float64) error
                                                +	RecordUsage(ctx context.Context, in RecordUsageInput) error
                                                +	SelectPolicyForRequest(ctx context.Context, in PolicySelectionInput) (*PolicySelectionResult, error)
                                                +
                                                +	// GetAgentConfigForUser backs the self-service agent-config endpoint.
                                                +	// Caller-scoped, so it skips the role permission gate; see
                                                +	// the implementation. The caller's own usage and requests come
                                                +	// through GetUsageOverview / ListAccessLogs, which self-scope when
                                                +	// the account-wide grant is missing.
                                                +	GetAgentConfigForUser(ctx context.Context, accountID, userID string) (*types.AgentConfig, error)
                                                +}
                                                +
                                                +// PolicySelectionInput is the per-request selection envelope. The
                                                +// proxy populates it from CapturedData (account, user, groups) plus
                                                +// the provider llm_router resolved and the model it extracted.
                                                +type PolicySelectionInput struct {
                                                +	AccountID  string
                                                +	UserID     string
                                                +	GroupIDs   []string
                                                +	ProviderID string
                                                +	// Model is the already-normalised upstream model id the proxy extracted
                                                +	// (parser strips Bedrock region/version, Vertex @version), so a
                                                +	// case-insensitive compare suffices. Empty = undetermined → not permitted
                                                +	// (fail closed).
                                                +	Model string
                                                +}
                                                +
                                                +// PolicySelectionResult names the policy that "pays" for this request
                                                +// plus the deny envelope when every applicable policy has exhausted
                                                +// every cap. AttributionGroupID is the lowest group id (string sort)
                                                +// of caller_groups ∩ selected_policy.source_groups; empty when no
                                                +// group dimension applies. WindowSeconds is the chosen policy's
                                                +// effective window length in seconds (token_limit's wins when both
                                                +// halves are enabled with mismatched windows; budget_limit's
                                                +// otherwise; 0 when no caps are configured at all).
                                                +type PolicySelectionResult struct {
                                                +	Allow              bool
                                                +	SelectedPolicyID   string
                                                +	AttributionGroupID string
                                                +	WindowSeconds      int64
                                                +	DenyCode           string
                                                +	DenyReason         string
                                                +}
                                                +
                                                +type managerImpl struct {
                                                +	store              store.Store
                                                +	accountManager     account.Manager
                                                +	permissionsManager permissions.Manager
                                                +	proxyController    proxy.Controller
                                                +
                                                +	// modelDiscovery queries vendors for the models a credential can reach.
                                                +	// An interface rather than the concrete client because it is now on a
                                                +	// write path: the credential check runs inside CreateProvider and
                                                +	// UpdateProvider, so every test that saves a provider would otherwise
                                                +	// reach a vendor over the network to do it.
                                                +	//
                                                +	// One instance serves every request for the process's lifetime, so its
                                                +	// fields must stay read-only after construction: lazy initialisation
                                                +	// inside Fetch or httpClient would race across request goroutines.
                                                +	modelDiscovery ModelLister
                                                +
                                                +	// reconcileCache holds the last set of synthesised proxy mappings
                                                +	// per account, each paired with the proxy that served it, so a change
                                                +	// of serving proxy can be diffed without re-deriving it.
                                                +	reconcileMu    sync.Mutex
                                                +	reconcileCache map[string]map[string]syntheticMapping
                                                +}
                                                +
                                                +// ManagerOption replaces a manager dependency at construction. Production
                                                +// passes none; each option exists for something a test cannot let run for
                                                +// real.
                                                +type ManagerOption func(*managerImpl)
                                                +
                                                +// WithModelLister replaces the vendor call behind the provider credential
                                                +// check. A test that saves a provider needs this — the check runs inside
                                                +// CreateProvider and UpdateProvider, so the write path reaches a vendor
                                                +// without it.
                                                +func WithModelLister(lister ModelLister) ManagerOption {
                                                +	return func(m *managerImpl) { m.modelDiscovery = lister }
                                                +}
                                                +
                                                +// NewManager constructs the persistent Agent Network manager. The
                                                +// manager persists provider/policy/guardrail configuration and, on
                                                +// every mutation, reconciles the in-memory synthesised reverse-proxy
                                                +// services with the proxy cluster via proxyController. Pass nil for
                                                +// proxyController to disable the reconcile push (useful in tests).
                                                +func NewManager(
                                                +	store store.Store,
                                                +	permissionsManager permissions.Manager,
                                                +	accountManager account.Manager,
                                                +	proxyController proxy.Controller,
                                                +	opts ...ManagerOption,
                                                +) Manager {
                                                +	m := &managerImpl{
                                                +		store:              store,
                                                +		accountManager:     accountManager,
                                                +		permissionsManager: permissionsManager,
                                                +		proxyController:    proxyController,
                                                +		modelDiscovery:     &modeldiscovery.Client{},
                                                +		reconcileCache:     make(map[string]map[string]syntheticMapping),
                                                +	}
                                                +	for _, opt := range opts {
                                                +		opt(m)
                                                +	}
                                                +	return m
                                                +}
                                                +
                                                +// GetAllProviders returns the account's providers for callers holding the
                                                +// providers read grant (connection config redacted unless they can also
                                                +// update). A caller without the grant self-scopes instead of being denied
                                                +// — mirroring the usage and log endpoints: they get the providers their
                                                +// own policies authorize, redacted to the display surface, which is what
                                                +// feeds the dashboard's provider filter for plain users.
                                                +func (m *managerImpl) GetAllProviders(ctx context.Context, accountID, userID string) ([]*types.Provider, error) {
                                                +	ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Read)
                                                +	if err != nil {
                                                +		return nil, status.NewPermissionValidationError(err)
                                                +	}
                                                +	if !ok {
                                                +		return m.callerScopedProviders(ctx, accountID, userID)
                                                +	}
                                                +	providers, err := m.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID)
                                                +	if err != nil {
                                                +		return nil, err
                                                +	}
                                                +	return m.redactProvidersForViewer(ctx, accountID, userID, providers)
                                                +}
                                                +
                                                +// GetProvider self-scopes like GetAllProviders: a caller without the read
                                                +// grant may fetch a provider their own policies authorize (redacted), and
                                                +// gets the same not-found answer for any other id — an out-of-scope
                                                +// provider must be indistinguishable from a nonexistent one.
                                                +func (m *managerImpl) GetProvider(ctx context.Context, accountID, userID, providerID string) (*types.Provider, error) {
                                                +	ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Read)
                                                +	if err != nil {
                                                +		return nil, status.NewPermissionValidationError(err)
                                                +	}
                                                +	if !ok {
                                                +		scoped, err := m.callerScopedProviders(ctx, accountID, userID)
                                                +		if err != nil {
                                                +			return nil, err
                                                +		}
                                                +		for _, p := range scoped {
                                                +			if p.ID == providerID {
                                                +				return p, nil
                                                +			}
                                                +		}
                                                +		return nil, status.NewAgentNetworkProviderNotFoundError(providerID)
                                                +	}
                                                +	provider, err := m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, providerID)
                                                +	if err != nil {
                                                +		return nil, err
                                                +	}
                                                +	redacted, err := m.redactProvidersForViewer(ctx, accountID, userID, []*types.Provider{provider})
                                                +	if err != nil {
                                                +		return nil, err
                                                +	}
                                                +	return redacted[0], nil
                                                +}
                                                +
                                                +// callerScopedProviders returns the providers the caller's own policies
                                                +// authorize — the same selection the self-service setup answer and the
                                                +// proxy's routing derive from — each reduced to the display surface. No
                                                +// role permission is needed: the answer is scoped strictly to the caller,
                                                +// and a caller outside every policy gets an empty list, indistinguishable
                                                +// from an account with nothing configured.
                                                +func (m *managerImpl) callerScopedProviders(ctx context.Context, accountID, userID string) ([]*types.Provider, error) {
                                                +	user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
                                                +	if err != nil {
                                                +		return nil, fmt.Errorf("get user: %w", err)
                                                +	}
                                                +	authorized, applicable, err := m.authorizedProvidersForGroups(ctx, accountID, user.AutoGroups)
                                                +	if err != nil {
                                                +		return nil, err
                                                +	}
                                                +	var guardrailsByID map[string]*types.Guardrail
                                                +	if anyPolicyHasGuardrails(applicable) {
                                                +		guardrailsByID, err = m.loadGuardrailsByID(ctx, accountID)
                                                +		if err != nil {
                                                +			return nil, err
                                                +		}
                                                +	}
                                                +	out := make([]*types.Provider, 0, len(authorized))
                                                +	for _, p := range authorized {
                                                +		r := p.RedactedForViewer()
                                                +		// The model list follows the same effective computation the setup
                                                +		// answer and the proxy use: allowlist-restricted callers see only
                                                +		// the models their guardrails permit, and an unrestricted policy
                                                +		// on a provider without an operator declaration surfaces the
                                                +		// catalog models, matching the setup response — so the dashboard's
                                                +		// model filter never offers a model the caller's own requests
                                                +		// could not use, and never comes up empty when the setup page
                                                +		// lists models. Grant holders keep the full declared lists —
                                                +		// their usage view spans everyone's requests.
                                                +		_, effective := effectiveModelsForProvider(p, policiesForProvider(applicable, p.ID), guardrailsByID)
                                                +		r.Models = providerModelsByID(p, effective)
                                                +		out = append(out, r)
                                                +	}
                                                +	return out, nil
                                                +}
                                                +
                                                +// redactProvidersForViewer strips the connection configuration from
                                                +// providers handed to a caller who holds only the read grant on
                                                +// agent_network.providers. Update is the managing signal: a role that can
                                                +// edit a provider sees its config in the edit form anyway, while a
                                                +// read-only role (usage_viewer) only needs the display surface the usage
                                                +// filters resolve against — upstream URLs and operator-supplied header
                                                +// values are not part of that. Validation errors fail closed.
                                                +func (m *managerImpl) redactProvidersForViewer(ctx context.Context, accountID, userID string, providers []*types.Provider) ([]*types.Provider, error) {
                                                +	canManage, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Update)
                                                +	if err != nil {
                                                +		return nil, status.NewPermissionValidationError(err)
                                                +	}
                                                +	if canManage {
                                                +		return providers, nil
                                                +	}
                                                +	out := make([]*types.Provider, 0, len(providers))
                                                +	for _, p := range providers {
                                                +		if p == nil {
                                                +			out = append(out, nil)
                                                +			continue
                                                +		}
                                                +		out = append(out, p.RedactedForViewer())
                                                +	}
                                                +	return out, nil
                                                +}
                                                +
                                                +// DiscoverProviderModels asks the vendor which models a credential can reach.
                                                +//
                                                +// recordID, when set, names an existing provider whose stored credential is
                                                +// used instead of the one in req — so the dashboard can refresh the list
                                                +// without ever holding the key. An upstream in req overrides the stored one,
                                                +// which is what lets a form list against a URL the operator has typed but not
                                                +// saved yet, using the credential they cannot retype.
                                                +//
                                                +// Gated on Create rather than Read: this spends the operator's credential
                                                +// against a third party, which is not something a read-only role should be
                                                +// able to make the server do. That one check also covers reading the stored
                                                +// record — Create is strictly stronger than Read here, and the lookup is
                                                +// scoped to accountID, so another account's record is never reachable.
                                                +func (m *managerImpl) DiscoverProviderModels(ctx context.Context, accountID, userID string, req modeldiscovery.Request, recordID string) ([]modeldiscovery.Model, error) {
                                                +	if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Create); err != nil {
                                                +		return nil, err
                                                +	}
                                                +
                                                +	if recordID != "" {
                                                +		record, err := m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, recordID)
                                                +		if err != nil {
                                                +			return nil, err
                                                +		}
                                                +		// The catalog id comes from the stored record too: letting the caller
                                                +		// name a different one would run a provider's credential against
                                                +		// whichever vendor endpoint they picked.
                                                +		req.CatalogID = record.ProviderID
                                                +		req.APIKey = record.APIKey
                                                +		// The upstream is the one field the caller may override, so that a URL
                                                +		// typed into the form can be listed against before it is saved.
                                                +		//
                                                +		// It sends the stored credential to a host the caller named, which is
                                                +		// a capability they already have: the same permission set updates the
                                                +		// record's upstream, and that write runs this same check against
                                                +		// whatever it is pointed at. What it would not otherwise be is silent,
                                                +		// since the write leaves an activity event behind — so the override is
                                                +		// recorded here.
                                                +		if strings.TrimSpace(req.UpstreamURL) == "" {
                                                +			req.UpstreamURL = record.UpstreamURL
                                                +		} else if req.UpstreamURL != record.UpstreamURL {
                                                +			log.WithContext(ctx).Infof("agent network provider %s listed against caller-supplied upstream %s by user %s",
                                                +				recordID, req.UpstreamURL, userID)
                                                +		}
                                                +	}
                                                +
                                                +	models, err := m.modelDiscovery.Fetch(ctx, req)
                                                +	if err != nil {
                                                +		return nil, discoveryFailure(ctx, req.CatalogID, err)
                                                +	}
                                                +	return models, nil
                                                +}
                                                +
                                                +// CreateProvider persists a new provider for the account. Providers have no
                                                +// settings side effects: the account's endpoint is bootstrapped separately and
                                                +// explicitly via CreateSettings, and every provider in the account routes
                                                +// through it.
                                                +func (m *managerImpl) CreateProvider(ctx context.Context, userID string, provider *types.Provider) (*types.Provider, error) {
                                                +	if err := m.requirePermission(ctx, provider.AccountID, userID, modules.AgentNetworkProviders, operations.Create); err != nil {
                                                +		return nil, err
                                                +	}
                                                +
                                                +	// An empty api_key would silently produce a synthesised service
                                                +	// that 401s on every upstream request. Surface the misconfiguration
                                                +	// at create time instead.
                                                +	if strings.TrimSpace(provider.APIKey) == "" {
                                                +		return nil, status.Errorf(status.InvalidArgument, "api_key is required when creating an agent network provider")
                                                +	}
                                                +	// Stored as it will be sent. The vendor call below trims the key before
                                                +	// building the auth header while the synthesiser substitutes the stored
                                                +	// value verbatim, so a key pasted with surrounding whitespace would pass
                                                +	// its check and then fail every request the provider serves.
                                                +	provider.APIKey = strings.TrimSpace(provider.APIKey)
                                                +
                                                +	// Before anything is persisted: a record whose upstream or credential does
                                                +	// not work is rejected here rather than discovered later as a failed
                                                +	// request with nothing pointing back at it.
                                                +	if err := m.checkProviderCredential(ctx, provider); err != nil {
                                                +		return nil, err
                                                +	}
                                                +
                                                +	if provider.ID == "" {
                                                +		fresh := types.NewProvider(provider.AccountID)
                                                +		provider.ID = fresh.ID
                                                +		provider.CreatedAt = fresh.CreatedAt
                                                +		provider.UpdatedAt = fresh.UpdatedAt
                                                +	}
                                                +
                                                +	if err := ensureSessionKeys(provider); err != nil {
                                                +		return nil, err
                                                +	}
                                                +
                                                +	if err := m.store.SaveAgentNetworkProvider(ctx, provider); err != nil {
                                                +		return nil, fmt.Errorf("save agent network provider: %w", err)
                                                +	}
                                                +
                                                +	m.accountManager.StoreEvent(ctx, userID, provider.ID, provider.AccountID, activity.AgentNetworkProviderCreated, provider.EventMeta())
                                                +	m.reconcile(ctx, provider.AccountID)
                                                +
                                                +	return provider, nil
                                                +}
                                                +
                                                +func (m *managerImpl) UpdateProvider(ctx context.Context, userID string, provider *types.Provider) (*types.Provider, error) {
                                                +	if err := m.requirePermission(ctx, provider.AccountID, userID, modules.AgentNetworkProviders, operations.Update); err != nil {
                                                +		return nil, err
                                                +	}
                                                +
                                                +	existing, err := m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthUpdate, provider.AccountID, provider.ID)
                                                +	if err != nil {
                                                +		return nil, fmt.Errorf("failed to get agent network provider: %w", err)
                                                +	}
                                                +
                                                +	// Preserve the API key if the caller didn't rotate it. A
                                                +	// whitespace-only value is treated as "not rotated" rather than a
                                                +	// real key, but it must not silently overwrite a valid stored key.
                                                +	switch trimmed := strings.TrimSpace(provider.APIKey); {
                                                +	case provider.APIKey == "":
                                                +		// Trimmed on the way through: a record stored before keys were
                                                +		// normalised carries whitespace the proxy still sends, and an edit
                                                +		// that preserves the key is the occasion to repair it. Doing so makes
                                                +		// the comparison below see a change, which is correct — that key has
                                                +		// never been tested in the form it is about to be sent in.
                                                +		provider.APIKey = strings.TrimSpace(existing.APIKey)
                                                +	case trimmed == "":
                                                +		return nil, status.Errorf(status.InvalidArgument, "api_key must be non-blank when rotating an agent network provider")
                                                +	default:
                                                +		// See CreateProvider: the key is stored in the form the proxy will
                                                +		// send, so the check below tests what the provider will actually use.
                                                +		provider.APIKey = trimmed
                                                +	}
                                                +
                                                +	// Only the fields the vendor would judge are worth a round-trip. This same
                                                +	// call carries renames, model rows and price edits, and none of those
                                                +	// should wait on a vendor — or be refused because one is having a bad day.
                                                +	//
                                                +	// The catalog entry counts as one of them: it decides which vendor is
                                                +	// asked, under which auth header, so moving a record from one to another
                                                +	// sends an unchanged credential somewhere it has never been accepted.
                                                +	//
                                                +	// The comparison runs after the merge above, so an update that changes only
                                                +	// the URL reads as unchanged on the key and is checked against the stored
                                                +	// one, which is the only credential the operator has to offer here.
                                                +	//
                                                +	// Turning TLS verification back on is the fourth: the record was stored
                                                +	// unchecked precisely because that flag was set, so this is the first
                                                +	// moment it can be checked at all, and nothing else about it need change
                                                +	// for that to be true.
                                                +	if provider.UpstreamURL != existing.UpstreamURL ||
                                                +		provider.APIKey != existing.APIKey ||
                                                +		provider.ProviderID != existing.ProviderID ||
                                                +		(existing.SkipTLSVerification && !provider.SkipTLSVerification) {
                                                +		if err := m.checkProviderCredential(ctx, provider); err != nil {
                                                +			return nil, err
                                                +		}
                                                +	}
                                                +
                                                +	// Always preserve the session keypair across updates so existing
                                                +	// session cookies stay valid. The keys are server-managed and
                                                +	// never surfaced through the API.
                                                +	provider.SessionPrivateKey = existing.SessionPrivateKey
                                                +	provider.SessionPublicKey = existing.SessionPublicKey
                                                +	if err := ensureSessionKeys(provider); err != nil {
                                                +		return nil, err
                                                +	}
                                                +	provider.CreatedAt = existing.CreatedAt
                                                +	provider.UpdatedAt = time.Now().UTC()
                                                +
                                                +	if err := m.store.SaveAgentNetworkProvider(ctx, provider); err != nil {
                                                +		return nil, fmt.Errorf("save agent network provider: %w", err)
                                                +	}
                                                +
                                                +	m.accountManager.StoreEvent(ctx, userID, provider.ID, provider.AccountID, activity.AgentNetworkProviderUpdated, provider.EventMeta())
                                                +	m.reconcile(ctx, provider.AccountID)
                                                +
                                                +	return provider, nil
                                                +}
                                                +
                                                +func (m *managerImpl) DeleteProvider(ctx context.Context, accountID, userID, providerID string) error {
                                                +	if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Delete); err != nil {
                                                +		return err
                                                +	}
                                                +
                                                +	provider, err := m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthUpdate, accountID, providerID)
                                                +	if err != nil {
                                                +		return fmt.Errorf("failed to get agent network provider: %w", err)
                                                +	}
                                                +
                                                +	// Refuse to delete while any policy still references this provider.
                                                +	// The operator must detach it first.
                                                +	policies, err := m.store.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, accountID)
                                                +	if err != nil {
                                                +		return fmt.Errorf("failed to get agent network policies: %w", err)
                                                +	}
                                                +	var blocking []string
                                                +	for _, p := range policies {
                                                +		if slices.Contains(p.DestinationProviderIDs, providerID) {
                                                +			blocking = append(blocking, p.Name)
                                                +		}
                                                +	}
                                                +	if len(blocking) > 0 {
                                                +		return status.Errorf(
                                                +			status.InvalidArgument,
                                                +			"provider is in use by %d %s (%s); detach it before deleting",
                                                +			len(blocking),
                                                +			pluralize(len(blocking), "policy", "policies"),
                                                +			strings.Join(blocking, ", "),
                                                +		)
                                                +	}
                                                +
                                                +	if err := m.store.DeleteAgentNetworkProvider(ctx, accountID, providerID); err != nil {
                                                +		return fmt.Errorf("failed to delete agent network provider: %w", err)
                                                +	}
                                                +
                                                +	m.accountManager.StoreEvent(ctx, userID, providerID, accountID, activity.AgentNetworkProviderDeleted, provider.EventMeta())
                                                +	m.reconcile(ctx, accountID)
                                                +
                                                +	return nil
                                                +}
                                                +
                                                +func pluralize(n int, singular, plural string) string {
                                                +	if n == 1 {
                                                +		return singular
                                                +	}
                                                +	return plural
                                                +}
                                                +
                                                +func (m *managerImpl) GetAllPolicies(ctx context.Context, accountID, userID string) ([]*types.Policy, error) {
                                                +	if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkPolicies, operations.Read); err != nil {
                                                +		return nil, err
                                                +	}
                                                +	return m.store.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, accountID)
                                                +}
                                                +
                                                +func (m *managerImpl) GetPolicy(ctx context.Context, accountID, userID, policyID string) (*types.Policy, error) {
                                                +	if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkPolicies, operations.Read); err != nil {
                                                +		return nil, err
                                                +	}
                                                +	return m.store.GetAgentNetworkPolicyByID(ctx, store.LockingStrengthNone, accountID, policyID)
                                                +}
                                                +
                                                +func (m *managerImpl) CreatePolicy(ctx context.Context, userID string, policy *types.Policy) (*types.Policy, error) {
                                                +	if err := m.requirePermission(ctx, policy.AccountID, userID, modules.AgentNetworkPolicies, operations.Create); err != nil {
                                                +		return nil, err
                                                +	}
                                                +
                                                +	if policy.ID == "" {
                                                +		fresh := types.NewPolicy(policy.AccountID)
                                                +		policy.ID = fresh.ID
                                                +		policy.CreatedAt = fresh.CreatedAt
                                                +		policy.UpdatedAt = fresh.UpdatedAt
                                                +	}
                                                +
                                                +	if err := m.validateProviderRefs(ctx, policy.AccountID, policy.DestinationProviderIDs); err != nil {
                                                +		return nil, err
                                                +	}
                                                +
                                                +	if err := m.store.SaveAgentNetworkPolicy(ctx, policy); err != nil {
                                                +		return nil, fmt.Errorf("failed to save agent network policy: %w", err)
                                                +	}
                                                +
                                                +	m.accountManager.StoreEvent(ctx, userID, policy.ID, policy.AccountID, activity.AgentNetworkPolicyCreated, policy.EventMeta())
                                                +	m.reconcile(ctx, policy.AccountID)
                                                +
                                                +	return policy, nil
                                                +}
                                                +
                                                +func (m *managerImpl) UpdatePolicy(ctx context.Context, userID string, policy *types.Policy) (*types.Policy, error) {
                                                +	if err := m.requirePermission(ctx, policy.AccountID, userID, modules.AgentNetworkPolicies, operations.Update); err != nil {
                                                +		return nil, err
                                                +	}
                                                +
                                                +	existing, err := m.store.GetAgentNetworkPolicyByID(ctx, store.LockingStrengthUpdate, policy.AccountID, policy.ID)
                                                +	if err != nil {
                                                +		return nil, fmt.Errorf("failed to get agent network policy: %w", err)
                                                +	}
                                                +
                                                +	if err := m.validateProviderRefs(ctx, policy.AccountID, policy.DestinationProviderIDs); err != nil {
                                                +		return nil, err
                                                +	}
                                                +
                                                +	policy.CreatedAt = existing.CreatedAt
                                                +	policy.UpdatedAt = time.Now().UTC()
                                                +
                                                +	if err := m.store.SaveAgentNetworkPolicy(ctx, policy); err != nil {
                                                +		return nil, fmt.Errorf("failed to save agent network policy: %w", err)
                                                +	}
                                                +
                                                +	m.accountManager.StoreEvent(ctx, userID, policy.ID, policy.AccountID, activity.AgentNetworkPolicyUpdated, policy.EventMeta())
                                                +	m.reconcile(ctx, policy.AccountID)
                                                +
                                                +	return policy, nil
                                                +}
                                                +
                                                +func (m *managerImpl) DeletePolicy(ctx context.Context, accountID, userID, policyID string) error {
                                                +	if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkPolicies, operations.Delete); err != nil {
                                                +		return err
                                                +	}
                                                +
                                                +	policy, err := m.store.GetAgentNetworkPolicyByID(ctx, store.LockingStrengthUpdate, accountID, policyID)
                                                +	if err != nil {
                                                +		return fmt.Errorf("failed to get agent network policy: %w", err)
                                                +	}
                                                +
                                                +	if err := m.store.DeleteAgentNetworkPolicy(ctx, accountID, policyID); err != nil {
                                                +		return fmt.Errorf("failed to delete agent network policy: %w", err)
                                                +	}
                                                +
                                                +	m.accountManager.StoreEvent(ctx, userID, policyID, accountID, activity.AgentNetworkPolicyDeleted, policy.EventMeta())
                                                +	m.reconcile(ctx, accountID)
                                                +
                                                +	return nil
                                                +}
                                                +
                                                +func (m *managerImpl) GetAllGuardrails(ctx context.Context, accountID, userID string) ([]*types.Guardrail, error) {
                                                +	if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkGuardrails, operations.Read); err != nil {
                                                +		return nil, err
                                                +	}
                                                +	return m.store.GetAccountAgentNetworkGuardrails(ctx, store.LockingStrengthNone, accountID)
                                                +}
                                                +
                                                +func (m *managerImpl) GetGuardrail(ctx context.Context, accountID, userID, guardrailID string) (*types.Guardrail, error) {
                                                +	if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkGuardrails, operations.Read); err != nil {
                                                +		return nil, err
                                                +	}
                                                +	return m.store.GetAgentNetworkGuardrailByID(ctx, store.LockingStrengthNone, accountID, guardrailID)
                                                +}
                                                +
                                                +func (m *managerImpl) CreateGuardrail(ctx context.Context, userID string, guardrail *types.Guardrail) (*types.Guardrail, error) {
                                                +	if err := m.requirePermission(ctx, guardrail.AccountID, userID, modules.AgentNetworkGuardrails, operations.Create); err != nil {
                                                +		return nil, err
                                                +	}
                                                +
                                                +	if guardrail.ID == "" {
                                                +		fresh := types.NewGuardrail(guardrail.AccountID)
                                                +		guardrail.ID = fresh.ID
                                                +		guardrail.CreatedAt = fresh.CreatedAt
                                                +		guardrail.UpdatedAt = fresh.UpdatedAt
                                                +	}
                                                +
                                                +	if err := m.store.SaveAgentNetworkGuardrail(ctx, guardrail); err != nil {
                                                +		return nil, fmt.Errorf("failed to save agent network guardrail: %w", err)
                                                +	}
                                                +
                                                +	m.accountManager.StoreEvent(ctx, userID, guardrail.ID, guardrail.AccountID, activity.AgentNetworkGuardrailCreated, guardrail.EventMeta())
                                                +	m.reconcile(ctx, guardrail.AccountID)
                                                +
                                                +	return guardrail, nil
                                                +}
                                                +
                                                +func (m *managerImpl) UpdateGuardrail(ctx context.Context, userID string, guardrail *types.Guardrail) (*types.Guardrail, error) {
                                                +	if err := m.requirePermission(ctx, guardrail.AccountID, userID, modules.AgentNetworkGuardrails, operations.Update); err != nil {
                                                +		return nil, err
                                                +	}
                                                +
                                                +	existing, err := m.store.GetAgentNetworkGuardrailByID(ctx, store.LockingStrengthUpdate, guardrail.AccountID, guardrail.ID)
                                                +	if err != nil {
                                                +		return nil, fmt.Errorf("failed to get agent network guardrail: %w", err)
                                                +	}
                                                +
                                                +	guardrail.CreatedAt = existing.CreatedAt
                                                +	guardrail.UpdatedAt = time.Now().UTC()
                                                +
                                                +	if err := m.store.SaveAgentNetworkGuardrail(ctx, guardrail); err != nil {
                                                +		return nil, fmt.Errorf("failed to save agent network guardrail: %w", err)
                                                +	}
                                                +
                                                +	m.accountManager.StoreEvent(ctx, userID, guardrail.ID, guardrail.AccountID, activity.AgentNetworkGuardrailUpdated, guardrail.EventMeta())
                                                +	m.reconcile(ctx, guardrail.AccountID)
                                                +
                                                +	return guardrail, nil
                                                +}
                                                +
                                                +func (m *managerImpl) DeleteGuardrail(ctx context.Context, accountID, userID, guardrailID string) error {
                                                +	if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkGuardrails, operations.Delete); err != nil {
                                                +		return err
                                                +	}
                                                +
                                                +	guardrail, err := m.store.GetAgentNetworkGuardrailByID(ctx, store.LockingStrengthUpdate, accountID, guardrailID)
                                                +	if err != nil {
                                                +		return fmt.Errorf("failed to get agent network guardrail: %w", err)
                                                +	}
                                                +
                                                +	if err := m.store.DeleteAgentNetworkGuardrail(ctx, accountID, guardrailID); err != nil {
                                                +		return fmt.Errorf("failed to delete agent network guardrail: %w", err)
                                                +	}
                                                +
                                                +	m.accountManager.StoreEvent(ctx, userID, guardrailID, accountID, activity.AgentNetworkGuardrailDeleted, guardrail.EventMeta())
                                                +	m.reconcile(ctx, accountID)
                                                +
                                                +	return nil
                                                +}
                                                +
                                                +// GetAllBudgetRules returns every account-level budget rule for the account.
                                                +func (m *managerImpl) GetAllBudgetRules(ctx context.Context, accountID, userID string) ([]*types.AccountBudgetRule, error) {
                                                +	if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkBudgets, operations.Read); err != nil {
                                                +		return nil, err
                                                +	}
                                                +	return m.store.GetAccountAgentNetworkBudgetRules(ctx, store.LockingStrengthNone, accountID)
                                                +}
                                                +
                                                +// GetBudgetRule returns a single account-level budget rule.
                                                +func (m *managerImpl) GetBudgetRule(ctx context.Context, accountID, userID, ruleID string) (*types.AccountBudgetRule, error) {
                                                +	if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkBudgets, operations.Read); err != nil {
                                                +		return nil, err
                                                +	}
                                                +	return m.store.GetAgentNetworkBudgetRuleByID(ctx, store.LockingStrengthNone, accountID, ruleID)
                                                +}
                                                +
                                                +// CreateBudgetRule persists a new account-level budget rule. Budget rules are
                                                +// enforced at request time (CheckLLMPolicyLimits), not baked into the synth
                                                +// proxy config, so no reconcile is needed.
                                                +func (m *managerImpl) CreateBudgetRule(ctx context.Context, userID string, rule *types.AccountBudgetRule) (*types.AccountBudgetRule, error) {
                                                +	if err := m.requirePermission(ctx, rule.AccountID, userID, modules.AgentNetworkBudgets, operations.Create); err != nil {
                                                +		return nil, err
                                                +	}
                                                +
                                                +	if rule.ID == "" {
                                                +		fresh := types.NewAccountBudgetRule(rule.AccountID)
                                                +		rule.ID = fresh.ID
                                                +		rule.CreatedAt = fresh.CreatedAt
                                                +		rule.UpdatedAt = fresh.UpdatedAt
                                                +	}
                                                +
                                                +	if err := m.store.SaveAgentNetworkBudgetRule(ctx, rule); err != nil {
                                                +		return nil, fmt.Errorf("save agent network budget rule: %w", err)
                                                +	}
                                                +
                                                +	m.accountManager.StoreEvent(ctx, userID, rule.ID, rule.AccountID, activity.AgentNetworkBudgetRuleCreated, rule.EventMeta())
                                                +
                                                +	return rule, nil
                                                +}
                                                +
                                                +// UpdateBudgetRule updates an existing account-level budget rule.
                                                +func (m *managerImpl) UpdateBudgetRule(ctx context.Context, userID string, rule *types.AccountBudgetRule) (*types.AccountBudgetRule, error) {
                                                +	if err := m.requirePermission(ctx, rule.AccountID, userID, modules.AgentNetworkBudgets, operations.Update); err != nil {
                                                +		return nil, err
                                                +	}
                                                +
                                                +	existing, err := m.store.GetAgentNetworkBudgetRuleByID(ctx, store.LockingStrengthUpdate, rule.AccountID, rule.ID)
                                                +	if err != nil {
                                                +		return nil, fmt.Errorf("get agent network budget rule: %w", err)
                                                +	}
                                                +
                                                +	rule.CreatedAt = existing.CreatedAt
                                                +	rule.UpdatedAt = time.Now().UTC()
                                                +
                                                +	if err := m.store.SaveAgentNetworkBudgetRule(ctx, rule); err != nil {
                                                +		return nil, fmt.Errorf("save agent network budget rule: %w", err)
                                                +	}
                                                +
                                                +	m.accountManager.StoreEvent(ctx, userID, rule.ID, rule.AccountID, activity.AgentNetworkBudgetRuleUpdated, rule.EventMeta())
                                                +
                                                +	return rule, nil
                                                +}
                                                +
                                                +// DeleteBudgetRule removes an account-level budget rule.
                                                +func (m *managerImpl) DeleteBudgetRule(ctx context.Context, accountID, userID, ruleID string) error {
                                                +	if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkBudgets, operations.Delete); err != nil {
                                                +		return err
                                                +	}
                                                +
                                                +	rule, err := m.store.GetAgentNetworkBudgetRuleByID(ctx, store.LockingStrengthUpdate, accountID, ruleID)
                                                +	if err != nil {
                                                +		return fmt.Errorf("get agent network budget rule: %w", err)
                                                +	}
                                                +
                                                +	if err := m.store.DeleteAgentNetworkBudgetRule(ctx, accountID, ruleID); err != nil {
                                                +		return fmt.Errorf("delete agent network budget rule: %w", err)
                                                +	}
                                                +
                                                +	m.accountManager.StoreEvent(ctx, userID, ruleID, accountID, activity.AgentNetworkBudgetRuleDeleted, rule.EventMeta())
                                                +
                                                +	return nil
                                                +}
                                                +
                                                +// UpdateSettings replaces the mutable account-level settings — the collection
                                                +// toggles and retention — on the account's row. The identity fields (Domain,
                                                +// ProxyAddress) are assigned at bootstrap (CreateSettings) and immutable: the
                                                +// request carries them, matching the PUT convention of every other endpoint,
                                                +// but they are only compared against the stored row — a request carrying
                                                +// different values is rejected, and the stored values are never overwritten.
                                                +// When the account has no settings row yet the update fails with NotFound.
                                                +// Because the collection toggles change the synthesised service config
                                                +// (prompt-capture gating, access-log emission), a reconcile is triggered so
                                                +// the proxy and peer network maps converge on the new state.
                                                +func (m *managerImpl) UpdateSettings(ctx context.Context, userID string, settings *types.Settings) (*types.Settings, error) {
                                                +	if err := m.requirePermission(ctx, settings.AccountID, userID, modules.AgentNetworkSettings, operations.Update); err != nil {
                                                +		return nil, err
                                                +	}
                                                +
                                                +	// The row lock from LockingStrengthUpdate only holds for the duration of
                                                +	// the surrounding transaction, so the read and the save must share one —
                                                +	// otherwise concurrent PUTs could interleave between them.
                                                +	var updated *types.Settings
                                                +	err := m.store.ExecuteInTransaction(ctx, func(tx store.Store) error {
                                                +		existing, err := tx.GetAgentNetworkSettings(ctx, store.LockingStrengthUpdate, settings.AccountID)
                                                +		switch {
                                                +		case err == nil:
                                                +		case isNotFound(err):
                                                +			return status.Errorf(status.NotFound, "agent network settings have not been bootstrapped yet; POST /api/agent-network/settings to bootstrap them")
                                                +		default:
                                                +			return fmt.Errorf("get agent network settings: %w", err)
                                                +		}
                                                +
                                                +		// The identity echo is compared leniently (trimmed, case-insensitive):
                                                +		// the stored values are normalized lowercase, and a client replaying a
                                                +		// GET response must never be rejected over casing it didn't choose.
                                                +		if !hostnamesEquivalent(settings.Domain, existing.Domain) {
                                                +			return status.Errorf(status.InvalidArgument, "endpoint is immutable: it must match the assigned endpoint %q; delete the settings to release it and bootstrap again", existing.Domain)
                                                +		}
                                                +		if !hostnamesEquivalent(settings.ProxyAddress, existing.ProxyAddress) {
                                                +			return status.Errorf(status.InvalidArgument, "proxy_address is immutable: it must match the assigned proxy address %q; delete the settings to release it and bootstrap again", existing.ProxyAddress)
                                                +		}
                                                +
                                                +		existing.EnableLogCollection = settings.EnableLogCollection
                                                +		existing.EnablePromptCollection = settings.EnablePromptCollection
                                                +		existing.RedactPii = settings.RedactPii
                                                +		existing.AccessLogRetentionDays = settings.AccessLogRetentionDays
                                                +		existing.UpdatedAt = time.Now().UTC()
                                                +
                                                +		if err := tx.SaveAgentNetworkSettings(ctx, existing); err != nil {
                                                +			return fmt.Errorf("save agent network settings: %w", err)
                                                +		}
                                                +		updated = existing
                                                +		return nil
                                                +	})
                                                +	if err != nil {
                                                +		return nil, err
                                                +	}
                                                +
                                                +	m.accountManager.StoreEvent(ctx, userID, settings.AccountID, settings.AccountID, activity.AgentNetworkSettingsUpdated, map[string]any{
                                                +		"log_collection":    updated.EnableLogCollection,
                                                +		"prompt_collection": updated.EnablePromptCollection,
                                                +		"redact_pii":        updated.RedactPii,
                                                +	})
                                                +	m.reconcile(ctx, settings.AccountID)
                                                +
                                                +	return updated, nil
                                                +}
                                                +
                                                +// hostnamesEquivalent reports whether a caller-supplied hostname names the
                                                +// same host as a stored (normalized, lowercase) one: equal after trimming and
                                                +// case folding. No structural validation — an arbitrary mismatch and a
                                                +// malformed value are both simply "not the assigned value".
                                                +func hostnamesEquivalent(supplied, stored string) bool {
                                                +	return strings.EqualFold(strings.TrimSpace(supplied), stored)
                                                +}
                                                +
                                                +// DeleteSettings removes the account's settings row, releasing the endpoint.
                                                +// Two guards make this a bootstrap-repair operation rather than a way to tear
                                                +// down a serving gateway, both re-checked under the row lock:
                                                +//
                                                +//   - No Agent Network providers may exist for the account. Providers route
                                                +//     through the endpoint; delete them first.
                                                +//   - No proxy may be actively serving the endpoint — that is, no active proxy
                                                +//     declares the endpoint hostname as its cluster address. This is the
                                                +//     dedicated (self-addressed) shape's guard: the proxy at the address IS
                                                +//     this account's gateway. A labeled endpoint hangs beneath a shared
                                                +//     cluster's address, and with the account's providers already gone the
                                                +//     shared proxy serves nothing of the account's, so the parent cluster
                                                +//     being up does not block the delete.
                                                +//
                                                +// Bootstrapping again after a delete allocates fresh — the released hostname
                                                +// is not reserved. That full-reset semantic is what gives clients that model
                                                +// immutability as replace-on-change (e.g. Terraform's RequiresReplace) a real
                                                +// path: tear down providers, delete, re-create.
                                                +func (m *managerImpl) DeleteSettings(ctx context.Context, accountID, userID string) error {
                                                +	if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkSettings, operations.Delete); err != nil {
                                                +		return err
                                                +	}
                                                +
                                                +	var deleted *types.Settings
                                                +	err := m.store.ExecuteInTransaction(ctx, func(tx store.Store) error {
                                                +		existing, err := tx.GetAgentNetworkSettings(ctx, store.LockingStrengthUpdate, accountID)
                                                +		switch {
                                                +		case err == nil:
                                                +		case isNotFound(err):
                                                +			return status.Errorf(status.NotFound, "agent network settings have not been bootstrapped yet; there is nothing to delete")
                                                +		default:
                                                +			return fmt.Errorf("get agent network settings: %w", err)
                                                +		}
                                                +
                                                +		providers, err := tx.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID)
                                                +		if err != nil {
                                                +			return fmt.Errorf("get agent network providers: %w", err)
                                                +		}
                                                +		if len(providers) > 0 {
                                                +			return status.Errorf(status.PreconditionFailed, "agent network settings cannot be deleted while %d provider(s) exist; delete the providers first", len(providers))
                                                +		}
                                                +
                                                +		serving, err := tx.HasActiveProxyAtClusterAddress(ctx, existing.Domain)
                                                +		if err != nil {
                                                +			return fmt.Errorf("check for a proxy serving the endpoint: %w", err)
                                                +		}
                                                +		if serving {
                                                +			return status.Errorf(status.PreconditionFailed, "agent network settings cannot be deleted while a proxy is actively serving the endpoint %q", existing.Domain)
                                                +		}
                                                +
                                                +		if err := tx.DeleteAgentNetworkSettings(ctx, accountID); err != nil {
                                                +			return fmt.Errorf("delete agent network settings: %w", err)
                                                +		}
                                                +		deleted = existing
                                                +		return nil
                                                +	})
                                                +	if err != nil {
                                                +		return err
                                                +	}
                                                +
                                                +	m.accountManager.StoreEvent(ctx, userID, accountID, accountID, activity.AgentNetworkSettingsDeleted, map[string]any{
                                                +		"endpoint":      deleted.Domain,
                                                +		"proxy_address": deleted.ProxyAddress,
                                                +	})
                                                +	m.reconcile(ctx, accountID)
                                                +
                                                +	return nil
                                                +}
                                                +
                                                +// isNotFound reports whether err is a status.NotFound error.
                                                +func isNotFound(err error) bool {
                                                +	var sErr *status.Error
                                                +	return errors.As(err, &sErr) && sErr.Type() == status.NotFound
                                                +}
                                                +
                                                +// validateProviderRefs ensures every destination provider id refers to a
                                                +// provider that exists in the same account.
                                                +func (m *managerImpl) validateProviderRefs(ctx context.Context, accountID string, providerIDs []string) error {
                                                +	if len(providerIDs) == 0 {
                                                +		return nil
                                                +	}
                                                +	for _, id := range providerIDs {
                                                +		if _, err := m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, id); err != nil {
                                                +			// Only a genuine not-found means the reference is invalid; a
                                                +			// store/runtime error must propagate as-is rather than be
                                                +			// masked as a client validation error.
                                                +			var sErr *status.Error
                                                +			if errors.As(err, &sErr) && sErr.Type() == status.NotFound {
                                                +				return status.Errorf(status.InvalidArgument, "destination_provider_ids: provider %s does not exist", id)
                                                +			}
                                                +			return fmt.Errorf("get destination provider %s: %w", id, err)
                                                +		}
                                                +	}
                                                +	return nil
                                                +}
                                                +
                                                +// GetSettings returns the agent-network settings row for the account. When no
                                                +// row has been bootstrapped yet, the defaults are returned (without
                                                +// persisting) with cluster and subdomain empty — settings always read as an
                                                +// object, like the account and DNS settings endpoints.
                                                +func (m *managerImpl) GetSettings(ctx context.Context, accountID, userID string) (*types.Settings, error) {
                                                +	if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkSettings, operations.Read); err != nil {
                                                +		return nil, err
                                                +	}
                                                +	settings, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
                                                +	switch {
                                                +	case err == nil:
                                                +		return settings, nil
                                                +	case isNotFound(err):
                                                +		return types.DefaultSettings(accountID), nil
                                                +	default:
                                                +		return nil, err
                                                +	}
                                                +}
                                                +
                                                +// maxDomainAllocationAttempts bounds the label search when bootstrapping a
                                                +// labeled endpoint. Package-level (rather than function-local) so tests can
                                                +// assert on the exhaustion path without duplicating the literal.
                                                +const maxDomainAllocationAttempts = 10
                                                +
                                                +// CreateSettings bootstraps the per-account settings row, assigning the
                                                +// account's immutable endpoint. Exactly one of proxyAddress and endpoint must
                                                +// be non-empty: proxyAddress allocates a labeled endpoint one label beneath
                                                +// the given cluster address; endpoint claims the given hostname verbatim as a
                                                +// self-addressed (dedicated) endpoint — a legitimate claim before any proxy
                                                +// declares the address (address-first). settings carries the account ID and
                                                +// the initial collection toggles; its identity fields are assigned here.
                                                +func (m *managerImpl) CreateSettings(ctx context.Context, userID string, settings *types.Settings, proxyAddress, endpoint string) (*types.Settings, error) {
                                                +	if settings == nil || settings.AccountID == "" {
                                                +		return nil, status.Errorf(status.InvalidArgument, "account id is required")
                                                +	}
                                                +	if err := m.requirePermission(ctx, settings.AccountID, userID, modules.AgentNetworkSettings, operations.Create); err != nil {
                                                +		return nil, err
                                                +	}
                                                +
                                                +	hasProxyAddress := strings.TrimSpace(proxyAddress) != ""
                                                +	hasEndpoint := strings.TrimSpace(endpoint) != ""
                                                +	if hasProxyAddress == hasEndpoint {
                                                +		return nil, status.Errorf(status.InvalidArgument, "exactly one of proxy_address and endpoint is required")
                                                +	}
                                                +
                                                +	// Fail fast on an existing row for a clean 409; the insert below stays
                                                +	// the authority against concurrent bootstraps (the primary key wins).
                                                +	if _, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, settings.AccountID); err == nil {
                                                +		return nil, status.Errorf(status.AlreadyExists, "agent network settings already bootstrapped for account %s", settings.AccountID)
                                                +	} else if !isNotFound(err) {
                                                +		return nil, fmt.Errorf("get agent network settings: %w", err)
                                                +	}
                                                +
                                                +	now := time.Now().UTC()
                                                +	settings.CreatedAt = now
                                                +	settings.UpdatedAt = now
                                                +
                                                +	var err error
                                                +	if hasEndpoint {
                                                +		err = m.bootstrapSelfAddressed(ctx, settings, endpoint)
                                                +	} else {
                                                +		err = m.bootstrapLabeled(ctx, settings, proxyAddress)
                                                +	}
                                                +	if err != nil {
                                                +		return nil, err
                                                +	}
                                                +
                                                +	m.accountManager.StoreEvent(ctx, userID, settings.AccountID, settings.AccountID, activity.AgentNetworkSettingsUpdated, map[string]any{
                                                +		"bootstrapped": true,
                                                +		"endpoint":     settings.Domain,
                                                +		"dedicated":    settings.Dedicated(),
                                                +	})
                                                +	m.reconcile(ctx, settings.AccountID)
                                                +
                                                +	return settings, nil
                                                +}
                                                +
                                                +// bootstrapSelfAddressed claims the given hostname as the account's endpoint,
                                                +// served only by a proxy declaring exactly that address (Domain ==
                                                +// ProxyAddress). The domain unique index is the arbiter of availability.
                                                +func (m *managerImpl) bootstrapSelfAddressed(ctx context.Context, settings *types.Settings, endpoint string) error {
                                                +	hostname, err := types.NormalizeHostname(endpoint)
                                                +	if err != nil {
                                                +		return status.Errorf(status.InvalidArgument, "invalid endpoint: %s", err)
                                                +	}
                                                +
                                                +	settings.Domain = hostname
                                                +	settings.ProxyAddress = hostname
                                                +	if err := m.store.CreateAgentNetworkSettings(ctx, settings); err != nil {
                                                +		if isUniqueConstraintError(err) {
                                                +			// The violation is either the account primary key (a concurrent
                                                +			// bootstrap for the same account won) or the domain index
                                                +			// (another account holds the hostname). Distinguish by re-read.
                                                +			if _, getErr := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, settings.AccountID); getErr == nil {
                                                +				return status.Errorf(status.AlreadyExists, "agent network settings already bootstrapped for account %s", settings.AccountID)
                                                +			}
                                                +			return status.Errorf(status.AlreadyExists, "endpoint %s is already taken", hostname)
                                                +		}
                                                +		return fmt.Errorf("create agent network settings: %w", err)
                                                +	}
                                                +	return nil
                                                +}
                                                +
                                                +// bootstrapLabeled allocates a labeled endpoint one label beneath the given
                                                +// cluster address: Domain =